fix: resolve review findings and refresh PR #870 against main

This commit is contained in:
Said Turksever committed 2026-11-03 16:17:31 +00:00
commit 66a5c483c2
2198 files changed
+307770 -27593

No files matched your search

+170
View File
@@ -0,0 +1,170 @@
import * as Cesium from 'cesium';
import {
feedProvenanceEnvelope,
layerSnapshot,
layerSnapshots,
} from '../data/layerSnapshot.js';
import { contextModeWord } from '../contextModePolicy.js';
export const TRACKABLE_FAMILIES = Object.freeze([
{ layerId: 'flights', kind: 'aircraft' },
{ layerId: 'military', kind: 'aircraft' },
{ layerId: 'ais-live-vessels', kind: 'vessel' },
{ layerId: 'satellites', kind: 'satellite' },
]);
const CONTEXT_MODE_RESULT_FIELDS = Object.freeze([
{ field: 'mode', emptyAs: 'off' },
{ field: 'entering', emptyAs: null },
{ field: 'priorMode', emptyAs: null },
]);
const NESTED_CONTEXT_RESULT_FIELDS = Object.freeze([
'context',
'contextRollback',
]);
/**
* Translate context-mode payloads into the public tool vocabulary while
* retaining the internal id beside each translated field.
*/
export function withContextModeVocabulary(state) {
if (!state || typeof state !== 'object') return state;
let out = state;
const mutable = () => {
if (out === state) out = { ...state };
return out;
};
for (const { field, emptyAs } of CONTEXT_MODE_RESULT_FIELDS) {
if (!(field in state)) continue;
const internal = state[field] ?? null;
const target = mutable();
target[field] = contextModeWord(internal, { emptyAs });
target[`${field}Internal`] = internal;
}
for (const field of NESTED_CONTEXT_RESULT_FIELDS) {
const nested = state[field];
if (!nested || typeof nested !== 'object') continue;
const translated = withContextModeVocabulary(nested);
if (translated !== nested) mutable()[field] = translated;
}
return out;
}
/** Read tracked entities from their canonical layer owners. */
export function collectTrackedEntities(dataManager) {
const tracked = [];
for (const family of TRACKABLE_FAMILIES) {
const module = dataManager?.layers?.get(family.layerId)?.module;
if (!module) continue;
try {
const info =
family.kind === 'vessel'
? module.getSelectedInfo?.()
: module.getTrackedInfo?.();
if (info)
tracked.push({
kind: family.kind,
layerId: family.layerId,
...info,
});
} catch {
// A not-yet-ready layer contributes no tracked observation.
}
}
return tracked;
}
/** Read the active Contacts window from its owning awareness layer. */
export function activeContactsWindow(dataManager) {
try {
const layer = dataManager?.layers?.get('military-awareness')?.module;
return (
layer?.contactsWindowFromSnapshot?.(layer.getContextSnapshot?.()) ?? null
);
} catch {
return null;
}
}
/**
* Build the model-free fast World Packet from existing application owners.
*
* This performs no network requests and intentionally mirrors the historical
* get_current_view_state payload so voice can delegate without behavior drift.
*/
export function getWorldPacket(options = {}) {
const {
viewer,
styleManager,
dataManager,
sceneDirector = null,
now: requestedNow,
} = options ?? {};
// A default parameter only covers undefined; null or NaN would age every
// feed against the epoch and report stale data as "just now".
const now = Number.isFinite(requestedNow) ? requestedNow : Date.now();
if (!viewer?.camera?.positionWC)
throw new TypeError('World Packet requires a viewer camera');
if (!dataManager || typeof dataManager.getAll !== 'function')
throw new TypeError('World Packet requires a data manager');
const cartographic = Cesium.Cartographic.fromCartesian(
viewer.camera.positionWC,
);
// Read the roster once. Voice must not rewalk disabled layers: preserving the
// current enabled-only view keeps the model's context budget bounded.
const rows = dataManager.getAll();
const enabledRows = rows.filter((layer) => layer.enabled);
const contactsWindow = activeContactsWindow(dataManager);
const contextState =
typeof styleManager?.getContextModeState === 'function'
? withContextModeVocabulary(styleManager.getContextModeState())
: null;
return {
ok: true,
action: 'get_current_view_state',
camera: {
latitude: Cesium.Math.toDegrees(cartographic.latitude),
longitude: Cesium.Math.toDegrees(cartographic.longitude),
heightM: cartographic.height,
},
style: styleManager?.activeStyle || 'normal',
context: contextState
? {
...contextState,
...(contactsWindow ? { contactsWindow } : {}),
}
: null,
cockpit:
typeof styleManager?.getCockpitState === 'function'
? styleManager.getCockpitState()
: null,
controls:
typeof styleManager?.getControlState === 'function'
? styleManager.getControlState()
: null,
scenePlayback: sceneDirector?.getPlaybackStatus?.() || null,
tracked: collectTrackedEntities(dataManager),
disabledLayerCount: rows.length - enabledRows.length,
// Preserve get_current_view_state's existing compact enabled-only shape.
layers: enabledRows.map((layer) => {
const snapshot = layerSnapshot(layer, { now });
return {
id: layer.id,
name: layer.name,
enabled: layer.enabled,
count: layer.stats?.count || 0,
error: layer.stats?.error || null,
feedState: snapshot.feedState,
source: snapshot.source,
lastUpdate: snapshot.lastUpdate,
};
}),
feedProvenance: feedProvenanceEnvelope(
layerSnapshots(rows, { now }).filter((layer) => layer.enabled),
{ now },
),
};
}
+335
View File
@@ -0,0 +1,335 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { getWorldPacket } from './worldPacket.js';
import { createGevActionRunner } from '../voice/gevActions.js';
function withoutVoicePresentation(result) {
const packet = { ...result };
delete packet.say;
delete packet.display;
delete packet.schedule;
return packet;
}
function harness() {
const position = Cesium.Cartesian3.fromDegrees(-97.74, 30.26, 500);
const viewer = {
trackedEntity: undefined,
clock: { onTick: { addEventListener: () => () => {} } },
scene: {
canvas: {
clientWidth: 1200,
clientHeight: 800,
addEventListener() {},
removeEventListener() {},
},
globe: { getHeight: () => 0 },
tweens: [],
},
camera: {
positionWC: position,
positionCartographic: Cesium.Cartographic.fromCartesian(position),
heading: 0,
pitch: Cesium.Math.toRadians(-45),
moveEnd: { addEventListener: () => () => {} },
cancelFlight() {},
lookAtTransform() {},
},
};
const rows = [
{
id: 'traffic',
name: 'Traffic',
enabled: true,
stats: {
count: 3,
source: 'fixture',
lastUpdate: null,
},
},
];
const dataManager = {
layers: new Map([['traffic', { module: null }]]),
getAll: () => rows,
};
const styleManager = {
activeStyle: 'normal',
getContextModeState: () => ({ mode: 'flights' }),
getCockpitState: () => null,
getControlState: () => ({ hud: { visible: 'auto' } }),
};
return { viewer, dataManager, styleManager };
}
test('World Packet preserves get_current_view_state parity', async () => {
const { viewer, dataManager, styleManager } = harness();
const packet = getWorldPacket({ viewer, dataManager, styleManager });
const runner = createGevActionRunner({ viewer, dataManager, styleManager });
const voice = await runner('get_current_view_state');
assert.deepEqual(withoutVoicePresentation(voice), packet);
assert.equal(packet.context.mode, 'contacts');
assert.equal(packet.context.modeInternal, 'flights');
assert.equal(packet.layers[0].feedState, 'nominal');
assert.equal(packet.feedProvenance.overall, 'nominal');
});
test('World Packet keeps stale/unavailable feed semantics distinct from empty', () => {
const { viewer, dataManager, styleManager } = harness();
dataManager.getAll = () => [
{
id: 'traffic',
name: 'Traffic',
enabled: true,
stats: {
count: 0,
source: 'fixture',
error: 'fixture unavailable',
lastUpdate: null,
},
},
];
const packet = getWorldPacket({ viewer, dataManager, styleManager });
assert.equal(packet.layers[0].count, 0);
assert.notEqual(packet.layers[0].feedState, 'nominal');
assert.notEqual(packet.feedProvenance.overall, 'nominal');
assert.match(packet.feedProvenance.note, /fixture unavailable/i);
});
// The 2026-10 main voice contract omits disabled layers from the model payload.
test('World Packet and voice preserve enabled-only payload and count disabled layers', async () => {
const { viewer, dataManager, styleManager } = harness();
const rows = dataManager.getAll();
rows.push({
id: 'earthquakes',
name: 'Earthquakes',
enabled: false,
stats: { count: 99, source: 'fixture', error: 'stale' },
});
let reads = 0;
dataManager.getAll = () => {
reads += 1;
return rows;
};
const packet = getWorldPacket({ viewer, dataManager, styleManager });
assert.equal(reads, 1, 'collect the roster once rather than repeatedly');
assert.equal(packet.disabledLayerCount, 1);
assert.deepEqual(
packet.layers.map((layer) => layer.id),
['traffic'],
);
assert.equal(packet.feedProvenance.overall, 'nominal');
const runner = createGevActionRunner({ viewer, dataManager, styleManager });
const voice = await runner('get_current_view_state');
assert.deepEqual(withoutVoicePresentation(voice), packet);
});
const PINNED_NOW = Date.UTC(2026, 9, 17, 12, 0, 0);
// Exercises every packet field: context vocabulary and Contacts window,
// cockpit, controls, scene playback, tracked families (one not ready),
// enabled and disabled layers, and an aged and an unavailable feed.
function pinnedHarness() {
const { viewer } = harness();
const rows = [
{
id: 'flights',
name: 'Flights',
enabled: true,
stats: {
count: 12,
source: 'fixture-adsb',
lastUpdate: PINNED_NOW - 90_000,
},
},
{
id: 'traffic',
name: 'Traffic',
enabled: true,
stats: {
count: 0,
source: 'fixture',
error: 'fixture unavailable',
lastUpdate: null,
},
},
{
id: 'earthquakes',
name: 'Earthquakes',
enabled: false,
stats: { count: 99, source: 'fixture', error: 'stale' },
},
];
const module = (methods) => ({ module: methods });
const dataManager = {
layers: new Map([
[
'flights',
module({
getTrackedInfo: () => ({ icao24: 'abc123', callsign: 'TEST1' }),
}),
],
[
'ais-live-vessels',
module({ getSelectedInfo: () => ({ mmsi: '366000001' }) }),
],
[
'satellites',
module({
getTrackedInfo: () => {
throw new Error('not ready');
},
}),
],
[
'military-awareness',
module({
getContextSnapshot: () => ({ snapshot: true }),
contactsWindowFromSnapshot: (snapshot) =>
snapshot ? { windowMin: 30, count: 4 } : null,
}),
],
]),
getAll: () => rows,
};
const styleManager = {
activeStyle: 'night-vision',
getContextModeState: () => ({
mode: 'flights',
entering: null,
priorMode: null,
}),
getCockpitState: () => ({ active: false }),
getControlState: () => ({ hud: { visible: 'auto' } }),
};
const sceneDirector = {
getPlaybackStatus: () => ({ playing: false, sceneId: 'fixture' }),
};
return { viewer, dataManager, styleManager, sceneDirector };
}
// Captured from main's get_current_view_state before the extraction
// (bilawalsidhu/gods-eye-view@3d8e1ef0) with Date.now pinned to PINNED_NOW,
// minus the speech envelope. Key order is part of the contract.
const PRE_EXTRACTION_PAYLOAD = {
ok: true,
action: 'get_current_view_state',
camera: {
latitude: 30.259999999999994,
longitude: -97.74,
heightM: 499.99999999901877,
},
style: 'night-vision',
context: {
mode: 'contacts',
entering: null,
priorMode: null,
modeInternal: 'flights',
enteringInternal: null,
priorModeInternal: null,
contactsWindow: { windowMin: 30, count: 4 },
},
cockpit: { active: false },
controls: { hud: { visible: 'auto' } },
scenePlayback: { playing: false, sceneId: 'fixture' },
tracked: [
{
kind: 'aircraft',
layerId: 'flights',
icao24: 'abc123',
callsign: 'TEST1',
},
{ kind: 'vessel', layerId: 'ais-live-vessels', mmsi: '366000001' },
],
disabledLayerCount: 1,
layers: [
{
id: 'flights',
name: 'Flights',
enabled: true,
count: 12,
error: null,
feedState: 'nominal',
source: 'fixture-adsb',
lastUpdate: 1792238310000,
},
{
id: 'traffic',
name: 'Traffic',
enabled: true,
count: 0,
error: 'fixture unavailable',
feedState: 'unavailable',
source: 'fixture',
lastUpdate: 0,
},
],
feedProvenance: {
overall: 'unavailable',
layers: [
{
id: 'flights',
name: 'Flights',
enabled: true,
feedState: 'nominal',
source: 'fixture-adsb',
count: 12,
lastUpdate: 1792238310000,
ageSec: 90,
ageLabel: '1m ago',
error: null,
},
{
id: 'traffic',
name: 'Traffic',
enabled: true,
feedState: 'unavailable',
source: 'fixture',
count: 0,
lastUpdate: 0,
ageSec: null,
ageLabel: null,
error: 'fixture unavailable',
},
],
note: 'Traffic is UNAVAILABLE (fixture, fixture unavailable). Narrate that provenance with any count; do not describe this as live.',
},
};
function assertPinnedPayload(actual) {
assert.deepEqual(actual, PRE_EXTRACTION_PAYLOAD);
// deepEqual ignores key order; the serialized form does not.
assert.equal(JSON.stringify(actual), JSON.stringify(PRE_EXTRACTION_PAYLOAD));
}
test('World Packet matches the pre-extraction get_current_view_state payload', () => {
const packet = getWorldPacket({ ...pinnedHarness(), now: PINNED_NOW });
assertPinnedPayload(packet);
});
test('voice get_current_view_state still returns the pre-extraction payload', async (t) => {
t.mock.method(Date, 'now', () => PINNED_NOW);
const runner = createGevActionRunner(pinnedHarness());
const voice = await runner('get_current_view_state');
assertPinnedPayload(withoutVoicePresentation(voice));
});
test('World Packet ages feeds against the clock when now is not a finite number', (t) => {
t.mock.method(Date, 'now', () => PINNED_NOW);
for (const now of [null, Number.NaN, '2026-10-17']) {
const packet = getWorldPacket({ ...pinnedHarness(), now });
const flights = packet.feedProvenance.layers.find(
(layer) => layer.id === 'flights',
);
assert.equal(flights.ageSec, 90, `now: ${String(now)}`);
assert.equal(flights.ageLabel, '1m ago', `now: ${String(now)}`);
}
});
test('World Packet rejects null options with the camera requirement', () => {
assert.throws(() => getWorldPacket(null), /requires a viewer camera/);
});
+87
View File
@@ -120,6 +120,93 @@ test('Georgia resolves from camera geography without a geocoder', async () => {
assert.deepEqual(geocodes, []);
});
test('Gold Coast in Australia cannot acquire its West African namesake outline', async () => {
for (const types of [['locality', 'political'], ['natural_feature']]) {
const { resolve, geocodes } = harness({
lat: -28.0167,
lng: 153.4,
label: 'Gold Coast, Australia',
name: 'Gold Coast',
types,
});
const resolved = await resolve('Gold Coast', viewerAt(-28.0167, 153.4));
assert.equal(resolved?.source, 'geocode');
assert.equal(resolved.lat, -28.0167);
assert.equal(resolved.lon, 153.4);
assert.equal(resolved.naturalRegion, undefined);
const outline = await resolved.resolveOutline();
assert.equal(outline?.naturalRegion, undefined);
assert.deepEqual(geocodes, ['Gold Coast']);
}
});
test('natural annotation outlines require a geocoded anchor', async () => {
for (const viewer of [AUSTIN, {}, viewerAt(46.5, 10, 248_000)]) {
const miss = harness();
assert.equal(await miss.resolve('the Alps', viewer), null);
assert.ok(miss.geocodes.includes('the Alps'));
}
const anchored = harness({
lat: 46.5,
lng: 10,
label: 'Alps',
name: 'Alps',
types: ['natural_feature'],
});
const resolved = await anchored.resolve('the Alps', {});
assert.equal(resolved.source, 'geocode');
const outline = await resolved.resolveOutline();
assert.equal(outline.naturalRegion, 'Alps');
assert.ok(outline.ring.length >= 8);
});
test('overlapping natural candidates continue through geocoding without choosing a ring', async () => {
const { resolve, geocodes } = harness({
lat: 45.6336,
lng: -74.86493,
label: 'Canadian Shield',
name: 'Canadian Shield',
types: ['natural_feature'],
});
const resolved = await resolve(
'Canadian Shield',
viewerAt(45.6336, -74.86493),
);
assert.equal(resolved?.source, 'geocode');
assert.equal((await resolved.resolveOutline())?.naturalRegion, undefined);
assert.deepEqual(geocodes, ['Canadian Shield']);
});
test('geocoded natural outlines respect district and around intent', async () => {
for (const extra of [
{ entityKind: 'district' },
{ intent: 'around_the_thing' },
]) {
const { resolve, geocodes } = harness({
lat: 46.5,
lng: 10,
label: 'Alps',
name: 'Alps',
types:
extra.entityKind === 'district' ? ['locality'] : ['natural_feature'],
});
const resolved = await resolve('the Alps', viewerAt(46.5, 10), extra);
assert.ok(geocodes.includes('the Alps'));
assert.equal((await resolved.resolveOutline())?.naturalRegion, undefined);
}
});
test('cancellation while a bundled admin lookup is pending cannot publish an annotation', async () => {
const controller = new AbortController();
const { resolve, geocodes } = harness();
const pending = resolve('the Alps', viewerAt(46.5, 10), {
signal: controller.signal,
});
controller.abort();
await assert.rejects(pending, { name: 'AbortError' });
assert.deepEqual(geocodes, []);
});
test('country asks and geocoded country scopes use the bundled outline', async () => {
const direct = harness();
for (const name of ['Switzerland', 'Iran', 'France', 'Japan']) {
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+10 -10
View File
@@ -200,7 +200,7 @@ test('ask-side admin bypass: "the Texas Capitol" recovers near-view despite a fa
const calls = [];
installGoogleMocks(t, async (url) => {
calls.push(String(url));
if (String(url).startsWith('https://maps.googleapis.com/')) {
if (String(url).startsWith('/api/google/geocode')) {
return { json: async () => geocodePayload({
lat: 31.0000,
lon: -99.0000,
@@ -235,7 +235,7 @@ test('ask-side admin bypass: explicit "state of Texas" skips recovery and proxim
const calls = [];
installGoogleMocks(t, async (url) => {
calls.push(String(url));
assert.match(String(url), /^https:\/\/maps\.googleapis\.com\/maps\/api\/geocode/);
assert.match(String(url), /^\/api\/google\/geocode\?/);
return { json: async () => geocodePayload({
lat: 31.0000,
lon: -99.0000,
@@ -273,7 +273,7 @@ for (const fixture of [
const calls = [];
installGoogleMocks(t, async (url) => {
calls.push(String(url));
if (String(url).startsWith('https://maps.googleapis.com/')) {
if (String(url).startsWith('/api/google/geocode')) {
return { json: async () => geocodePayload({
lat: fixture.lat,
lon: fixture.lon,
@@ -299,7 +299,7 @@ test('ask-side admin bypass: bare "Texas" remains on the guarded recovery path',
const calls = [];
installGoogleMocks(t, async (url) => {
calls.push(String(url));
if (String(url).startsWith('https://maps.googleapis.com/')) {
if (String(url).startsWith('/api/google/geocode')) {
return { json: async () => geocodePayload({
lat: 31.0000,
lon: -99.0000,
@@ -328,7 +328,7 @@ test('ask-side admin bypass: admin level 2/3 result types never grant a township
const calls = [];
installGoogleMocks(t, async (url) => {
calls.push(String(url));
if (String(url).startsWith('https://maps.googleapis.com/')) {
if (String(url).startsWith('/api/google/geocode')) {
const query = new URL(String(url)).searchParams.get('address');
return { json: async () => geocodePayload({
lat: 39.7817,
@@ -380,7 +380,7 @@ function installCapitolMocks(t, elements, components = [
{ long_name: 'Capitol Hill', types: ['neighborhood', 'political'] },
]) {
installGoogleMocks(t, async (url) => {
if (String(url).startsWith('https://maps.googleapis.com/')) {
if (String(url).startsWith('/api/google/geocode')) {
return { json: async () => ({ status: 'OK', results: [{
formatted_address: 'Washington, DC 20004, USA',
types: ['establishment', 'landmark', 'point_of_interest', 'tourist_attraction'],
@@ -458,7 +458,7 @@ test('keyless: a key that geocodes to nothing still anchors the annotation', asy
const requests = [];
installGoogleMocks(t, async (url) => {
requests.push(String(url));
if (String(url).startsWith('https://maps.googleapis.com/')) return GOOGLE_FOUND_NOTHING;
if (String(url).startsWith('/api/google/geocode')) return GOOGLE_FOUND_NOTHING;
return {
ok: true,
json: async () => ({
@@ -495,7 +495,7 @@ test('keyless: a key that geocodes to nothing still anchors the annotation', asy
high: { latitude: 30.28, longitude: -97.68 },
});
// Google is asked first and exactly once; one Photon call answers it.
assert.equal(requests.filter((url) => url.includes('maps.googleapis.com')).length, 1);
assert.equal(requests.filter((url) => url.includes('/api/google/geocode')).length, 1);
assert.equal(requests.filter((url) => url.includes('photon.komoot.io')).length, 1);
});
@@ -512,7 +512,7 @@ test('keyless: OSM matching uses the feature\'s canonical name, not the user\'s
installGoogleMocks(t, async (url, init) => {
const href = String(url);
if (href.startsWith('https://maps.googleapis.com/')) return GOOGLE_FOUND_NOTHING;
if (href.startsWith('/api/google/geocode')) return GOOGLE_FOUND_NOTHING;
if (href.startsWith('/api/google/text-search')) return { ok: true, json: async () => ({ places: [] }) };
if (href.startsWith('https://photon.komoot.io/')) {
return {
@@ -575,7 +575,7 @@ test('keyless: a Photon outage is not remembered as "no such place"', async (t)
const requests = [];
installGoogleMocks(t, async (url) => {
requests.push(String(url));
if (String(url).startsWith('https://maps.googleapis.com/')) return GOOGLE_FOUND_NOTHING;
if (String(url).startsWith('/api/google/geocode')) return GOOGLE_FOUND_NOTHING;
if (!photonReachable) throw new Error('network down');
return photonUp;
});
+76
View File
@@ -0,0 +1,76 @@
/**
* The shapes drawn by hand, as UTILITIES ▸ Draw lists them: one row per shape
* with its colour, its name, and show/hide, fly-to and remove, the way Import
* lists its files. The board is the annotation engine's; voice marks are not
* listed, only `manual` ones.
*/
import { msg, t } from '../i18n/index.js';
export const DRAW_COLORS = Object.freeze([
'primary',
'amber',
'cyan',
'green',
'red',
]);
/** Each colour as drawn: the preview, the swatches and the drawn shape. */
export const DRAW_COLOR_CSS = Object.freeze({
primary: '#8be9ff',
amber: '#ffb547',
cyan: '#39d0ff',
green: '#5dff9f',
red: '#ff6b6b',
});
export const DRAW_COLOR_NAMES = Object.freeze({
primary: msg('Primary'),
amber: msg('Amber'),
cyan: msg('Cyan'),
green: msg('Green'),
red: msg('Red'),
});
const SHAPE_NAMES = Object.freeze({
area: msg('Area'),
route: msg('Line'),
pin: msg('Pin'),
});
/** What a row calls a shape that has no name of its own. */
export function drawMarkFallbackName(mark) {
const shape = t(SHAPE_NAMES[mark?.type] || SHAPE_NAMES.pin);
// An unnamed line's label is its length; keep it, it is the useful part.
return mark?.type === 'route' && !mark.name && mark.label
? `${shape} · ${mark.label}`
: shape;
}
/**
* The rows for the hand-drawn marks on the board, in board order. Text is
* worded here, in the current language; the view only lays it out.
* @param {object[]} marks The engine's `list()`.
*/
export function presentDrawMarks(marks = []) {
return marks
.filter((mark) => mark?.manual === true)
.map((mark) => {
const fallback = drawMarkFallbackName(mark);
const title = mark.name || fallback;
const visible = !mark.hidden;
return {
id: mark.id,
name: mark.name || '',
placeholder: fallback,
color: DRAW_COLORS.includes(mark.color) ? mark.color : 'primary',
hidden: !visible,
labels: {
name: t('Name of {name}', { name: title }),
swatch: t('Change the colour of {name}', { name: title }),
colors: t('Colour of {name}', { name: title }),
toggle: visible
? t('Hide {name}', { name: title })
: t('Show {name}', { name: title }),
fly: t('Fly to {name}', { name: title }),
remove: t('Remove {name}', { name: title }),
},
};
});
}
+80
View File
@@ -0,0 +1,80 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
DRAW_COLORS,
DRAW_COLOR_CSS,
drawMarkFallbackName,
presentDrawMarks,
} from './drawMarks.js';
test('an unnamed shape is called by its kind, and an unnamed line by its length too', () => {
assert.equal(drawMarkFallbackName({ type: 'area' }), 'Area');
assert.equal(drawMarkFallbackName({ type: 'pin' }), 'Pin');
assert.equal(
drawMarkFallbackName({ type: 'route', label: '300 m' }),
'Line · 300 m',
);
assert.equal(
drawMarkFallbackName({
type: 'route',
name: 'Walk',
label: 'Walk — 300 m',
}),
'Line',
);
});
test('rows list only hand-drawn marks, in board order, with their words', () => {
const rows = presentDrawMarks([
{
id: 'a1',
type: 'route',
manual: true,
name: null,
label: '950 m',
color: 'amber',
},
{ id: 'a2', type: 'area', manual: false, label: 'Texas', color: 'primary' },
{
id: 'a3',
type: 'pin',
manual: true,
name: 'Camp',
color: 'teal',
hidden: true,
},
]);
assert.deepEqual(
rows.map(({ id, name, placeholder, color, hidden }) => ({
id,
name,
placeholder,
color,
hidden,
})),
[
{
id: 'a1',
name: '',
placeholder: 'Line · 950 m',
color: 'amber',
hidden: false,
},
{
id: 'a3',
name: 'Camp',
placeholder: 'Pin',
color: 'primary',
hidden: true,
},
],
);
assert.equal(rows[0].labels.toggle, 'Hide Line · 950 m');
assert.equal(rows[1].labels.toggle, 'Show Camp');
assert.equal(rows[1].labels.fly, 'Fly to Camp');
assert.equal(rows[1].labels.colors, 'Colour of Camp');
});
test('every colour has its swatch', () => {
assert.deepEqual(Object.keys(DRAW_COLOR_CSS), [...DRAW_COLORS]);
});
+31 -27
View File
@@ -1,3 +1,5 @@
import { t, tn } from '../i18n/index.js';
/**
* Manual whiteboard drawing: the pure half.
*
@@ -18,6 +20,9 @@
* `drawTool.js`.
*/
import { greatCircleMeters } from '../geoDistance.js';
import { formatDistanceText } from '../data/distanceFormat.js';
export const DRAW_SHAPES = Object.freeze(['area', 'line', 'pin']);
export const MIN_VERTICES = Object.freeze({ area: 3, line: 2, pin: 1 });
/**
@@ -27,10 +32,10 @@ export const MIN_VERTICES = Object.freeze({ area: 3, line: 2, pin: 1 });
*/
export const MAX_VERTICES = 512;
/** Two clicks closer than this are one vertex: a double-click to finish must not add a stray point. */
export const MIN_VERTEX_SEPARATION_M = 0.5;
const MIN_VERTEX_SEPARATION_M = 0.5;
/** A line shorter than this, or an area thinner than this, is a mis-click rather than a shape. */
export const MIN_PATH_LENGTH_M = 1;
export const MIN_AREA_M2 = 1;
const MIN_PATH_LENGTH_M = 1;
const MIN_AREA_M2 = 1;
/** @param {string} shape @returns {'area'|'line'|'pin'} */
export function normalizeShape(shape) {
@@ -46,15 +51,8 @@ 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)));
function greatCircleM(a, b) {
return greatCircleMeters(a, b);
}
/**
@@ -210,16 +208,16 @@ export function ringCentroid(vertices) {
export function formatMeasure(session) {
if (!session) return '';
if (session.shape === 'line') {
const m = pathLengthM(session.vertices);
return m >= 1000
? `${(m / 1000).toFixed(m >= 10000 ? 0 : 1)} km`
: `${Math.round(m)} m`;
return formatDistanceText(pathLengthM(session.vertices)) ?? '';
}
if (session.shape === 'area') {
const m2 = ringAreaM2(session.vertices);
if (m2 >= 1e6) return `${(m2 / 1e6).toFixed(2)} km²`;
if (m2 >= 1e4) return `${(m2 / 1e4).toFixed(1)} ha`;
return `${Math.round(m2)} m²`;
// Round before each unit cut, as for a line. 9,999.6 m² was "10000 m²".
const whole = Math.round(m2);
const ha = (m2 / 1e4).toFixed(1);
if (whole < 1e4) return `${whole} m²`;
if (Number(ha) < 100) return `${ha} ha`;
return `${(m2 / 1e6).toFixed(2)} km²`;
}
return '';
}
@@ -284,20 +282,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 },
);
}
+63
View File
@@ -330,3 +330,66 @@ test('longitude unwrapping is continuous and re-wraps to one canonical range', (
assert.equal(wrapLongitude(180), -180);
assert.equal(unwrapLongitudes([]).length, 0);
});
test('a line that rounds up to a kilometre reads 1.0 km, not 1000 m', () => {
const metresPerDegree = (6371.0088 * 1000 * Math.PI) / 180;
const lat = 999.6 / metresPerDegree;
const line = createDrawSession('line');
addVertex(line, { lon: 0, lat: 0 });
addVertex(line, { lon: 0, lat });
const metres = pathLengthM(line.vertices);
assert.ok(
metres >= 999.5 && metres < 1000,
`length should round to 1000 m, got ${metres}`,
);
assert.equal(formatMeasure(line), '1.0 km');
assert.match(drawHint(line), /^1\.0 km · /);
});
test('a line that rounds to 10.0 km reads in whole kilometres', () => {
const metresPerDegree = (6371.0088 * 1000 * Math.PI) / 180;
const line = createDrawSession('line');
addVertex(line, { lon: 0, lat: 0 });
addVertex(line, { lon: 0, lat: 9960 / metresPerDegree });
assert.equal(formatMeasure(line), '10 km');
});
test('an area that rounds up to the next unit reads in that unit', () => {
// A square on the equator whose shoelace area is about `m2`.
const square = (m2) => {
const side = Math.sqrt(m2) / 111320;
const area = createDrawSession('area');
for (const [lon, lat] of [
[0, 0],
[side, 0],
[side, side],
[0, side],
])
addVertex(area, { lon, lat });
return area;
};
const almostHectare = square(9999.6);
const metres = ringAreaM2(almostHectare.vertices);
assert.ok(
metres >= 9999.5 && metres < 1e4,
`area should round to 10000 m², got ${metres}`,
);
assert.equal(formatMeasure(almostHectare), '1.0 ha');
assert.match(drawHint(almostHectare), /^1\.0 ha · /);
assert.equal(formatMeasure(square(9999.4)), '9999 m²');
// Pin the shoelace area too, so a geodesy change fails here and not as a unit.
const almostKm2 = square(999_960);
const nearKm2 = ringAreaM2(almostKm2.vertices);
assert.ok(
nearKm2 >= 999_500 && nearKm2 < 1e6,
`area should round to 100.0 ha, got ${nearKm2}`,
);
assert.equal(formatMeasure(almostKm2), '1.00 km²');
const under = square(999_400);
const underM2 = ringAreaM2(under.vertices);
assert.ok(
underM2 >= 998_500 && underM2 < 999_500,
`area should round to 99.9 ha, got ${underM2}`,
);
assert.equal(formatMeasure(under), '99.9 ha');
});
+142 -89
View File
@@ -1,9 +1,10 @@
/**
* Manual whiteboard drawing: the Cesium + DOM half.
*
* DISPLAY ▸ Draw turns the globe into a whiteboard you draw on by hand: pick a
* shape (area, line or pin), click the vertices on the real world, double-click
* or press Enter to finish, type a label. Each finished shape goes through the
* UTILITIES ▸ Draw turns the globe into a whiteboard you draw on by hand: pick a
* shape (area, line or pin) and a colour, click the vertices on the real world,
* double-click or press Enter to finish, then name, recolour, hide, fly to or
* remove the shape from its row (drawMarks.js). Each finished shape goes through the
* SAME `annotationEngine.annotate()` the voice agent uses, with its geometry
* supplied and `manual: true`, so it renders with the whiteboard look,
* persists, de-dups, shows up in `.list()`, and clears with the board.
@@ -36,6 +37,7 @@ import {
pointerOwner,
releasePointer,
} from '../data/inputOwnership.js';
import { pointerOwnerName } from '../data/pointerOwnerNames.js';
import {
DRAW_SHAPES,
MAX_VERTICES,
@@ -48,40 +50,44 @@ import {
removeLastVertex,
} from './drawMode.js';
import { DRAW_COLORS, DRAW_COLOR_CSS, presentDrawMarks } from './drawMarks.js';
import { onLocaleChange, t, translateKnown } from '../i18n/index.js';
/** The id this tool claims the pointer under. */
export const DRAW_POINTER_OWNER = 'draw';
const DRAW_POINTER_OWNER = 'draw';
const PREVIEW_DATA_SOURCE_NAME = 'gev-draw-preview';
const COLORS = ['primary', 'amber', 'cyan', 'green', 'red'];
const PREVIEW = {
primary: '#8be9ff',
amber: '#ffb547',
cyan: '#39d0ff',
green: '#5dff9f',
red: '#ff6b6b',
};
const PREVIEW = DRAW_COLOR_CSS;
/**
* Wire the Draw control. Returns a handle with a `destroy()` the application
* Run the Draw control. Returns a handle with a `destroy()` the application
* lifetime owns, plus the console/test seam (`window.__gevDrawTool`).
* @param {{viewer: Cesium.Viewer, annotations: {annotate: Function, clear: Function}}} deps
* The tool hands what the controls show (`{ active, shape, color, hint,
* marks }`) to `publish`, which the view renders, and answers the controls
* through the actions it gives `connect`, which returns their disconnect.
* A row's fly-to is an immediate navigation through `navigation`, like
* Import's, so Cockpit can refuse it and a running orbit or follow lets go.
* @param {{viewer: Cesium.Viewer, annotations: {annotate: Function, clear: Function}, navigation?: object, publish?: Function, connect?: Function}} deps
* @returns {{destroy: Function}|null}
*/
export function initDrawTool({ viewer, annotations }) {
const toggle = document.getElementById('draw-toggle');
const modeRow = document.getElementById('draw-mode-row');
const labelRow = document.getElementById('draw-label-row');
const labelInput = document.getElementById('draw-label-input');
const colorSelect = document.getElementById('draw-color-select');
const clearButton = document.getElementById('draw-clear');
const hint = document.getElementById('draw-hint');
if (!viewer || !annotations || !toggle) return null;
export function initDrawTool({
viewer,
annotations,
navigation = null,
publish = () => {},
connect = () => () => {},
}) {
// A page without the Draw controls (the Display controls view omitted) has
// no draw tool.
if (!viewer || !annotations || !document.getElementById('draw-toggle'))
return null;
let active = false;
let destroyed = false;
let session = null;
let shape = 'area';
let color = 'primary';
let hint = '';
let handler = null;
let lease = null;
let savedDoubleClick = null;
@@ -92,11 +98,10 @@ export function initDrawTool({ viewer, annotations }) {
// one of those must not write its outcome over the newer state.
let generation = 0;
const previewEntities = [];
// Every DOM listener this tool adds, so teardown is one loop rather than a
// list that can fall out of step with the bindings below.
// Every DOM listener this tool adds (the session's keydown), so teardown is
// one loop rather than a list that can fall out of step with the bindings.
const domListeners = [];
const listen = (target, type, listener, options) => {
if (!target) return;
target.addEventListener(type, listener, options);
domListeners.push([target, type, listener, options]);
};
@@ -110,9 +115,25 @@ export function initDrawTool({ viewer, annotations }) {
() => null,
);
const present = () =>
publish({
active,
shape,
color,
hint,
marks: presentDrawMarks(annotations.list?.() || []),
});
// The hint is kept as the call that words it, so a language switch can
// word it again.
let hintText = () => '';
const setHint = (text) => {
if (hint) hint.textContent = text;
hintText = text;
hint = text();
present();
};
const offLocale = onLocaleChange(() => {
if (!destroyed) setHint(hintText);
});
// ---- preview ---------------------------------------------------------
const vertexPositions = () =>
@@ -147,7 +168,7 @@ export function initDrawTool({ viewer, annotations }) {
previewEntities.length = 0;
if (!session) {
previewLine.show = false;
setHint(drawHint(null));
setHint(() => drawHint(null));
viewer.scene.requestRender();
return;
}
@@ -173,7 +194,7 @@ export function initDrawTool({ viewer, annotations }) {
}),
);
}
setHint(drawHint(session));
setHint(() => drawHint(session));
viewer.scene.requestRender();
};
@@ -196,11 +217,14 @@ export function initDrawTool({ viewer, annotations }) {
// A click that changed nothing still deserves an answer when the reason is
// 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.`,
setHint(() =>
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;
@@ -218,17 +242,17 @@ export function initDrawTool({ viewer, annotations }) {
if (reason !== 'ok') {
// 'too-few' is just "keep clicking" and the hint already says so;
// a degenerate or off-globe shape needs to be told why it was refused.
if (reason !== 'too-few') setHint(drawHint(session));
if (reason !== 'too-few') setHint(() => drawHint(session));
return null;
}
const spec = finishSpec(session, { label: labelInput?.value || '', color });
// Named afterwards, in the shape's row.
const spec = finishSpec(session, { label: '', color });
if (!spec) return null;
// Start a fresh session BEFORE awaiting, so a second Enter or the second
// half of a double-click cannot submit the same shape twice.
session = createDrawSession(shape);
cursor = null;
syncPreview();
if (labelInput) labelInput.value = '';
const attempt = generation;
try {
const result = await annotations.annotate([spec], {
@@ -236,11 +260,16 @@ 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,13 +287,13 @@ 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 ----
const typingElsewhere = (event) => {
const t = event.target;
if (!t || t === labelInput) return false;
if (!t) return false;
return (
t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable
);
@@ -272,12 +301,12 @@ export function initDrawTool({ viewer, annotations }) {
/**
* Enter is the finish key, but on a focused control Enter means "press this
* control". Keyboard-focusing Clear and pressing Enter must clear the board,
* not finish the shape — so the finish key is only taken from the canvas,
* the page body, or the label field this tool owns.
* not finish the shape — so the finish key is only taken from the canvas or
* the page body.
*/
const enterBelongsToDrawing = (event) => {
const t = event.target;
if (!t || t === labelInput) return true;
if (!t) return true;
if (
typeof t.closest === 'function' &&
t.closest('button, a, select, [role="button"], [role="radio"]')
@@ -289,6 +318,9 @@ export function initDrawTool({ viewer, annotations }) {
};
const onKey = (event) => {
if (!active || destroyed || typingElsewhere(event)) return;
// Keys on the shape list's own controls are the list's (Esc closes a
// colour picker there), not the drawing's.
if (event.target?.closest?.('#draw-mark-list')) return;
// Enter goes to finish() once a shape has been started; finish() owns the
// decision and says WHY it refused. Swallowing the key on a degenerate
// shape left the person pressing Enter at a silent panel. A focused button
@@ -307,7 +339,7 @@ export function initDrawTool({ viewer, annotations }) {
else setActive(false);
return;
}
if (event.key === 'Backspace' && event.target !== labelInput) {
if (event.key === 'Backspace') {
if (removeLastVertex(session)) {
event.preventDefault();
syncPreview();
@@ -324,15 +356,16 @@ 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;
}
}
active = next;
toggle.classList.toggle('active', active);
toggle.setAttribute('aria-pressed', String(active));
modeRow?.classList.toggle('visible', active);
labelRow?.classList.toggle('visible', active);
present();
document.body.classList.toggle('gev-drawing', active);
if (active) {
session = createDrawSession(shape);
@@ -394,8 +427,7 @@ export function initDrawTool({ viewer, annotations }) {
);
savedDoubleClick = null;
}
// Drop just the keydown listener bound alongside this handler; the control
// listeners below outlive a session and come off in destroy().
// Drop the keydown listener bound alongside this handler.
for (let i = domListeners.length - 1; i >= 0; i -= 1) {
const [target, type, listener, options] = domListeners[i];
if (listener !== onKey) continue;
@@ -404,38 +436,56 @@ export function initDrawTool({ viewer, annotations }) {
}
}
listen(toggle, 'click', () => setActive(!active));
const shapeButtons = [
...(modeRow?.querySelectorAll('.pp-mode-btn[data-shape]') || []),
];
for (const btn of shapeButtons) {
listen(btn, 'click', () => {
shape = normalizeShape(btn.dataset.shape);
for (const other of shapeButtons) {
const on = other === btn;
other.classList.toggle('active', on);
other.setAttribute('aria-checked', String(on));
}
if (active) {
generation += 1;
session = createDrawSession(shape);
cursor = null;
syncPreview();
}
});
/**
* Frame one drawn shape: an immediate navigation, so Cockpit can refuse it
* and the current orbit, follow or track lets go first.
*/
function flyToMark(id) {
if (typeof navigation?.runImmediateNavigation === 'function')
return (
navigation.runImmediateNavigation('drawing', () =>
annotations.frame?.(id),
) !== false
);
return annotations.frame?.(id) ?? false;
}
listen(colorSelect, 'change', () => {
color = COLORS.includes(colorSelect.value) ? colorSelect.value : 'primary';
syncPreview();
});
listen(clearButton, 'click', clearAll);
listen(labelInput, 'keydown', (event) => {
if (event.key === 'Enter' && session?.vertices?.length) {
event.preventDefault();
void finish();
/** Draw `next` from now on; an open shape starts over in it. */
function chooseShape(next) {
shape = normalizeShape(next);
present();
if (active) {
generation += 1;
session = createDrawSession(shape);
cursor = null;
syncPreview();
}
}
const disconnect = connect({
toggleDraw: () => setActive(!active),
setDrawShape: chooseShape,
setDrawColor: (value) => {
color = DRAW_COLORS.includes(value) ? value : 'primary';
present();
syncPreview();
},
clearDraw: clearAll,
renameDrawMark: (id, name) => annotations.restyle?.(id, { name }),
recolorDrawMark: (id, next) => annotations.restyle?.(id, { color: next }),
toggleDrawMark: (id) => {
const mark = annotations.list?.().find((entry) => entry.id === id);
if (mark) annotations.setHidden?.(id, !mark.hidden);
},
flyToDrawMark: flyToMark,
removeDrawMark: (id) => annotations.remove?.(id),
});
setHint(drawHint(null));
// The rows follow the board: a shape drawn, renamed, hidden or removed
// here, by voice or by a clear.
const offBoard =
annotations.onChange?.(() => {
if (!destroyed) present();
}) || (() => {});
setHint(() => drawHint(null));
const api = {
get active() {
@@ -448,12 +498,15 @@ export function initDrawTool({ viewer, annotations }) {
return session;
},
setActive,
setShape(next) {
const btn = modeRow?.querySelector(
`.pp-mode-btn[data-shape="${normalizeShape(next)}"]`,
);
btn?.click();
setShape: chooseShape,
/** The hand-drawn marks the list shows, as its rows. */
marks: () => presentDrawMarks(annotations.list?.() || []),
setColor(next) {
color = DRAW_COLORS.includes(next) ? next : 'primary';
present();
syncPreview();
},
flyToMark,
/** Test seam: add a vertex from lon/lat as if it had been clicked. */
addVertex(lon, lat, height = 0) {
if (!session) return false;
@@ -502,6 +555,9 @@ export function initDrawTool({ viewer, annotations }) {
generation += 1;
if (active) setActive(false);
destroyed = true;
offLocale();
offBoard();
disconnect();
releaseSceneHandler();
releasePointer(lease);
lease = null;
@@ -521,11 +577,8 @@ export function initDrawTool({ viewer, annotations }) {
}
});
document.body.classList.remove('gev-drawing');
toggle.classList.remove('active');
toggle.setAttribute('aria-pressed', 'false');
modeRow?.classList.remove('visible');
labelRow?.classList.remove('visible');
if (hint) hint.textContent = '';
hint = '';
present();
if (window.__gevDrawTool === api) delete window.__gevDrawTool;
return attaching;
},
+57 -21
View File
@@ -23,7 +23,7 @@ test('the application composition wires the draw tool and owns its teardown', ()
const tools = read('src/app/tools.js');
const engineAt = tools.indexOf('const annotations = initAnnotations({');
const drawAt = tools.indexOf(
'const drawTool = initDrawTool({ viewer, annotations });',
'const drawTool = initDrawTool({\n viewer,\n annotations,\n navigation: styleManager,\n publish: (view) => displayStore.publishDraw(view),',
);
assert.ok(engineAt >= 0, 'initAnnotations call missing');
assert.ok(
@@ -62,37 +62,67 @@ test('the engine resolves manual geometry before any name resolution', () => {
});
test('the Draw control markup carries the ids the tool binds to', () => {
// The DISPLAY rail is a component template now, assembled into index.html at
// build time — the markup is not in index.html any more.
const html = read('src/ui/templates/display-controls.html');
// UTILITIES ▸ Draw is a Vue view (DrawControls, with DrawMarkList for the
// drawn shapes' rows), rendered into the Utilities panel.
const html = [
read('src/ui/views/panels/DrawControls.vue'),
read('src/ui/views/panels/DrawMarkList.vue'),
].join('\n');
const ids = [
'draw-toggle',
'draw-mode-row',
'draw-colors',
'draw-label-row',
'draw-label-input',
'draw-color-select',
'draw-clear',
'draw-hint',
'draw-mark-list',
];
for (const id of ids) {
assert.match(
html,
new RegExp(`id="${id}"`),
`#${id} missing from index.html`,
`#${id} missing from the Draw views`,
);
}
for (const shape of ['area', 'line', 'pin']) {
// Shapes are icons, named for screen readers and tooltips.
for (const [shape, icon] of [
['area', 'pentagon'],
['line', 'timeline'],
['pin', 'location_on'],
]) {
assert.match(
html,
new RegExp(`data-shape="${shape}"`),
new RegExp(`icon: '${icon}', attrs: \\{ 'data-shape': '${shape}' \\}`),
`shape button ${shape} missing`,
);
}
for (const id of ids) {
assert.ok(
read('src/ui/views/LayerPanelsView.vue').includes('<DrawControls />'),
'the Utilities panel renders Draw',
);
assert.ok(
!read('src/ui/views/panels/DisplayPanel.vue').includes('draw-toggle'),
'Draw is no longer in DISPLAY',
);
// The rows and the hint render what the tool publishes; the controls
// call the actions the tool connects to the display store.
for (const action of [
"act('toggleDraw')",
"act('setDrawShape', $event)",
"act('setDrawColor', $event)",
"act('clearDraw')",
"act('renameDrawMark', row.id, next)",
"act('recolorDrawMark', row.id, color)",
"act('toggleDrawMark', row.id)",
"act('flyToDrawMark', row.id)",
"act('removeDrawMark', row.id)",
]) {
assert.ok(html.includes(action), `the Draw views must call ${action}`);
const name = /act\('([a-zA-Z]+)'/.exec(action)[1];
assert.match(
tool(),
new RegExp(`getElementById\\('${id}'\\)`),
`drawTool must bind #${id}`,
new RegExp(`\\b${name}: `),
`drawTool must answer ${name}`,
);
}
});
@@ -102,20 +132,22 @@ test('the DISPLAY rail styles live in their current owner, not the shim', () =>
// these rules lived in the root style.css. That file is an import shim now,
// and putting rules back into it would silently undo the split.
const shim = read('style.css');
assert.ok(
shim.split('\n').length < 40,
'style.css is an import shim, not a stylesheet',
);
const rules = shim
.replace(/\/\*[\s\S]*?\*\//g, '')
.split('\n')
.filter((line) => line.trim() && !/^@import '[^']+';$/.test(line.trim()));
assert.deepEqual(rules, [], 'style.css is an import shim, not a stylesheet');
assert.doesNotMatch(
shim,
/draw-hint|pp-text-input/,
/draw-hint|draw-color/,
'draw styles belong to a component stylesheet',
);
const controls = read('src/ui/styles/controls.css');
for (const rule of [
'#draw-mode-row',
'#draw-label-row',
'.pp-text-input',
'.draw-color',
'.draw-mark-name',
'.draw-clear-btn',
'.draw-hint',
'body.gev-drawing',
@@ -138,10 +170,14 @@ test('nothing promises a GeoJSON export for drawn shapes', () => {
`${file} still claims a GeoJSON export`,
);
}
for (const file of ['README.md', 'CHANGELOG.md', 'docs/CURRENT-STATE.md']) {
// Draw lives in UTILITIES; only the changelog's history still says DISPLAY.
for (const [file, heading] of [
['README.md', 'UTILITIES ▸ **Draw**'],
['CHANGELOG.md', 'DISPLAY ▸ Draw'],
['docs/CURRENT-STATE.md', 'UTILITIES ▸ **Draw**'],
]) {
const source = read(file);
const at = source.indexOf('DISPLAY ▸ **Draw**');
const start = at >= 0 ? at : source.indexOf('DISPLAY ▸ Draw');
const start = source.indexOf(heading);
assert.ok(start >= 0, `${file} should describe the Draw control`);
const section = source.slice(start, start + 1200);
assert.doesNotMatch(
+206 -39
View File
@@ -46,7 +46,6 @@ class FakeElement {
this.attributes = new Map();
this.textContent = '';
this.value = '';
this.children = [];
this.listeners = [];
}
setAttribute(name, value) {
@@ -64,16 +63,6 @@ class FakeElement {
);
if (at >= 0) this.listeners.splice(at, 1);
}
querySelectorAll(selector) {
if (selector === '.pp-mode-btn[data-shape]')
return this.children.filter((child) => child.dataset.shape);
const shape = /\.pp-mode-btn\[data-shape="([a-z]+)"\]/.exec(selector)?.[1];
if (shape) return this.children.filter((child) => child.dataset.shape === shape);
return [];
}
querySelector(selector) {
return this.querySelectorAll(selector)[0] ?? null;
}
click() {
return this.emit('click');
}
@@ -107,18 +96,10 @@ function fakeDom() {
byId.set(id, element);
return element;
};
// The control whose presence means the page has Draw, and a focused button
// whose Enter is its own.
make('draw-toggle', 'BUTTON');
const modeRow = make('draw-mode-row');
for (const shape of ['area', 'line', 'pin']) {
const button = new FakeElement('BUTTON');
button.dataset.shape = shape;
modeRow.children.push(button);
}
make('draw-label-row');
make('draw-label-input', 'INPUT');
make('draw-color-select', 'SELECT');
make('draw-clear', 'BUTTON');
make('draw-hint', 'SPAN');
const body = new FakeElement('BODY');
const documentDouble = {
@@ -194,9 +175,16 @@ function fakeViewer() {
}
function fakeAnnotations() {
const calls = { annotate: [], clear: 0 };
const calls = { annotate: [], clear: 0, restyle: [], setHidden: [], frame: [], remove: [] };
const marks = [];
const listeners = new Set();
const changed = () => listeners.forEach((listener) => listener());
const find = (id) => marks.find((mark) => mark.id === id);
return {
calls,
marks,
listeners,
changed,
async annotate(specs, options) {
calls.annotate.push({ specs, options });
return { drawn: specs.length };
@@ -204,11 +192,38 @@ function fakeAnnotations() {
clear() {
calls.clear += 1;
},
list: () => marks,
onChange(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
restyle(id, change) {
calls.restyle.push([id, change]);
Object.assign(find(id), change);
changed();
return true;
},
setHidden(id, hidden) {
calls.setHidden.push([id, hidden]);
find(id).hidden = hidden;
changed();
return true;
},
frame(id) {
calls.frame.push(id);
return true;
},
remove(id) {
calls.remove.push(id);
marks.splice(marks.indexOf(find(id)), 1);
changed();
return true;
},
};
}
/** Stand up a tool against fresh doubles, with globals restored afterwards. */
function harness() {
function harness({ navigation = null } = {}) {
resetPointerOwnership();
const { byId, documentDouble } = fakeDom();
const viewer = fakeViewer();
@@ -217,14 +232,30 @@ function harness() {
const previousWindow = globalThis.window;
globalThis.document = documentDouble;
globalThis.window = globalThis.window || {};
const tool = initDrawTool({ viewer, annotations });
const shown = { view: null };
// The controls' actions, as the display store holds them.
const controls = { actions: null };
const tool = initDrawTool({
viewer,
annotations,
navigation,
publish: (view) => (shown.view = view),
connect: (actions) => {
controls.actions = actions;
return () => (controls.actions = null);
},
});
return {
tool,
shown,
viewer,
annotations,
byId,
documentDouble,
element: (id) => byId.get(id),
controls,
/** A control's change, as the view sends it. */
act: (name, ...args) => controls.actions?.[name]?.(...args),
restore() {
resetPointerOwnership();
if (previousDocument === undefined) delete globalThis.document;
@@ -244,11 +275,11 @@ test('turning Draw on takes a lease and turning it off gives it back', async ()
const h = harness();
try {
assert.equal(isPointerFree(), true);
h.element('draw-toggle').emit('click');
h.act('toggleDraw');
assert.equal(pointerOwner(), 'draw', 'the session owns the pointer');
assert.equal(h.tool.active, true);
h.element('draw-toggle').emit('click');
h.act('toggleDraw');
assert.equal(isPointerFree(), true, 'leaving draw mode frees the pointer');
assert.equal(h.tool.active, false);
} finally {
@@ -260,14 +291,18 @@ test('Draw refuses to start when something else holds the pointer', async () =>
const h = harness();
try {
const other = claimPointer('directions');
h.element('draw-toggle').emit('click');
h.act('toggleDraw');
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.shown.view.hint,
'Directions is using the pointer — close it first.',
);
assert.equal(h.tool.diagnostics().sceneHandler, false, 'no handler was bound');
releasePointer(other);
h.element('draw-toggle').emit('click');
h.act('toggleDraw');
assert.equal(h.tool.active, true, 'and it starts once the pointer is free');
} finally {
h.restore();
@@ -277,13 +312,13 @@ test('Draw refuses to start when something else holds the pointer', async () =>
test('a destroyed instance cannot free the pointer its replacement holds', async () => {
const first = harness();
try {
first.element('draw-toggle').emit('click');
first.act('toggleDraw');
assert.equal(pointerOwner(), 'draw');
// The replacement takes over before the old one finishes tearing down.
first.tool.setActive(false);
const second = harness();
try {
second.element('draw-toggle').emit('click');
second.act('toggleDraw');
assert.equal(pointerOwner(), 'draw');
await first.tool.destroy();
assert.equal(
@@ -308,7 +343,7 @@ test('both stock viewer click actions are borrowed for the session and given bac
h.viewer.screenSpaceEventHandler.setInputAction(stockClick, LEFT_CLICK);
h.viewer.screenSpaceEventHandler.setInputAction(stockDouble, LEFT_DOUBLE_CLICK);
h.element('draw-toggle').emit('click');
h.act('toggleDraw');
assert.equal(
h.viewer.screenSpaceEventHandler.getInputAction(LEFT_CLICK),
undefined,
@@ -319,7 +354,7 @@ test('both stock viewer click actions are borrowed for the session and given bac
undefined,
);
h.element('draw-toggle').emit('click');
h.act('toggleDraw');
assert.equal(
h.viewer.screenSpaceEventHandler.getInputAction(LEFT_CLICK),
stockClick,
@@ -337,7 +372,7 @@ test('both stock viewer click actions are borrowed for the session and given bac
test('Enter finishes from the canvas and the label field, never from a focused button', async () => {
const h = harness();
try {
h.element('draw-toggle').emit('click');
h.act('toggleDraw');
h.tool.setShape('area');
h.tool.addVertex(-97.75, 30.26);
h.tool.addVertex(-97.74, 30.26);
@@ -367,7 +402,7 @@ test('Enter finishes from the canvas and the label field, never from a focused b
test('the live preview hangs at the height the click landed on', async () => {
const h = harness();
try {
h.element('draw-toggle').emit('click');
h.act('toggleDraw');
h.tool.setShape('line');
h.tool.addVertex(-97.75, 30.26, 187.5);
h.tool.addVertex(-97.74, 30.26, 212.25);
@@ -421,10 +456,11 @@ test('destroy releases the pointer, the handler, the listeners and the stock act
try {
const stockClick = () => 'viewer picks and selects';
h.viewer.screenSpaceEventHandler.setInputAction(stockClick, LEFT_CLICK);
h.element('draw-toggle').emit('click');
h.act('toggleDraw');
h.tool.addVertex(-97.75, 30.26);
assert.equal(pointerOwner(), 'draw');
assert.ok(h.tool.diagnostics().domListeners > 0);
assert.equal(h.annotations.listeners.size, 1, 'the rows follow the board');
await h.tool.destroy();
await Promise.resolve();
@@ -435,13 +471,14 @@ test('destroy releases the pointer, the handler, the listeners and the stock act
assert.equal(after.sceneHandler, false, 'the Cesium handler is gone');
assert.equal(after.domListeners, 0, 'every DOM listener came off');
assert.equal(h.documentDouble.listeners.length, 0, 'including the keydown');
assert.equal(h.element('draw-toggle').listeners.length, 0);
assert.equal(h.controls.actions, null, 'and the controls are disconnected');
assert.equal(
h.viewer.screenSpaceEventHandler.getInputAction(LEFT_CLICK),
stockClick,
'the viewer got its selecting click back',
);
assert.equal(h.viewer._attached.length, 0, 'the preview data source is detached');
assert.equal(h.annotations.listeners.size, 0, 'and it no longer follows the board');
// Idempotent: a second destroy is harmless.
await h.tool.destroy();
@@ -453,15 +490,145 @@ test('destroy releases the pointer, the handler, the listeners and the stock act
test('Clear wipes the board and the shape in progress', async () => {
const h = harness();
try {
h.element('draw-toggle').emit('click');
h.act('toggleDraw');
h.tool.addVertex(-97.75, 30.26);
h.tool.addVertex(-97.74, 30.26);
assert.equal(h.tool.session.vertices.length, 2);
h.element('draw-clear').emit('click');
h.act('clearDraw');
assert.equal(h.annotations.calls.clear, 1, 'placed marks are removed');
assert.equal(h.tool.session.vertices.length, 0, 'and so is the shape in progress');
} finally {
h.restore();
}
});
test('the controls choose the shape and colour the next shape is drawn with; it is named in its row', async () => {
const h = harness();
try {
h.act('toggleDraw');
h.act('setDrawShape', 'line');
h.act('setDrawColor', 'amber');
assert.deepEqual(
{ ...h.shown.view, hint: undefined },
{ active: true, shape: 'line', color: 'amber', hint: undefined, marks: [] },
'the view shows what the controls chose',
);
h.act('setDrawColor', 'purple');
assert.equal(h.shown.view.color, 'primary', 'an unknown colour falls back');
h.act('setDrawColor', 'cyan');
h.tool.addVertex(-97.75, 30.26);
// Choosing a shape again starts the open one over.
h.act('setDrawShape', 'line');
assert.equal(h.tool.session.vertices.length, 0);
h.tool.addVertex(-97.75, 30.26);
h.tool.addVertex(-97.74, 30.26);
h.documentDouble.emit('keydown', { key: 'Enter', target: h.documentDouble.body });
await Promise.resolve();
await Promise.resolve();
const spec = h.annotations.calls.annotate[0].specs[0];
assert.equal(spec.type, 'route', 'a drawn line is a manual route');
assert.equal(spec.color, 'cyan');
assert.ok(!spec.label, 'a shape is named afterwards, in its row');
} finally {
h.restore();
}
});
test('each drawn shape is a row whose actions reach the board, and fly-to is a navigation', async () => {
const navigations = [];
const h = harness({
navigation: {
runImmediateNavigation: (noun, navigate) => {
navigations.push(noun);
return navigate();
},
},
});
try {
const board = h.annotations;
board.marks.push(
{ id: 'a1', type: 'route', manual: true, name: null, label: '950 m', color: 'amber', hidden: false },
{ id: 'a2', type: 'pin', manual: false, label: 'Voice pin', color: 'primary', hidden: false },
);
board.changed();
assert.deepEqual(
h.shown.view.marks.map(({ id, placeholder, hidden }) => ({ id, placeholder, hidden })),
[{ id: 'a1', placeholder: 'Line · 950 m', hidden: false }],
'only hand-drawn marks are listed, an unnamed line by its length',
);
h.act('renameDrawMark', 'a1', 'Ridge walk');
h.act('recolorDrawMark', 'a1', 'green');
h.act('toggleDrawMark', 'a1');
h.act('flyToDrawMark', 'a1');
h.act('removeDrawMark', 'a1');
assert.deepEqual(board.calls.restyle, [
['a1', { name: 'Ridge walk' }],
['a1', { color: 'green' }],
]);
assert.deepEqual(board.calls.setHidden, [['a1', true]]);
assert.deepEqual(board.calls.frame, ['a1']);
assert.deepEqual(navigations, ['drawing'], 'fly-to goes through the navigation policy');
assert.deepEqual(board.calls.remove, ['a1']);
assert.deepEqual(h.shown.view.marks, [], 'the row goes with its mark');
} finally {
h.restore();
}
});
test('Backspace on the page removes the last vertex', () => {
const h = harness();
try {
h.act('toggleDraw');
h.tool.addVertex(-97.75, 30.26);
h.tool.addVertex(-97.74, 30.26);
h.documentDouble.emit('keydown', { key: 'Backspace', target: h.documentDouble.body });
assert.equal(h.tool.session.vertices.length, 1);
} finally {
h.restore();
}
});
test('keys on the shape list are the list\'s, not the drawing\'s', async () => {
const h = harness();
try {
const listButton = new FakeElement('BUTTON');
listButton.closest = (selector) => (selector === '#draw-mark-list' ? {} : null);
const elsewhere = new FakeElement('INPUT', 'location-search');
h.act('toggleDraw');
h.tool.addVertex(-97.75, 30.26);
h.tool.addVertex(-97.74, 30.26);
h.documentDouble.emit('keydown', { key: 'Escape', target: listButton });
assert.equal(h.tool.session.vertices.length, 2, 'Escape in the list keeps the shape');
assert.equal(h.tool.active, true, 'and keeps Draw on');
h.documentDouble.emit('keydown', { key: 'Enter', target: elsewhere });
await Promise.resolve();
assert.equal(h.annotations.calls.annotate.length, 0, 'another field keeps its Enter');
h.documentDouble.emit('keydown', { key: 'Escape', target: h.documentDouble.body });
assert.equal(h.tool.session.vertices.length, 0, 'Escape on the page drops the open shape');
} finally {
h.restore();
}
});
test('a page without the Draw controls gets no draw tool', () => {
resetPointerOwnership();
const previousDocument = globalThis.document;
globalThis.document = { getElementById: () => null };
try {
let connected = false;
const tool = initDrawTool({
viewer: fakeViewer(),
annotations: fakeAnnotations(),
connect: () => {
connected = true;
return () => {};
},
});
assert.equal(tool, null);
assert.equal(connected, false);
} finally {
if (previousDocument === undefined) delete globalThis.document;
else globalThis.document = previousDocument;
}
});
+146 -10
View File
@@ -1,3 +1,4 @@
import { recordWorkSlice } from '../sources/phaseTiming.js';
import { createWorldAnnotationRenderer } from './worldAnnotationRenderer.js';
import { createScreenAnnotationRenderer } from './screenAnnotationRenderer.js';
@@ -16,15 +17,19 @@ import { createScreenAnnotationRenderer } from './screenAnnotationRenderer.js';
* label-less) so every caption shares one consistent style.
*
* The hybrid surface honors add/update/remove/sync/destroy; update coordinates the
* one route change that spans both sub-renderers.
* one route change that spans both sub-renderers. `screen` passes options to the
* screen renderer (its store, in tests).
*/
export function createHybridAnnotationRenderer(viewer) {
export function createHybridAnnotationRenderer(
viewer,
{ screen: screenOptions } = {},
) {
const world = createWorldAnnotationRenderer(viewer);
const screen = createScreenAnnotationRenderer(viewer);
const screen = createScreenAnnotationRenderer(viewer, screenOptions);
// anno.id -> { worldProxy?, screenProxy? } — proxies own the sub-renderer state.
const routed = new Map();
function add(anno) {
function add(anno, options = {}) {
const entry = {};
// Route the entry FIRST, then fill it in. A sub-renderer throw (WebGL loss
// mid-add) otherwise stranded whatever the earlier sub-renderer had already
@@ -36,7 +41,20 @@ export function createHybridAnnotationRenderer(viewer) {
if (anno.type === 'area' && anno.ring && anno.ring.length >= 3) {
// Drape the footprint in world space, but without its own label...
entry.worldProxy = liveProxy(anno, { label: null });
world.add(entry.worldProxy);
const completion = world.add(
entry.worldProxy,
worldOptions(anno, entry, options),
);
if (completion?.then) {
if (options.pendingVisible !== false) {
entry.screenProxy = liveProxy(anno, {
ring: null,
pendingOutline: true,
});
screen.add(entry.screenProxy);
}
return finishBuilding(anno, entry, completion, options);
}
// ...and place the caption as a screen-space callout at the centroid.
if (anno.label) {
entry.screenProxy = liveProxy(anno, { type: 'label', ring: null });
@@ -71,27 +89,45 @@ export function createHybridAnnotationRenderer(viewer) {
* World geometry is new, but the reticle's SVG group is converted in place to
* the centroid callout so there is no overlapping fade-out/fade-in pair.
*/
function update(anno) {
function update(anno, options = {}) {
const entry = routed.get(anno.id);
if (!entry) {
add(anno);
return;
return add(anno, options);
}
if (anno.type !== 'area' || !anno.ring || anno.ring.length < 3) {
if (anno.outlineUnavailable && entry.screenProxy)
if (anno.outlineUnavailable && entry.screenProxy) {
entry.screenProxy = liveProxy(anno, { ring: null });
screen.update(entry.screenProxy);
}
return;
}
if (!entry.worldProxy) {
entry.worldProxy = liveProxy(anno, { label: null });
world.add(entry.worldProxy);
const completion = world.add(
entry.worldProxy,
worldOptions(anno, entry, options),
);
if (completion?.then) {
if (entry.screenProxy) {
const pending = liveProxy(anno, { ring: null, pendingOutline: true });
screen.update(pending);
entry.screenProxy = pending;
}
return finishBuilding(anno, entry, completion, options);
}
}
updateCaption(anno, entry);
}
function updateCaption(anno, entry) {
const screenProxy = liveProxy(anno, { type: 'label', ring: null });
if (entry.screenProxy) {
screen.update(screenProxy, { empty: !anno.label });
} else if (anno.label) {
// Publish before add: a partially created caption must remain removable.
entry.screenProxy = screenProxy;
screen.add(screenProxy);
}
entry.screenProxy = entry.screenProxy
@@ -101,6 +137,106 @@ export function createHybridAnnotationRenderer(viewer) {
: null;
}
function finishBuilding(anno, entry, completion, options) {
const workTrace = options.workTrace;
const proxy = entry.worldProxy;
return completion
.then((result) => {
const workStart = workTrace ? performance.now() : 0;
try {
if (
routed.get(anno.id) !== entry ||
entry.worldProxy !== proxy ||
options.signal?.aborted ||
!(options.isCurrent?.() ?? true)
) {
world.remove(proxy);
if (routed.get(anno.id) === entry) remove(anno);
if (workTrace) workTrace.rendererStatus = 'cancelled';
return { ...result, status: 'cancelled' };
}
if (result.status !== 'installed') {
world.remove(proxy);
entry.worldProxy = null;
if (workTrace) workTrace.rendererStatus = result.status;
return result;
}
if (performance.now() >= result.timing.deadlineMs) {
world.remove(proxy);
entry.worldProxy = null;
if (workTrace) workTrace.rendererStatus = 'timeout';
return { ...result, status: 'timeout' };
}
const at = performance.now();
try {
updateCaption(anno, entry);
} finally {
const ms = performance.now() - at;
result.timing.workMs += ms;
result.timing.lastSliceMs += ms;
result.timing.maxSliceMs = Math.max(
result.timing.maxSliceMs,
result.timing.lastSliceMs,
);
}
if (performance.now() >= result.timing.deadlineMs) {
world.remove(proxy);
entry.worldProxy = null;
if (options.pendingVisible === false) {
if (entry.screenProxy) screen.remove(entry.screenProxy);
entry.screenProxy = null;
} else {
// The caption conversion already reshaped the group; redraw the pending point.
const pending = liveProxy(anno, {
ring: null,
pendingOutline: true,
});
if (entry.screenProxy) screen.remove(entry.screenProxy);
entry.screenProxy = pending;
screen.add(pending);
}
if (workTrace) workTrace.rendererStatus = 'timeout';
return { ...result, status: 'timeout' };
}
if (workTrace) workTrace.rendererStatus = result.status;
return result;
} finally {
if (workTrace)
recordWorkSlice(workTrace, 'hybrid-complete', workStart);
}
})
.catch((error) => {
const workStart = workTrace ? performance.now() : 0;
try {
// A later caption throw must roll back the already installed world parts too.
if (routed.get(anno.id) === entry) {
try {
remove(anno);
} catch {
/* original error wins; failed routes stay addressable */
}
} else world.remove(proxy);
throw error;
} finally {
if (workTrace) {
workTrace.rendererStatus = 'failed';
recordWorkSlice(workTrace, 'hybrid-rollback', workStart);
}
}
});
}
function worldOptions(anno, entry, options) {
const proxy = entry.worldProxy;
return {
...options,
isCurrent: () =>
routed.get(anno.id) === entry &&
entry.worldProxy === proxy &&
(options.isCurrent?.() ?? true),
};
}
/**
* Release both routes. Tolerates PARTIAL state (only one sub-renderer got
* its content) and an absent id, so it doubles as the rollback path for a
+459 -174
View File
@@ -1,143 +1,25 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createPinia } from 'pinia';
import { useScreenAnnotationsStore } from '../ui/stores/screenAnnotations.js';
import { createHybridAnnotationRenderer } from './hybridAnnotationRenderer.js';
import { showOsmCredit, hideOsmCredit } from '../data/dataCredits.js';
class FakeClassList {
constructor(owner) {
this.owner = owner;
this.names = new Set();
}
reset(value) {
this.names = new Set(String(value || '').split(/\s+/).filter(Boolean));
}
add(...names) {
for (const name of names) this.names.add(name);
this.owner.attributes.set('class', Array.from(this.names).join(' '));
}
remove(...names) {
for (const name of names) this.names.delete(name);
this.owner.attributes.set('class', Array.from(this.names).join(' '));
}
contains(name) {
return this.names.has(name);
}
/** A hybrid renderer whose screen board is presented to its own store. */
function hybridWithBoard(viewer) {
const store = useScreenAnnotationsStore(createPinia());
const renderer = createHybridAnnotationRenderer(viewer, { screen: { store } });
return { store, renderer };
}
class FakeElement {
constructor(tagName) {
this.tagName = tagName;
this.attributes = new Map();
this.children = [];
this.parentNode = null;
this.style = {};
this.classList = new FakeClassList(this);
this.textContent = '';
}
setAttribute(name, value) {
this.attributes.set(name, String(value));
if (name === 'class') this.classList.reset(value);
}
getAttribute(name) {
return this.attributes.get(name) ?? null;
}
set className(value) {
this.setAttribute('class', value);
}
get className() {
return this.getAttribute('class') || '';
}
set id(value) {
this.setAttribute('id', value);
}
get id() {
return this.getAttribute('id') || '';
}
appendChild(child) {
child.parentNode = this;
this.children.push(child);
return child;
}
removeChild(child) {
const index = this.children.indexOf(child);
if (index >= 0) this.children.splice(index, 1);
child.parentNode = null;
return child;
}
remove() {
this.parentNode?.removeChild(this);
}
querySelector(selector) {
return this.querySelectorAll(selector)[0] || null;
}
querySelectorAll(selector) {
const className = selector.startsWith('.') ? selector.slice(1) : null;
const matches = [];
const visit = (node) => {
for (const child of node.children) {
if (className && child.classList.contains(className)) matches.push(child);
visit(child);
}
};
visit(this);
return matches;
}
addEventListener() {}
getBBox() {
return { x: 0, y: 0, width: 40, height: 16 };
}
set innerHTML(value) {
this._innerHTML = value;
}
/** The screen marks the board shows, fading ones included. */
function annotationGroups(store) {
return store.board?.marks ?? [];
}
function fakeDocument() {
const head = new FakeElement('head');
const body = new FakeElement('body');
const roots = [head, body];
return {
head,
body,
createElement: (tag) => new FakeElement(tag),
createElementNS: (_namespace, tag) => new FakeElement(tag),
getElementById(id) {
let found = null;
const visit = (node) => {
if (node.getAttribute('id') === id) found = node;
for (const child of node.children) visit(child);
};
for (const root of roots) visit(root);
return found;
},
};
}
function findAnnotationGroup(document) {
const layer = document.body.children.find((child) => child.classList.contains('gev-screen-whiteboard'));
const svg = layer.children.find((child) => child.classList.contains('gev-screen-whiteboard-svg'));
return {
svg,
group: svg.children.find((child) => child.classList.contains('gev-anno')),
};
}
const childrenWithClass = (mark, name) =>
mark.children.filter((child) => child.attrs?.class?.split(' ').includes(name));
function cameraLookingAt(lon, lat) {
const target = Cesium.Cartesian3.fromDegrees(lon, lat);
@@ -184,7 +66,6 @@ function cameraLookingAt(lon, lat) {
/** Install the browser globals both renderers touch, restored after the test. */
function installBrowserGlobals(t) {
const originalDocument = globalThis.document;
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
const originalWindow = globalThis.window;
const originalBrowserConstructors = new Map([
@@ -193,13 +74,10 @@ function installBrowserGlobals(t) {
['ImageBitmap', globalThis.ImageBitmap],
['OffscreenCanvas', globalThis.OffscreenCanvas],
]);
globalThis.document = fakeDocument();
globalThis.requestAnimationFrame = (callback) => { callback(); return 1; };
globalThis.window = { setTimeout: (fn) => { fn(); return 1; } };
for (const name of originalBrowserConstructors.keys()) globalThis[name] = class {};
t.after(() => {
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
if (originalRequestAnimationFrame === undefined) delete globalThis.requestAnimationFrame;
else globalThis.requestAnimationFrame = originalRequestAnimationFrame;
if (originalWindow === undefined) delete globalThis.window;
@@ -213,19 +91,31 @@ function installBrowserGlobals(t) {
/** A viewer whose scene/camera is just enough for both sub-renderers. */
function fakeViewer(lon, lat) {
const credits = new Set();
const camera = cameraLookingAt(lon, lat);
const dataSources = [];
const frames = controlledFrames();
const scene = {
camera,
canvas: { clientWidth: 1280, clientHeight: 720, width: 1280, height: 720 },
frameState: { mode: Cesium.SceneMode.SCENE3D },
clampToHeightSupported: true,
clampToHeight: (world) => world,
postRender: { addEventListener() {}, removeEventListener() {} },
postRender: frames.event,
renderRequests: 0,
requestRender() {
scene.renderRequests += 1;
},
};
return {
dataSources,
frames,
viewer: {
credits,
creditDisplay: {
addStaticCredit: (credit) => credits.add(credit),
removeStaticCredit: (credit) => credits.delete(credit),
},
scene,
camera,
trackedEntity: null,
@@ -244,8 +134,30 @@ function fakeViewer(lon, lat) {
};
}
function annotationGroups(svg) {
return svg.children.filter((child) => child.classList.contains('gev-anno'));
function controlledFrames() {
const listeners = new Set();
return {
event: {
addEventListener(listener) { listeners.add(listener); },
removeEventListener(listener) { listeners.delete(listener); },
},
emit() { for (const listener of [...listeners]) listener(); },
snapshot() { return [...listeners]; },
get size() { return listeners.size; },
};
}
function buildingAnnotation(id, offset = 0) {
const ring = [
[-99 + offset, 31], [-98.999 + offset, 31],
[-98.999 + offset, 31.001], [-99 + offset, 31.001],
[-99 + offset, 31],
];
return {
id, type: 'area', color: 'primary', label: null, alpha: 1,
anchor: { ...naiveRingCentroid(ring), height: 100 },
ring, footprintKind: 'building', buildingHeight: 20, outlineCredit: 'tiles',
};
}
function naiveRingCentroid(ring) {
@@ -256,23 +168,23 @@ function naiveRingCentroid(ring) {
return { lon: totals.lon / ring.length, lat: totals.lat / ring.length, height: 0 };
}
test('hybrid outline upgrade preserves the screen group and adds world geometry', (t) => {
const originalDocument = globalThis.document;
test('hybrid outline upgrade preserves the screen group and adds world geometry', async (t) => {
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
const originalWindow = globalThis.window;
globalThis.window = { setTimeout: (fn) => { fn(); return 1; } };
const originalBrowserConstructors = new Map([
['HTMLCanvasElement', globalThis.HTMLCanvasElement],
['HTMLImageElement', globalThis.HTMLImageElement],
['ImageBitmap', globalThis.ImageBitmap],
['OffscreenCanvas', globalThis.OffscreenCanvas],
]);
globalThis.document = fakeDocument();
globalThis.requestAnimationFrame = (callback) => { callback(); return 1; };
for (const name of originalBrowserConstructors.keys()) globalThis[name] = class {};
t.after(() => {
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
if (originalRequestAnimationFrame === undefined) delete globalThis.requestAnimationFrame;
else globalThis.requestAnimationFrame = originalRequestAnimationFrame;
if (originalWindow === undefined) delete globalThis.window;
else globalThis.window = originalWindow;
for (const [name, original] of originalBrowserConstructors) {
if (original === undefined) delete globalThis[name];
else globalThis[name] = original;
@@ -281,13 +193,15 @@ test('hybrid outline upgrade preserves the screen group and adds world geometry'
const camera = cameraLookingAt(-99, 31);
const dataSources = [];
const frames = controlledFrames();
const scene = {
camera,
canvas: { clientWidth: 1280, clientHeight: 720, width: 1280, height: 720 },
frameState: { mode: Cesium.SceneMode.SCENE3D },
clampToHeightSupported: true,
clampToHeight: (world) => world,
postRender: { addEventListener() {}, removeEventListener() {} },
postRender: frames.event,
requestRender() {},
};
const viewer = {
scene,
@@ -305,7 +219,7 @@ test('hybrid outline upgrade preserves the screen group and adds world geometry'
},
},
};
const renderer = createHybridAnnotationRenderer(viewer);
const { store, renderer } = hybridWithBoard(viewer);
const anno = {
id: 'anno-hybrid-fb3',
type: 'area',
@@ -319,10 +233,8 @@ test('hybrid outline upgrade preserves the screen group and adds world geometry'
};
renderer.add(anno);
const before = findAnnotationGroup(globalThis.document);
const originalCallout = before.group.querySelector('.gev-anno-callout');
const originalDot = before.group.querySelector('.gev-anno-dot');
assert.equal(before.group.querySelectorAll('.gev-anno-ring').length, 2);
const [before] = annotationGroups(store);
assert.equal(childrenWithClass(before, 'gev-anno-ring').length, 2);
assert.equal(dataSources.length, 1);
assert.equal(dataSources[0].entities.values.length, 0);
@@ -339,23 +251,83 @@ test('hybrid outline upgrade preserves the screen group and adds world geometry'
anno.footprintKind = 'area';
renderer.update(anno);
const after = findAnnotationGroup(globalThis.document);
const [after, ...others] = annotationGroups(store);
const expectedWindow = Cesium.SceneTransforms.worldToWindowCoordinates(
scene,
Cesium.Cartesian3.fromDegrees(centroid.lon, centroid.lat, centroid.height),
);
assert.equal(after.group, before.group, 'the real hybrid keeps the existing SVG group');
assert.equal(after.svg.children.filter((child) => child.classList.contains('gev-anno')).length, 1);
assert.equal(after.group.querySelectorAll('.gev-anno-ring').length, 0, 'screen reticle rings are removed');
assert.equal(after.group.querySelector('.gev-anno-callout'), originalCallout, 'the callout node is retained');
assert.equal(after.group.querySelector('.gev-anno-dot'), originalDot, 'the anchor dot is retained');
assert.equal(originalDot.getAttribute('cx'), expectedWindow.x.toFixed(1));
assert.equal(originalDot.getAttribute('cy'), expectedWindow.y.toFixed(1));
assert.equal(after.key, before.key, 'the real hybrid keeps the existing screen mark');
assert.equal(others.length, 0);
assert.equal(childrenWithClass(after, 'gev-anno-ring').length, 0, 'screen reticle rings are removed');
assert.deepEqual(
after.children.map((child) => child.key),
['dot', 'leader', 'callout'],
'the dot, leader and callout are retained',
);
const dot = after.children.find((child) => child.key === 'dot');
assert.equal(dot.attrs.cx, expectedWindow.x.toFixed(1));
assert.equal(dot.attrs.cy, expectedWindow.y.toFixed(1));
assert.deepEqual(centroid, { lon: -100.8, lat: 29.4, height: 0 }, 'closed-ring vertex mean stays pinned');
assert.equal(dataSources[0].entities.values.length, 2, 'world area adds one fill and one outline');
assert.equal(dataSources[0].entities.values.filter((entity) => entity.polygon).length, 1);
assert.equal(dataSources[0].entities.values.filter((entity) => entity.polyline).length, 1);
assert.equal(Object.hasOwn(anno, '_entities'), false, 'world entity state stays on the inherited proxy');
const outer = [
[-99, 31],
[-98.999, 31],
[-98.999, 31.001],
[-99, 31.001],
[-99, 31],
];
const hole = [
[-98.9998, 31.0002],
[-98.9996, 31.0002],
[-98.9996, 31.0004],
[-98.9998, 31.0004],
[-98.9998, 31.0002],
];
const island = outer.map(([lon, lat]) => [lon + 0.002, lat]);
const building = {
...anno,
id: 'anno-multipart-building',
label: 'Building',
anchor: { lon: -99, lat: 31, height: 0 },
ring: outer,
polygons: [[outer, hole], [island]],
footprintKind: 'building',
buildingHeight: 20,
};
const completion = renderer.add(building);
assert.equal(dataSources[0].entities.values.length, 2, 'building geometry waits for all probes');
for (let frame = 0; frame < 8; frame += 1) frames.emit();
assert.equal((await completion).status, 'installed');
const added = dataSources[0].entities.values.slice(2);
const fills = added.filter(
(entity) => entity.polygon && entity.polygon.fill?.getValue() !== false,
);
assert.equal(
fills.length,
2,
'every building part gets a classification volume',
);
assert.equal(fills[0].polygon.hierarchy.getValue().holes.length, 1);
assert.equal(fills[1].polygon.hierarchy.getValue().holes.length, 0);
assert.equal(
fills[0].polygon.material,
fills[1].polygon.material,
'parts share the fill material',
);
assert.equal(
added.filter((entity) => entity.polyline).length,
3,
'all outer and hole outlines are retained',
);
renderer.remove(building);
assert.equal(
dataSources[0].entities.values.length,
2,
'multipart removal leaves only the earlier area',
);
renderer.destroy();
});
@@ -370,7 +342,7 @@ test('hybrid outline upgrade preserves the screen group and adds world geometry'
test('a sub-renderer throw mid-add leaves state the rollback can still remove', (t) => {
installBrowserGlobals(t);
const { viewer, dataSources } = fakeViewer(-99, 31);
const renderer = createHybridAnnotationRenderer(viewer);
const { store, renderer } = hybridWithBoard(viewer);
const ring = [[-106, 25], [-93, 25], [-93, 36], [-106, 36], [-106, 25]];
const anno = {
id: 'anno-partial-add',
@@ -386,20 +358,19 @@ test('a sub-renderer throw mid-add leaves state the rollback can still remove',
// The world drape lands first; the screen caption then fails the way a lost
// context does, AFTER the world geometry is already on the board.
const { svg } = findAnnotationGroup(globalThis.document);
const appendChild = svg.appendChild.bind(svg);
const publish = store.publish;
let failNextInsert = true;
svg.appendChild = (child) => {
store.publish = (board) => {
if (failNextInsert) {
failNextInsert = false;
throw new Error('screen insert failed');
}
return appendChild(child);
return publish(board);
};
assert.throws(() => renderer.add(anno), /screen insert failed/);
assert.equal(dataSources[0].entities.values.length, 2, 'the world drape is already live');
assert.equal(annotationGroups(svg).length, 0, 'the half-built screen group detached itself');
assert.equal(annotationGroups(store).length, 0, 'the half-built screen group detached itself');
// The engine's rollback path — it must reach the partial state by id.
renderer.remove(anno);
@@ -409,7 +380,7 @@ test('a sub-renderer throw mid-add leaves state the rollback can still remove',
// an orphan nothing owns.
renderer.add(anno);
assert.equal(dataSources[0].entities.values.length, 2, 'the retry draws exactly one world drape');
assert.equal(annotationGroups(svg).length, 1, 'and exactly one screen caption');
assert.equal(annotationGroups(store).length, 1, 'and exactly one screen caption');
renderer.remove(anno);
assert.equal(dataSources[0].entities.values.length, 0);
renderer.destroy();
@@ -418,7 +389,7 @@ test('a sub-renderer throw mid-add leaves state the rollback can still remove',
test('a release that throws keeps the mark addressable for a retry cleanup', (t) => {
installBrowserGlobals(t);
const { viewer, dataSources } = fakeViewer(-99, 31);
const renderer = createHybridAnnotationRenderer(viewer);
const { store, renderer } = hybridWithBoard(viewer);
const ring = [[-106, 25], [-93, 25], [-93, 36], [-106, 36], [-106, 25]];
const anno = {
id: 'anno-release-throw',
@@ -433,35 +404,34 @@ test('a release that throws keeps the mark addressable for a retry cleanup', (t)
};
renderer.add(anno);
const { svg } = findAnnotationGroup(globalThis.document);
assert.equal(dataSources[0].entities.values.length, 2);
assert.equal(annotationGroups(svg).length, 1);
assert.equal(annotationGroups(store).length, 1);
// The world route releases fine, then the screen release fails part-way
// (its fade-out timer throws) — so its SVG group is still in the document.
// (its fade-out timer throws) — so its mark is still on the board.
const workingSetTimeout = globalThis.window.setTimeout;
globalThis.window.setTimeout = () => { throw new Error('teardown scheduling failed'); };
assert.throws(() => renderer.remove(anno), /teardown scheduling failed/);
assert.equal(dataSources[0].entities.values.length, 0, 'the world route did release');
assert.equal(annotationGroups(svg).length, 1, 'the screen route did NOT — that is the orphan');
assert.equal(annotationGroups(store).length, 1, 'the screen route did NOT — that is the orphan');
// Dropping the routing entry here would make that orphan permanently
// unreachable. It must still be addressable, so a retry finishes the job.
globalThis.window.setTimeout = workingSetTimeout;
renderer.remove(anno);
assert.equal(annotationGroups(svg).length, 0, 'the retry cleanup must reach the orphan');
assert.equal(annotationGroups(store).length, 0, 'the retry cleanup must reach the orphan');
assert.equal(dataSources[0].entities.values.length, 0, 'without double-releasing the world route');
// Fully released now — a third call is inert.
renderer.remove(anno);
assert.equal(annotationGroups(svg).length, 0);
assert.equal(annotationGroups(store).length, 0);
renderer.destroy();
});
test('a world add that fails mid-way leaves its landed entities removable', (t) => {
installBrowserGlobals(t);
const { viewer, dataSources } = fakeViewer(-99, 31);
const renderer = createHybridAnnotationRenderer(viewer);
const { store, renderer } = hybridWithBoard(viewer);
const ring = [[-106, 25], [-93, 25], [-93, 36], [-106, 36], [-106, 25]];
const anno = {
id: 'anno-partial-world-add',
@@ -493,9 +463,324 @@ test('a world add that fails mid-way leaves its landed entities removable', (t)
assert.equal(entities.values.length, 0,
'the rollback must see the entities that landed before the throw');
const { svg } = findAnnotationGroup(globalThis.document);
renderer.add(anno);
assert.equal(entities.values.length, 2, 'the retry draws a complete mark');
assert.equal(annotationGroups(svg).length, 1);
assert.equal(annotationGroups(store).length, 1);
renderer.destroy();
});
function workTrace() {
return { timeOrigin: performance.timeOrigin, slices: [], dropped: 0 };
}
test('buildings share one probe per frame and preserve probe geometry, filtering and fallback', async (t) => {
installBrowserGlobals(t);
const { viewer, dataSources, frames } = fakeViewer(-99, 31);
const renderer = createHybridAnnotationRenderer(viewer);
t.after(() => renderer.destroy());
const first = buildingAnnotation('sampled-building');
const second = buildingAnnotation('fallback-building', 0.01);
second.outlineCredit = 'osm';
const otherOwner = {};
showOsmCredit(viewer, otherOwner);
const probes = [];
const outcomes = [-500, 120, 30, null, new Error('tile unavailable'), 60, 9001, 90];
viewer.scene.clampToHeight = (position) => {
const cartographic = Cesium.Cartographic.fromCartesian(position);
const lon = Cesium.Math.toDegrees(cartographic.longitude);
const lat = Cesium.Math.toDegrees(cartographic.latitude);
probes.push([lon, lat]);
const outcome = outcomes[probes.length - 1];
if (outcome instanceof Error) throw outcome;
return outcome == null ? undefined : Cesium.Cartesian3.fromDegrees(lon, lat, outcome);
};
// Suppress screen points: these counts concern the eight outside-building
// probes, not the screen renderer's separate anchor-height sampling.
const firstTrace = workTrace();
const secondTrace = workTrace();
let diagnosticWriteFaults = 0;
Object.defineProperty(secondTrace, 'groundDetailDropped', {
get: () => 0,
set() {
diagnosticWriteFaults += 1;
throw new Error('diagnostic sink unavailable');
},
enumerable: true,
});
const firstCompletion = renderer.add(first, { pendingVisible: false, workTrace: firstTrace });
const secondCompletion = renderer.add(second, { pendingVisible: false, workTrace: secondTrace });
assert.equal(probes.length, 0, 'enqueue must not probe synchronously');
assert.equal(dataSources[0].entities.values.length, 0);
assert.equal([...viewer.credits].filter(c => c.showOnScreen).length, 1,
'pending outlines add no tile credit to the other owner');
for (let frame = 0; frame < 16; frame += 1) {
const before = probes.length;
frames.emit();
assert.equal(probes.length, before + 1, 'the two buildings share one total probe budget');
if (frame < 7) assert.equal(dataSources[0].entities.values.length, 0, 'no partial world parts');
}
const [installed, fallback] = await Promise.all([firstCompletion, secondCompletion]);
assert.equal(installed.status, 'installed');
assert.equal(fallback.status, 'installed');
assert.equal([...viewer.credits].filter(c => c.showOnScreen).length, 2);
assert.equal(installed.timing.probes, 8);
assert.equal(fallback.timing.probes, 8);
assert.ok(diagnosticWriteFaults > 0, 'the diagnostic failure was exercised');
for (const trace of [firstTrace, secondTrace]) {
assert.equal(trace.probes, 8);
assert.equal(trace.samplerStatus, 'installed');
assert.equal(trace.rendererStatus, 'installed');
assert.equal(trace.slices.filter(s => s.phase === 'ground-step').length, 8);
assert.equal(trace.slices.filter(s => s.phase === 'hybrid-complete').length, 1);
assert.equal(trace.dropped, 0);
assert.ok(trace.slices.every(s => Number.isFinite(s.start) && Number.isFinite(s.end) && s.end >= s.start));
assert.equal(trace.timeOrigin, performance.timeOrigin);
const parents = trace.slices.filter(s => s.phase === 'ground-step');
for (const [index, parent] of parents.entries()) {
const detail = parent.detail;
assert.equal(detail.index, index);
assert.equal(detail.status, trace === firstTrace ? 'recorded' : 'missing');
assert.equal(detail.clampResult,
trace === firstTrace && index === 4 ? 'threw' : 'returned');
assert.ok(Number.isFinite(detail.clampStart) && Number.isFinite(detail.clampEnd));
assert.ok(parent.start <= detail.clampStart && detail.clampStart <= detail.clampEnd && detail.clampEnd <= parent.end);
if (index === 7) {
assert.equal(detail.commitStatus, 'attempted');
assert.ok(Number.isFinite(detail.commitStart) && Number.isFinite(detail.commitEnd));
assert.ok(detail.clampEnd <= detail.commitStart && detail.commitStart <= detail.commitEnd && detail.commitEnd <= parent.end);
} else {
assert.equal(detail.commitStatus, 'not-run');
assert.equal(detail.commitStart, null);
assert.equal(detail.commitEnd, null);
}
}
}
assert.notEqual(firstTrace.slices, secondTrace.slices);
assert.ok(firstTrace.slices.filter(s => s.phase === 'ground-step').at(-1).end <= secondTrace.slices.find(s => s.phase === 'ground-step').start, 'each queued task records only its own sampling frames');
const centroid = naiveRingCentroid(first.ring);
const lonScale = 111320 * Math.cos(Cesium.Math.toRadians(centroid.lat));
const radius = Math.max(...first.ring.map(([lon, lat]) =>
Math.hypot((lon - centroid.lon) * lonScale, (lat - centroid.lat) * 111320)));
const distance = radius * 1.5 + 12;
const directions = [[1, 0], [-1, 0], [0, 1], [0, -1], [0.7, 0.7], [-0.7, -0.7], [0.7, -0.7], [-0.7, 0.7]];
for (let index = 0; index < 8; index += 1) {
const [dx, dy] = directions[index];
assert.ok(Math.abs(probes[index][0] - (centroid.lon + dx * distance / lonScale)) < 1e-10);
assert.ok(Math.abs(probes[index][1] - (centroid.lat + dy * distance / 111320)) < 1e-10);
assert.ok(Math.abs(probes[index + 8][0] - probes[index][0] - 0.01) < 1e-10);
assert.ok(Math.abs(probes[index + 8][1] - probes[index][1]) < 1e-10);
}
assert.equal(new Set(probes.slice(0, 8).map((point) => point.join(','))).size, 8);
const fills = dataSources[0].entities.values.filter((entity) => entity.polygon?.fill?.getValue() !== false && entity.polygon);
assert.equal(fills.length, 2);
assert.ok(Math.abs(fills[0].polygon.height.getValue() - 57) < 1e-6, 'four accepted samples select floor(4 * 0.3), height 60 minus 3');
assert.ok(Math.abs(fills[1].polygon.height.getValue() - 77) < 1e-6, 'all unavailable probes retain anchor minus building height fallback');
const afterCompletion = probes.length;
frames.emit();
assert.equal(probes.length, afterCompletion, 'completion releases the sampler');
renderer.remove(first);
assert.equal([...viewer.credits].filter(c => c.showOnScreen).length, 1,
'removing the last tile proxy retains the OSM building and other owner');
renderer.remove(second);
assert.equal([...viewer.credits].filter(c => c.showOnScreen).length, 1);
hideOsmCredit(viewer, otherOwner);
assert.equal(viewer.credits.size, 0);
});
test('a building queued behind others gets its full budget on a slow device', async (t) => {
installBrowserGlobals(t);
t.mock.timers.enable({ apis: ['setTimeout'] });
t.after(() => t.mock.timers.reset());
// The renderers' per-frame budget checks read performance.now(); it follows
// the same mocked clock as the timers.
let now = 0;
t.mock.method(globalThis.performance, 'now', () => now);
const { viewer, frames } = fakeViewer(-99, 31);
const renderer = createHybridAnnotationRenderer(viewer);
viewer.scene.clampToHeight = (position) => position;
const traces = [workTrace(), workTrace(), workTrace()];
const completions = [0, 0.01, 0.02].map((offset, index) =>
renderer.add(buildingAnnotation(`queued-${index}`, offset), {
pendingVisible: false,
workTrace: traces[index],
}));
// Eight probes per building at one probe per frame, at about 5 fps: each
// building samples for 1.5 s, so the third waits 3 s in line. Pending
// promise work runs between frames, as it does in a browser.
for (let building = 0; building < 3; building += 1) {
for (let frame = 0; frame < 8; frame += 1) {
now += 187.5;
t.mock.timers.tick(187.5);
frames.emit();
await new Promise((resolve) => setImmediate(resolve));
}
}
const results = await Promise.all(completions);
assert.deepEqual(results.map((result) => result.status), ['installed', 'installed', 'installed']);
assert.deepEqual(traces.map((trace) => trace.samplerStatus), ['installed', 'installed', 'installed']);
assert.ok(traces[2].samplerHeadAt - traces[2].samplerStart >= 2000, 'the third waited longer than one budget');
assert.ok(traces[2].samplerHeadAt >= traces[1].samplerHeadAt);
});
test('building cancellation, queued deadline and destroy prevent saved callbacks from committing', async (t) => {
installBrowserGlobals(t);
t.mock.timers.enable({ apis: ['setTimeout'] });
t.after(() => t.mock.timers.reset());
const { viewer, dataSources, frames } = fakeViewer(-99, 31);
const renderer = createHybridAnnotationRenderer(viewer);
let probeCount = 0;
viewer.scene.clampToHeight = (position) => { probeCount += 1; return position; };
const cancelled = buildingAnnotation('cancelled-building');
const cancelledTrace = workTrace();
const cancelledCompletion = renderer.add(cancelled, { pendingVisible: false, workTrace: cancelledTrace });
const savedCancelled = frames.snapshot();
renderer.remove(cancelled);
assert.equal((await cancelledCompletion).status, 'cancelled');
assert.equal(cancelledTrace.samplerStatus, 'cancelled');
assert.equal(cancelledTrace.rendererStatus, 'cancelled');
assert.equal(cancelledTrace.slices.filter(s => s.phase === 'ground-step').length, 0);
for (const listener of savedCancelled) listener();
assert.equal(probeCount, 0);
assert.equal(viewer.credits.size, 0);
const head = buildingAnnotation('deadline-head');
const tail = buildingAnnotation('deadline-tail', 0.01);
const headCompletion = renderer.add(head, { pendingVisible: false });
const tailTrace = workTrace();
const tailCompletion = renderer.add(tail, { pendingVisible: false, workTrace: tailTrace });
frames.emit();
assert.equal(probeCount, 1);
const savedTimeout = frames.snapshot();
// No further renderer frames arrive: the head's deadline fires, then the
// tail gets its own budget from the moment it reaches the head and still
// times out without frames, even though it never had a chance to sample.
const rendersBefore = viewer.scene.renderRequests;
t.mock.timers.tick(2000);
assert.equal((await headCompletion).status, 'timeout');
assert.ok(viewer.scene.renderRequests > rendersBefore, 'a promoted tail asks for a frame');
assert.equal(tailTrace.samplerStatus, undefined, 'queue wait does not spend the tail budget');
assert.ok(Number.isFinite(tailTrace.samplerHeadAt), 'the tail starts its own budget at the head');
t.mock.timers.tick(2000);
const tailResult = await tailCompletion;
assert.equal(tailResult.status, 'timeout');
assert.equal(tailResult.timing.probes, 0);
assert.equal(tailTrace.samplerStatus, 'timeout');
assert.equal(tailTrace.rendererStatus, 'timeout');
assert.equal(tailTrace.slices.filter(s => s.phase === 'ground-step').length, 0);
assert.equal(tailTrace.slices.filter(s => s.phase === 'hybrid-complete').length, 1);
assert.equal(viewer.credits.size, 0);
for (const listener of savedTimeout) listener();
assert.equal(probeCount, 1, 'saved callbacks cannot revive timed-out tasks');
assert.equal(dataSources[0].entities.values.length, 0);
const pending = buildingAnnotation('destroyed-building');
const destruction = renderer.add(pending, { pendingVisible: false });
const savedDestroy = frames.snapshot();
renderer.destroy();
assert.equal((await destruction).status, 'cancelled');
for (const listener of savedDestroy) listener();
t.mock.timers.tick(2000);
assert.equal(probeCount, 1);
assert.equal(dataSources.length, 0, 'destroy removes the owned data source');
assert.equal(frames.size, 0, 'destroy removes frame listeners');
assert.equal(viewer.credits.size, 0);
});
test('a timed-out building upgrade keeps its existing pending screen group', async (t) => {
installBrowserGlobals(t);
t.mock.timers.enable({ apis: ['setTimeout'] });
t.after(() => t.mock.timers.reset());
const { viewer, dataSources, frames } = fakeViewer(-99, 31);
const { store, renderer } = hybridWithBoard(viewer);
const anno = { ...buildingAnnotation('pending-upgrade'), ring: null, label: 'Building' };
renderer.add(anno);
const [before] = annotationGroups(store);
anno.ring = buildingAnnotation('geometry').ring;
const completion = renderer.update(anno);
t.mock.timers.tick(2000);
assert.equal((await completion).status, 'timeout');
const [after, ...others] = annotationGroups(store);
assert.equal(after.key, before.key);
assert.equal(others.length, 0);
assert.ok(childrenWithClass(after, 'gev-anno-ring').length > 0, 'the honest pending point is not converted');
assert.equal(dataSources[0].entities.values.length, 0);
frames.emit();
assert.equal(dataSources[0].entities.values.length, 0);
renderer.destroy();
});
test('an asynchronous caption-conversion throw rolls back multipart world geometry and remains removable', async (t) => {
installBrowserGlobals(t);
const { viewer, dataSources, frames } = fakeViewer(-99, 31);
const { store, renderer } = hybridWithBoard(viewer);
const anno = { ...buildingAnnotation('caption-failure'), ring: null, label: 'Building' };
renderer.add(anno);
anno.ring = buildingAnnotation('geometry').ring;
const hole = anno.ring.map(([lon, lat]) => [lon + 0.0002, lat + 0.0002]);
const island = anno.ring.map(([lon, lat]) => [lon + 0.002, lat]);
anno.polygons = [[anno.ring, hole], [island]];
const captionTrace = workTrace();
const completion = renderer.update(anno, { workTrace: captionTrace });
// Frame projection is still ordinary pending-point work. Arm the failure
// only after sampling completes, before its promise converts the caption.
for (let frame = 0; frame < 8; frame += 1) frames.emit();
assert.ok(dataSources[0].entities.values.length > 0, 'multipart world geometry landed before caption conversion');
assert.equal(viewer.credits.size, 2, 'credit belongs to the committed world proxy');
// The caption conversion fails when it presents the converted mark.
const publish = store.publish;
const failure = new Error('caption conversion failed');
let armed = true;
store.publish = (board) => {
if (armed) {
armed = false;
throw failure;
}
return publish(board);
};
// Also fail screen teardown once: the routing entry must survive this second
// failure so explicit cleanup can still reach the partial screen state.
const workingSetTimeout = globalThis.window.setTimeout;
globalThis.window.setTimeout = () => { throw new Error('teardown scheduling failed'); };
const rejected = assert.rejects(completion, (error) => error === failure);
await rejected;
assert.equal(captionTrace.rendererStatus, 'failed');
assert.equal(captionTrace.slices.filter(s => s.phase === 'ground-step').length, 8);
assert.equal(captionTrace.slices.filter(s => s.phase === 'hybrid-complete').length, 1);
assert.equal(captionTrace.slices.filter(s => s.phase === 'hybrid-rollback').length, 1);
assert.equal(dataSources[0].entities.values.length, 0, 'all classification, cage and outline parts roll back');
assert.equal(viewer.credits.size, 0, 'caption rollback releases that same proxy');
assert.equal(annotationGroups(store).length, 1, 'failed screen cleanup remains addressable');
globalThis.window.setTimeout = workingSetTimeout;
store.publish = publish;
renderer.remove(anno);
assert.equal(annotationGroups(store).length, 0);
renderer.remove(anno);
renderer.destroy();
});
test('a deadline passed during caption conversion leaves a drawn pending point', async (t) => {
installBrowserGlobals(t);
let now = 0;
t.mock.method(globalThis.performance, 'now', () => now);
const { viewer, frames } = fakeViewer(-99, 31);
const { store, renderer } = hybridWithBoard(viewer);
// Unlabeled: the conversion empties the screen group rather than relabelling it.
const anno = { ...buildingAnnotation('late-timeout'), ring: null };
renderer.add(anno);
anno.ring = buildingAnnotation('geometry').ring;
const completion = renderer.update(anno);
for (let frame = 0; frame < 8; frame += 1) frames.emit();
// The clock passes the deadline while the conversion presents the board.
const publish = store.publish;
store.publish = (board) => {
now = 1e9;
return publish(board);
};
assert.equal((await completion).status, 'timeout');
store.publish = publish;
const live = annotationGroups(store).filter((mark) => !mark.className?.includes('gev-out'));
assert.equal(live.length, 1);
assert.ok(childrenWithClass(live[0], 'gev-anno-ring').length > 0, 'the pending point still draws');
renderer.remove(anno);
renderer.destroy();
});
+164
View File
@@ -0,0 +1,164 @@
// Multipart outline identity and street highlights in the annotation engine.
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createAnnotationEngine,
promoteStreetHighlight,
} from './annotationEngine.js';
function installAnimationFrameStubs(t) {
const raf = globalThis.requestAnimationFrame;
const caf = globalThis.cancelAnimationFrame;
globalThis.requestAnimationFrame = () => 1;
globalThis.cancelAnimationFrame = () => {};
t.after(() => {
if (raf === undefined) delete globalThis.requestAnimationFrame;
else globalThis.requestAnimationFrame = raf;
if (caf === undefined) delete globalThis.cancelAnimationFrame;
else globalThis.cancelAnimationFrame = caf;
});
}
const renderer = { add() {}, update() {}, remove() {}, sync() {} };
const outer = [
[0, 0],
[0.01, 0],
[0.01, 0.01],
[0, 0.01],
[0, 0],
];
const hole = (d) => [
[0.004, 0.004],
[0.004 + d, 0.004],
[0.004 + d, 0.004 + d],
[0.004, 0.004],
];
test('outlines sharing an outer ring but not their holes stay two marks', async (t) => {
installAnimationFrameStubs(t);
const shapes = {
a: [[outer, hole(0.001)]],
b: [[outer, hole(0.002)]],
same: [[outer, hole(0.001)]],
};
const resolveTarget = async ({ target }) => ({
lon: 0.005,
lat: 0.005,
height: 0,
label: target,
source: 'fake',
ring: outer,
polygons: shapes[target],
footprintKind: 'area',
});
const engine = createAnnotationEngine({
viewer: {},
renderer,
resolveTarget,
});
await engine.annotate({ type: 'area', target: 'a', label: 'A' });
await engine.annotate({ type: 'area', target: 'b', label: 'B' });
assert.equal(engine.list().length, 2, 'different holes: different marks');
await engine.annotate({ type: 'area', target: 'same', label: 'Same' });
assert.equal(engine.list().length, 2, 'identical parts and holes: one mark');
});
test('a street highlight asks for the street outline', () => {
assert.deepEqual(
promoteStreetHighlight({ type: 'highlight', target: 'Congress Avenue' }),
{ type: 'area', target: 'Congress Avenue', entityKind: 'street' },
);
assert.equal(
promoteStreetHighlight({
type: 'highlight',
target: 'Lombard St, San Francisco',
}).type,
'area',
);
assert.equal(
promoteStreetHighlight({
type: 'highlight',
target: 'the Alamo',
entityKind: 'street',
}).type,
'area',
);
for (const spec of [
{ type: 'highlight', target: "St Peter's Basilica" },
{ type: 'highlight', target: 'Zilker Park' },
{ type: 'highlight', target: 'Congress Avenue', footprint: false },
{ type: 'highlight', target: 'Congress Avenue', entityKind: 'building' },
{ type: 'pin', target: 'Congress Avenue' },
])
assert.equal(promoteStreetHighlight(spec), spec, JSON.stringify(spec));
});
test('the engine resolves a bare street highlight with its footprint', async (t) => {
installAnimationFrameStubs(t);
const seen = [];
const resolveTarget = async (args) => {
seen.push(args);
return {
lon: -97.74,
lat: 30.27,
height: 0,
label: args.target,
source: 'fake',
ring: null,
};
};
const engine = createAnnotationEngine({
viewer: {},
renderer,
resolveTarget,
});
await engine.annotate({
type: 'highlight',
target: 'Congress Avenue',
label: 'Congress',
});
assert.equal(seen[0].footprint, true);
assert.equal(seen[0].entityKind, 'street');
});
test('a duplicate that fails to clean up never costs the new outline', async (t) => {
installAnimationFrameStubs(t);
const polygons = [[outer]];
const outline = { ring: outer, polygons, footprintKind: 'area' };
const point = { lon: 0.005, lat: 0.005, height: 0, source: 'fake' };
// "Old" draws at once; "New" resolves the same polygon later.
const resolveTarget = async ({ target }) =>
target === 'old'
? { ...point, label: target, ...outline }
: {
...point,
label: target,
resolveOutline: async () => ({ ...point, ...outline }),
};
const stuck = new Set();
const outcomes = [];
const engine = createAnnotationEngine({
viewer: {},
renderer: {
...renderer,
add(anno) {
if (anno.label === 'Old') stuck.add(anno);
},
remove(anno) {
if (stuck.has(anno)) throw new Error('renderer refused');
},
},
resolveTarget,
});
engine.onOutlineEvent((event) => outcomes.push(event));
await engine.annotate({ type: 'area', target: 'old', label: 'Old' });
await engine.annotate({ type: 'area', target: 'new', label: 'New' });
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
const fresh = engine.list().find((mark) => mark.label === 'New');
assert.equal(fresh?.footprintKind, 'area', 'the new mark keeps its outline');
assert.notEqual(fresh.outlineUnavailable, true);
assert.deepEqual(
outcomes.filter((event) => event.label === 'New').map((e) => e.status),
['resolved'],
);
});
+371
View File
@@ -0,0 +1,371 @@
import {
bufferCorridor,
ringAreaM2,
ringCentroid,
} from '../sources/featureGeometry.js';
import { isUnavailableCapability } from '../sources/capability.js';
import { containsAnchor } from '../data/adminBoundaries.js';
import { lonLatToTile } from '../data/tomtomTiles.js';
import {
BUILDING_MAX_DISTANCE_M,
GROUNDS_WORDS_RE,
OUTLINE_TILE_ZOOM,
STREET_RADIUS_M,
boxAround,
buildingFromTiles,
createOpenFreeMapOutlineSource,
enclosingAreaFromTiles,
groundsClassesFor,
streetFromTiles,
} from '../sources/openFreeMapOutlines.js';
import { createNominatimOutlineClient } from '../sources/nominatimOutlines.js';
export { containsAnchor };
/**
* Outline rungs that need no Overpass, around the resolver's existing ladder:
*
* bundled rungs (inside the base ladder)
* → OpenFreeMap tiles for streets and pointed-at buildings
* → operator Overpass, when configured (the base ladder, unchanged)
* → the server's guarded Nominatim outline (cities, landmarks, grounds,
* parks, lakes, campuses, named buildings)
* → OpenFreeMap building at the point, for a named building nobody outlined
* → the base answer: an honest point marked "outline unavailable".
*
* Every rung returns the resolver's footprint shape
* `{ ring, polygons?, kind, heightM, synthesized }` and hands it to the
* resolver's `finish`, which applies the scope caps and drift bound and
* re-centres the anchor. Nothing here fetches without an explicit ask.
*/
/** Half-width (m) of the ribbon drawn along a street. */
const STREET_HALF_WIDTH_M = 11;
/** A Nominatim "grounds" answer smaller than this is a building, not grounds. */
const GROUNDS_MIN_AREA_M2 = 20_000;
/** Words that point at the thing rather than name it. */
const DEICTIC_RE =
/^(?:(?:this|that|the|my)\s+)?(?:building|house|structure|tower|place|one|thing|spot|here|there|it)$/i;
/** Whether the ask names a thing (vs. "this building" at a pointer). */
export function isNamedAsk(target) {
const text = String(target || '').trim();
return Boolean(text) && !DEICTIC_RE.test(text) && /\p{L}/u.test(text);
}
/** The words sent to Nominatim: the ask without grounds words or articles. */
export function outlineQueryText(target) {
return String(target || '')
.replace(GROUNDS_WORDS_RE, ' ')
.replace(/^\s*the\s+/i, '')
.replace(/\s+/g, ' ')
.trim();
}
/**
* Which Nominatim ask kind (if any) suits a resolver scope.
* Neighbourhoods and streets are not sent: bundled packs and tiles own them.
*/
export function nominatimKindFor({ scope, groundsLike, pointLike, around }) {
if (pointLike) return null;
// "Around" asks get a buffer, except grounds: the grounds ARE the thing.
if (around && !groundsLike) return null;
if (scope === 'city') return 'city';
if (scope === 'state' || scope === 'county' || scope === 'country')
return 'admin';
if (groundsLike) return 'landmark';
if (scope === 'building') return 'building';
if (scope === 'compound' || scope === 'auto') return 'landmark';
return null;
}
/**
* Parts with the one holding the anchor outside its holes first. A boundary
* can be largest at sea (Tokyo's island municipalities), so size alone does
* not pick the part the ask is about; with no part holding the anchor, the
* order is kept.
*/
export function anchorPartFirst(polygons, points) {
for (const { lat, lon } of points) {
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
const at = polygons.findIndex((polygon) =>
containsAnchor([polygon], lat, lon),
);
if (at > 0) return [polygons[at], ...polygons.filter((_, i) => i !== at)];
if (at === 0) return polygons;
}
return polygons;
}
/** Whether a base answer is final (a real outline, or "try again later"). */
function keepBase(result) {
if (result === undefined) return true;
if (result?.rateLimited === true) return true;
if (isUnavailableCapability(result)) return false;
return Boolean(result?.ring) && !result.synthesized;
}
/** Street polylines → one ribbon polygon per chain (longest first). */
export function streetFootprint(street) {
const polygons = street.lines
.filter((line) => line.length >= 2)
.map((line) => [bufferCorridor(line, STREET_HALF_WIDTH_M)]);
if (!polygons.length) return null;
return {
ring: polygons[0][0],
polygons,
lines: street.lines,
kind: 'area',
heightM: null,
synthesized: false,
};
}
// Rungs built without their own tiles share one outline source, so a second
// resolver instance cannot add another outline cache to the tile budget.
let defaultOutlineTiles = null;
const sharedOutlineTiles = () =>
(defaultOutlineTiles ??= createOpenFreeMapOutlineSource());
export function createOutlineRungs({
tiles = sharedOutlineTiles(),
nominatim = createNominatimOutlineClient(),
} = {}) {
/**
* Decoded tiles around a point, or null when the tiles cannot be read. With
* `needAnchor`, a partial load that lost the point's own tile is null too: a
* neighbouring tile alone cannot show what is at the point, so a nearby
* building or area would stand in for it.
*/
async function tilesAround(lat, lon, radiusM, signal, needAnchor = false) {
try {
const {
tiles: decoded,
partial,
loadedTiles,
} = await tiles.fetchBounds(boxAround(lat, lon, radiusM), {
zoom: OUTLINE_TILE_ZOOM,
signal,
});
if (needAnchor && partial && Array.isArray(loadedTiles)) {
const anchor = lonLatToTile(lon, lat, OUTLINE_TILE_ZOOM);
if (!loadedTiles.some((t) => t.x === anchor.x && t.y === anchor.y))
return null;
}
return decoded;
} catch (error) {
signal?.throwIfAborted();
if (error?.name === 'AbortError') throw error;
return null;
}
}
async function streetRung(ctx) {
const name = ctx.matchName || ctx.target;
if (!isNamedAsk(name)) return null;
const decoded = await tilesAround(
ctx.lat,
ctx.lon,
STREET_RADIUS_M,
ctx.signal,
);
if (!decoded) return null;
const street = streetFromTiles(decoded, {
name,
lat: ctx.lat,
lon: ctx.lon,
});
return street ? streetFootprint(street) : null;
}
async function buildingRung(ctx, requireContainment = false) {
const decoded = await tilesAround(
ctx.lat,
ctx.lon,
BUILDING_MAX_DISTANCE_M * 2,
ctx.signal,
true,
);
const building = decoded && buildingFromTiles(decoded, ctx);
if (!building || (requireContainment && building.contains !== true))
return null;
return {
ring: building.ring,
polygons: building.polygons,
kind: 'building',
heightM: building.heightM,
synthesized: false,
};
}
/** The open area enclosing `seed` (the named feature) or the anchor. */
async function groundsRung(ctx, seed = null) {
const at = seed || { lat: ctx.lat, lon: ctx.lon };
const decoded = await tilesAround(at.lat, at.lon, 600, ctx.signal, true);
const area =
decoded &&
enclosingAreaFromTiles(decoded, {
lat: at.lat,
lon: at.lon,
name: outlineQueryText(ctx.target || ctx.matchName),
// The kind the ask names ("… School grounds") lets unnamed tile
// landuse of that class answer; read before grounds words are cut.
unnamedClasses: groundsClassesFor(ctx.target || ctx.matchName),
minAreaM2: GROUNDS_MIN_AREA_M2,
});
if (!area || !containsAnchor(area.polygons, ctx.lat, ctx.lon)) return null;
return {
ring: area.ring,
polygons: area.polygons,
kind: 'area',
heightM: null,
synthesized: false,
};
}
async function nominatimRung(ctx, kind) {
const query = outlineQueryText(
kind === 'city' || kind === 'admin'
? ctx.matchName || ctx.target
: ctx.target || ctx.matchName,
);
if (!isNamedAsk(query)) return null;
const bias = ctx.view || { lat: ctx.lat, lon: ctx.lon };
const outline = await nominatim.lookup(
{
query,
kind,
lat: bias.lat,
lon: bias.lon,
anchorLat: ctx.lat,
anchorLon: ctx.lon,
},
{ signal: ctx.signal },
);
if (!outline?.polygons?.length) return outline; // null | undefined | unavailable | rateLimited
// Named outlines must retain the resolved place's authority,
// including holes; a nearby same-name polygon is not the selected place.
if (!containsAnchor(outline.polygons, ctx.lat, ctx.lon)) return null;
const polygons = anchorPartFirst(outline.polygons, [
{ lat: ctx.lat, lon: ctx.lon },
]);
const ring = polygons[0][0];
// A grounds ask answered by a building-sized polygon found the named
// building, not its grounds: keep its position to find what encloses it.
if (ctx.groundsLike && ringAreaM2(ring) < GROUNDS_MIN_AREA_M2)
return { groundsSeed: ringCentroid(ring) };
return {
ring,
polygons,
kind:
kind === 'building' || outline.class === 'building'
? 'building'
: 'area',
heightM: null,
synthesized: false,
outlineName: outline.name,
};
}
/**
* Run the ladder for one resolved anchor.
*
* @param {object} ctx scope, target, matchName, lat, lon, named anchor facts,
* view centre, signal, and `credit(kind)`.
* @param {{base: () => Promise<any>, finish: (fp: object, scope: string) => any}} ladder
*/
async function resolve(ctx, { base, finish }) {
const { scope } = ctx;
const named = ctx.fromName && isNamedAsk(ctx.target);
// A cancelled ask (board cleared, superseded) stops between rungs, so it
// never spends a Nominatim request.
const alive = () => ctx.signal?.throwIfAborted();
const accept = (fp, asScope = scope, provider) => {
alive();
if (!fp) return null;
const patch = finish(fp, asScope);
if (patch?.ring) {
patch.outlineCredit = provider === 'openfreemap' ? 'tiles' : 'osm';
ctx.credit?.(patch.outlineCredit);
// One line per answered outline, like the resolver's own trace.
console.log(
`[Outline] "${ctx.target || ''}": scope=${asScope} → ${provider}` +
` (${patch.footprintKind}, ${patch.polygons?.length || 1} part(s))`,
);
return patch;
}
return null;
};
// Tiles first for streets and for a building pointed at rather than named.
if (scope === 'street') {
const street = accept(await streetRung(ctx), 'street', 'openfreemap');
if (street) return street;
} else if (scope === 'building' && !named && !ctx.around) {
const building = accept(
await buildingRung(ctx),
'building',
'openfreemap',
);
if (building) return building;
}
alive();
const baseResult = await base();
alive();
if (keepBase(baseResult)) return baseResult;
// A transient or busy Nominatim answer is kept aside while the tile
// fallbacks run; it is returned only if nothing else answers.
let retryLater = null;
let groundsSeed = null;
const kind = named ? nominatimKindFor(ctx) : null;
if (kind) {
const found = await nominatimRung(ctx, kind);
alive();
if (found?.groundsSeed) {
groundsSeed = found.groundsSeed;
} else if (found?.ring) {
const patch = accept(
found,
ctx.groundsLike ? 'compound' : scope,
'nominatim',
);
if (patch) return patch;
} else if (found === undefined || found?.rateLimited) {
retryLater = { value: found };
}
}
// Grounds absent from the named lookup: a matching named tile area.
if (ctx.groundsLike && named) {
// Around the named building when Nominatim found it, else the anchor.
let grounds = groundsSeed
? accept(await groundsRung(ctx, groundsSeed), 'compound', 'openfreemap')
: null;
grounds ||= accept(await groundsRung(ctx), 'compound', 'openfreemap');
if (grounds) return grounds;
}
// A named building nobody outlined: the footprint holding its point. Never for
// a grounds ask, where the building is the wrong shape.
if (scope === 'building' && named && !ctx.around && !ctx.groundsLike) {
const building = accept(
await buildingRung(ctx, true),
'building',
'openfreemap',
);
if (building) return building;
}
// Keep the mark retryable only when nothing else could answer. A
// definitive miss or an approximation from the configured ladder stands
// rather than inviting repeated lookups.
if (retryLater && isUnavailableCapability(baseResult))
return retryLater.value;
return baseResult;
}
return { resolve };
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,174 @@
// Tile fallbacks still run after a busy or failed Nominatim answer, and
// grounds asks phrased "around" still reach their outline rungs.
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { createOutlineRungs, nominatimKindFor } from './outlineRungs.js';
import { decodeOpenFreeMapOutlineTile } from '../sources/openFreeMapOutlines.js';
const TILES = [6745, 6746].map((y) =>
decodeOpenFreeMapOutlineTile(
readFileSync(
new URL(
`../data/fixtures/ofm-outlines-austin-14-3743-${y}.pbf`,
import.meta.url,
),
),
14,
3743,
y,
),
);
const UNAVAILABLE = { unavailable: true, retryable: false };
const CAPITOL = { lat: 30.27472, lon: -97.74035 };
function setup(answer, tiles = TILES) {
const tileCalls = [];
const asks = [];
const rungs = createOutlineRungs({
tiles: {
fetchBounds: async (box) => {
tileCalls.push(box);
return { tiles };
},
},
nominatim: {
lookup: async (ask) => {
asks.push(ask);
return answer;
},
},
});
return { rungs, tileCalls, asks };
}
const ladder = {
base: async () => UNAVAILABLE,
finish: (fp) => ({ ring: fp.ring, footprintKind: fp.kind }),
};
const ctx = (overrides) => ({
scope: 'building',
target: 'Texas State Capitol',
matchName: 'Texas State Capitol',
fromName: true,
...CAPITOL,
view: CAPITOL,
...overrides,
});
for (const [label, answer] of [
['busy', { rateLimited: true, retryAfterMs: 5000 }],
['transient', undefined],
]) {
test(`a ${label} Nominatim answer does not skip the tile fallbacks`, async () => {
const building = setup(answer);
const found = await building.rungs.resolve(ctx(), ladder);
assert.equal(building.asks.length, 1);
assert.ok(building.tileCalls.length > 0, 'tiles were consulted');
assert.equal(found.footprintKind, 'building');
const grounds = setup(answer);
const lawn = await grounds.rungs.resolve(
ctx({ target: 'the Texas Capitol grounds', groundsLike: true }),
ladder,
);
assert.ok(grounds.tileCalls.length > 0, 'grounds tiles were consulted');
assert.equal(lawn, answer, 'unnamed grounds preserve the retryable answer');
});
}
test('with no tile answer either, the busy result keeps the mark retryable', async () => {
const busy = { rateLimited: true, retryAfterMs: 5000 };
const rungs = createOutlineRungs({
tiles: { fetchBounds: async () => ({ tiles: [] }) },
nominatim: { lookup: async () => busy },
});
assert.equal(await rungs.resolve(ctx(), ladder), busy);
});
test('"around the Capitol grounds" is still a grounds ask', async () => {
assert.equal(
nominatimKindFor({ scope: 'building', groundsLike: true, around: true }),
'landmark',
);
assert.equal(nominatimKindFor({ scope: 'compound', around: true }), null);
const { rungs, asks } = setup(null);
const lawn = await rungs.resolve(
ctx({
target: 'the Texas Capitol grounds',
groundsLike: true,
around: true,
}),
ladder,
);
assert.equal(asks.length, 1);
});
test('named grounds around a building seed still require the original anchor', async () => {
// A place-search anchor ~250 m from the dome, outside the Capitol lawn.
const offLawn = { lat: 30.27265, lon: -97.73925 };
const d = 0.0003;
const dome = {
polygons: [
[
[
[CAPITOL.lon - d, CAPITOL.lat - d],
[CAPITOL.lon + d, CAPITOL.lat - d],
[CAPITOL.lon + d, CAPITOL.lat + d],
[CAPITOL.lon - d, CAPITOL.lat + d],
[CAPITOL.lon - d, CAPITOL.lat - d],
],
],
],
class: 'office',
};
const lawnRadius = 0.001;
const namedTiles = [
{
areas: [
{
name: 'Texas Capitol',
class: 'park',
clipped: false,
rings: [
[
[CAPITOL.lon - lawnRadius, CAPITOL.lat - lawnRadius],
[CAPITOL.lon + lawnRadius, CAPITOL.lat - lawnRadius],
[CAPITOL.lon + lawnRadius, CAPITOL.lat + lawnRadius],
[CAPITOL.lon - lawnRadius, CAPITOL.lat + lawnRadius],
[CAPITOL.lon - lawnRadius, CAPITOL.lat - lawnRadius],
],
],
},
],
},
];
const withoutSeed = setup(null, namedTiles);
assert.equal(
await withoutSeed.rungs.resolve(
ctx({
...offLawn,
target: 'the Texas Capitol grounds',
groundsLike: true,
}),
ladder,
),
UNAVAILABLE,
'the anchor alone is not inside the grounds',
);
const { rungs, tileCalls } = setup(dome, namedTiles);
const lawn = await rungs.resolve(
ctx({ ...offLawn, target: 'the Texas Capitol grounds', groundsLike: true }),
ladder,
);
assert.ok(tileCalls.length > 0, 'the matching named enclosure was consulted');
assert.deepEqual(
tileCalls,
withoutSeed.tileCalls,
'a rejected polygon cannot add a shifted grounds-seed lookup',
);
assert.equal(
lawn,
UNAVAILABLE,
'a building seed cannot move the original anchor into the named grounds',
);
});
+687
View File
@@ -0,0 +1,687 @@
// Area annotations draw bundled US Census place and neighborhood outlines
// (San Francisco DataSF first, then Who's On First) with no network lookup.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { FEATURE_SOURCE_METHODS } from '../sources/featureSource.js';
import { createAnnotationResolver } from './resolver.js';
import { polygonsContain } from '../data/adminBoundaries.js';
import { streetFootprint } from './outlineRungs.js';
function viewerAt(lat, lon, height = 20_000) {
return {
camera: {
positionCartographic: {
latitude: (lat * Math.PI) / 180,
longitude: (lon * Math.PI) / 180,
height,
},
},
};
}
/** A resolver whose boundary lookups and geocoder record every call. */
function harness(place = null, { outlineRungs } = {}) {
const lookups = [];
const featureSource = {};
for (const method of FEATURE_SOURCE_METHODS)
featureSource[method] = async () => {
lookups.push(method);
return null;
};
const geocodes = [];
const placeSearch = {
async geocode(query) {
geocodes.push(query);
return { place };
},
async textSearch() {
geocodes.push('text-search');
return [];
},
};
const { resolveAnnotationTarget } = createAnnotationResolver({
featureSource,
...(outlineRungs ? { outlineRungs } : {}),
});
const resolve = (target, viewer, extra = {}) =>
resolveAnnotationTarget({
placeSearch,
viewer,
target,
footprint: true,
deferFootprint: true,
...extra,
});
return { resolve, lookups, geocodes };
}
const AUSTIN = viewerAt(30.2672, -97.7431, 3000);
const SAN_FRANCISCO = viewerAt(37.76, -122.42, 3000);
const LONDON = viewerAt(51.51, -0.16, 3000);
function assertBundledArea(resolved, label) {
assert.equal(resolved?.source, 'bundled', label);
assert.equal(resolved.label, label);
assert.equal(resolved.footprintKind, 'area');
assert.equal(resolved.synthesized, false);
assert.equal(
resolved.outlineCredit || null,
null,
'bundled data is not an OSM outline',
);
assert.equal(resolved.resolveOutline, undefined, 'drawn at once');
assert.deepEqual(resolved.ring[0], resolved.ring.at(-1), 'ring closed');
assert.ok(resolved.polygons.length >= 1);
}
test('a city name that is only a neighborhood alias goes to the geocoder', async () => {
const { resolve, geocodes } = harness();
// Sé lists "São Paulo" among its names; the ask means the city.
const saoPaulo = viewerAt(-23.55, -46.633, 60_000);
const city = await resolve('São Paulo', saoPaulo);
assert.notEqual(city?.label, 'Se');
assert.ok(geocodes.includes('São Paulo'));
// A district ask still takes the alias.
const district = await resolve('São Paulo', saoPaulo, {
entityKind: 'district',
});
assert.equal(district?.label, 'Se');
});
test('"Austin" over Austin draws the Census place with no lookup', async () => {
const { resolve, lookups, geocodes } = harness();
for (const target of ['Austin', 'Austin, Texas', 'City of Austin']) {
const resolved = await resolve(target, AUSTIN);
assertBundledArea(resolved, 'Austin');
assert.ok(resolved.polygons.length >= 4, 'every part of the city');
}
// A qualified name needs no view.
const springfield = await resolve('Springfield, Illinois', AUSTIN);
assertBundledArea(springfield, 'Springfield');
assert.ok(Math.abs(springfield.lat - 39.78) < 0.2);
assert.deepEqual(geocodes, []);
assert.deepEqual(lookups, []);
});
test("the SF DataSF pack answers before Who's On First in San Francisco", async () => {
const { resolve, lookups, geocodes } = harness();
const sf = JSON.parse(
readFileSync(
new URL(
'../data/local_data/neighborhoods/san-francisco.json',
import.meta.url,
),
),
);
const mission = sf.features.find((f) => f.properties.name === 'Mission');
const missionRing = mission.geometry.coordinates[0];
for (const target of ['the Mission District', 'Mission', 'the Mission']) {
const resolved = await resolve(target, SAN_FRANCISCO, {
entityKind: 'district',
});
assertBundledArea(resolved, 'Mission');
assert.deepEqual(resolved.polygons[0][0], missionRing, target);
}
// A name only WOF has in San Francisco falls through to it.
const dogpatch = await resolve('Dogpatch', SAN_FRANCISCO);
assertBundledArea(dogpatch, 'Dogpatch');
// A park asked for as a compound is not a neighborhood of its name.
await resolve('Mission Dolores Park', SAN_FRANCISCO, {
entityKind: 'compound',
});
assert.equal(geocodes.filter((q) => q !== 'text-search').length, 1);
assert.deepEqual(lookups, []);
});
test("Who's On First neighborhoods resolve near the view", async () => {
const { resolve, geocodes } = harness();
assertBundledArea(await resolve('Notting Hill', LONDON), 'Notting Hill');
assertBundledArea(
await resolve('Le Marais', viewerAt(48.857, 2.352, 3000)),
'Le Marais',
);
assertBundledArea(
await resolve('Williamsburg', viewerAt(40.71, -73.96, 3000)),
'Williamsburg',
);
assert.deepEqual(geocodes, []);
});
test('campuses, qualified hoods and far names still go to the geocoder', async () => {
const { resolve, geocodes } = harness();
// Stanford is a CDP, but a campus ask wants the campus.
const stanford = await resolve('Stanford', viewerAt(37.43, -122.17, 3000), {
entityKind: 'compound',
});
assert.notEqual(stanford?.source, 'bundled');
await resolve('the Capitol grounds', AUSTIN);
await resolve('Notting Hill', AUSTIN);
assert.equal(geocodes.filter((q) => q !== 'text-search').length, 3);
// A state settles a place name wherever the camera is: not Brooklyn's
// Williamsburg, the Virginia city.
const virginia = await resolve(
'Williamsburg, Virginia',
viewerAt(40.71, -73.96, 3000),
);
assertBundledArea(virginia, 'Williamsburg');
assert.ok(Math.abs(virginia.lat - 37.27) < 0.1, 'Virginia');
});
test('a geocoded city or neighborhood upgrades to the bundled outline', async () => {
const city = harness({
lat: 30.2672,
lng: -97.7431,
label: 'Austin, TX, USA',
name: 'Austin',
types: ['locality', 'political'],
});
const controller = new AbortController();
const credits = [];
const cancelled = await city.resolve(
'Austin',
{
...viewerAt(39, -100, 5_000_000),
creditDisplay: { addStaticCredit: (credit) => credits.push(credit) },
},
{ signal: controller.signal },
);
const pending = cancelled.resolveOutline();
controller.abort();
await assert.rejects(pending, { name: 'AbortError' });
assert.deepEqual(
credits,
[],
'a cancelled bundled continuation cannot mutate viewer credits',
);
// Seen from far away: the view does not settle the name, the geocoder does.
const resolved = await city.resolve('Austin', viewerAt(39, -100, 5_000_000));
assert.equal(resolved.source, 'geocode');
const outline = await resolved.resolveOutline();
assert.equal(outline.adminArea, 'Austin');
assert.ok(outline.polygons.length >= 4);
assert.deepEqual(city.lookups, [], 'no boundary lookup');
const hood = harness({
lat: 51.5118,
lng: -0.2046,
label: 'Notting Hill, London, UK',
name: 'Notting Hill',
types: ['neighborhood', 'political'],
});
const nottingHill = await hood.resolve(
'Notting Hill',
viewerAt(48, 10, 5_000_000),
);
const hoodOutline = await nottingHill.resolveOutline();
assert.equal(hoodOutline.adminArea, 'Notting Hill');
assert.deepEqual(hood.lookups, []);
});
test('a district ask prefers a neighborhood; a place must hold the view', async () => {
const { resolve, geocodes } = harness();
// "Richmond" over SF's Richmond District: never Richmond, California.
const richmond = await resolve('Richmond', viewerAt(37.78, -122.48, 3000), {
entityKind: 'district',
});
assertBundledArea(richmond, 'Richmond District');
assert.ok(Math.abs(richmond.lat - 37.78) < 0.05, 'not the East Bay city');
// Omitted kind at a street-level view: the district in view, too.
const bare = await resolve('Richmond', viewerAt(37.78, -122.48, 3000));
assertBundledArea(bare, 'Richmond District');
// Over the East Bay city, the city.
const wide = await resolve('Richmond', viewerAt(37.94, -122.35, 40_000));
assert.equal(wide?.source, 'bundled');
assert.ok(wide.lat > 37.88, 'Richmond, California');
assert.deepEqual(geocodes, []);
});
test('landmark-named neighborhoods wait for the landmark ladder', async () => {
const manhattan = viewerAt(40.78, -73.965, 3000);
const cases = [
['Central Park', {}],
['Central Park', { entityKind: 'compound' }],
['Central Park', { intent: 'around_the_thing' }],
['Hyde Park', {}],
['Notting Hill', { intent: 'around_the_thing' }],
];
for (const [target, extra] of cases) {
const { resolve, geocodes } = harness();
const viewer = target === 'Central Park' ? manhattan : LONDON;
const resolved = await resolve(target, viewer, extra);
assert.notEqual(
resolved?.source,
'bundled',
`${target} ${JSON.stringify(extra)}`,
);
assert.ok(geocodes.length >= 1, `${target} is geocoded`);
}
// Named as a district, the WOF neighborhood answers.
const { resolve } = harness();
const park = await resolve('Central Park', manhattan, {
entityKind: 'district',
});
assert.equal(park?.source, 'bundled');
// "Park Slope" ends in no landmark word: still a neighborhood.
const slope = await resolve('Park Slope', viewerAt(40.671, -73.98, 3000));
assertBundledArea(slope, 'Park Slope');
});
/** Outline rungs whose Nominatim answers `answer` (null: nothing found). */
function rungsAnswering(answer) {
const asked = [];
return {
asked,
resolve: async (ctx, { base, finish }) => {
asked.push({ scope: ctx.scope, target: ctx.target });
const baseResult = await base();
if (baseResult?.ring && !baseResult.synthesized) return baseResult;
return answer ? finish(answer, ctx.scope) : baseResult;
},
};
}
const PARIS_PLACE = {
lat: 48.8566,
lng: 2.3522,
label: 'Paris, France',
name: 'Paris',
types: ['locality', 'political'],
};
const square = (lat, lon, d) => [
[lon - d, lat - d],
[lon + d, lat - d],
[lon + d, lat + d],
[lon - d, lat + d],
[lon - d, lat - d],
];
test('a courtyard outline with no filled candidate anchor remains a point', async () => {
const lat = 0.0005;
const lon = 0.0005;
const ring = square(lat, lon, 0.0005);
const hole = square(lat, lon, 0.0002);
const polygons = [[ring, hole]];
const { resolve } = harness(null, {
outlineRungs: rungsAnswering({
ring,
polygons,
kind: 'building',
heightM: 20,
}),
});
const options = { latitude: lat, longitude: lon, entityKind: 'building' };
const pending = await resolve(
'this building',
viewerAt(lat, lon, 1000),
options,
);
const outline = await pending.resolveOutline();
assert.equal(
outline,
null,
'neither the mean nor the courtyard point is filled',
);
const inline = await resolve('this building', viewerAt(lat, lon, 1000), {
...options,
deferFootprint: false,
});
assert.equal(inline.ring, null);
assert.deepEqual(
[inline.lat, inline.lon],
[lat, lon],
'keep the honest point',
);
for (const resolved of [outline, inline]) {
if (resolved?.polygons)
assert.ok(polygonsContain(resolved.polygons, resolved.lat, resolved.lon));
}
});
test('a bent street keeps its outline when the anchor sits beside the centreline', async () => {
// 500 m east then 500 m north: the ring mean lands off the 22 m ribbon.
const lat0 = 30.27;
const lon0 = -97.74;
const dLon = 500 / (111320 * Math.cos((lat0 * Math.PI) / 180));
const dLat = 500 / 111320;
const line = [
[lon0, lat0],
[lon0 + dLon / 2, lat0],
[lon0 + dLon, lat0],
[lon0 + dLon, lat0 + dLat / 2],
[lon0 + dLon, lat0 + dLat],
];
const footprint = streetFootprint({ lines: [line] });
// A geocoded point 15 m north of the first leg, outside the ribbon.
const lat = lat0 + 15 / 111320;
const lon = lon0 + dLon / 2;
assert.equal(polygonsContain(footprint.polygons, lat, lon), false);
const { resolve } = harness(
{ lat, lng: lon, name: 'Bent Street', label: 'Bent St', types: ['route'] },
{ outlineRungs: rungsAnswering(footprint) },
);
const pending = await resolve('Bent Street', viewerAt(lat, lon, 3000), {
entityKind: 'street',
});
const outline = await pending.resolveOutline();
assert.equal(outline?.ring, footprint.ring, 'the street is still drawn');
assert.ok(polygonsContain(outline.polygons, outline.lat, outline.lon));
assert.ok(Math.abs(outline.lat - lat0) < 1e-7, 'centred on the street');
assert.ok(Math.abs(outline.lon - lon) < 1e-7);
// A same-name street that never comes near the anchor is not that street.
const far = lat0 + 300 / 111320;
const away = harness(
{
lat: far,
lng: lon,
name: 'Bent Street',
label: 'Bent St',
types: ['route'],
},
{ outlineRungs: rungsAnswering(footprint) },
);
const distant = await away.resolve('Bent Street', viewerAt(far, lon, 3000), {
entityKind: 'street',
});
assert.equal((await distant.resolveOutline())?.ring ?? null, null);
});
test('local dateline outlines share a frame for area, centering and filled anchors', async () => {
const ring = [
[179.9995, -0.0005],
[-179.9995, -0.0005],
[-179.9995, 0.0005],
[179.9995, 0.0005],
[179.9995, -0.0005],
];
const hole = [
[179.9998, -0.0003],
[-179.9998, -0.0003],
[-179.9998, 0.0003],
[179.9998, 0.0003],
[179.9998, -0.0003],
];
const polygons = [[ring, hole]];
const { resolve } = harness(null, {
outlineRungs: rungsAnswering({
ring,
polygons,
kind: 'building',
heightM: 20,
}),
});
for (const deferFootprint of [true, false]) {
for (const [lat, lon, filled] of [
[0.0004, 179.9996, true],
[0, 179.99999, false],
[0.0004, 0, false],
]) {
const result = await resolve('this building', viewerAt(lat, lon, 1000), {
latitude: lat,
longitude: lon,
entityKind: 'building',
deferFootprint,
});
const outline = deferFootprint ? await result.resolveOutline() : result;
if (!filled) {
assert.equal(outline?.ring ?? null, null);
continue;
}
assert.equal(outline.ring, ring);
assert.equal(outline.polygons, polygons);
assert.deepEqual(
[outline.lat, outline.lon],
[lat, lon],
'keep the filled shell anchor',
);
}
}
const plain = harness(null, {
outlineRungs: rungsAnswering({ ring, kind: 'building', heightM: 20 }),
});
const pending = await plain.resolve(
'this building',
viewerAt(0, 179.9999, 1000),
{
latitude: 0,
longitude: 179.9999,
entityKind: 'building',
},
);
const centered = await pending.resolveOutline();
assert.equal(centered.ring, ring);
assert.ok(
Math.abs(centered.lon) > 179.999 &&
centered.lon >= -180 &&
centered.lon < 180,
);
});
test('a coarse bundled admin-1 city waits for a finer outline', async () => {
const overParis = viewerAt(48.8566, 2.3522, 40_000);
// The outer-ring vertex mean is in the hole; the resolved anchor is in
// the shell. Completion must not move that valid point into the hole.
const ring = square(48.8566, 2.3822, 0.04);
const hole = square(48.8566, 2.3822, 0.02);
const polygons = [[ring, hole]];
const rungs = rungsAnswering({ ring, polygons, kind: 'area', heightM: null });
const fine = harness(PARIS_PLACE, { outlineRungs: rungs });
const resolved = await fine.resolve('Paris', overParis);
assert.equal(
resolved.source,
'geocode',
'the 11-vertex admin-1 is not drawn at once',
);
const outline = await resolved.resolveOutline();
assert.deepEqual(outline.ring, ring, 'the finer outline wins');
assert.deepEqual(outline.polygons, polygons);
assert.equal(outline.lat, PARIS_PLACE.lat);
assert.equal(outline.lon, PARIS_PLACE.lng);
assert.ok(polygonsContain(outline.polygons, outline.lat, outline.lon));
assert.deepEqual(rungs.asked, [{ scope: 'city', target: 'Paris' }]);
const inline = await fine.resolve('Paris', overParis, {
deferFootprint: false,
});
assert.deepEqual(inline.polygons, polygons);
assert.equal(inline.lat, outline.lat);
assert.equal(inline.lon, outline.lon);
// Nothing finer (Nominatim off, busy or capped): the bundled shape, not a point.
const coarse = harness(PARIS_PLACE, { outlineRungs: rungsAnswering(null) });
const fallback = await (
await coarse.resolve('Paris', overParis)
).resolveOutline();
assert.equal(fallback.adminArea, 'Paris');
assert.ok(fallback.ring.length < 32);
// No geocoder answer at all: the bundled shape at once.
const offline = harness(null, { outlineRungs: rungsAnswering(null) });
const direct = await offline.resolve('Paris', overParis);
assert.equal(direct.source, 'bundled');
assert.equal(direct.label, 'Paris');
const overDallas = viewerAt(32.7767, -96.797, 20_000);
assert.equal(
await offline.resolve('Paris', overDallas),
null,
'a missing geocode cannot select the distant coarse homonym',
);
assert.equal(
await coarse.resolve('Paris', overDallas),
null,
'a rejected foreign geocode cannot restore the same distant polygon',
);
const outsideParis = harness(
{ ...PARIS_PLACE, lat: 48.9, lng: 2.6 },
{ outlineRungs: rungsAnswering(null) },
);
const anchored = await outsideParis.resolve('Paris', overParis);
assert.equal(anchored.source, 'geocode');
assert.equal(
await anchored.resolveOutline(),
undefined,
'a nearby anchor outside Paris preserves the transient base, not its coarse polygon',
);
});
test('detailed admin-1 units and admin asks keep the bundled shape', async () => {
const { resolve } = harness(null, { outlineRungs: rungsAnswering(null) });
for (const [name, lat, lon] of [
['Berlin', 52.52, 13.4],
['Bavaria', 48.9, 11.4],
['Texas', 31, -99],
]) {
const resolved = await resolve(name, viewerAt(lat, lon, 400_000));
assert.equal(resolved?.source, 'bundled', name);
}
// Countries are never coarse cities, however few vertices.
const singapore = await resolve('Singapore', viewerAt(1.35, 103.82, 60_000));
assert.equal(singapore?.source, 'bundled');
// An explicit admin ask takes the admin unit as it is.
const dc = await resolve('Paris', viewerAt(48.8566, 2.3522, 40_000), {
entityKind: 'state',
});
assert.equal(dc?.source, 'bundled');
});
test('a campus ask reaches the campus, not the Census place of its town', async () => {
const stanfordTown = {
lat: 37.4241,
lng: -122.1661,
label: 'Stanford, CA, USA',
name: 'Stanford',
types: ['locality', 'political'],
};
const campus = square(37.4275, -122.1697, 0.01);
for (const ask of ['Stanford University', 'Stanford campus']) {
const rungs = rungsAnswering({ ring: campus, kind: 'area', heightM: null });
const { resolve } = harness(stanfordTown, { outlineRungs: rungs });
const resolved = await resolve(ask, viewerAt(37.43, -122.17, 8000));
assert.notEqual(resolved?.source, 'bundled', ask);
const outline = await resolved.resolveOutline();
assert.deepEqual(outline.ring, campus, ask);
assert.equal(
outline.outlineCredit,
null,
'an injected outline is not inferred OSM',
);
assert.equal(
rungs.asked[0].scope,
'compound',
`${ask}: past the Census rung`,
);
}
// "Stanford" said to be a compound is the campus too.
const kindRungs = rungsAnswering({
ring: campus,
kind: 'area',
heightM: null,
});
const byKind = harness(stanfordTown, { outlineRungs: kindRungs });
const compound = await (
await byKind.resolve('Stanford', viewerAt(37.43, -122.17, 8000), {
entityKind: 'compound',
})
).resolveOutline();
assert.deepEqual(compound.ring, campus);
assert.equal(kindRungs.asked[0].scope, 'compound');
const labelRungs = rungsAnswering({
ring: campus,
kind: 'area',
heightM: null,
});
const byLabel = harness(stanfordTown, { outlineRungs: labelRungs });
const labelled = await byLabel.resolve(
'Stanford',
viewerAt(37.43, -122.17, 8000),
{ labelHint: 'Stanford campus' },
);
assert.notEqual(labelled.source, 'bundled');
assert.deepEqual((await labelled.resolveOutline()).ring, campus);
assert.equal(labelRungs.asked[0].scope, 'compound');
// "Stanford" alone is the town (the Stanford CDP), and a town named after a
// college stays a town.
const { resolve } = harness(stanfordTown, {
outlineRungs: rungsAnswering(null),
});
const town = await (
await resolve('Stanford', viewerAt(39, -100, 5_000_000))
).resolveOutline();
assert.equal(town.adminArea, 'Stanford');
const station = harness(
{
lat: 30.628,
lng: -96.3344,
label: 'College Station, TX, USA',
name: 'College Station',
types: ['locality', 'political'],
},
{ outlineRungs: rungsAnswering(null) },
);
const collegeStation = await (
await station.resolve('College Station', viewerAt(39, -100, 5_000_000))
).resolveOutline();
assert.equal(collegeStation.adminArea, 'College Station');
});
test('a grounds ask never takes the state the geocoder typed', async () => {
const texas = {
lat: 31.0,
lng: -99.0,
label: 'Texas, USA',
name: 'Texas',
types: ['administrative_area_level_1', 'political'],
};
for (const [ask, extra] of [
['the Texas Capitol grounds', {}],
['Texas Capitol', { entityKind: 'compound' }],
// The grounds wording is only in the label.
['Texas Capitol', { labelHint: 'Capitol grounds' }],
]) {
const rungs = rungsAnswering(null);
const { resolve } = harness(texas, { outlineRungs: rungs });
const resolved = await resolve(ask, viewerAt(31, -99, 3_000_000), extra);
assert.notEqual(resolved?.source, 'bundled', ask);
const outline = await resolved.resolveOutline();
assert.notEqual(outline?.adminArea, 'Texas', ask);
assert.equal(rungs.asked[0].scope, 'compound', ask);
}
});
test('configured OSM geometry retains its origin through inline, deferred and cached base results', async () => {
const ring = square(31, -99, 0.001);
let calls = 0;
const resolver = createAnnotationResolver({
boundarySource: {
query: async () => {
calls++;
return [
{
type: 'way',
id: 1,
tags: { name: 'Library', building: 'yes' },
geometry: ring.map(([lon, lat]) => ({ lon, lat })),
},
];
},
},
outlineRungs: { resolve: (_ctx, { base }) => base() },
});
for (const deferFootprint of [false, true]) {
const result = await resolver.resolveAnnotationTarget({
viewer: viewerAt(31, -99, 3000),
latitude: 31,
longitude: -99,
target: 'Library',
entityKind: 'building',
footprint: true,
deferFootprint,
});
const outline = deferFootprint ? await result.resolveOutline() : result;
assert.equal(outline.footprintKind, 'building');
assert.equal(outline.outlineCredit, 'osm');
assert.deepEqual(outline.ring, ring);
}
assert.equal(calls, 1, 'cached geometry retains its original provider');
});
+111 -2
View File
@@ -44,16 +44,123 @@ test('region ring: Natural Earth names resolve without a geocoder', async () =>
for (const name of ['Gulf of Mexico', 'the Alps']) {
const region = await resolveRegionRingForQuery(name);
assert.ok(region?.ring?.length >= 3, `${name} resolves to a ring`);
assert.equal(region.source, 'natural-earth');
assert.equal(region.error, undefined);
}
});
test('region ring: duplicate natural names need a containing geocoded anchor', async () => {
const { resolveRegionRingForQuery } = createAnnotationResolver({
featureSource: featureSource(),
});
const miss = geocoderFor(null);
assert.equal(
await resolveRegionRingForQuery('Cordillera Oriental', undefined, miss),
null,
);
assert.deepEqual(miss.calls, ['Cordillera Oriental']);
const inside = geocoderFor({
lat: 5,
lng: -73,
label: 'Cordillera Oriental',
name: 'Cordillera Oriental',
types: ['natural_feature'],
});
const region = await resolveRegionRingForQuery(
'Cordillera Oriental',
undefined,
inside,
);
assert.equal(region?.source, 'natural-earth');
assert.equal(region.name, 'Cordillera Oriental');
assert.ok(region.ring.length >= 8);
assert.deepEqual(inside.calls, ['Cordillera Oriental']);
});
test('region ring: outside and overlapping geocoded natural anchors stay unresolved', async () => {
const { resolveRegionRingForQuery } = createAnnotationResolver({
featureSource: featureSource(),
});
for (const [name, lat, lng] of [
['Cordillera Oriental', 30.27, -97.74],
['Canadian Shield', 45.6336, -74.86493],
]) {
const geocoder = geocoderFor({
lat,
lng,
label: name,
name,
types: ['natural_feature'],
});
assert.equal(
await resolveRegionRingForQuery(name, undefined, geocoder),
null,
);
assert.deepEqual(geocoder.calls, [name]);
}
});
test('region ring: geocoded administrative scope wins over duplicate natural geography', async () => {
const lookups = [];
const { resolveRegionRingForQuery } = createAnnotationResolver({
featureSource: featureSource({
getAdministrativeAreas: async (point) => {
lookups.push(point);
return [];
},
}),
});
const geo = geocoderFor({
lat: 5,
lng: -73,
label: 'Cordillera Oriental',
name: 'Cordillera Oriental',
types: ['locality'],
});
const resolved = await resolveRegionRingForQuery(
'Cordillera Oriental',
undefined,
geo,
);
assert.equal(
resolved,
null,
'an unavailable city boundary is not a natural-region outline',
);
assert.deepEqual(lookups, [{ lat: 5, lon: -73 }]);
assert.deepEqual(geo.calls, ['Cordillera Oriental']);
});
test('region ring: cancellation during bundled lookup and geocode never returns a region', async () => {
const { resolveRegionRingForQuery } = createAnnotationResolver({
featureSource: featureSource(),
});
const controller = new AbortController();
const pending = resolveRegionRingForQuery('the Alps', controller.signal);
controller.abort();
await assert.rejects(pending, { name: 'AbortError' });
const later = new AbortController();
await assert.rejects(
resolveRegionRingForQuery('Cordillera Oriental', later.signal, {
async geocode() {
later.abort();
return { place: null };
},
}),
{ name: 'AbortError' },
);
});
test('region ring: a slow admin-boundary lookup returns region-timeout within the budget', async () => {
let adminSignal;
let release;
let lookupStarted;
const { resolveRegionRingForQuery } = createAnnotationResolver({
featureSource: featureSource({
getAdministrativeAreas(_point, { signal }) {
lookupStarted = Date.now();
adminSignal = signal;
return new Promise((resolve) => {
release = resolve;
@@ -61,7 +168,6 @@ test('region ring: a slow admin-boundary lookup returns region-timeout within th
},
}),
});
const started = Date.now();
const region = await resolveRegionRingForQuery(
'Atlantis',
undefined,
@@ -73,8 +179,10 @@ test('region ring: a slow admin-boundary lookup returns region-timeout within th
ring: null,
error: 'region-timeout',
});
// Timed from the boundary lookup, so the bundled pack scans before it do not
// count. Under the 3 s default budget, so an ignored budgetMs still fails.
assert.ok(
Date.now() - started < 1000,
Date.now() - lookupStarted < 2_000,
'returns at the budget, not the lookup',
);
// The lookup is left running so it can fill the boundary cache.
@@ -116,6 +224,7 @@ test('region ring: bundled states and counties resolve without a geocoder', asyn
]) {
const region = await resolveRegionRingForQuery(query, undefined, geocoder);
assert.equal(region?.name, name, query);
assert.equal(region.source, 'bundled-admin');
assert.ok(region.ring.length >= 8, `${query} has a real ring`);
}
assert.deepEqual(geocoder.calls, [], 'no geocoding');
+459 -42
View File
@@ -11,6 +11,7 @@ import {
approximateAreaM2,
approximateDistanceM,
pointInPolygon,
nearestPointOnLines,
} from '../sources/featureGeometry.js';
import { defaultGeospatial } from '../search/defaults.js';
import * as Cesium from 'cesium';
@@ -19,17 +20,83 @@ import {
lookupNaturalRegionOutline,
findNaturalRegion,
} from '../data/naturalEarthRegions.js';
import { findAdminArea, findAdminAreaAt } from '../data/adminBoundaries.js';
import {
findAdminArea,
findAdminAreaAt,
polygonsContain as polygonsContainPoint,
} from '../data/adminBoundaries.js';
import {
findNeighborhoodArea,
findNeighborhoodAreaAt,
findPlaceArea,
findPlaceAreaAt,
NEIGHBORHOOD_NEAR_KM,
normalizePlaceName,
PLACE_NEAR_KM,
} from '../data/placeBoundaries.js';
import {
registerDynamicCredit,
NATURAL_EARTH_CREDIT,
US_CENSUS_CREDIT,
OSM_CREDIT,
OPENMAPTILES_CREDIT,
WOF_CREDIT,
} from '../data/dataCredits.js';
import { createOutlineRungs } from './outlineRungs.js';
import { unavailablePlaceSearch } from '../search/placeSearch.js';
import { isPickedWorldPosition } from '../data/scenePick.js';
/** Prepare only local dateline outlines for planar admission and centering. */
function localOutlineFrame(fp) {
const original = { ring: fp?.ring, polygons: fp?.polygons, reference: null };
if (!Array.isArray(fp?.ring) || fp.ring.length < 3) return original;
let west = Infinity;
let east = -Infinity;
for (const [lon] of fp.ring) {
west = Math.min(west, lon);
east = Math.max(east, lon);
}
if (east - west <= 180) return original;
const reference = fp.ring[0][0];
west = Infinity;
east = -Infinity;
const shift = (ring) =>
ring.map(([lon, lat]) => {
const x = lon - 360 * Math.round((lon - reference) / 360);
west = Math.min(west, x);
east = Math.max(east, x);
return [x, lat];
});
const ring = shift(fp.ring);
const polygons = Array.isArray(fp.polygons)
? fp.polygons.map((part) => part.map(shift))
: fp.polygons;
// Shapes still spanning half the globe retain their existing behavior. Source coordinates
// are never rewritten, including holes and unrelated multipart geometry.
if (!Number.isFinite(east - west) || east - west >= 180) return original;
return { ring, polygons, reference };
}
/** Ms the analyst region lookup waits on geocode + admin boundary. */
export const REGION_FALLBACK_BUDGET_MS = 3_000;
const REGION_FALLBACK_BUDGET_MS = 3_000;
/** How far a street may run from its anchor when the ribbon holds neither. */
const STREET_SNAP_MAX_M = 100;
/**
* Names that end like a landmark rather than a district: "Central Park",
* "Lake Merritt"-style suffixes are checked on the last word only, so "Park
* Slope" and "Notting Hill" stay districts.
*/
const LANDMARK_NAME =
/\b(?:park|gardens?|square|plaza|lake|river|bay|beach|pier|harbou?r|airport|station|stadium|arena|cemetery|zoo|university|college|campus|palace|castle|cathedral|church|temple|mosque|bridge|market|mall|cent(?:er|re)|tower|museum|reservoir|forest|woods?|heath|common|green)$/i;
/** The name part of an ask, before any comma qualifier. */
function stripQualifiers(query) {
return String(query || '')
.split(',')[0]
.trim();
}
/** Entity facts that rule out a state/county reading of the ask. */
const NON_ADMIN_ENTITY_KINDS = new Set([
@@ -47,8 +114,12 @@ export function createAnnotationResolver({
boundarySource,
signal: lifetime,
}),
// Overpass-free rungs (OpenFreeMap tiles, guarded Nominatim) around the ladder.
outlineRungs = createOutlineRungs(),
} = {}) {
requireFeatureSource(featureSource);
const featureOutlineCredit =
featureSource.outlineCredit === 'osm' ? 'osm' : null;
lifetime?.throwIfAborted();
/**
@@ -103,18 +174,6 @@ export function createAnnotationResolver({
cacheWrite(cache, key, null);
}
/** Wire an external AbortSignal to a local controller; returns a detach fn. */
function linkAbort(controller, externalSignal) {
if (!externalSignal) return () => {};
if (externalSignal.aborted) {
controller.abort();
return () => {};
}
const onAbort = () => controller.abort();
externalSignal.addEventListener('abort', onAbort, { once: true });
return () => externalSignal.removeEventListener('abort', onAbort);
}
/**
* Resolve a single annotation target to a normalized world anchor.
*
@@ -176,6 +235,21 @@ export function createAnnotationResolver({
// locality suffixes; the types classify the entity (point-like vs area-like).
let placesPrimary = null;
let placeTypes = [];
// A coarse bundled admin-1 shape that carries a city's name (Natural
// Earth's 11-vertex "Paris"): kept as the fallback while the finer rungs
// (Census, Nominatim) try first.
let coarseCityShape = null;
let bundledCenter = null;
const coarseCityHolds = (point) =>
coarseCityShape &&
point &&
polygonsContainPoint(coarseCityShape.polygons, point.lat, point.lon);
const compoundAsk =
entityKind === 'compound' ||
isCampusAsk(target) ||
isGroundsLikeQuery(target) ||
isGroundsLikeQuery(labelHint);
const askNamesAdmin = Boolean(adminScopeFromAsk(target, entityKind));
// Instrumentation (logged once per target at the end): which sources were tried and what they returned.
const trace = {
query: String(target || '').trim(),
@@ -193,15 +267,32 @@ export function createAnnotationResolver({
footprint &&
(!Number.isFinite(lat) || !Number.isFinite(lon)) &&
trace.query &&
!compoundAsk &&
!NON_ADMIN_ENTITY_KINDS.has(entityKind)
) {
const center =
pickWorldFromScreen(viewer, 0.5, 0.5) || viewportProximity(viewer);
bundledCenter = center;
const admin = await findAdminArea(trace.query, { near: center }).catch(
() => null,
);
signal?.throwIfAborted();
if (admin) return bundledAdminTarget(viewer, admin, trace.query);
if (admin && !askNamesAdmin && isCoarseCityShape(admin))
coarseCityShape = admin;
else if (admin)
return bundledAdminTarget(viewer, admin, trace.query, signal);
// Bundled US places and neighborhoods named at or near the view. A
// campus, grounds or monument ask ("Stanford" as a compound, "the
// Capitol grounds") wants that feature, not a district of its name,
// and "the area around X" is drawn by the ladder below.
if (intent !== 'around_the_thing' && !isMonumentLikeQuery(trace.query)) {
const area = await bundledAreaNear(trace.query, center, {
entityKind,
viewRadiusKm: viewportProximity(viewer)?.radiusKm ?? null,
});
signal?.throwIfAborted();
if (area) return bundledAdminTarget(viewer, area, trace.query, signal);
}
}
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
@@ -329,7 +420,10 @@ export function createAnnotationResolver({
}
}
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
if (!Number.isFinite(lat) || !Number.isFinite(lon))
return coarseCityHolds(bundledCenter)
? bundledAdminTarget(viewer, coarseCityShape, trace.query, signal)
: null;
// Monument/memorial/statue names geocode unreliably — Google scatters them across the city (e.g.
// several Texas Capitol monuments landed blocks-to-miles apart, looking "all over the map"). When
@@ -394,7 +488,9 @@ export function createAnnotationResolver({
`osmSnap=${trace.osmSnap} → FINAL source=rejected (proximity gate, ${drift.driftKm.toFixed(0)}km > ${drift.limitKm.toFixed(0)}km)`,
);
}
return null;
return coarseCityHolds(bundledCenter)
? bundledAdminTarget(viewer, coarseCityShape, trace.query, signal)
: null;
}
}
@@ -414,7 +510,18 @@ export function createAnnotationResolver({
// an admin boundary for a city/state, the enclosing compound for a mall/campus, a single
// building for a premise. The voice model's entityKind (an entity FACT) refines an
// unresolved 'auto' scope only — real geocode types always win.
const baseScope = refineScope(scopeFromTypes(geocodeTypes), entityKind);
// A campus or grounds ask ("Stanford University", "Stanford campus",
// "the Capitol grounds", in the target or its label) or a compound by
// entity fact wants that compound even when the geocoder types the town
// or the state: never a Census place, neighborhood or admin boundary.
const typedScope = refineScope(scopeFromTypes(geocodeTypes), entityKind);
const baseScope =
compoundAsk &&
['country', 'state', 'county', 'city', 'neighborhood'].includes(
typedScope,
)
? 'compound'
: typedScope;
// Point-like targets (monuments/statues/memorials/…) resolve POINT-FIRST: only an
// (almost) exactly-named, monument-scale polygon may replace the point; a nearby polygon
// sharing locality words must not (docs/field-test-rootcause-2026-06-30.md §1).
@@ -443,7 +550,8 @@ export function createAnnotationResolver({
* The inline path awaits it right here; progressive callers (deferFootprint) invoke it
* AFTER the anchor mark is drawn and upgrade the mark in place.
*/
const resolveOutline = async () => {
const resolveBaseOutline = async () => {
signal?.throwIfAborted();
let scope = baseScope;
const isAdmin =
scope === 'country' ||
@@ -469,8 +577,10 @@ export function createAnnotationResolver({
const ne = await lookupNaturalRegionOutline(target, lat, lon).catch(
() => null,
);
signal?.throwIfAborted();
if (ne) {
registerDynamicCredit(viewer, NATURAL_EARTH_CREDIT);
traceBundledOutline(ne.name, 'natural-earth');
const neCentroid = ringCentroid(ne.ring);
return {
ring: ne.ring,
@@ -526,7 +636,20 @@ export function createAnnotationResolver({
lon,
scope,
).catch(() => null);
if (admin) return bundledAdminOutline(viewer, admin);
signal?.throwIfAborted();
if (admin && !askNamesAdmin && isCoarseCityShape(admin))
coarseCityShape ||= admin;
else if (admin) return bundledAdminOutline(viewer, admin, signal);
}
// A geocoded US city, town or CDP: the Census place that holds it.
if (scope === 'city') {
const place = await findPlaceAreaAt(
[target, matchName],
lat,
lon,
).catch(() => null);
signal?.throwIfAborted();
if (place) return bundledAdminOutline(viewer, place, signal);
}
// Pure admin: only an admin boundary is correct — never fall back to a
// building/landuse (a city is never a single building).
@@ -537,7 +660,21 @@ export function createAnnotationResolver({
// resolve here instantly to a REAL boundary, sidestepping the slow/flaky live-Overpass
// path that times out and falls back to points (see docs/field-test-2-analysis.md).
const ext = await lookupNeighborhoodRing(lat, lon, matchName);
if (ext) fp = { ring: ext.ring, kind: 'area', heightM: null };
signal?.throwIfAborted();
if (ext) {
traceBundledOutline(ext.name || matchName, 'datasf');
fp = { ring: ext.ring, kind: 'area', heightM: null };
}
// THEN a bundled Who's On First neighborhood carrying the name at the point.
if (!fp) {
const wof = await findNeighborhoodAreaAt(
[target, matchName],
lat,
lon,
).catch(() => null);
signal?.throwIfAborted();
if (wof) return bundledAdminOutline(viewer, wof, signal);
}
// Else fall through to the OSM admin/place → named-landuse → synthesis ladder. Each
// returns a footprint, null (definitively no polygon), or undefined (transient
// upstream failure). Synthesize a blob (the "Mission" problem, research §3b) ONLY
@@ -663,6 +800,12 @@ export function createAnnotationResolver({
}
}
}
return finishOutline(fp, scope);
};
/** Validate a footprint for its scope and re-centre it (resolveOutline's patch). */
const finishOutline = (fp, scope) => {
const frame = localOutlineFrame(fp);
// Scope sanity: reject a real footprint whose area is wildly wrong for the asked
// scope (a building/compound/neighborhood intent must never draw a state-sized
// blob). Synthesized discs are deliberately sized and exempt.
@@ -671,15 +814,44 @@ export function createAnnotationResolver({
!fp.synthesized &&
Array.isArray(fp.ring) &&
fp.ring.length >= 3 &&
exceedsScopeArea(fp, scope)
exceedsScopeArea(frame, scope)
) {
fp = null;
}
if (isUnavailableCapability(fp) || isRateLimitedOutcome(fp)) return fp;
if (fp === undefined) return undefined; // TRANSIENT — a backoff retry may still find it
if (!fp || !Array.isArray(fp.ring) || fp.ring.length < 3) return null;
const centroid = ringCentroid(fp.ring);
let centroid = ringCentroid(frame.ring);
if (!centroid) return null;
// An outer-ring mean can lie in a hole or outside a concave part. Keep
// a filled original anchor or decline, without changing ordinary centering
// or inventing a new interior point.
if (
Array.isArray(frame.polygons) &&
!polygonsContainPoint(frame.polygons, centroid.lat, centroid.lon)
) {
const anchorLon =
frame.reference === null
? lon
: lon - 360 * Math.round((lon - frame.reference) / 360);
if (polygonsContainPoint(frame.polygons, lat, anchorLon)) {
centroid = { lat, lon };
} else if (Array.isArray(fp.lines)) {
// A bent street's ribbon rarely holds its ring mean, and a geocoded
// anchor can sit beside the centreline: centre on the street itself,
// only when it runs beside the anchor.
const onStreet = nearestPointOnLines(fp.lines, lat, lon);
if (!onStreet || onStreet.distanceM > STREET_SNAP_MAX_M) return null;
centroid = { lat: onStreet.lat, lon: onStreet.lon };
} else {
return null;
}
}
if (
frame.reference !== null &&
(centroid.lon < -180 || centroid.lon >= 180)
)
centroid.lon = ((((centroid.lon + 180) % 360) + 360) % 360) - 180;
// Same drift bound as the anchor gate: a polygon whose centroid would drag a good
// anchor out of plausibility is the wrong feature — drop it, keep the point.
if (
@@ -693,17 +865,57 @@ export function createAnnotationResolver({
footprintKind: fp.kind,
buildingHeight: fp.heightM,
synthesized: Boolean(fp.synthesized),
outlineCredit: fp.outlineCredit || null,
...(Array.isArray(fp.polygons) ? { polygons: fp.polygons } : {}),
lat: centroid.lat,
lon: centroid.lon,
height: sampleGroundHeight(viewer, centroid.lon, centroid.lat),
};
};
// Streets and pointed-at buildings from tiles, then this ladder, then the
// guarded Nominatim outline (see outlineRungs.js).
const outlineContext = {
scope: baseScope,
target,
matchName,
lat,
lon,
fromName: fromGeocode || source === 'places' || source === 'osm-local',
pointLike,
groundsLike,
around: intent === 'around_the_thing',
view: viewportProximity(viewer),
signal,
credit: (kind) =>
registerDynamicCredit(
viewer,
kind === 'tiles' ? OPENMAPTILES_CREDIT : OSM_CREDIT,
),
};
const resolveOutline = async () => {
const result = await outlineRungs.resolve(outlineContext, {
base: resolveBaseOutline,
finish: finishOutline,
});
signal?.throwIfAborted();
// Nothing finer answered (no polygon, Nominatim off, busy or capped):
// the coarse bundled shape beats a point.
if (
coarseCityHolds({ lat, lon }) &&
!(result?.ring && !result.synthesized)
)
return bundledAdminOutline(viewer, coarseCityShape, signal);
return result;
};
let ring = null;
let footprintKind = null; // 'building' | 'area'
let buildingHeight = null; // meters, only for buildings
let outlineUnavailable = false;
let synthesized = false; // true = buffered/approximate area, render dashed/feathered
let polygons = null; // every part of a multi-part outline, when known
let outlineCredit = null;
if (footprint && !deferFootprint) {
const fp = await resolveOutline();
outlineUnavailable = isUnavailableCapability(fp);
@@ -712,6 +924,8 @@ export function createAnnotationResolver({
footprintKind = fp.footprintKind;
buildingHeight = fp.buildingHeight;
synthesized = fp.synthesized;
polygons = fp.polygons || null;
outlineCredit = fp.outlineCredit || null;
// Re-center the anchor on the resolved footprint centroid.
lat = fp.lat;
lon = fp.lon;
@@ -739,6 +953,8 @@ export function createAnnotationResolver({
source,
synthesized,
outlineUnavailable,
outlineCredit,
...(polygons ? { polygons } : {}),
viewport: placeViewport,
...(footprint && deferFootprint ? { resolveOutline } : {}),
};
@@ -778,13 +994,110 @@ export function createAnnotationResolver({
const GROUNDS_RADIUS_M = 300; // "X grounds/compound/campus" loose disc when OSM has no polygon
const GROUNDS_RADIUS_MIN_M = 150; // viewport-derived grounds disc is clamped to this band so a tiny
const GROUNDS_RADIUS_MAX_M = 1200; // place can't shrink to a dot, nor a city-wide viewport balloon
const COARSE_CITY_MAX_KM2 = 5_000; // city-sized admin-1 units (see isCoarseCityShape)
const COARSE_CITY_MAX_VERTICES = 32;
/** Credit the pack a bundled boundary came from. */
function creditAdminSource(viewer, admin) {
registerDynamicCredit(
viewer,
admin.source === 'us-census' ? US_CENSUS_CREDIT : NATURAL_EARTH_CREDIT,
);
const credit = {
'natural-earth': NATURAL_EARTH_CREDIT,
'us-census': US_CENSUS_CREDIT,
wof: WOF_CREDIT,
}[admin.source];
if (credit) registerDynamicCredit(viewer, credit);
}
/**
* The bundled US place or neighborhood an ask names at or near the view,
* from Census places, the San Francisco DataSF pack (first in SF), then
* Who's On First. How far "near" reaches follows the view (half its
* radius); a state-qualified place ("Springfield, Illinois") needs no
* view. A district ask takes a neighborhood first and a place only when
* the view is inside it ("Richmond" over SF is not Richmond, CA). Without
* an entity kind the candidate that holds the view wins, else the nearer.
* A name that ends like a landmark ("Central Park", "Lake Merritt") is
* left to the landmark ladder unless the ask is a district.
*/
async function bundledAreaNear(
query,
center,
{ entityKind = null, viewRadiusKm = null } = {},
) {
const district = entityKind === 'district';
if (!district && LANDMARK_NAME.test(stripQualifiers(query))) return null;
const reach = Number.isFinite(viewRadiusKm) ? viewRadiusKm / 2 : null;
const clamp = (km, lo, hi) =>
km === null ? hi : Math.min(hi, Math.max(lo, km));
const placeReach = district ? 0 : clamp(reach, 3, PLACE_NEAR_KM);
const place = await findPlaceArea(query, {
near: center,
nearKm: placeReach,
}).catch(() => null);
const holds = (area) =>
Boolean(center) &&
polygonsContainPoint(area.polygons, center.lat, center.lon);
// A district ask only takes a place the view is inside (or one a state
// qualifier named outright).
const placeOk =
place && (!district || holds(place) || /,/.test(query)) ? place : null;
if (!center) return placeOk;
let hood = null;
const sf = await lookupNeighborhoodRing(center.lat, center.lon, query, {
exact: true,
}).catch(() => null);
if (sf) hood = sfNeighborhoodArea(sf);
else
hood = await findNeighborhoodArea(query, {
near: center,
nearKm: clamp(reach, 1, NEIGHBORHOOD_NEAR_KM),
}).catch(() => null);
// Outside a district ask, an alternate name unrelated to the name is too
// weak: Se in Sao Paulo lists "Sao Paulo" among its names, and "outline
// Sao Paulo" means the city ("Richmond" for Richmond District still counts).
// Whole words: an alias hit on "York" does not keep "Yorkville".
if (
!district &&
hood?.byAlias &&
!` ${normalizePlaceName(hood.name)} `.includes(
` ${normalizePlaceName(stripQualifiers(query))} `,
)
)
hood = null;
if (!hood || !placeOk) return district ? hood || placeOk : placeOk || hood;
if (district) return hood;
const hoodHolds = holds(hood);
const placeHolds = holds(placeOk);
if (hoodHolds !== placeHolds) return hoodHolds ? hood : placeOk;
const distance = (area) =>
approximateDistanceM(
center.lat,
center.lon,
area.label.lat,
area.label.lon,
);
return distance(hood) <= distance(placeOk) ? hood : placeOk;
}
/** An SF DataSF ring in the bundled-area shape `bundledAdminTarget` takes. */
function sfNeighborhoodArea({ ring, name }) {
const lons = ring.map((p) => p[0]);
const lats = ring.map((p) => p[1]);
return {
kind: 'neighborhood',
name,
region: null,
source: 'datasf',
polygons: [[ring]],
ring,
bbox: [
Math.min(...lons),
Math.min(...lats),
Math.max(...lons),
Math.max(...lats),
],
label: ringCentroid(ring),
candidates: 1,
};
}
/**
@@ -794,8 +1107,10 @@ export function createAnnotationResolver({
* draw them. Like the Natural Earth rung, it bypasses the scope caps and the
* centroid drift bound: the unit IS the asked scope.
*/
function bundledAdminOutline(viewer, admin) {
function bundledAdminOutline(viewer, admin, signal) {
signal?.throwIfAborted();
creditAdminSource(viewer, admin);
traceBundledOutline(admin.name, admin.source, admin.polygons?.length);
const { lat, lon } = admin.label;
return {
ring: closeRing([...admin.ring]), // copy: closeRing mutates, the pack is shared
@@ -810,9 +1125,34 @@ export function createAnnotationResolver({
};
}
/**
* Whether a bundled unit is too coarse to stand for a city of its name: a
* Natural Earth admin-1 at city size (up to 5,000 km²) drawn with fewer
* than 32 vertices. Paris (11), Buenos Aires (11), Washington DC (9),
* Vienna and Prague (23) and Seoul (28) are; Berlin (48), Hamburg (43),
* Bremen (39) and every larger state are not. Countries never are.
*/
function isCoarseCityShape(admin) {
if (admin?.source !== 'natural-earth' || admin.kind !== 'state')
return false;
if (!(admin.areaKm2 <= COARSE_CITY_MAX_KM2)) return false;
const vertices = (admin.polygons || []).reduce(
(n, poly) => n + poly.reduce((m, ring) => m + ring.length, 0),
0,
);
return vertices < COARSE_CITY_MAX_VERTICES;
}
/** One trace line per outline a bundled pack answered (same shape as outlineRungs). */
function traceBundledOutline(name, source, parts = 1) {
console.log(
`[Outline] "${name}": bundled → ${source} (${parts || 1} part(s))`,
);
}
/** A complete resolved target for a bundled administrative unit (no outline pending). */
function bundledAdminTarget(viewer, admin, query) {
const outline = bundledAdminOutline(viewer, admin);
function bundledAdminTarget(viewer, admin, query, signal) {
const outline = bundledAdminOutline(viewer, admin, signal);
const [west, south, east, north] = admin.bbox;
console.log(
`[Resolver] "${query}": bundled ${admin.kind} "${admin.name}"` +
@@ -1239,7 +1579,12 @@ export function createAnnotationResolver({
? 40
: 120;
ring = simplifyRing(ring, tolM);
const fp = { ring, kind: 'area', heightM: null };
const fp = {
ring,
kind: 'area',
heightM: null,
outlineCredit: featureOutlineCredit,
};
// Scope sanity BEFORE caching: a city-suffixed neighborhood name ("Downtown San
// Francisco") can match the CITY admin. Caching an oversized ring would poison the
@@ -1323,7 +1668,14 @@ export function createAnnotationResolver({
bestRing = closeRing(coords.map((p) => [p.lon, p.lat]));
}
}
return bestRing ? { ring: bestRing, kind: 'area', heightM: null } : null;
return bestRing
? {
ring: bestRing,
kind: 'area',
heightM: null,
outlineCredit: featureOutlineCredit,
}
: null;
}
/**
@@ -1366,7 +1718,12 @@ export function createAnnotationResolver({
}
}
if (bestRing) {
const fp = { ring: bestRing, kind: 'area', heightM: null };
const fp = {
ring: bestRing,
kind: 'area',
heightM: null,
outlineCredit: featureOutlineCredit,
};
cacheWrite(footprintCache, cacheKey, fp);
return fp;
}
@@ -1397,6 +1754,7 @@ export function createAnnotationResolver({
ring: bufferCorridor(line, 11),
kind: 'area',
heightM: null,
outlineCredit: featureOutlineCredit,
};
cacheWrite(footprintCache, cacheKey, fp);
return fp;
@@ -1535,7 +1893,12 @@ export function createAnnotationResolver({
areaM2 < bestArea * 0.999 ||
(areaM2 <= bestArea * 1.05 && nameMatch && !bestNameMatch); // near-tie: name-match wins
if (better) {
best = { ring: ringLonLat, kind: 'area', heightM: null };
best = {
ring: ringLonLat,
kind: 'area',
heightM: null,
outlineCredit: featureOutlineCredit,
};
bestArea = areaM2;
bestNameMatch = nameMatch;
}
@@ -1561,6 +1924,19 @@ export function createAnnotationResolver({
/** A target that reads like an enclosing GROUNDS / COMPOUND / CAMPUS (e.g. "Texas Capitol grounds")
* rather than a single building — used to synthesize a loose-footprint disc when OSM has no real
* polygon, instead of failing outright. */
/**
* A university, college or campus named as such: "Stanford University",
* "Boston College", "University of Texas", "Stanford campus". Towns named
* after one ("College Station", "University Park") are not.
*/
function isCampusAsk(query) {
const text = stripQualifiers(String(query || '')).trim();
return (
/\b(?:university|college|campus)$/i.test(text) ||
/^(?:the\s+)?(?:university|college)\s+of\b/i.test(text)
);
}
function isGroundsLikeQuery(query) {
return /\b(grounds|compound|campus|complex|quad|plaza)\b/i.test(
String(query || ''),
@@ -1784,6 +2160,7 @@ export function createAnnotationResolver({
best = {
ring: closeRing(coords.map((p) => [p.lon, p.lat])),
kind: isBuilding ? 'building' : 'area',
outlineCredit: featureOutlineCredit,
heightM: isBuilding
? (element.heightM ??
Math.max(10, Math.min(70, Math.sqrt(Math.max(1, areaM2)) * 0.6)))
@@ -2004,8 +2381,8 @@ export function createAnnotationResolver({
/**
* Region ring for ANALYST queries ("how many flights over Texas / the Alps") —
* a name-only entry point that reuses this module's boundary machinery
* without the annotation pipeline. Natural Earth pack first (offline,
* instant; largest-area match is correct for a global name-only ask), then
* without the annotation pipeline. Unique Natural Earth names resolve first
* (offline); duplicate names require a geocoded containment anchor, then
* geocode + admin boundary for states/countries/counties (Tier A disk-cached
* Overpass, so repeat asks are instant). Returns null when the name doesn't
* resolve to a region-like boundary — the analyst engine reports that
@@ -2020,7 +2397,7 @@ export function createAnnotationResolver({
* @param {AbortSignal} [signal]
* @param {object} [placeSearch]
* @param {{budgetMs?: number}} [options] `Infinity` waits for the lookup.
* @returns {Promise<{name:string, ring:Array<[number,number]>}|{name:string, ring:null, error:'region-timeout'}|null>}
* @returns {Promise<{name:string, ring:Array<[number,number]>, source:string}|{name:string, ring:null, error:'region-timeout'}|null>}
*/
async function resolveRegionRingForQuery(
name,
@@ -2037,16 +2414,24 @@ export function createAnnotationResolver({
const q = String(name || '').trim();
if (!q) return null;
const ne = await findNaturalRegion(q).catch(() => null);
if (ne?.polygons?.length) {
signal?.throwIfAborted();
if (ne?.candidates === 1 && ne.polygons?.length) {
// Largest ring carries the query scope; multi-ring regions (Andes) keep
// their main cordillera — good enough for containment counting.
const ring = [...ne.polygons].sort((a, b) => b.length - a.length)[0];
if (ring?.length >= 3) return { name: ne.name, ring };
if (ring?.length >= 3)
return { name: ne.name, ring, source: 'natural-earth' };
}
// Bundled states/provinces/counties by name (offline); the main part only,
// like the Natural Earth rung above.
const admin = await findAdminArea(q).catch(() => null);
if (admin) return { name: admin.name, ring: [...admin.ring] };
signal?.throwIfAborted();
if (admin)
return {
name: admin.name,
ring: [...admin.ring],
source: 'bundled-admin',
};
const lookup = resolveAdminRegionRing(q, signal, placeSearch);
if (!Number.isFinite(budgetMs)) return lookup;
let timer;
@@ -2067,8 +2452,26 @@ export function createAnnotationResolver({
const geo = await geocodePlace(q, null, signal, placeSearch).catch(
() => null,
);
signal?.throwIfAborted();
if (!geo) return null;
const scope = scopeFromTypes(geo.types);
if (
scope === 'auto' ||
(scope === 'compound' && geo.types?.includes('natural_feature'))
) {
const natural = await lookupNaturalRegionOutline(
q,
geo.lat,
geo.lon,
).catch(() => null);
signal?.throwIfAborted();
if (natural)
return {
name: natural.name,
ring: natural.ring,
source: 'natural-earth',
};
}
if (!['country', 'state', 'county', 'city'].includes(scope)) return null;
if (scope === 'state' || scope === 'county' || scope === 'country') {
const admin = await findAdminAreaAt(
@@ -2077,12 +2480,26 @@ export function createAnnotationResolver({
geo.lon,
scope,
).catch(() => null);
if (admin) return { name: q, ring: [...admin.ring] };
signal?.throwIfAborted();
if (admin)
return { name: q, ring: [...admin.ring], source: 'bundled-admin' };
}
if (scope === 'city') {
const place = await findPlaceAreaAt(
[q, geo.primaryName],
geo.lat,
geo.lon,
).catch(() => null);
signal?.throwIfAborted();
if (place)
return { name: q, ring: [...place.ring], source: 'bundled-admin' };
}
const fp = await fetchAdminArea(geo.lat, geo.lon, q, scope, signal).catch(
() => null,
);
if (fp?.ring?.length >= 3) return { name: q, ring: fp.ring };
signal?.throwIfAborted();
if (fp?.ring?.length >= 3)
return { name: q, ring: fp.ring, source: 'remote-boundary' };
return null;
}
File diff suppressed because it is too large. Load diff
+477 -228
View File
@@ -1,164 +1,177 @@
import '../testSupport/registerDom.mjs';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createApp, nextTick } from 'vue';
import { createPinia } from 'pinia';
import { useScreenAnnotationsStore } from '../ui/stores/screenAnnotations.js';
import ScreenAnnotations from '../ui/views/panels/ScreenAnnotations.vue';
import { createScreenAnnotationRenderer } from './screenAnnotationRenderer.js';
class FakeClassList {
constructor(owner) {
this.owner = owner;
this.names = new Set();
}
reset(value) {
this.names = new Set(String(value || '').split(/\s+/).filter(Boolean));
}
add(...names) {
for (const name of names) this.names.add(name);
this.owner.attributes.set('class', Array.from(this.names).join(' '));
}
remove(...names) {
for (const name of names) this.names.delete(name);
this.owner.attributes.set('class', Array.from(this.names).join(' '));
}
contains(name) {
return this.names.has(name);
}
// happy-dom does not lay out SVG; every element measures as a 40 x 16 box.
for (const type of [globalThis.SVGElement, globalThis.SVGGraphicsElement]) {
if (type) type.prototype.getBBox = () => ({ x: 0, y: 0, width: 40, height: 16 });
}
class FakeElement {
constructor(tagName) {
this.tagName = tagName;
this.attributes = new Map();
this.children = [];
this.parentNode = null;
this.style = {};
this.classList = new FakeClassList(this);
this.textContent = '';
}
const settle = async () => {
for (let flush = 0; flush < 4; flush += 1) await nextTick();
};
setAttribute(name, value) {
this.attributes.set(name, String(value));
if (name === 'class') this.classList.reset(value);
}
getAttribute(name) {
return this.attributes.get(name) ?? null;
}
set className(value) {
this.setAttribute('class', value);
}
get className() {
return this.getAttribute('class') || '';
}
set id(value) {
this.setAttribute('id', value);
}
get id() {
return this.getAttribute('id') || '';
}
appendChild(child) {
child.parentNode = this;
this.children.push(child);
return child;
}
removeChild(child) {
const index = this.children.indexOf(child);
if (index >= 0) this.children.splice(index, 1);
child.parentNode = null;
return child;
}
remove() {
this.parentNode?.removeChild(this);
}
querySelector(selector) {
return this.querySelectorAll(selector)[0] || null;
}
querySelectorAll(selector) {
const className = selector.startsWith('.') ? selector.slice(1) : null;
const matches = [];
const visit = (node) => {
for (const child of node.children) {
if (className && child.classList.contains(className)) matches.push(child);
visit(child);
}
};
visit(this);
return matches;
}
addEventListener() {}
getBBox() {
return { x: 0, y: 0, width: 40, height: 16 };
}
set innerHTML(value) {
this._innerHTML = value;
}
}
function fakeDocument() {
const head = new FakeElement('head');
const body = new FakeElement('body');
const roots = [head, body];
return {
head,
body,
createElement: (tag) => new FakeElement(tag),
createElementNS: (_namespace, tag) => new FakeElement(tag),
getElementById(id) {
let found = null;
const visit = (node) => {
if (node.getAttribute('id') === id) found = node;
for (const child of node.children) visit(child);
};
for (const root of roots) visit(root);
return found;
},
};
}
function findAnnotationGroup(document) {
const layer = document.body.children.find((child) => child.classList.contains('gev-screen-whiteboard'));
const svg = layer.children.find((child) => child.classList.contains('gev-screen-whiteboard-svg'));
return {
svg,
group: svg.children.find((child) => child.classList.contains('gev-anno')),
};
}
test('outline upgrade preserves the existing SVG group identity', (t) => {
const originalDocument = globalThis.document;
/** Mount the board's view; animation frames run when `frames()` is called. */
function board(t) {
document.body.replaceChildren();
const pinia = createPinia();
const store = useScreenAnnotationsStore(pinia);
const app = createApp(ScreenAnnotations).use(pinia);
app.mount(document.body.appendChild(document.createElement('div')));
const queued = [];
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
globalThis.document = fakeDocument();
globalThis.requestAnimationFrame = (callback) => { callback(); return 1; };
globalThis.requestAnimationFrame = (callback) => queued.push(callback);
t.after(() => {
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
if (originalRequestAnimationFrame === undefined) delete globalThis.requestAnimationFrame;
else globalThis.requestAnimationFrame = originalRequestAnimationFrame;
globalThis.requestAnimationFrame = originalRequestAnimationFrame;
app.unmount();
});
const frames = () => {
for (const callback of queued.splice(0)) callback();
};
const svg = () => document.querySelector('.gev-screen-whiteboard-svg');
const groups = () => [...(svg()?.querySelectorAll('g.gev-anno') ?? [])];
return { store, frames, svg, groups };
}
function cameraOver(lon, lat) {
const positionWC = Cesium.Cartesian3.fromDegrees(lon, lat, 1000);
const directionWC = Cesium.Cartesian3.normalize(
Cesium.Cartesian3.negate(positionWC, new Cesium.Cartesian3()),
new Cesium.Cartesian3(),
);
// Low enough that point marks keep their full size.
return { positionWC, directionWC, positionCartographic: { height: 500 } };
}
function sceneFor(camera, extra = {}) {
return {
camera,
canvas: { clientWidth: 800, clientHeight: 600, width: 800, height: 600 },
clampToHeightSupported: false,
postRender: { addEventListener() {}, removeEventListener() {} },
...extra,
};
}
/** Project every point to the given window position for the test's length. */
function projectTo(t, at) {
const original = Cesium.SceneTransforms.worldToWindowCoordinates;
Cesium.SceneTransforms.worldToWindowCoordinates = typeof at === 'function' ? at : () => at;
t.after(() => {
Cesium.SceneTransforms.worldToWindowCoordinates = original;
});
}
test('a pin renders its reticle, leader and callout on the page board', async (t) => {
const { store, frames, svg, groups } = board(t);
projectTo(t, { x: 200, y: 300 });
const camera = cameraOver(0, 0);
const renderer = createScreenAnnotationRenderer(
{ scene: sceneFor(camera), camera, trackedEntity: null },
{ store, activeTrackedReadoutId: () => null },
);
t.after(() => renderer.destroy());
renderer.add({
id: 'pin',
type: 'pin',
color: 'amber',
label: 'Capitol',
alpha: 1,
anchor: { lon: 0, lat: 0, height: 0 },
});
await settle();
const layer = document.body.querySelector(':scope > .gev-screen-whiteboard');
assert.ok(layer);
assert.equal(svg().getAttribute('preserveAspectRatio'), 'none');
assert.equal(svg().getAttribute('viewBox'), '0 0 800 600');
assert.ok(svg().querySelector('defs > filter#gev-sketch > feTurbulence'));
const [group] = groups();
assert.equal(group.getAttribute('class'), 'gev-anno');
assert.equal(group.getAttribute('opacity'), '1');
assert.deepEqual(
[...group.children].map((child) => [child.tagName.toLowerCase(), child.getAttribute('class')]),
[
['circle', 'gev-anno-ring gev-pulse'],
['circle', 'gev-anno-ring'],
['circle', 'gev-anno-dot'],
['line', 'gev-anno-leader'],
['g', 'gev-anno-callout'],
],
);
const [outer, inner, dot, leader, callout] = group.children;
assert.equal(outer.getAttribute('stroke'), '#ffb547');
assert.equal(outer.getAttribute('cx'), '200.0');
assert.equal(outer.getAttribute('r'), '34.0');
assert.equal(inner.getAttribute('fill-opacity'), '0.12');
assert.equal(inner.getAttribute('filter'), 'url(#gev-sketch)');
assert.equal(dot.getAttribute('r'), '5.0');
assert.equal(leader.getAttribute('x1'), '200.0');
assert.equal(leader.getAttribute('x2'), '218.0');
// The card is sized to its measured text, and the leader meets its bottom.
const [card, accent, text] = callout.children;
assert.equal(text.textContent, 'Capitol');
assert.equal(text.getAttribute('x'), '9');
assert.equal(card.getAttribute('width'), '58');
assert.equal(card.getAttribute('height'), '26');
assert.equal(accent.getAttribute('height'), '18');
assert.equal(accent.getAttribute('fill'), '#ffb547');
assert.equal(callout.getAttribute('transform'), 'translate(218.0, 266.0)');
assert.equal(leader.getAttribute('y2'), '292.0');
frames();
await settle();
assert.equal(group.getAttribute('class'), 'gev-anno gev-in');
});
test('an arrow defines its arrowhead and drops the dash once drawn on', async (t) => {
const { store, frames, svg, groups } = board(t);
let calls = 0;
projectTo(t, () => (calls++ % 2 ? { x: 400, y: 100 } : { x: 100, y: 100 }));
const camera = cameraOver(0, 0);
const renderer = createScreenAnnotationRenderer(
{ scene: sceneFor(camera), camera, trackedEntity: null },
{ store, activeTrackedReadoutId: () => null },
);
t.after(() => renderer.destroy());
renderer.add({
id: 'arrow',
type: 'arrow',
color: 'green',
alpha: 1,
anchor: { lon: 0, lat: 0 },
to: { lon: 0.01, lat: 0 },
});
await settle();
const marker = svg().querySelector('defs > marker#gev-arrow-green');
assert.equal(marker.getAttribute('orient'), 'auto-start-reverse');
assert.equal(marker.querySelector('path').getAttribute('fill'), '#5dff9f');
const path = groups()[0].querySelector('path');
assert.equal(path.getAttribute('class'), 'gev-anno-arrow gev-draw');
assert.equal(path.getAttribute('marker-end'), 'url(#gev-arrow-green)');
assert.match(path.getAttribute('d'), /^M 100\.0 100\.0 Q /);
frames();
await settle();
const ended = new Event('transitionend', { bubbles: true });
ended.propertyName = 'stroke-dashoffset';
path.dispatchEvent(ended);
await settle();
assert.equal(path.style.strokeDasharray, 'none');
});
test('outline upgrade preserves the existing SVG group identity', async (t) => {
const { store, svg, groups } = board(t);
const camera = {
positionWC: Cesium.Cartesian3.ZERO,
directionWC: Cesium.Cartesian3.ZERO,
positionCartographic: { height: 1000 },
};
const sampledAnchors = [];
const scene = {
camera,
const scene = sceneFor(camera, {
canvas: { clientWidth: 1280, clientHeight: 720, width: 1280, height: 720 },
clampToHeightSupported: true,
clampToHeight(world) {
@@ -169,9 +182,12 @@ test('outline upgrade preserves the existing SVG group identity', (t) => {
]);
return world;
},
postRender: { addEventListener() {}, removeEventListener() {} },
};
const renderer = createScreenAnnotationRenderer({ scene, camera, trackedEntity: null });
});
const renderer = createScreenAnnotationRenderer(
{ scene, camera, trackedEntity: null },
{ store, activeTrackedReadoutId: () => null },
);
t.after(() => renderer.destroy());
const anno = {
id: 'anno-fb3',
type: 'area',
@@ -182,8 +198,11 @@ test('outline upgrade preserves the existing SVG group identity', (t) => {
ring: null,
};
renderer.add(anno);
const before = findAnnotationGroup(globalThis.document);
assert.ok(before.group.querySelector('.gev-anno-ring'), 'pending area starts as a reticle');
await settle();
const [before] = groups();
assert.ok(before.querySelector('.gev-anno-ring'), 'pending area starts as a reticle');
const callout = before.querySelector('.gev-anno-callout');
const dot = before.querySelector('.gev-anno-dot');
const labelProxy = Object.assign(Object.create(anno), {
type: 'label',
@@ -191,47 +210,65 @@ test('outline upgrade preserves the existing SVG group identity', (t) => {
anchor: { lon: -97.5, lat: 31.2, height: 0 },
});
renderer.update(labelProxy);
const after = findAnnotationGroup(globalThis.document);
await settle();
const [after] = groups();
assert.equal(after.group, before.group, 'the rendered group is mutated, not replaced');
assert.ok(after === before, 'the rendered group is mutated, not replaced');
assert.deepEqual(sampledAnchors.at(-1), [-97.5, 31.2], 'the existing group projects from the re-seated anchor');
assert.equal(after.svg.children.filter((child) => child.classList.contains('gev-anno')).length, 1);
assert.equal(after.group.querySelector('.gev-anno-ring'), null, 'reticle rings are removed in place');
assert.ok(after.group.querySelector('.gev-anno-callout'), 'the original callout remains in the group');
renderer.destroy();
assert.equal(groups().length, 1);
assert.equal(after.querySelector('.gev-anno-ring'), null, 'reticle rings are removed in place');
assert.ok(after.querySelector('.gev-anno-callout') === callout, 'the original callout remains in the group');
assert.ok(after.querySelector('.gev-anno-dot') === dot, 'the anchor dot remains in the group');
assert.ok(svg());
});
test('annotation fade consumes the actual tracked host paint rectangle after layout', (t) => {
const originalDocument = globalThis.document;
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
const originalProjection = Cesium.SceneTransforms.worldToWindowCoordinates;
globalThis.document = fakeDocument();
globalThis.requestAnimationFrame = (callback) => { callback(); return 1; };
Cesium.SceneTransforms.worldToWindowCoordinates = () => ({ x: 0, y: 0 });
t.after(() => {
Cesium.SceneTransforms.worldToWindowCoordinates = originalProjection;
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
if (originalRequestAnimationFrame === undefined) delete globalThis.requestAnimationFrame;
else globalThis.requestAnimationFrame = originalRequestAnimationFrame;
});
const positionWC = Cesium.Cartesian3.fromDegrees(0, 0, 1000);
const directionWC = Cesium.Cartesian3.normalize(
Cesium.Cartesian3.negate(positionWC, new Cesium.Cartesian3()),
new Cesium.Cartesian3(),
);
const camera = { positionWC, directionWC, positionCartographic: { height: 1000 } };
const scene = {
camera,
canvas: { clientWidth: 800, clientHeight: 600, width: 800, height: 600 },
clampToHeightSupported: false,
postRender: { addEventListener() {}, removeEventListener() {} },
test('a callout whose text changes is sized to the new text', async (t) => {
const { store, groups } = board(t);
projectTo(t, { x: 100, y: 100 });
// Text measures 7px a character; everything else keeps the shared box.
const original = globalThis.SVGGraphicsElement.prototype.getBBox;
globalThis.SVGGraphicsElement.prototype.getBBox = function () {
return this.tagName.toLowerCase() === 'text'
? { x: 0, y: 0, width: this.textContent.length * 7, height: 16 }
: original.call(this);
};
t.after(() => {
globalThis.SVGGraphicsElement.prototype.getBBox = original;
});
const camera = cameraOver(0, 0);
const renderer = createScreenAnnotationRenderer(
{ scene: sceneFor(camera), camera, trackedEntity: null },
{ store, activeTrackedReadoutId: () => null },
);
t.after(() => renderer.destroy());
const anno = {
id: 'upgrade',
type: 'label',
color: 'primary',
label: 'Texas',
alpha: 1,
anchor: { lon: 0, lat: 0 },
};
renderer.add(anno);
await settle();
const card = () => groups()[0].querySelector('.gev-anno-card');
assert.equal(card().getAttribute('width'), String(5 * 7 + 18));
renderer.update({ ...anno, outlineUnavailable: true });
await settle();
const text = 'Texas · Detailed outline unavailable';
assert.equal(groups()[0].querySelector('text').textContent, text);
assert.equal(card().getAttribute('width'), String(text.length * 7 + 18));
});
test('annotation fade consumes the actual tracked host paint rectangle after layout', async (t) => {
const { store, groups } = board(t);
projectTo(t, { x: 0, y: 0 });
const camera = cameraOver(0, 0);
const paintRectCalls = [];
const renderer = createScreenAnnotationRenderer(
{ scene, camera, trackedEntity: null },
{ scene: sceneFor(camera), camera, trackedEntity: null },
{
store,
activeTrackedReadoutId: () => 'installations:test',
overlayPaintRect(sourceId, entryId) {
paintRectCalls.push({ sourceId, entryId });
@@ -239,6 +276,7 @@ test('annotation fade consumes the actual tracked host paint rectangle after lay
},
},
);
t.after(() => renderer.destroy());
renderer.add({
id: 'anno-overlap',
type: 'label',
@@ -247,7 +285,8 @@ test('annotation fade consumes the actual tracked host paint rectangle after lay
alpha: 1,
anchor: { lon: 0, lat: 0, height: 0 },
});
const { group } = findAnnotationGroup(globalThis.document);
await settle();
const [group] = groups();
assert.deepEqual(paintRectCalls.at(-1), {
sourceId: 'tracked',
entryId: 'installations:test',
@@ -256,44 +295,59 @@ test('annotation fade consumes the actual tracked host paint rectangle after lay
Number(group.getAttribute('opacity')) < 1,
'final annotation bbox fades against the complete painted card rectangle',
);
});
test('a removed mark fades out, then leaves the board', async (t) => {
const { store, groups } = board(t);
projectTo(t, { x: 100, y: 100 });
const camera = cameraOver(0, 0);
const renderer = createScreenAnnotationRenderer(
{ scene: sceneFor(camera), camera, trackedEntity: null },
{ store, activeTrackedReadoutId: () => null },
);
t.after(() => renderer.destroy());
const anno = {
id: 'fading',
type: 'label',
color: 'primary',
label: 'Gone soon',
alpha: 1,
anchor: { lon: 0, lat: 0 },
};
renderer.add(anno);
await settle();
const [group] = groups();
renderer.remove(anno);
// A new mark for the same place gets its own group beside the fading one.
renderer.add(anno);
await settle();
assert.equal(group.getAttribute('class'), 'gev-anno gev-out');
assert.equal(groups().length, 2);
assert.ok(groups()[1] !== group);
await new Promise((resolve) => setTimeout(resolve, 400));
await settle();
assert.deepEqual(groups().length, 1);
assert.equal(group.isConnected, false);
renderer.destroy();
await settle();
assert.equal(document.querySelector('.gev-screen-whiteboard'), null);
});
// ── Partial-add unwind (review round 2) ───────────────────────────────────────
//
// add() inserts the group and records it, then does more live-document work
// (draw-on wiring, the first projection pass). A throw in that tail used to
// leave an ORPHANED <g> plus a record nobody owned — the engine's rollback only
// deletes its own map entry — so re-annotating the same place stacked a second
// mark over the corpse.
// add() records the mark, then does more work (presenting it, the first
// projection pass). A throw in that tail used to leave an ORPHANED mark plus a
// record nobody owned — the engine's rollback only deletes its own map entry —
// so re-annotating the same place stacked a second mark over the corpse.
test('an add that throws after inserting its group unwinds instead of orphaning it', (t) => {
const originalDocument = globalThis.document;
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
const originalProjection = Cesium.SceneTransforms.worldToWindowCoordinates;
globalThis.document = fakeDocument();
globalThis.requestAnimationFrame = (callback) => { callback(); return 1; };
t.after(() => {
Cesium.SceneTransforms.worldToWindowCoordinates = originalProjection;
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
if (originalRequestAnimationFrame === undefined) delete globalThis.requestAnimationFrame;
else globalThis.requestAnimationFrame = originalRequestAnimationFrame;
});
const positionWC = Cesium.Cartesian3.fromDegrees(0, 0, 1000);
const directionWC = Cesium.Cartesian3.normalize(
Cesium.Cartesian3.negate(positionWC, new Cesium.Cartesian3()),
new Cesium.Cartesian3(),
test('an add that throws after inserting its group unwinds instead of orphaning it', async (t) => {
const { store, groups } = board(t);
const camera = cameraOver(0, 0);
const renderer = createScreenAnnotationRenderer(
{ scene: sceneFor(camera, { canvas: { clientWidth: 1280, clientHeight: 720, width: 1280, height: 720 } }), camera, trackedEntity: null },
{ store, activeTrackedReadoutId: () => null },
);
const camera = { positionWC, directionWC, positionCartographic: { height: 1000 } };
const scene = {
camera,
canvas: { clientWidth: 1280, clientHeight: 720, width: 1280, height: 720 },
clampToHeightSupported: false,
postRender: { addEventListener() {}, removeEventListener() {} },
};
const renderer = createScreenAnnotationRenderer({ scene, camera, trackedEntity: null });
t.after(() => renderer.destroy());
const anno = {
id: 'anno-partial-screen',
type: 'pin',
@@ -303,26 +357,221 @@ test('an add that throws after inserting its group unwinds instead of orphaning
anchor: { lon: 0, lat: 0, height: 0 },
};
// The group is in the document and recorded by the time add() reaches its
// first projection pass — so a throw there is real partial state, not a
// clean bail-out.
Cesium.SceneTransforms.worldToWindowCoordinates = () => { throw new Error('projection failed'); };
// The mark is on the board by the time add() reaches its first projection
// pass — so a throw there is real partial state, not a clean bail-out.
projectTo(t, () => {
throw new Error('projection failed');
});
assert.throws(() => renderer.add(anno), /projection failed/);
Cesium.SceneTransforms.worldToWindowCoordinates = () => ({ x: 0, y: 0 });
const { svg } = findAnnotationGroup(globalThis.document);
assert.equal(
svg.children.filter((child) => child.classList.contains('gev-anno')).length,
0,
'the half-built group must not stay in the document',
);
await settle();
assert.equal(groups().length, 0, 'the half-built group must not stay in the document');
// The id is free again: a retry draws exactly one mark, and remove() still
// owns it (a leaked record would leave a second group behind).
renderer.add(anno);
assert.equal(
svg.children.filter((child) => child.classList.contains('gev-anno')).length,
1,
'the retry must draw one mark, not stack over an orphan',
await settle();
assert.equal(groups().length, 1, 'the retry must draw one mark, not stack over an orphan');
});
test("an earlier renderer leaves a later renderer's board alone", async (t) => {
const { store, groups } = board(t);
projectTo(t, { x: 100, y: 100 });
const camera = cameraOver(0, 0);
const viewer = { scene: sceneFor(camera), camera, trackedEntity: null };
const options = { store, activeTrackedReadoutId: () => null };
const earlier = createScreenAnnotationRenderer(viewer, options);
const later = createScreenAnnotationRenderer(viewer, options);
t.after(() => later.destroy());
later.add({ id: 'kept', type: 'label', label: 'Kept', alpha: 1, anchor: { lon: 0, lat: 0 } });
earlier.add({ id: 'ignored', type: 'label', label: 'Ignored', alpha: 1, anchor: { lon: 0, lat: 0 } });
earlier.destroy();
await settle();
assert.equal(groups().length, 1);
assert.equal(groups()[0].querySelector('text').textContent, 'Kept');
});
test('a multipart area with holes draws every visible part and keeps holes open', async (t) => {
const { store, svg } = board(t);
// A fixed projection: 0.001 degrees spans 100 px.
projectTo(t, (_scene, world) => {
const c = Cesium.Cartographic.fromCartesian(world);
return {
x: 100 + Cesium.Math.toDegrees(c.longitude) * 1e5,
y: 100 + Cesium.Math.toDegrees(c.latitude) * 1e5,
};
});
const camera = cameraOver(0, 0);
const renderer = createScreenAnnotationRenderer(
{ scene: sceneFor(camera), camera, trackedEntity: null },
{ store, activeTrackedReadoutId: () => null },
);
renderer.destroy();
t.after(() => renderer.destroy());
const outer = [[0, 0], [0.001, 0], [0.001, 0.001], [0, 0.001], [0, 0]];
const hole = [[0.0002, 0.0002], [0.0004, 0.0002], [0.0004, 0.0004], [0.0002, 0.0004], [0.0002, 0.0002]];
const island = outer.map(([lon, lat]) => [lon + 0.002, lat]);
const anno = {
id: 'anno-multipart-screen',
label: 'Multipart',
type: 'area',
color: 'primary',
alpha: 1,
anchor: { lon: 0, lat: 0, height: 0 },
ring: outer,
polygons: [[outer, hole], [island]],
synthesized: false,
};
renderer.add(anno);
await settle();
const area = () => svg().querySelector('.gev-anno-area');
const subpaths = () => (area().getAttribute('d').match(/M /g) || []).length;
assert.equal(area().tagName.toLowerCase(), 'path');
assert.equal(area().getAttribute('fill-rule'), 'evenodd');
assert.equal(subpaths(), 3, 'screen fill retains both parts and the hole');
// The first part is behind the globe: the label centres on the next part.
anno.polygons = [[island.map(([lon, lat]) => [lon + 180, lat])], [outer, hole]];
renderer.sync();
await settle();
assert.equal(subpaths(), 2, 'an island behind the globe cannot hide the visible part and its hole');
assert.notEqual(area().parentNode.style.display, 'none');
assert.doesNotMatch(
area().parentNode.querySelector('.gev-anno-callout').getAttribute('transform'),
/NaN/,
);
anno.polygons = [[outer, hole]];
projectTo(t, (_scene, world) => {
const lon = Cesium.Math.toDegrees(Cesium.Cartographic.fromCartesian(world).longitude);
return { x: lon > 0.0009 ? 2401 : 0, y: 0 };
});
renderer.sync();
await settle();
assert.equal(subpaths(), 2, 'finite offscreen shell coordinates cannot hide a visible holed part');
assert.notEqual(area().parentNode.style.display, 'none');
anno.polygons = [[outer, hole], [island]];
projectTo(t, (_scene, world) => {
const lon = Cesium.Math.toDegrees(Cesium.Cartographic.fromCartesian(world).longitude);
return Math.abs(lon - 0.0002) < 1e-7 ? undefined : { x: 0, y: 0 };
});
renderer.sync();
await settle();
assert.equal(subpaths(), 1, 'an unprojectable hole omits its entire part, not the independently visible island');
assert.notEqual(area().parentNode.style.display, 'none');
});
test('Preview avoids the map callout without moving its world anchor', async (t) => {
const { store, svg, groups } = board(t);
projectTo(t, (_scene, world) => {
const lon = Cesium.Math.toDegrees(Cesium.Cartographic.fromCartesian(world).longitude);
return { x: 400, y: lon > 0.0005 ? 166 : 200 };
});
const camera = cameraOver(0, 0);
const scene = sceneFor(camera, {
canvas: { clientWidth: 1280, clientHeight: 720, width: 1280, height: 720 },
});
const panel = document.body.appendChild(document.createElement('section'));
panel.id = 'gev-voice-panel';
panel.dataset.view = 'preview';
panel.hidden = true;
let panelRect = { left: 210, top: 120, width: 100, height: 120 };
let panelVisible = true;
panel.getBoundingClientRect = () => panelRect;
panel.checkVisibility = () => panelVisible;
const renderer = createScreenAnnotationRenderer(
{ scene, camera, trackedEntity: null },
{ store, activeTrackedReadoutId: () => null },
);
t.after(() => renderer.destroy());
const anno = { id: 'preview-label', type: 'label', color: 'primary', label: 'Texas', alpha: 1, anchor: { lon: 0, lat: 0, height: 0 } };
renderer.add(anno);
await settle();
// Non-unit, independent axis scales and a nonzero client offset: the
// overlay uses preserveAspectRatio="none", not letterboxing.
svg().getBoundingClientRect = () => ({ left: 10, top: 20, width: 640, height: 400 });
renderer.sync();
await settle();
const [group] = groups();
const callout = () => group.querySelector('.gev-anno-callout');
const leader = group.querySelector('.gev-anno-leader');
const dot = group.querySelector('.gev-anno-dot');
const natural = callout().getAttribute('transform');
const anchor = structuredClone(anno.anchor);
panel.hidden = false;
renderer.sync();
await settle();
assert.equal(callout().getAttribute('transform'), 'translate(418.0, 146.0)');
assert.equal(leader.getAttribute('x2'), '418.0');
assert.equal(leader.getAttribute('y2'), '172.0');
assert.equal(leader.getAttribute('x1'), '400.0');
assert.equal(leader.getAttribute('y1'), '200.0');
assert.equal(dot.getAttribute('cx'), '400.0');
assert.equal(dot.getAttribute('cy'), '200.0');
assert.deepEqual(anno.anchor, anchor);
assert.ok(groups()[0] === group, 'the group is kept, not replaced');
panel.hidden = true;
renderer.sync();
await settle();
assert.equal(callout().getAttribute('transform'), natural, 'closing Preview restores natural placement');
for (const variant of ['settings', 'invisible', 'no-overlap', 'no-room', 'collision']) {
panel.hidden = false;
panel.dataset.view = variant === 'settings' ? 'settings' : 'preview';
panelVisible = variant !== 'invisible';
panelRect = variant === 'no-overlap'
? { left: 510, top: 120, width: 100, height: 120 }
: variant === 'no-room'
? { left: 210, top: 25, width: 100, height: 180 }
: { left: 210, top: 120, width: 100, height: 120 };
if (variant === 'collision') renderer.add({ ...anno, id: 'above-slot', anchor: { lon: 0.001, lat: 0, height: 0 } });
renderer.sync();
await settle();
assert.equal(callout().getAttribute('transform'), natural, variant);
}
});
test('a mark hidden from Draw is not drawn or placed, and comes back when shown', async (t) => {
// happy-dom measures every SVG box the same, so callout spreading cannot
// settle here; a hidden mark simply has to stay out of drawing and
// placement, which is what keeps it from pushing a visible callout aside.
const { store, svg } = board(t);
projectTo(t, { x: 200, y: 300 });
const camera = cameraOver(0, 0);
let project = null;
const renderer = createScreenAnnotationRenderer(
{
scene: sceneFor(camera, {
postRender: {
addEventListener: (listener) => (project = listener),
removeEventListener() {},
},
}),
camera,
trackedEntity: null,
},
{ store, activeTrackedReadoutId: () => null },
);
t.after(() => renderer.destroy());
const camp = {
id: 'camp',
type: 'pin',
color: 'amber',
label: 'Camp',
alpha: 1,
hidden: true,
anchor: { lon: 0, lat: 0, height: 0 },
};
renderer.add(camp);
project();
await settle();
const drawn = () =>
[...svg().querySelectorAll('g.gev-anno')].some(
(group) =>
group.textContent.includes('Camp') && group.style.display !== 'none',
);
assert.equal(drawn(), false, 'the hidden mark is not drawn');
camp.hidden = false;
project();
await settle();
assert.equal(drawn(), true, 'shown again, it is drawn');
});
+360 -87
View File
@@ -1,3 +1,5 @@
import { recordWorkSlice } from '../sources/phaseTiming.js';
import { ringCentroid } from '../sources/featureGeometry.js';
import * as Cesium from 'cesium';
import { showOsmCredit, hideOsmCredit } from '../data/dataCredits.js';
@@ -51,6 +53,232 @@ export function createWorldAnnotationRenderer(viewer) {
const dataSource = new Cesium.CustomDataSource('gev-annotations');
viewer.dataSources.add(dataSource);
const osmAnnotations = new Set();
// Every mark with entities on the globe, so sync() can take a hidden one out.
const drawn = new Set();
const pendingGround = new Map();
const groundQueue = [];
let observingGround = false;
let destroyed = false;
function sampleBuilding(anno, options) {
pendingGround.get(anno)?.finish('cancelled');
return new Promise((resolve, reject) => {
const queuedAt = performance.now();
// The 2 s budget starts when this task reaches the head of the queue, so
// waiting behind other buildings on a slow device never spends it.
let started = null;
const workTrace = options.workTrace;
if (workTrace) workTrace.samplerStart = queuedAt;
let groundDetailSlots = 0;
let groundDetailReady = true;
if (workTrace) {
try {
workTrace.groundDetailDropped = 0;
} catch {
groundDetailReady = false;
}
}
const task = {
anno,
points: groundProbePositions(anno.ring),
samples: [],
index: 0,
};
const timing = {
workMs: 0,
maxSliceMs: 0,
lastSliceMs: 0,
probes: 0,
queuedMs: 0,
deadlineMs: null,
};
task.current = () =>
!destroyed &&
!options.signal?.aborted &&
pendingGround.get(anno) === task &&
(options.isCurrent?.() ?? true);
task.begin = () => {
if (started !== null) return;
started = performance.now();
timing.queuedMs = started - queuedAt;
timing.deadlineMs = started + 2000;
if (workTrace) workTrace.samplerHeadAt = started;
task.timer = setTimeout(() => task.finish('timeout'), 2000);
// A task promoted outside a frame (its predecessor timed out or was
// cancelled) still needs frames in request-render mode.
try {
viewer.scene.requestRender?.();
} catch {
/* the enqueue path reports render failures */
}
};
task.finish = (status, error) => {
if (pendingGround.get(anno) !== task) return;
pendingGround.delete(anno);
const position = groundQueue.indexOf(task);
groundQueue.splice(position, 1);
clearTimeout(task.timer);
if (position === 0) groundQueue[0]?.begin();
options.signal?.removeEventListener('abort', task.abort);
if (!groundQueue.length && observingGround) {
viewer.scene.postRender.removeEventListener(onGroundFrame);
observingGround = false;
}
timing.elapsedMs = performance.now() - (started ?? queuedAt);
if (workTrace) {
workTrace.probes = timing.probes;
workTrace.samplerStatus = status;
}
if (error) reject(error);
else resolve({ status, timing });
};
task.abort = () => task.finish('cancelled');
task.step = () => {
const workStart = workTrace ? performance.now() : 0;
// Nested call/commit wall-time only; the original parent remains the gate.
const detail =
workTrace && groundDetailSlots++ < 8
? {
index: task.index,
status: groundDetailReady ? 'recorded' : 'missing',
clampStart: null,
clampEnd: null,
clampResult: 'not-run',
commitStart: null,
commitEnd: null,
commitStatus: 'not-run',
}
: undefined;
const note = detail
? (key) => {
try {
const at = performance.now();
if (Number.isFinite(at)) detail[key] = at;
else detail.status = 'missing';
} catch {
detail.status = 'missing';
}
}
: null;
if (workTrace && !detail) {
try {
workTrace.groundDetailDropped += 1;
} catch {
// Missing attribution must not change the sampler outcome.
}
}
try {
if (!task.current()) return task.finish('cancelled');
if (performance.now() - started >= 2000)
return task.finish('timeout');
const sliceStart = performance.now();
let error;
try {
if (
viewer.scene.clampToHeightSupported &&
typeof viewer.scene.clampToHeight === 'function'
) {
const [lon, lat] = task.points[task.index++];
timing.probes += 1;
try {
const position = Cesium.Cartesian3.fromDegrees(lon, lat, 0);
let c;
note?.('clampStart');
if (detail) detail.clampResult = 'threw';
try {
c = viewer.scene.clampToHeight(position);
if (detail) detail.clampResult = 'returned';
} finally {
note?.('clampEnd');
}
if (c) {
const h = Cesium.Cartographic.fromCartesian(c).height;
if (Number.isFinite(h) && h > -430 && h < 9000)
task.samples.push(h);
}
} catch {
/* surface not ready: preserve the existing sample filter */
}
} else task.index = task.points.length;
if (performance.now() - started >= 2000) {
task.finish('timeout');
return;
}
if (!task.current()) return task.finish('cancelled');
if (task.index === task.points.length) {
if (detail) detail.commitStatus = 'attempted';
note?.('commitStart');
task.samples.sort((a, b) => a - b);
const ground = task.samples.length
? task.samples[Math.floor(task.samples.length * 0.3)]
: null;
add(anno, {
sampledGroundHeight: ground,
isCurrent: task.current,
});
if (performance.now() >= timing.deadlineMs) {
task.finish('timeout');
remove(anno);
return;
}
}
} catch (caught) {
error = caught;
} finally {
timing.lastSliceMs = performance.now() - sliceStart;
timing.workMs += timing.lastSliceMs;
timing.maxSliceMs = Math.max(timing.maxSliceMs, timing.lastSliceMs);
}
if (error) {
task.finish('failed', error);
try {
remove(anno);
} catch {
/* retain the original commit error */
}
} else if (task.index === task.points.length)
task.finish('installed');
} finally {
// Commit includes sort/selection/add/deadline/finish or rollback,
// including intervening bookkeeping; it is not exclusive entity cost.
if (detail?.commitStatus === 'attempted') note('commitEnd');
if (workTrace)
recordWorkSlice(
workTrace,
'ground-step',
workStart,
undefined,
detail,
);
}
};
pendingGround.set(anno, task);
groundQueue.push(task);
if (groundQueue[0] === task) task.begin();
options.signal?.addEventListener('abort', task.abort, { once: true });
if (!task.current()) return task.finish('cancelled');
if (!observingGround) {
viewer.scene.postRender.addEventListener(onGroundFrame);
observingGround = true;
}
try {
viewer.scene.requestRender?.();
} catch (error) {
task.finish('failed', error);
}
});
}
function onGroundFrame() {
// One building probe TOTAL, not one per mark. Other renderers have their own work.
const task = groundQueue[0];
try {
task?.step();
if (groundQueue.length) viewer.scene.requestRender?.();
} catch (error) {
for (const pending of [...groundQueue]) pending.finish('failed', error);
}
}
// Register the GevRouteFlow fabric once so Cesium's Material.fromType() can build the
// material the route pipeline renders. The animated `time` uniform is read straight
@@ -81,7 +309,7 @@ export function createWorldAnnotationRenderer(viewer) {
}, false);
}
function add(anno) {
function add(anno, options = {}) {
const base = colorFor(anno);
// Published to the mark BEFORE anything is added, and mutated in place as
// each entity lands: a mid-add failure (bad geometry, lost context) must
@@ -89,21 +317,42 @@ export function createWorldAnnotationRenderer(viewer) {
// path cannot reach them and they stay on the globe forever.
const entities = [];
anno._entities = entities;
drawn.add(anno);
if (
anno.ring?.length >= 3 &&
anno.footprintKind === 'building' &&
!Object.hasOwn(options, 'sampledGroundHeight')
) {
return sampleBuilding(anno, options);
}
if (
anno.ring &&
anno.ring.length >= 3 &&
anno.footprintKind === 'building'
) {
const addBuildingEntity = (definition) => {
if (!(options.isCurrent?.() ?? true))
throw new Error('Building install cancelled');
const entity = dataSource.entities.add(definition);
if (!(options.isCurrent?.() ?? true)) {
dataSource.entities.remove(entity);
throw new Error('Building install cancelled');
}
return entity;
};
// Single building → the PRIMARY highlight is an extruded CLASSIFICATION
// volume that tints the real photogrammetry mesh (dome, walls, roof —
// everything inside the column) amber and pulses. A flat footprint
// extrusion is only 2.5D and misses domes; classification colors the
// actual tiles. A faint wireframe cage rides on top as a secondary cue.
const buffered = bufferRing(anno.ring, 3);
const positions = Cesium.Cartesian3.fromDegreesArray(buffered.flat());
const parts =
Array.isArray(anno.polygons) && anno.polygons.length
? anno.polygons
: [[anno.ring]];
const groundH =
sampleGroundOutside(viewer.scene, anno.ring) ??
options.sampledGroundHeight ??
(Number.isFinite(anno.anchor.height)
? anno.anchor.height - (anno.buildingHeight || 25)
: 0);
@@ -112,55 +361,73 @@ export function createWorldAnnotationRenderer(viewer) {
// empty air above the roof isn't tinted — this just guarantees we cover
// under-tagged building heights.
const topH = groundH + Math.max(18, anno.buildingHeight || 25) + 24;
// 1) Classification volume — tints the mesh amber, pulsing.
entities.push(
dataSource.entities.add({
polygon: {
hierarchy: new Cesium.PolygonHierarchy(positions),
height: baseH,
extrudedHeight: topH,
perPositionHeight: false,
classificationType: CLASSIFY,
material: new Cesium.ColorMaterialProperty(
liveColor(anno, base, { alpha: 0.45, pulse: true }),
),
},
}),
const fillMaterial = new Cesium.ColorMaterialProperty(
liveColor(anno, base, { alpha: 0.45, pulse: true }),
);
// 2) Faint, SIMPLIFIED wireframe cage — secondary, lower opacity. Decimate
// the footprint so the extruded outline draws ~14 clean verticals, not one
// per (often 100+) ring vertex.
const cagePositions = Cesium.Cartesian3.fromDegreesArray(
decimateRing(buffered, 14).flat(),
);
entities.push(
dataSource.entities.add({
polygon: {
hierarchy: new Cesium.PolygonHierarchy(cagePositions),
height: baseH,
extrudedHeight: topH,
perPositionHeight: false,
fill: false,
outline: true,
outlineColor: liveColor(anno, base, { alpha: 0.32, pulse: true }),
},
}),
);
// 3) Crisp draped base outline so the footprint reads on the ground.
entities.push(
dataSource.entities.add({
polyline: {
positions,
width: 3,
material: new Cesium.PolylineGlowMaterialProperty({
glowPower: 0.25,
color: liveColor(anno, base, { alpha: 0.7 }),
const lineMaterial = new Cesium.PolylineGlowMaterialProperty({
glowPower: 0.25,
color: liveColor(anno, base, { alpha: 0.7 }),
});
for (const [outer, ...holes] of parts) {
const buffered = bufferRing(outer, 3);
const positions = Cesium.Cartesian3.fromDegreesArray(buffered.flat());
// 1) Classification volume — tints the mesh amber, pulsing.
entities.push(
addBuildingEntity({
polygon: {
hierarchy: new Cesium.PolygonHierarchy(
positions,
holes.map(
(hole) =>
new Cesium.PolygonHierarchy(
Cesium.Cartesian3.fromDegreesArray(hole.flat()),
),
),
),
height: baseH,
extrudedHeight: topH,
perPositionHeight: false,
classificationType: CLASSIFY,
material: fillMaterial,
},
}),
);
// 2) Faint, SIMPLIFIED wireframe cage — secondary, lower opacity. Decimate
// the footprint so the extruded outline draws ~14 clean verticals, not one
// per (often 100+) ring vertex.
const cagePositions = Cesium.Cartesian3.fromDegreesArray(
decimateRing(buffered, 14).flat(),
);
entities.push(
addBuildingEntity({
polygon: {
hierarchy: new Cesium.PolygonHierarchy(cagePositions),
height: baseH,
extrudedHeight: topH,
perPositionHeight: false,
fill: false,
outline: true,
outlineColor: liveColor(anno, base, { alpha: 0.32, pulse: true }),
},
}),
);
// 3) Crisp draped base outline so the footprint reads on the ground.
for (const ring of [outer, ...holes]) {
entities.push(
addBuildingEntity({
polyline: {
positions: Cesium.Cartesian3.fromDegreesArray(
closedRing(ring).flat(),
),
width: 3,
material: lineMaterial,
clampToGround: true,
classificationType: CLASSIFY,
},
}),
clampToGround: true,
classificationType: CLASSIFY,
},
}),
);
);
}
}
} else if (anno.ring && anno.ring.length >= 3) {
// Larger area (district / compound / park / state) → flat fill draped on
// the tiles + a glowing outline. Draping is right here: you can't extrude
@@ -247,6 +514,7 @@ export function createWorldAnnotationRenderer(viewer) {
width: 9,
material: new FlowMaterialProperty(
PALETTE[anno.color] || PALETTE.primary,
() => anno.alpha ?? 1,
),
clampToGround: true,
classificationType: CLASSIFY,
@@ -300,6 +568,19 @@ export function createWorldAnnotationRenderer(viewer) {
}
entities.push(pointMarker(anno, base));
}
if (
entities.length &&
anno.ring?.length >= 3 &&
!anno.synthesized &&
(anno.outlineCredit === 'tiles' || anno.outlineCredit === 'osm') &&
(options.isCurrent?.() ?? true)
) {
// The actual world proxy owns both geometry and credit, including rollback.
osmAnnotations.add(anno);
showOsmCredit(viewer, anno, {
openMapTiles: anno.outlineCredit === 'tiles',
});
}
}
// Captions belong to the hybrid renderer's screen-space callouts.
@@ -318,8 +599,10 @@ export function createWorldAnnotationRenderer(viewer) {
}
function remove(anno) {
pendingGround.get(anno)?.finish('cancelled');
hideOsmCredit(viewer, anno);
osmAnnotations.delete(anno);
drawn.delete(anno);
if (!anno?._entities) return;
for (const entity of anno._entities) {
try {
@@ -331,13 +614,25 @@ export function createWorldAnnotationRenderer(viewer) {
anno._entities = null;
}
/**
* CallbackProperty drives per-frame alpha and pulse. A hidden mark is taken
* out whole, so no material that keeps its own colour (the route's flow),
* ground classification or pick can still show it.
*/
function sync() {
// No-op: CallbackProperty drives per-frame alpha/pulse animation.
for (const anno of drawn) {
const show = !anno.hidden;
for (const entity of anno._entities || [])
if (entity.show !== show) entity.show = show;
}
}
function destroy() {
destroyed = true;
for (const task of [...pendingGround.values()]) task.finish('cancelled');
for (const anno of osmAnnotations) hideOsmCredit(viewer, anno);
osmAnnotations.clear();
drawn.clear();
try {
viewer.dataSources.remove(dataSource, true);
} catch {
@@ -407,8 +702,11 @@ export function ensureFlowFabricRegistered() {
* returned a standalone Material Cesium never rendered, or treated `result` as a
* Material — both left the real uniforms untouched, so nothing animated.)
*/
export function FlowMaterialProperty(colorCss) {
export function FlowMaterialProperty(colorCss, alphaOf = () => 1) {
this._color = Cesium.Color.fromCssColorString(colorCss).withAlpha(0.95);
// The mark's live alpha (fade in and out, hidden), applied every frame.
this._alphaOf = alphaOf;
this._live = this._color.clone();
this._definitionChanged = new Cesium.Event();
}
Object.defineProperties(FlowMaterialProperty.prototype, {
@@ -431,7 +729,8 @@ FlowMaterialProperty.prototype.getValue = function getValue(time, result) {
// `time` is read straight from the wall clock so the dashes flow every rendered
// frame (the scene renders continuously; no requestRenderMode here).
if (!Cesium.defined(result)) result = {};
result.color = this._color;
this._live.alpha = this._color.alpha * this._alphaOf();
result.color = this._live;
result.time = performance.now() / 1000; // the per-frame animated value
result.repeat = 64.0;
result.duty = 0.46;
@@ -458,25 +757,11 @@ function decimateRing(ring, n) {
}
/**
* Sample the ground height (m) just OUTSIDE a building footprint. Sampling at
* the centroid would clamp onto the roof, so we probe several points beyond the
* footprint radius and take a low percentile (≈ ground). Returns null if the
* tiles under those points aren't loaded yet.
* The original eight outside-footprint probes. Keep their coordinates and order
* while scheduling the expensive scene picks across rendered frames.
*/
function sampleGroundOutside(scene, ring) {
if (
!scene?.clampToHeightSupported ||
typeof scene.clampToHeight !== 'function'
)
return null;
let clon = 0;
let clat = 0;
for (const [lon, lat] of ring) {
clon += lon;
clat += lat;
}
clon /= ring.length;
clat /= ring.length;
function groundProbePositions(ring) {
const { lon: clon, lat: clat } = ringCentroid(ring);
const latS = 111320;
const lonS = latS * Math.cos(Cesium.Math.toRadians(clat));
let maxR = 0;
@@ -494,23 +779,11 @@ function sampleGroundOutside(scene, ring) {
[0.7, -0.7],
[-0.7, 0.7],
];
const samples = [];
for (const [dx, dy] of dirs) {
return dirs.map(([dx, dy]) => {
const lon = clon + (dx * out) / lonS;
const lat = clat + (dy * out) / latS;
try {
const c = scene.clampToHeight(Cesium.Cartesian3.fromDegrees(lon, lat, 0));
if (c) {
const h = Cesium.Cartographic.fromCartesian(c).height;
if (Number.isFinite(h) && h > -430 && h < 9000) samples.push(h);
}
} catch {
/* tile not ready */
}
}
if (!samples.length) return null;
samples.sort((a, b) => a - b);
return samples[Math.floor(samples.length * 0.3)];
return [lon, lat];
});
}
/**
@@ -0,0 +1,70 @@
// A mark hidden from Draw's list leaves the globe whole: every entity it put
// there is taken out on sync and comes back when shown, and the line's
// flowing stroke, which keeps its own colour, follows the mark's alpha.
import test from 'node:test';
import assert from 'node:assert/strict';
import * as C from 'cesium';
import {
FlowMaterialProperty,
createWorldAnnotationRenderer,
} from './worldAnnotationRenderer.js';
const noop = () => {};
function viewer(t) {
for (const name of ['HTMLCanvasElement', 'HTMLImageElement', 'ImageBitmap', 'OffscreenCanvas']) {
const original = globalThis[name];
globalThis[name] = class {};
t.after(() => { if (original === undefined) delete globalThis[name]; else globalThis[name] = original; });
}
const before = globalThis.document;
globalThis.document = { addEventListener: noop, removeEventListener: noop };
t.after(() => { if (before === undefined) delete globalThis.document; else globalThis.document = before; });
return {
creditDisplay: { addStaticCredit: noop, removeStaticCredit: noop },
entities: new C.EntityCollection(),
dataSources: new C.DataSourceCollection(),
camera: { moveEnd: new C.Event(), positionCartographic: { height: 1000 } },
scene: { requestRender: noop, canvas: { addEventListener: noop, removeEventListener: noop }, preRender: new C.Event(),
primitives: { add: (p) => p, remove: noop }, screenSpaceCameraController: { enableInputs: true } },
};
}
const marks = () => [
{ id: 'route', type: 'route', color: 'amber', alpha: 1, anchor: { lat: 0, lon: 0 }, path: [{ lat: 0, lon: 0 }, { lat: 0.01, lon: 0.01 }] },
{ id: 'area', type: 'area', color: 'green', alpha: 1, anchor: { lat: 0.005, lon: 0.005 }, ring: [[0, 0], [0.01, 0], [0.01, 0.01], [0, 0]] },
{ id: 'pin', type: 'pin', color: 'primary', alpha: 1, anchor: { lat: 0.02, lon: 0.02 } },
];
test('a hidden mark has every entity taken off on sync, and back when shown', (t) => {
const renderer = createWorldAnnotationRenderer(viewer(t));
const [route, area, pin] = marks();
for (const anno of [route, area, pin]) renderer.add(anno);
const shown = (anno) => anno._entities.map((entity) => entity.show);
for (const anno of [route, area, pin]) assert.ok(anno._entities.length > 0, anno.id);
route.hidden = true;
area.hidden = true;
renderer.sync();
assert.ok(shown(route).every((show) => show === false), 'the line is off');
assert.ok(shown(area).every((show) => show === false), 'the area is off');
assert.ok(shown(pin).every((show) => show === true), 'a visible mark stays');
route.hidden = false;
renderer.sync();
assert.ok(shown(route).every((show) => show === true), 'the line is back');
renderer.remove(area);
renderer.sync();
assert.equal(area._entities, null, 'a removed mark is no longer synced');
renderer.destroy();
});
test('the line\'s flowing stroke follows its mark\'s alpha', () => {
let alpha = 1;
const material = new FlowMaterialProperty('#ffb547', () => alpha);
assert.ok(Math.abs(material.getValue(null, {}).color.alpha - 0.95) < 1e-9);
alpha = 0;
assert.equal(material.getValue(null, {}).color.alpha, 0, 'hidden: nothing painted');
alpha = 0.5;
assert.ok(Math.abs(material.getValue(null, {}).color.alpha - 0.475) < 1e-9, 'fading');
assert.equal(new FlowMaterialProperty('#fff').getValue(null, {}).color.alpha, 0.95, 'no alpha source: opaque as before');
});
+7 -1
View File
@@ -1,3 +1,4 @@
import { mountApplicationViews } from '../ui/views/index.js';
const START_ORDER = ['scene', 'controls', 'data', 'tools'];
// Controls cancel restoration while the data manager and viewer still exist.
const STOP_ORDER = ['tools', 'controls', 'data', 'scene'];
@@ -6,7 +7,9 @@ const STOP_ORDER = ['tools', 'controls', 'data', 'scene'];
* Own application startup and teardown without importing an application entry.
* Constructors receive earlier components, an AbortSignal and defer(cleanup).
* Register cleanup immediately after acquiring each resource, before any await.
* @param {{createScene:Function,createControls:Function,createData:Function,createTools:Function}} constructors
* The page's views render first; `omitViews` names the ones this application
* leaves out (see mountApplicationViews).
* @param {{createScene:Function,createControls:Function,createData:Function,createTools:Function,omitViews?:string[]}} constructors
* @returns {{start:Function,destroy:Function,subscribe:Function,getState:Function,getComponents:Function}}
*/
export function createApplication({
@@ -14,7 +17,10 @@ export function createApplication({
createControls,
createData,
createTools,
omitViews = [],
}) {
// A host's page needs its views before any phase reads the markup.
mountApplicationViews(globalThis.document, { omit: omitViews });
const factories = {
scene: createScene,
controls: createControls,
+13
View File
@@ -31,6 +31,8 @@ test('importing and constructing the package is inactive without browser globals
const result = spawnSync(
process.execPath,
[
// Views are single-file components; Node needs the loader hosts use.
'--import=gods-eye-view/build/register-vue',
'--input-type=module',
'-e',
`
@@ -219,6 +221,17 @@ test('the separate viewer export imports without constructing a browser viewer',
assert.throws(() => createApplicationViewer({}), /containers are required/);
});
test('the viewer gets the starry sky as an option, which keeps the Sun and Moon', async () => {
const { applicationViewerOptions } = await import('./viewer.js');
const { StarrySky } = await import('../sky/starrySky.js');
const creditContainer = {};
const options = applicationViewerOptions(creditContainer);
// CesiumWidget skips scene.sun and scene.moon when skyBox is false.
assert.ok(options.skyBox instanceof StarrySky);
assert.equal(options.creditContainer, creditContainer);
options.skyBox.destroy();
});
function pinchFixture({ zoomEventTypes } = {}) {
const handlers = new Set();
const relayed = [];
+1
View File
@@ -4,6 +4,7 @@ const CONTROL_LAYER_IDS = Object.freeze({
militaryFlightsLayer: 'military',
satellitesLayer: 'satellites',
cctvLayer: 'cctv',
streetLevelLayer: 'street-level',
radioLayer: 'radio',
bikeshareLayer: 'bikeshare',
transitLayer: 'transit',
+47 -2
View File
@@ -65,13 +65,28 @@ test('data setup seals the caller catalog before controls can restore and drains
},
},
},
catalog: createLayerCatalog(layers, metadata(layers)),
catalog: {
...createLayerCatalog(layers, metadata(layers)),
getSourceAvailability: (id) =>
id === 'one'
? {
available: false,
reason: 'One: data source not configured',
}
: undefined,
},
defer: (release) => releases.push(release),
onData: (manager) => {
observed = manager;
},
});
assert.equal(observed, dataManager);
assert.equal(await dataManager.setEnabled('one', true), false);
assert.equal(dataManager.layers.get('one').initialized, false);
assert.equal(
dataManager.getAll().find(({ id }) => id === 'one').stats.error,
'One: data source not configured',
);
assert.deepEqual(attached, [['one', 'two']]);
assert.deepEqual([...dataManager.layers.keys()], ['one', 'two']);
assert.throws(() => dataManager.register({ id: 'late' }), /finalized/);
@@ -79,6 +94,35 @@ test('data setup seals the caller catalog before controls can restore and drains
assert.equal(dataManager.layers.size, 0);
});
test('data setup keeps layers polling in an inline panel its host reports hidden', async (t) => {
const previous = globalThis.document;
globalThis.document = {
hidden: true,
visibilityState: 'hidden',
getElementById: () => null,
addEventListener() {},
removeEventListener() {},
};
t.after(() => {
delete globalThis.GEV_EMBED_INLINE;
if (previous === undefined) delete globalThis.document;
else globalThis.document = previous;
});
const releases = [];
const { dataManager } = createApplicationData({
scene: { viewer: {} },
controls: { styleManager: { attachDataManager() {} } },
catalog: createLayerCatalog([], []),
defer: (release) => releases.push(release),
});
const visibility = dataManager._visibilityTarget;
assert.equal(visibility.hidden, true, 'a hidden page pauses outside a panel');
globalThis.GEV_EMBED_INLINE = true;
assert.equal(visibility.hidden, false);
assert.equal(visibility.visibilityState, 'visible');
for (const release of releases.reverse()) await release();
});
test('controls bind catalog instances rather than similarly named defaults', () => {
const ids = [
'traffic',
@@ -93,6 +137,7 @@ test('controls bind catalog instances rather than similarly named defaults', ()
'military-awareness',
'military-installations',
'rocket-launches',
'street-level',
'local-adsb',
];
const layers = ids.map((id) => ({ id }));
@@ -101,7 +146,7 @@ test('controls bind catalog instances rather than similarly named defaults', ()
assert.equal(services.flightsLayer, layers[1]);
assert.equal(services.transitLayer, layers[7]);
assert.equal(services.aisLiveVesselsLayer, layers[8]);
assert.equal(services.localAdsbLayer, layers[12]);
assert.equal(services.localAdsbLayer, layers[13]);
assert.equal(new Set(Object.values(services)).size, layers.length);
assert.throws(
() => catalogControlServices(createLayerCatalog([], [])),
+28 -36
View File
@@ -1,3 +1,4 @@
import { resolveCatalogSources } from './sourceComposition.js';
import { createWeatherClock } from '../layers/weather/clock.js';
import { createWeatherLayer } from '../layers/weather/index.js';
import { createCyclonesLayer } from '../layers/cyclones/index.js';
@@ -23,40 +24,18 @@ import { createApplicationLocalAdsb } from './layers/localAdsb.js';
import { createApplicationAwareness } from './layers/militaryAwareness.js';
import { createApplicationFirms } from './layers/firms.js';
import { createApplicationEarthquakes } from './layers/earthquakes.js';
import { createApplicationAirQuality } from './layers/airQuality.js';
import { createApplicationFirePerimeters } from './layers/perimeters.js';
import { createApplicationGnssIntegrity } from './layers/gnss.js';
import { createApplicationGhostMaps } from './layers/ghostMaps.js';
import { createApplicationCables } from './layers/submarineCables.js';
import { createOsmOverlayLayers } from '../data/osmOverlays.js';
import { createApplicationStreetLevel } from './layers/streetLevel.js';
import { createInfrastructureLayers } from '../data/infrastructure.js';
import { localGeoJsonServices } from './localGeojsonServices.js';
import { createBhoteKoshiEventLayer } from '../data/bhoteKoshiEvent.js';
import { createBhoteKoshiLocatorLayer } from '../data/bhoteKoshiLocator.js';
const SOURCE_METHODS = Object.freeze({
flights: ['getSnapshot'],
military: ['getSnapshot'],
vessels: ['getSnapshot'],
cctv: ['getCatalog', 'getHealth', 'getFrameUrl', 'getMediaUrl'],
radio: ['getDirectory', 'recordClick'],
traffic: [
'requestRoads',
'getStatus',
'fetchFlowForBounds',
'getFlowSessionStats',
'resetFlowTileCache',
],
bikeshare: ['getStations'],
installations: ['getMappedSites', 'searchNearby'],
satellites: ['readGroup'],
launches: ['getLaunches', 'getActiveTle'],
alpr: ['fetch'],
firms: ['getSnapshot'],
wind: ['getSnapshot'],
weather: ['getSnapshot'],
cyclones: ['getSnapshot'],
earthquakes: ['getSnapshot'],
'fire-perimeters': ['getSnapshot'],
cables: ['fetch'],
});
/**
* Hardware-local layers are registered like any other but never enter share
* links or stored layer state: another browser cannot have this receiver.
@@ -83,6 +62,7 @@ export function createApplicationCatalog({
vesselOptions,
resolveAsset,
nepalBoundaryResolver,
streetLevelProviders,
}) {
if (!signal?.addEventListener)
throw new TypeError('An application lifetime signal is required');
@@ -90,12 +70,8 @@ export function createApplicationCatalog({
if (!surface?.groundFloor || !surface?.terrain)
throw new TypeError('Application surface services are required');
for (const [name, methods] of Object.entries(SOURCE_METHODS)) {
if (
methods.some((method) => typeof sources?.[name]?.[method] !== 'function')
)
throw new TypeError(`Invalid catalog source: ${name}`);
}
const sourceComposition = resolveCatalogSources(sources);
sources = sourceComposition.sources;
const militaryRegistry = createMilitaryRegistry();
const weatherClock = createWeatherClock();
const dispose = () => {
@@ -105,7 +81,8 @@ export function createApplicationCatalog({
};
signal.addEventListener('abort', dispose, { once: true });
try {
militaryRegistry.configureSource(sources.military, { signal });
if (sourceComposition.isConfigured('military'))
militaryRegistry.configureSource(sources.military, { signal });
const flights = createApplicationFlights({
surface,
source: sources.flights,
@@ -144,10 +121,21 @@ export function createApplicationCatalog({
...(resolveAsset ? { resolveAsset } : {}),
}),
createApplicationEarthquakes({ source: sources.earthquakes }),
createApplicationAirQuality({ source: sources['air-quality'] }),
createApplicationFirePerimeters({
source: sources['fire-perimeters'],
}),
createApplicationGnssIntegrity({
source: sources['gnss-integrity'],
windowMs: sources['gnss-integrity'].windowMs,
}),
createApplicationGhostMaps({ source: sources['s2-ghostmaps'] }),
createApplicationAlpr({ surface, source: sources.alpr }),
createApplicationStreetLevel({
surface,
sources: { mapillary: sources.mapillary },
providers: streetLevelProviders,
}),
satellites,
createApplicationLaunches({ source: sources.launches, satellites }),
createApplicationTraffic({ source: sources.traffic, surface }),
@@ -155,8 +143,8 @@ export function createApplicationCatalog({
createApplicationRadio({ surface, source: sources.radio }),
createApplicationTransit({ surface, source: sources.transit }),
createApplicationBikeshare({ source: sources.bikeshare }),
createApplicationDirections(),
createApplicationRecentImagery(),
createApplicationDirections({ source: sources.directions }),
createApplicationRecentImagery({ source: sources['recent-imagery'] }),
vessels,
installations,
createApplicationAwareness({
@@ -182,6 +170,7 @@ export function createApplicationCatalog({
clock: weatherClock,
}),
createCyclonesLayer({ feed: sources.cyclones }),
...createOsmOverlayLayers(),
...createInfrastructureLayers(localGeoJsonServices),
createApplicationCables({ source: sources.cables }),
createApplicationFirms({
@@ -197,6 +186,9 @@ export function createApplicationCatalog({
);
return Object.freeze({
...catalog,
getSourceAvailability: sourceComposition.createAvailabilityLookup(
catalog.layers,
),
militaryRegistry,
surface,
weatherClock,
+94 -2
View File
@@ -5,6 +5,7 @@ import { createApplicationCatalog } from './constructCatalog.js';
import { createStandaloneLayerSources } from '../standalone/layerSources.js';
import { catalogControlServices } from './catalog.js';
import { LayerLifecycle } from '../data/lifecycle.js';
import { fakeStreetLevelProvider } from '../testSupport/streetLevelFakes.mjs';
function fixtureSources(ids, calls) {
const sources = createStandaloneLayerSources();
@@ -39,7 +40,7 @@ test('catalogs construct distinct layers and classification from their supplied
signal: b.signal,
surface: fixtureSurface(b.signal),
});
assert.equal(first.layers.length, 29);
assert.equal(first.layers.length, 35);
assert.ok(first.get('local-adsb'), 'Local ADS-B is registered');
assert.deepEqual(
first.metadata.find(({ id }) => id === 'local-adsb'),
@@ -59,6 +60,8 @@ test('catalogs construct distinct layers and classification from their supplied
'2026-09-21T12:00:00.000Z',
);
assert.ok(first.get('fire-perimeters'));
assert.ok(first.get('gnss-integrity'));
assert.ok(first.get('s2-ghostmaps'));
assert.ok(first.get('transit'));
const order = first.layers.map(({ id }) => id);
assert.deepEqual(
@@ -116,7 +119,7 @@ test('invalid or already cancelled construction fails before classification can
assert.throws(
() =>
createApplicationCatalog({
sources: {},
sources: { flights: {} },
signal: lifetime.signal,
surface: fixtureSurface(lifetime.signal),
}),
@@ -140,3 +143,92 @@ function fixtureSurface(signal) {
eventTarget: null,
});
}
test('Mapillary can be omitted or replaced without changing catalog membership', (t) => {
const lifetime = new AbortController();
t.after(() => lifetime.abort());
const sources = fixtureSources([], []);
delete sources.mapillary;
const options = {
sources,
signal: lifetime.signal,
surface: fixtureSurface(lifetime.signal),
};
const catalog = createApplicationCatalog(options);
assert.equal(catalog.layers.length, 35);
assert.deepEqual(catalog.get('street-level').providerIds, []);
assert.equal(
catalogControlServices(catalog).streetLevelLayer,
catalog.get('street-level'),
);
assert.match(
catalog.get('street-level').getUIState().coverage.hint,
/No street-level imagery providers/,
);
const provider = fakeStreetLevelProvider({
id: 'example',
pickPrefix: 'example:',
});
const replacement = createApplicationCatalog({
...options,
streetLevelProviders: [provider],
});
assert.deepEqual(replacement.get('street-level').providerIds, ['example']);
assert.deepEqual(replacement.metadata, catalog.metadata);
assert.equal(provider.calls.activate, 0, 'construction must not acquire');
assert.deepEqual(
createApplicationCatalog({
...options,
sources: fixtureSources([], []),
streetLevelProviders: [],
}).get('street-level').providerIds,
[],
);
assert.throws(
() =>
createApplicationCatalog({
...options,
sources: { ...sources, mapillary: {} },
}),
/Mapillary source/,
);
});
test('a GNSS integrity source can set the layer evidence window', (t) => {
const lifetime = new AbortController();
t.after(() => lifetime.abort());
const gnssLayer = (descriptor) => {
const sources = fixtureSources([], []);
sources['gnss-integrity'] = {
getSnapshot: async () => ({}),
...descriptor,
};
return createApplicationCatalog({
sources,
signal: lifetime.signal,
surface: fixtureSurface(lifetime.signal),
}).get('gnss-integrity');
};
const shows = (layer, minutes) => {
assert.equal(layer.provenance.window.minutes, minutes);
const { info, legend } = layer.getRowControls();
assert.match(info, new RegExp(`visited views over ${minutes} min`));
assert.match(legend[0].blurb, new RegExp(`over ${minutes} minutes`));
};
shows(gnssLayer({ windowMs: 65 * 60_000 }), 65);
shows(gnssLayer({}), 30);
for (const windowMs of [
undefined,
null,
NaN,
Infinity,
0,
-60_000,
'3600000',
])
assert.equal(
gnssLayer({ windowMs }).provenance.window.minutes,
30,
`${String(windowMs)} falls back to the default`,
);
});
+14 -3
View File
@@ -2,8 +2,14 @@ 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. */
/**
* Construct the existing controls and camera presentation. `initialShareCode`
* opens a share code (the `<code>` of `/share/<code>`) at start-up, for a host
* whose address does not carry one. `localePicker: false` removes the language
* picker, for a host that does not initialize Core's locale.
*/
export function createApplicationControls({
scene: { viewer, mapStackController, operations },
loaderStatus,
@@ -12,6 +18,8 @@ export function createApplicationControls({
catalog,
placeSearch,
defer,
initialShareCode = null,
localePicker = true,
}) {
// Initialize the style manager (post-processing, HUD, locations, share links)
const styleManager = new Controls(viewer, {
@@ -26,6 +34,9 @@ export function createApplicationControls({
requestServices: operations.requests,
mapStackController,
placeSearch,
// A share code to open at start-up instead of the page's address.
initialShareCode,
localePicker,
});
defer(() => styleManager.orbitController.stop());
defer(() => styleManager.hud.destroy());
@@ -41,10 +52,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 };
+5
View File
@@ -1,6 +1,7 @@
import { LayerLifecycle } from '../data/lifecycle.js';
import { LayerPresentation } from './layerPresentation.js';
import { createCyberSonarScene } from '../cyberSonarScene.js';
import { panelAwareVisibility } from './embed.js';
/** Register the application layer catalog before allowing state restoration. */
export function createApplicationData({
scene: { viewer, mapStackController },
@@ -13,6 +14,10 @@ export function createApplicationData({
// Initialize data layer manager
const dataManager = new LayerLifecycle(viewer, {
allowQaRegistration,
// A panel's host may report it hidden while it is on screen; its layers
// keep polling (see keepPanelRendering in embed.js).
visibilityTarget: panelAwareVisibility(document),
getSourceAvailability: catalog?.getSourceAvailability,
});
defer(async () => {
await dataManager.destroyAll();
+327
View File
@@ -0,0 +1,327 @@
/**
* Embed mode and views in the running app.
*
* With `?embed=1` the app shows only the globe: clean view, with the HUD,
* welcome and setup prompts hidden. A page that frames it changes what it
* shows by posting `{ type: 'gev:view', id, view }`; the app applies the view
* through its own actions and answers `{ type: 'gev:view-applied', id, ok,
* steps }`. It announces `{ type: 'gev:ready' }` once it can take views.
*
* Annotations in a share link are drawn once the link has been restored,
* embedded or not.
*/
import * as Cesium from 'cesium';
import { announceNavigationAuthority } from '../navigationPolicy.js';
import { annotationsFromParams, createView } from '../view/index.js';
const EMBED_VIEW_MESSAGE = 'gev:view';
const EMBED_APPLIED_MESSAGE = 'gev:view-applied';
const EMBED_READY_MESSAGE = 'gev:ready';
const FOLLOW_LAYERS = {
aircraft: 'flights',
military_aircraft: 'military',
satellite: 'satellites',
};
const FOLLOW_ATTEMPTS = 20;
const FOLLOW_RETRY_MS = 1000;
const FLIGHT_SECONDS = 2;
/**
* Whether this page shows the app embedded: `?embed=1` when another page
* frames it, or inline when a panel page loads the app into itself and sets
* `globalThis.GEV_EMBED_INLINE` first.
*/
export function isEmbedded(location = globalThis.location) {
return (
isEmbeddedInline() ||
new URLSearchParams(location?.search || '').get('embed') === '1'
);
}
/** Whether a panel page loaded the app into itself. */
export function isEmbeddedInline() {
return globalThis.GEV_EMBED_INLINE === true;
}
/**
* `doc`'s visibility for work that pauses in a hidden page. A panel's host
* may report it hidden while it is on screen (see keepPanelRendering), so an
* inline panel always reads as visible.
*/
export function panelAwareVisibility(doc = globalThis.document) {
const hidden = () =>
(doc.hidden === true || doc.visibilityState === 'hidden') &&
!isEmbeddedInline();
return {
get hidden() {
return hidden();
},
get visibilityState() {
return hidden() ? 'hidden' : 'visible';
},
addEventListener: (type, listener) => doc.addEventListener(type, listener),
removeEventListener: (type, listener) =>
doc.removeEventListener(type, listener),
};
}
// A panel keeps drawing at about this rate when its host stops animation
// frames; see keepPanelRendering.
const PANEL_FRAME_MS = 33;
const MISSED_FRAMES_MS = 250;
/** A render error's details, including the plain objects workers report. */
function describeError(error) {
if (error instanceof Error)
return `${error.name}: ${error.message}\n${error.stack}`;
try {
return JSON.stringify(error, Object.getOwnPropertyNames(error ?? {}));
} catch {
return String(error);
}
}
/**
* Keep the globe drawing in an inline panel. Some hosts report a panel on
* screen as hidden, which stops the browser's animation frames and with them
* Cesium's render loop; while frames stop arriving, draw from a timer.
* Returns a function that stops it.
*/
export function keepPanelRendering(
viewer,
{ windowRef = globalThis.window, now = () => performance.now() } = {},
) {
let lastFrame = now();
let frameRequest = null;
const onFrame = () => {
lastFrame = now();
frameRequest = windowRef.requestAnimationFrame(onFrame);
};
frameRequest = windowRef.requestAnimationFrame(onFrame);
// Cesium stops drawing after a render error, and so does the timer.
// Report the error in full: workers report plain objects, which Cesium
// prints as [object Object].
let failed = false;
const removeErrorListener = viewer.scene?.renderError?.addEventListener(
(_scene, error) => {
failed = true;
console.error(
"[God's Eye View panel] render error:",
describeError(error),
);
},
);
const timer = windowRef.setInterval(() => {
if (
failed ||
now() - lastFrame < MISSED_FRAMES_MS ||
viewer.isDestroyed?.()
)
return;
viewer.resize();
viewer.render();
}, PANEL_FRAME_MS);
return () => {
windowRef.clearInterval(timer);
windowRef.cancelAnimationFrame(frameRequest);
removeErrorListener?.();
};
}
const delay = (ms, signal) =>
new Promise((resolve) => {
const timer = setTimeout(resolve, ms);
signal?.addEventListener(
'abort',
() => {
clearTimeout(timer);
resolve();
},
{ once: true },
);
});
/** Fly the camera to a view's camera and resolve when the flight ends. */
function flyTo(viewer, camera) {
announceNavigationAuthority('embed-view');
return new Promise((resolve) => {
viewer.camera.flyTo({
destination: Cesium.Cartesian3.fromDegrees(
camera.lon,
camera.lat,
camera.altitude_m,
),
orientation: {
heading: Cesium.Math.toRadians(camera.heading_deg),
pitch: Cesium.Math.toRadians(camera.pitch_deg),
roll: 0,
},
duration: FLIGHT_SECONDS,
complete: () => resolve(true),
cancel: () => resolve(false),
});
});
}
/**
* Make the app show a view: style and map, exactly the view's layers,
* annotations, the camera, and the followed entity. Each step runs through the
* app's own actions (`run(name, args)`); a failed step is reported and the
* rest still run. Resolves to the steps and whether each succeeded.
*/
export async function applyView(
view,
{ viewer, dataManager, run, signal, retryMs = FOLLOW_RETRY_MS },
) {
const steps = [];
const act = async (name, args) => {
let result = null;
try {
result = await run(name, args);
} catch (error) {
result = { ok: false, error: error?.message || String(error) };
}
steps.push({
step: name,
ok: result?.ok !== false,
...(result?.error ? { error: result.error } : {}),
});
return result;
};
if (view.style) await act('set_visual_style', { style: view.style });
if (view.map) await act('set_map_stack', { stack: view.map });
const wanted = new Set(view.layers);
for (const layer of dataManager.getAll()) {
if (layer.enabled && !wanted.has(layer.id))
await act('set_layer_visibility', { layerId: layer.id, enabled: false });
}
for (const layerId of view.layers) {
if (!dataManager.isEnabled(layerId))
await act('set_layer_visibility', { layerId, enabled: true });
}
if (!view.follow) await act('stop_tracking', {});
// Annotations go first: drawing them may frame the marks, and the view's
// own camera should have the last word.
await act('clear_annotations', {});
if (view.annotations.length)
await act('annotate_map', { annotations: [...view.annotations] });
if (!view.follow) {
steps.push({ step: 'camera', ok: await flyTo(viewer, view.camera) });
return steps;
}
// A followed entity owns the camera, and a camera flight would end the
// follow, so fly to the view only while the entity is not there yet. It
// appears once its layer has data; retry until then.
const layerId = FOLLOW_LAYERS[view.follow.kind];
const follow = async () => {
try {
return (
(await run('track_entity', { query: view.follow.id, layerId }))?.ok ===
true
);
} catch {
return false;
}
};
let followed = await follow();
if (!followed) {
steps.push({ step: 'camera', ok: await flyTo(viewer, view.camera) });
for (let attempt = 1; attempt < FOLLOW_ATTEMPTS && !followed; attempt++) {
if (signal?.aborted) break;
await delay(retryMs, signal);
followed = await follow();
}
}
steps.push({ step: 'follow', ok: followed });
if (followed && view.follow.cockpit)
await act('control_cockpit', { action: 'enter', targetLayer: layerId });
return steps;
}
/**
* Install embed mode when the page asks for it, and draw any annotations the
* opening link carries. Returns a function that removes the message handler.
*/
export function installViews({
shell,
viewer,
dataManager,
run,
signal,
location = globalThis.location,
windowRef = globalThis.window,
}) {
const ready = Promise.resolve(shell.initialRestorePromise).catch(() => {});
const linked = annotationsFromParams(
new URLSearchParams(String(location?.hash || '').replace(/^#/, '')),
);
if (linked.length)
void ready.then(() => {
if (!signal?.aborted) void run('annotate_map', { annotations: linked });
});
if (!isEmbedded(location)) return () => {};
windowRef.document.body.classList.add('ui-embed');
shell.setCleanView?.(true);
const stopRendering = isEmbeddedInline()
? keepPanelRendering(viewer, { windowRef })
: () => {};
// A framing page talks to the app across frames; an inline panel shares
// the page with it and talks through the page's own window.
const peer = isEmbeddedInline() ? windowRef : windowRef.parent;
// Answers go back only to the origin that asked. An inline panel posts to
// its own window, and a sandboxed framing page has no origin to name.
const post = (message, origin = '*') => {
if (peer && (peer !== windowRef || isEmbeddedInline()))
peer.postMessage(message, origin);
};
// Views apply one at a time, in the order they arrive.
let queue = ready;
const onMessage = (event) => {
if (event.source !== peer || event.data?.type !== EMBED_VIEW_MESSAGE)
return;
const { id = null } = event.data;
const replyOrigin =
isEmbeddedInline() || !event.origin || event.origin === 'null'
? '*'
: event.origin;
let view;
try {
view = createView(event.data.view);
} catch (error) {
post(
{
type: EMBED_APPLIED_MESSAGE,
id,
ok: false,
error: error.message,
},
replyOrigin,
);
return;
}
queue = queue.then(async () => {
if (signal?.aborted) return;
const steps = await applyView(view, { viewer, dataManager, run, signal });
post(
{
type: EMBED_APPLIED_MESSAGE,
id,
ok: steps.every((step) => step.ok),
steps,
},
replyOrigin,
);
});
};
windowRef.addEventListener('message', onMessage);
void ready.then(() => {
if (!signal?.aborted) post({ type: EMBED_READY_MESSAGE });
});
return () => {
stopRendering();
windowRef.removeEventListener('message', onMessage);
};
}
+395
View File
@@ -0,0 +1,395 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
applyView,
installViews,
isEmbedded,
keepPanelRendering,
panelAwareVisibility,
} from './embed.js';
import { LayerLifecycle } from '../data/lifecycle.js';
import { createView } from '../view/index.js';
const fakeViewer = () => {
const flights = [];
return {
flights,
camera: {
flyTo(options) {
flights.push(options);
options.complete();
},
},
};
};
const fakeLayers = (enabled) => ({
getAll: () =>
['flights', 'earthquakes', 'ais-live-vessels'].map((id) => ({
id,
enabled: enabled.has(id),
})),
isEnabled: (id) => enabled.has(id),
});
const recorder = (answers = {}) => {
const calls = [];
const run = async (name, args) => {
calls.push([name, args]);
const answer = answers[name];
return typeof answer === 'function'
? answer(calls)
: (answer ?? { ok: true });
};
return { calls, run };
};
test('embed mode is opt-in by query parameter', () => {
assert.equal(isEmbedded({ search: '?embed=1' }), true);
assert.equal(isEmbedded({ search: '?welcome=0' }), false);
});
test('a view is applied through the app actions in order', async () => {
const viewer = fakeViewer();
const { calls, run } = recorder();
const view = createView({
camera: { lat: 25, lon: 121, altitude_m: 300000, pitch_deg: -40 },
layers: ['ais-live-vessels'],
style: 'thermal',
map: 'osm',
annotations: [{ type: 'pin', target: 'Taipei 101' }],
});
const steps = await applyView(view, {
viewer,
dataManager: fakeLayers(new Set(['earthquakes'])),
run,
});
assert.deepEqual(
calls.map(([name, args]) => [
name,
args.layerId ?? args.style ?? args.stack ?? null,
args.enabled ?? null,
]),
[
['set_visual_style', 'thermal', null],
['set_map_stack', 'osm', null],
['set_layer_visibility', 'earthquakes', false],
['set_layer_visibility', 'ais-live-vessels', true],
['stop_tracking', null, null],
['clear_annotations', null, null],
['annotate_map', null, null],
],
);
assert.equal(viewer.flights.length, 1);
assert.ok(steps.every((step) => step.ok));
assert.ok(steps.some((step) => step.step === 'camera'));
});
test('a followed entity is retried until its layer has it', async () => {
let tries = 0;
const { run } = recorder({
track_entity: () => ({ ok: ++tries >= 3 }),
});
const view = createView({
camera: { lat: 0, lon: 0 },
follow: { kind: 'aircraft', id: 'abc123' },
});
const steps = await applyView(view, {
viewer: fakeViewer(),
dataManager: fakeLayers(new Set()),
run,
retryMs: 1,
});
assert.equal(tries, 3);
assert.deepEqual(steps.at(-1), { step: 'follow', ok: true });
});
test('an embedded page takes views only from its parent and answers it', async () => {
const posted = [];
const targets = [];
const parent = {
postMessage: (message, target) => {
posted.push(message);
targets.push(target);
},
};
const windowRef = new EventTarget();
windowRef.parent = parent;
windowRef.document = { body: { classList: new Set() } };
windowRef.document.body.classList.add = Set.prototype.add;
const shell = {
initialRestorePromise: Promise.resolve(),
clean: null,
setCleanView(value) {
this.clean = value;
},
};
const { calls, run } = recorder();
const remove = installViews({
shell,
viewer: fakeViewer(),
dataManager: fakeLayers(new Set()),
run,
location: { search: '?embed=1', hash: '' },
windowRef,
});
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(shell.clean, true);
assert.deepEqual(posted, [{ type: 'gev:ready' }]);
const send = (source, data, origin = 'https://host.example') => {
const event = new Event('message');
Object.assign(event, { source, data, origin });
windowRef.dispatchEvent(event);
};
send({}, { type: 'gev:view', id: 1, view: { camera: { lat: 1, lon: 2 } } });
send(parent, { type: 'gev:view', id: 2, view: { camera: {} } });
send(parent, {
type: 'gev:view',
id: 3,
view: { camera: { lat: 1, lon: 2 }, layers: ['flights'] },
});
// A view kept from before Snow was retired still applies, on Normal.
send(parent, {
type: 'gev:view',
id: 4,
view: { camera: { lat: 1, lon: 2 }, style: 'snow' },
});
await new Promise((resolve) => setTimeout(resolve, 10));
assert.deepEqual(posted[1], {
type: 'gev:view-applied',
id: 2,
ok: false,
error: 'A view needs a camera lat and lon',
});
assert.equal(posted[2].id, 3);
assert.equal(posted[2].ok, true);
assert.ok(
calls.some(
([name, args]) =>
name === 'set_layer_visibility' && args.layerId === 'flights',
),
);
assert.equal(posted[3].id, 4);
assert.equal(posted[3].ok, true);
assert.ok(
calls.some(
([name, args]) => name === 'set_visual_style' && args.style === 'normal',
),
);
assert.equal(posted.filter((message) => message.id === 1).length, 0);
// Ready names no data and goes to any parent; answers go only to the
// origin that asked.
assert.deepEqual(targets, [
'*',
'https://host.example',
'https://host.example',
'https://host.example',
]);
remove();
});
test('an app loaded inline into a panel page talks through its own window', async () => {
const posted = [];
const windowRef = new EventTarget();
windowRef.parent = windowRef;
windowRef.postMessage = (message) => posted.push(message);
windowRef.document = { body: { classList: new Set() } };
windowRef.document.body.classList.add = Set.prototype.add;
Object.assign(windowRef, {
requestAnimationFrame: () => 1,
cancelAnimationFrame() {},
setInterval: () => 1,
clearInterval() {},
});
globalThis.GEV_EMBED_INLINE = true;
try {
assert.equal(isEmbedded({ search: '' }), true);
const remove = installViews({
shell: { initialRestorePromise: Promise.resolve() },
viewer: fakeViewer(),
dataManager: fakeLayers(new Set()),
run: recorder().run,
location: { search: '', hash: '' },
windowRef,
});
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(posted, [{ type: 'gev:ready' }]);
const event = new Event('message');
Object.assign(event, {
source: windowRef,
data: { type: 'gev:view', id: 7, view: { camera: { lat: 1, lon: 2 } } },
});
windowRef.dispatchEvent(event);
await new Promise((resolve) => setTimeout(resolve, 10));
assert.equal(posted[1].id, 7);
assert.equal(posted[1].ok, true);
remove();
} finally {
delete globalThis.GEV_EMBED_INLINE;
}
});
test('polled layers keep refreshing in an inline panel its host reports hidden', async (t) => {
const listeners = new Set();
const doc = {
hidden: true,
visibilityState: 'hidden',
addEventListener: (_type, fn) => listeners.add(fn),
removeEventListener: (_type, fn) => listeners.delete(fn),
};
const settle = async () => {
for (let i = 0; i < 3; i++) await Promise.resolve();
};
t.mock.timers.enable({ apis: ['setInterval', 'Date'], now: 1000 });
let updates = 0;
const manager = new LayerLifecycle(
{},
{ visibilityTarget: panelAwareVisibility(doc) },
);
manager.register({
id: 'flights',
name: 'flights',
icon: '',
source: 'test',
updateInterval: 1000,
async init() {},
enable() {},
disable() {},
async update() {
updates++;
await Promise.resolve();
},
getStats: () => ({ count: 0, lastUpdate: null }),
});
globalThis.GEV_EMBED_INLINE = true;
try {
await manager.setEnabled('flights', true);
await settle();
const enabled = updates;
t.mock.timers.tick(1000);
await settle();
t.mock.timers.tick(1000);
await settle();
assert.equal(updates, enabled + 2, 'the panel keeps polling');
delete globalThis.GEV_EMBED_INLINE;
t.mock.timers.tick(1000);
await settle();
assert.equal(updates, enabled + 2, 'outside a panel a hidden page pauses');
} finally {
delete globalThis.GEV_EMBED_INLINE;
await manager.destroyAll();
}
assert.equal(listeners.size, 0);
});
test('annotations in a link are drawn after it restores, embedded or not', async () => {
const { calls, run } = recorder();
const an = encodeURIComponent(
JSON.stringify([{ type: 'pin', target: 'Austin' }]),
);
installViews({
shell: { initialRestorePromise: Promise.resolve() },
viewer: fakeViewer(),
dataManager: fakeLayers(new Set()),
run,
location: { search: '', hash: `#v=2&lat=1&lon=2&an=${an}` },
windowRef: new EventTarget(),
});
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(calls, [
['annotate_map', { annotations: [{ type: 'pin', target: 'Austin' }] }],
]);
});
test('a panel keeps drawing from a timer while animation frames stop', () => {
let clock = 0;
let tick = null;
let frame = null;
const windowRef = {
requestAnimationFrame: (fn) => ((frame = fn), 1),
cancelAnimationFrame: () => (frame = null),
setInterval: (fn) => ((tick = fn), 1),
clearInterval: () => (tick = null),
};
const drawn = [];
const viewer = {
resize: () => drawn.push('resize'),
render: () => drawn.push('render'),
};
const stop = keepPanelRendering(viewer, { windowRef, now: () => clock });
// Frames arrive: the render loop draws, so the timer does nothing.
clock = 100;
frame();
tick();
assert.deepEqual(drawn, []);
// Frames stop, as in a panel its host reports hidden: the timer draws.
clock = 400;
tick();
assert.deepEqual(drawn, ['resize', 'render']);
stop();
assert.equal(tick, null);
assert.equal(frame, null);
});
test('a panel reports a render error in full and stops drawing', () => {
let listener = null;
let tick = null;
const windowRef = {
requestAnimationFrame: () => 1,
cancelAnimationFrame() {},
setInterval: (fn) => ((tick = fn), 1),
clearInterval() {},
};
const drawn = [];
const viewer = {
resize() {},
render: () => drawn.push('render'),
scene: {
renderError: {
addEventListener: (fn) => ((listener = fn), () => (listener = null)),
},
},
};
const logged = [];
const original = console.error;
console.error = (...args) => logged.push(args.join(' '));
try {
let clock = 0;
const stop = keepPanelRendering(viewer, { windowRef, now: () => clock });
listener(viewer.scene, { name: 'RuntimeError', message: 'worker failed' });
assert.match(logged[0], /render error: .*"message":"worker failed"/);
clock = 1000;
tick();
assert.deepEqual(drawn, []);
stop();
assert.equal(listener, null);
} finally {
console.error = original;
}
});
test('a followed aircraft owns the camera, and cockpit view is entered after it', async () => {
const viewer = fakeViewer();
const { calls, run } = recorder();
const steps = await applyView(
createView({
camera: { lat: 32.7, lon: -117.2 },
follow: { kind: 'military_aircraft', id: 'AE1234', cockpit: true },
}),
{ viewer, dataManager: fakeLayers(new Set()), run, retryMs: 0 },
);
// Following worked at once, so no camera flight ended it.
assert.equal(viewer.flights.length, 0);
assert.deepEqual(calls.slice(-2), [
['track_entity', { query: 'ae1234', layerId: 'military' }],
['control_cockpit', { action: 'enter', targetLayer: 'military' }],
]);
assert.ok(steps.every((step) => step.ok));
assert.throws(
() =>
createView({
camera: { lat: 0, lon: 0 },
follow: { kind: 'satellite', id: '25544', cockpit: true },
}),
/no cockpit view/,
);
});
+669
View File
@@ -0,0 +1,669 @@
/**
* The God's Eye View panel's script, run inside the panel page a host shows.
* It is sent as source text (see src/tools/globePanel.js), so it must not use
* imports or anything outside the function.
*
* The page lives on the host's site, and hosts may refuse other addresses,
* such as a server on the user's machine. So the panel never requests the
* app's server itself: every request for the app's own paths goes to the
* panel's MCP server as a call to `config.toolName`, which requests the path
* from the app's server. The app's built page, scripts, styles, images and
* data all arrive that way; map imagery from providers loads directly.
*/
export function panelRuntime(config) {
const status = document.getElementById('status');
const open = document.getElementById('open');
const expand = document.getElementById('expand');
const say = (text) => {
if (status.isConnected) status.textContent = text;
};
let blocked = null;
document.addEventListener('securitypolicyviolation', (event) => {
blocked = `${event.effectiveDirective} blocked ${event.blockedURI || 'a resource'}`;
});
// JSON-RPC with the host.
let nextId = 1;
const pending = new Map();
const send = (message) =>
window.parent.postMessage({ jsonrpc: '2.0', ...message }, '*');
const request = (method, params) =>
new Promise((resolve, reject) => {
const id = nextId++;
pending.set(id, { resolve, reject });
send({ id, method, params });
});
const notify = (method, params) => send({ method, params });
// Requests to the app's server, through the MCP server.
const MAX_CALLS = 6;
let calls = 0;
const waiting = [];
async function callTool(args) {
if (calls >= MAX_CALLS) await new Promise((go) => waiting.push(go));
calls += 1;
try {
const result = await request('tools/call', {
name: config.toolName,
// The server refuses the panel's requests without its key.
arguments: { ...args, key: config.panelKey },
});
if (result?.isError || !result?.structuredContent)
throw new Error(result?.content?.[0]?.text || 'The request failed');
return result.structuredContent;
} finally {
calls -= 1;
waiting.shift()?.();
}
}
const toBase64 = (bytes) => {
let binary = '';
for (let start = 0; start < bytes.length; start += 0x8000)
binary += String.fromCharCode(...bytes.subarray(start, start + 0x8000));
return btoa(binary);
};
const fromBase64 = (text) =>
Uint8Array.from(atob(text), (char) => char.charCodeAt(0));
async function bodyBytes(body) {
if (body == null) return null;
if (typeof body === 'string') return new TextEncoder().encode(body);
if (body instanceof ArrayBuffer) return new Uint8Array(body);
if (ArrayBuffer.isView(body))
return new Uint8Array(body.buffer, body.byteOffset, body.byteLength);
return new Uint8Array(await new Response(body).arrayBuffer());
}
const NULL_BODY = new Set([101, 204, 205, 304]);
const held = new Map();
/** Request a path on the app's server; resolves to a Response. */
function appFetch(path, init = {}) {
const method = (init.method || 'GET').toUpperCase();
// Built files never change while the panel runs, so each loads once.
const shared = method === 'GET' && path.startsWith(config.panelBase);
if (shared && held.has(path)) return held.get(path).then(respond);
const loading = (async () => {
const headers = {};
new Headers(init.headers || {}).forEach((value, name) => {
headers[name] = value;
});
const bytes = await bodyBytes(init.body);
let part = await callTool({
path,
method,
headers,
...(bytes ? { body: toBase64(bytes) } : {}),
});
const parts = [fromBase64(part.body)];
while (part.nextOffset !== undefined) {
if (init.signal?.aborted) break;
part = await callTool({ id: part.id, offset: part.nextOffset });
parts.push(fromBase64(part.body));
}
let body = new Blob(parts);
if (part.encoding === 'gzip')
body = await new Response(
body.stream().pipeThrough(new DecompressionStream('gzip')),
).blob();
return {
body: await body.arrayBuffer(),
status: part.status,
statusText: part.statusText,
headers: part.headers,
};
})();
if (shared) {
held.set(path, loading);
loading.catch(() => held.delete(path));
}
const result = loading.then(respond);
if (!init.signal) return result;
return Promise.race([
result,
new Promise((_, reject) => {
const abort = () =>
reject(new DOMException('The request was aborted', 'AbortError'));
if (init.signal.aborted) abort();
init.signal.addEventListener('abort', abort, { once: true });
}),
]);
}
function respond({ body, status, statusText, headers }) {
return new Response(NULL_BODY.has(status) ? null : body.slice(0), {
status,
statusText,
headers,
});
}
// The app's own paths: relative ones, absolute ones on this page's site
// (matched by scheme and host, since a host may use its own scheme), and
// ones under the app's address in the page, an https address for code
// that needs one (`config.appBaseUrl`).
const page = new URL(document.baseURI);
const appOrigin = new URL(config.appBaseUrl).origin;
function appPath(value) {
let url;
try {
url = new URL(String(value), document.baseURI);
} catch {
return null;
}
const own =
url.origin === appOrigin ||
(url.protocol === page.protocol && url.host === page.host);
return own ? url.pathname + url.search : null;
}
const fileUrls = new Map();
/**
* A data: URL holding an app file, for elements that load by URL. Hosts
* allow data: images where some refuse blob: ones. Files of the panel
* build never change and are kept; others, such as live camera frames
* that change address on every refresh, are not.
*/
function appFileUrl(path) {
if (fileUrls.has(path)) return fileUrls.get(path);
const loading = appFetch(path)
.then((response) => response.blob())
.then(
(blob) =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(blob);
}),
);
if (path.startsWith(config.panelBase)) {
fileUrls.set(path, loading);
loading.catch(() => fileUrls.delete(path));
}
return loading;
}
function installRequests() {
const nativeFetch = window.fetch.bind(window);
window.fetch = async (input, init = {}) => {
const isRequest = input instanceof Request;
const path = appPath(isRequest ? input.url : input);
if (path === null) return nativeFetch(input, init);
if (!isRequest) return appFetch(path, init);
return appFetch(path, {
method: init.method || input.method,
headers: init.headers || input.headers,
body:
init.body ??
(['GET', 'HEAD'].includes(input.method)
? undefined
: await input.arrayBuffer()),
signal: init.signal || input.signal,
});
};
// XMLHttpRequest for app paths: answered from appFetch, with the
// instance's state replaced, and the usual events dispatched.
const xhr = XMLHttpRequest.prototype;
const nativeOpen = xhr.open;
const nativeSend = xhr.send;
const nativeHeader = xhr.setRequestHeader;
const nativeAbort = xhr.abort;
xhr.open = function (method, url, ...rest) {
const path = appPath(url);
if (path === null) {
this.__gev = null;
return nativeOpen.call(this, method, url, ...rest);
}
this.__gev = { method, path, headers: {}, controller: null };
setState(this, { readyState: 1 });
this.dispatchEvent(new Event('readystatechange'));
};
xhr.setRequestHeader = function (name, value) {
if (!this.__gev) return nativeHeader.call(this, name, value);
this.__gev.headers[name] = value;
};
xhr.abort = function () {
if (!this.__gev) return nativeAbort.call(this);
this.__gev.controller?.abort();
};
xhr.send = function (body) {
const state = this.__gev;
if (!state) return nativeSend.call(this, body);
state.controller = new AbortController();
appFetch(state.path, {
method: state.method,
headers: state.headers,
body,
signal: state.controller.signal,
})
.then(async (response) => {
const type = this.responseType;
const value =
type === 'arraybuffer'
? await response.arrayBuffer()
: type === 'blob'
? await response.blob()
: await response.text();
const text = typeof value === 'string' ? value : null;
let parsed = value;
if (type === 'json') {
try {
parsed = JSON.parse(text);
} catch {
parsed = null;
}
} else if (type === 'document')
parsed = new DOMParser().parseFromString(text, 'text/html');
const headers = [...response.headers]
.map(([name, val]) => `${name}: ${val}`)
.join('\r\n');
setState(this, {
readyState: 4,
status: response.status,
statusText: response.statusText,
response: parsed,
responseText: type === '' || type === 'text' ? text : '',
responseURL: new URL(state.path, document.baseURI).href,
getAllResponseHeaders: () => headers,
getResponseHeader: (name) => response.headers.get(name),
});
for (const name of ['readystatechange', 'load', 'loadend'])
this.dispatchEvent(
name === 'readystatechange'
? new Event(name)
: new ProgressEvent(name),
);
})
.catch((error) => {
setState(this, { readyState: 4, status: 0 });
const name = error?.name === 'AbortError' ? 'abort' : 'error';
for (const event of ['readystatechange', name, 'loadend'])
this.dispatchEvent(
event === 'readystatechange'
? new Event(event)
: new ProgressEvent(event),
);
});
};
function setState(target, values) {
for (const [name, value] of Object.entries(values))
Object.defineProperty(target, name, {
configurable: true,
...(typeof value === 'function' ? { value } : { get: () => value }),
});
}
const nativeBeacon = navigator.sendBeacon?.bind(navigator);
if (nativeBeacon)
navigator.sendBeacon = (url, data) => {
const path = appPath(url);
if (path === null) return nativeBeacon(url, data);
appFetch(path, { method: 'POST', body: data }).catch(() => {});
return true;
};
// Images name app files by URL; give them the file as a data: URL.
const image = Object.getOwnPropertyDescriptor(
HTMLImageElement.prototype,
'src',
);
Object.defineProperty(HTMLImageElement.prototype, 'src', {
...image,
set(value) {
const path = appPath(value);
if (path === null) return image.set.call(this, value);
appFileUrl(path).then(
(url) => image.set.call(this, url),
() => this.dispatchEvent(new Event('error')),
);
},
});
// Workers on the app's server start from a small worker here that is
// handed the script once it has loaded; messages wait until then.
const NativeWorker = window.Worker;
const BOOT =
'const early=[];let ready=false;' +
"addEventListener('message',e=>{if(e.data&&e.data.__gevScript){" +
'const go=()=>{ready=true;for(const d of early)dispatchEvent(new MessageEvent("message",{data:d}))};' +
'if(e.data.module)import(e.data.__gevScript).then(go);else{importScripts(e.data.__gevScript);go()}' +
'e.stopImmediatePropagation();return}' +
'if(!ready){early.push(e.data);e.stopImmediatePropagation()}});';
window.Worker = function Worker(url, options) {
const path = appPath(url);
if (path === null || String(url).startsWith('blob:'))
return new NativeWorker(url, options);
const worker = new NativeWorker(
URL.createObjectURL(new Blob([BOOT], { type: 'text/javascript' })),
options,
);
appFetch(path)
.then((response) => response.blob())
.then((blob) => URL.createObjectURL(blob))
.then((script) =>
worker.postMessage({
__gevScript: script,
module: options?.type === 'module',
}),
);
return worker;
};
window.Worker.prototype = NativeWorker.prototype;
// Some hosts drop the GPU contents of a panel's 2D canvases while they
// consider the panel off screen, and the overlays then cover the globe
// in black. Keep 2D canvases in memory instead, which hosts do not drop.
const getContext = HTMLCanvasElement.prototype.getContext;
HTMLCanvasElement.prototype.getContext = function (type, options) {
return type === '2d'
? getContext.call(this, type, { ...options, willReadFrequently: true })
: getContext.call(this, type, options);
};
// Markup the app adds as HTML text would request its images from this
// page's site at once; hold their addresses until the files are here.
const htmlWithHeldImages = (html) =>
String(html).replace(
/(<(?:img|source)\b[^>]*?\s)src=(["'])([^"']*)\2/gi,
(tag, before, quote, value) =>
appPath(value) === null
? tag
: `${before}data-gev-src=${quote}${value}${quote}`,
);
const fillHeldImages = (root) => {
for (const element of root?.querySelectorAll?.('[data-gev-src]') ?? []) {
const path = appPath(element.getAttribute('data-gev-src'));
element.removeAttribute('data-gev-src');
appFileUrl(path).then((url) => element.setAttribute('src', url));
}
};
const innerHtml = Object.getOwnPropertyDescriptor(
Element.prototype,
'innerHTML',
);
Object.defineProperty(Element.prototype, 'innerHTML', {
...innerHtml,
set(value) {
innerHtml.set.call(this, htmlWithHeldImages(value));
fillHeldImages(this);
},
});
const insertHtml = Element.prototype.insertAdjacentHTML;
Element.prototype.insertAdjacentHTML = function (position, html) {
insertHtml.call(this, position, htmlWithHeldImages(html));
fillHeldImages(this.parentNode ?? this);
};
// Markup and styles the app adds later name app files too.
new MutationObserver((records) => {
for (const record of records)
for (const node of record.addedNodes) {
if (node.nodeType !== 1) continue;
if (node.nodeName === 'STYLE') void inlineStyleUrls(node);
for (const element of [node, ...node.querySelectorAll('img[src]')]) {
if (element.nodeName !== 'IMG') continue;
const path = appPath(element.getAttribute('src'));
if (path !== null)
appFileUrl(path).then((url) => element.setAttribute('src', url));
}
}
}).observe(document.documentElement, { childList: true, subtree: true });
}
/** Replace url() references to app files in CSS with data: URLs. */
async function cssWithFiles(text, baseUrl) {
const found = new Map();
for (const match of text.matchAll(/url\(\s*(['"]?)([^'")]+)\1\s*\)/g)) {
const reference = match[2];
if (/^(data|blob):/.test(reference)) continue;
const path = appPath(new URL(reference, baseUrl).href);
if (path !== null && !found.has(reference))
found.set(
reference,
appFileUrl(path).catch(() => null),
);
}
let result = text;
for (const [reference, loading] of found) {
const url = await loading;
if (url) result = result.split(reference).join(url);
}
return result;
}
async function inlineStyleUrls(style) {
if (!/url\(/.test(style.textContent)) return;
style.textContent = await cssWithFiles(style.textContent, document.baseURI);
}
async function addStylesheet(href) {
const path = appPath(href);
const style = document.createElement('style');
document.head.appendChild(style);
if (path === null) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = href;
style.replaceWith(link);
return;
}
const text = await (await appFetch(path)).text();
style.textContent = await cssWithFiles(
text,
new URL(path, document.baseURI).href,
);
}
function runScript(text, type) {
const script = document.createElement('script');
if (type) script.type = type;
script.textContent = text;
document.body.appendChild(script);
}
/**
* Load God's Eye View into this page from the app's panel build, in
* inline embed mode, starting at the first view as its link would.
*/
async function startApp(url, view) {
window.GEV_EMBED_INLINE = true;
window.GEV_APP_BASE_URL = config.appBaseUrl;
try {
history.replaceState(
null,
'',
location.href.split('#')[0] + new URL(url).hash,
);
// The app restores this view from its link, following included, so
// it needs applying again only for what links cannot carry: cockpit.
if (queued === view && !view.follow?.cockpit) queued = null;
} catch {
// The view still arrives once the app is ready.
}
installRequests();
const response = await appFetch(config.panelBase);
if (!response.ok)
throw new Error(
response.status === 404
? 'the app has no panel build (npm run build:panel)'
: `the app answered ${response.status}`,
);
const built = new DOMParser().parseFromString(
await response.text(),
'text/html',
);
window.CESIUM_BASE_URL = `${config.panelBase}cesium/`;
const [prelude] = await Promise.all([
appFetch(config.panelBase + config.workerPreludePath).then((r) =>
r.text(),
),
...[...built.head.querySelectorAll('link[rel="stylesheet"]')].map(
(link) => addStylesheet(link.getAttribute('href')),
),
]);
// Cesium's script sets CESIUM_WORKERS, its workers as one script it
// starts from memory. Workers request files themselves, which this page
// cannot answer, so the prelude, holding those files, runs ahead of it.
let workers;
Object.defineProperty(window, 'CESIUM_WORKERS', {
configurable: true,
get: () => workers,
set: (script) => {
workers = prelude + script;
},
});
// Images in the markup load their files once added; give them the
// files instead of letting them request this page's site.
const images = [];
for (const image of built.body.querySelectorAll('img[src]')) {
const path = appPath(image.getAttribute('src'));
if (path === null) continue;
image.removeAttribute('src');
images.push([image, path]);
}
for (const node of [...built.body.childNodes]) {
if (node.nodeName !== 'SCRIPT')
document.body.insertBefore(document.adoptNode(node), status);
}
for (const [image, path] of images)
appFileUrl(path).then((url) => image.setAttribute('src', url));
const scripts = await Promise.all(
[...built.querySelectorAll('script')].map(async (script) => ({
type: script.type,
text: script.getAttribute('src')
? await (await appFetch(appPath(script.getAttribute('src')))).text()
: script.textContent,
})),
);
for (const { type, text } of scripts) runScript(text, type);
// Browsers cap live 3D contexts across pages and take one back when
// there are too many; say so rather than show no map.
document.addEventListener(
'webglcontextlost',
() => {
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.';
document.body.appendChild(notice);
},
{ capture: true, once: true },
);
}
// Tool results: the first view starts the app, later ones move it.
let started = false;
let ready = false;
let queued = null;
let currentUrl = null;
const postView = (view) =>
window.postMessage({ type: 'gev:view', id: nextId++, view }, '*');
function show(result) {
const data = result?.structuredContent;
const view = data?.view;
const url = data?.url || view?.url;
if (!view || !url) return;
currentUrl = url;
open.hidden = false;
if (ready) return postView(view);
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.",
);
}, config.loadTimeoutMs);
}
window.addEventListener('message', (event) => {
const message = event.data;
if (event.source === window) {
if (message?.type === 'gev:ready') {
ready = true;
status.remove();
if (queued) postView(queued);
queued = null;
}
return;
}
if (event.source !== window.parent || message?.jsonrpc !== '2.0') return;
if (
message.id !== undefined &&
pending.has(message.id) &&
!message.method
) {
const { resolve, reject } = pending.get(message.id);
pending.delete(message.id);
if (message.error) reject(message.error);
else resolve(message.result);
return;
}
if (message.method === 'ui/notifications/tool-result') show(message.params);
if (message.method === 'ui/notifications/host-context-changed')
showDisplayMode(message.params);
});
// Fullscreen, where the host offers it, gives the globe the room.
let displayMode = 'inline';
const showDisplayMode = (context) => {
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', () => {
request('ui/request-display-mode', {
mode: displayMode === 'fullscreen' ? 'inline' : 'fullscreen',
}).then(
(result) => showDisplayMode({ displayMode: result?.mode }),
() => {},
);
});
open.addEventListener('click', () => {
if (!currentUrl) return;
request('ui/open-link', { url: currentUrl }).catch(() =>
window.open(currentUrl, '_blank', 'noopener'),
);
});
// The handshake the MCP Apps SDK's App.connect performs: hosts validate
// these parameters, and keep a view hidden until it is initialized.
request('ui/initialize', {
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',
},
protocolVersion: config.protocolVersion,
}).then(
(result) => {
showDisplayMode(result?.hostContext);
send({ method: 'ui/notifications/initialized' });
notify('ui/notifications/size-changed', {
width: document.body.clientWidth,
height: config.panelHeight,
});
},
(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}` : '.'),
),
);
}
+6
View File
@@ -0,0 +1,6 @@
import { createAirQualityLayer } from '../../layers/airQuality/index.js';
import { overlayHost } from './overlayHost.js';
/** Wire air-quality readings to the application overlay host. */
export function createApplicationAirQuality(options) {
return createAirQualityLayer({ overlayHost, ...options });
}
+2
View File
@@ -9,6 +9,7 @@ import * as sprites from '../../data/spriteOrder.js';
import * as focus from '../../data/focusDeemphasis.js';
import * as worldFocus from '../../worldFocus.js';
import * as render from '../../renderGovernor.js';
import * as readouts from '../../hudReadouts.js';
/** Construct one layer using the application scene owners and a supplied source. */
export function createApplicationVessels({ source, options = {} }) {
@@ -26,6 +27,7 @@ export function createApplicationVessels({ source, options = {} }) {
focus,
worldFocus,
render,
readouts,
},
});
}
+2 -2
View File
@@ -3,7 +3,7 @@ import * as credits from '../../data/dataCredits.js';
import * as sprites from '../../data/spriteOrder.js';
import * as activation from '../../cctvFocusRequest.js';
import * as overlays from '../../overlays/worldOverlay.js';
import * as locations from '../../locations.js';
import { CITY_POIS } from '../../cityPois.js';
import * as picking from '../../data/pickRegistry.js';
import * as focus from '../../data/focusDeemphasis.js';
import * as render from '../../renderGovernor.js';
@@ -18,7 +18,7 @@ export function createApplicationCctv({ surface, source }) {
sprites,
activation,
overlays,
locations,
locations: { CITY_POIS },
picking,
terrain,
ground,
+3 -9
View File
@@ -1,12 +1,6 @@
import { createApplicationDirectionsLayer } from '../../data/directions.js';
/**
* Construct one Directions layer using the application scene owners.
*
* It takes no data source: a route is fetched from `/api/route` when the
* operator places both endpoints, so there is nothing to supply up front.
* @returns {object} A fresh layer instance for this catalog.
*/
export function createApplicationDirections() {
return createApplicationDirectionsLayer();
/** Construct Directions with application scene owners and an explicit route source. */
export function createApplicationDirections({ source }) {
return createApplicationDirectionsLayer({ source });
}
+23
View File
@@ -0,0 +1,23 @@
import * as Cesium from 'cesium';
import { createGhostMapsLayer } from '../../layers/ghostMaps/index.js';
import * as picking from '../../data/pickRegistry.js';
import * as overlays from '../../overlays/worldOverlay.js';
import { isPointerFree } from '../../data/inputOwnership.js';
/** Wire the S2 Underground GhostMaps layer into the application catalog. */
export function createApplicationGhostMaps(options) {
return createGhostMapsLayer({
overlayHost: {
setEntries: overlays.setOverlayEntries,
setVisible: overlays.setOverlaySourceVisible,
clearSource: overlays.clearOverlaySource,
hitTest: overlays.hitTestWorldOverlay,
},
openExternal: (url) => window.open(url, '_blank', 'noopener,noreferrer'),
screenSpaceEventHandlerFactory: (viewer) =>
new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas),
picking,
pointer: { isPointerFree },
...options,
});
}
+6
View File
@@ -0,0 +1,6 @@
import { createGnssIntegrityLayer } from '../../layers/gnss/index.js';
/** Wire the adsb.lol GNSS navigation-integrity layer into the application catalog. */
export function createApplicationGnssIntegrity(options) {
return createGnssIntegrityLayer(options);
}
+8 -8
View File
@@ -3,17 +3,17 @@ import { createRecentImageryRenderer } from '../../layers/recentImagery/renderin
import { createThumbnailLoader } from '../../layers/recentImagery/thumbnails.js';
import { governorRequestRender } from '../../renderGovernor.js';
/**
* Construct the Recent Imagery layer with the real GIBS renderer and the
* browser thumbnail loader. It takes no data source: the catalog is NASA
* CMR and the tiles are GIBS, both browser-direct and keyless.
* @returns {object} A fresh layer instance for this catalog.
*/
export function createApplicationRecentImagery() {
/** Construct imagery presentation over one explicitly supplied acquisition source. */
export function createApplicationRecentImagery({ source }) {
return createRecentImageryLayer({
catalog: { searchHls: (...args) => source.searchHls(...args) },
renderer: createRecentImageryRenderer({
requestRender: governorRequestRender,
tileTemplate: (...args) => source.getTileTemplate(...args),
credit: source.credit || source.label || 'Imagery',
}),
thumbnails: createThumbnailLoader({
requestThumbnail: (...args) => source.getThumbnail(...args),
}),
thumbnails: createThumbnailLoader(),
});
}
+42
View File
@@ -0,0 +1,42 @@
import { createStreetLevelLayer } from '../../layers/streetLevel/index.js';
import { createMapillaryProvider } from '../../layers/streetLevel/providers/mapillary/index.js';
import { createGoogleProvider } from '../../layers/streetLevel/providers/google/index.js';
import { createKartaViewProvider } from '../../layers/streetLevel/providers/kartaview/index.js';
import { createPanoramaxProvider } from '../../layers/streetLevel/providers/panoramax/index.js';
import * as sprites from '../../data/spriteOrder.js';
import * as picking from '../../data/pickRegistry.js';
import * as input from '../../data/inputOwnership.js';
import * as scenePick from '../../data/scenePick.js';
import * as render from '../../renderGovernor.js';
/** The browser Maps key the scene was started with (src/app/scene.js). */
const googleApiKey = () => globalThis.window?.__GOOGLE_MAPS_API_KEY__ || null;
/**
* Mapillary, Google Street View, KartaView and Panoramax, once the Mapillary
* source is supplied; else none.
*/
function defaultProviders(sources) {
if (sources.mapillary == null) return [];
return [
createMapillaryProvider({ source: sources.mapillary }),
createGoogleProvider({ getApiKey: googleApiKey }),
createKartaViewProvider(),
createPanoramaxProvider(),
];
}
/**
* Inject providers, or use the defaults. An explicit provider list replaces
* them; an unused sources.mapillary is neither constructed nor validated,
* including when the list is empty.
*/
export function createApplicationStreetLevel({
sources = {},
providers = defaultProviders(sources),
}) {
return createStreetLevelLayer({
providers,
services: { sprites, picking, input, scenePick, render },
});
}
+88
View File
@@ -0,0 +1,88 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createApplicationStreetLevel } from './streetLevel.js';
import { fakeStreetLevelProvider } from '../../testSupport/streetLevelFakes.mjs';
test('explicit providers replace the default source and an empty list removes it', () => {
const sources = { mapillary: {} };
const provider = fakeStreetLevelProvider({
id: 'example',
pickPrefix: 'example:',
});
const layer = createApplicationStreetLevel({
sources,
providers: [provider],
});
assert.deepEqual(layer.providerIds, ['example']);
assert.deepEqual(
createApplicationStreetLevel({ sources, providers: [] }).providerIds,
[],
);
assert.throws(
() => createApplicationStreetLevel({ sources }),
/Mapillary source/,
);
assert.throws(
() => createApplicationStreetLevel({ providers: null }),
/array/,
);
});
test('the defaults are Mapillary (given its source), Street View, KartaView and Panoramax, which start off and travel as provider switches', async () => {
const { createDefaultLayerState } = await import('../../data/layerState.js');
const mapillary = {
getStatus() {},
getTile() {},
getSequenceImages() {},
nearestImages() {},
};
const layer = createApplicationStreetLevel({ sources: { mapillary } });
assert.deepEqual(layer.providerIds, [
'mapillary',
'google',
'kartaview',
'panoramax',
]);
// A fresh page and a restored default agree: Street View is billed, so off.
layer.setParams(createDefaultLayerState().options['street-level']);
assert.equal(layer.getParams().google, false);
assert.equal(layer.getParams().mapillary, true);
// A link that lit Street View carries it in the shared switch field.
layer.setParams({ providerSwitches: 'google-1' });
assert.equal(layer.getParams().google, true);
assert.equal(
layer.getParams().providerSwitches,
'google-1*kartaview-0*panoramax-0',
);
// KartaView is keyless and free, but one source shows at a time.
layer.setParams({ providerSwitches: 'kartaview-1' });
assert.equal(layer.getParams().kartaview, true);
assert.equal(layer.getParams().google, false);
assert.equal(layer.getParams().mapillary, false);
// As is Panoramax: lighting it puts KartaView out.
layer.setParams({ providerSwitches: 'panoramax-1' });
assert.equal(layer.getParams().panoramax, true);
assert.equal(layer.getParams().kartaview, false);
assert.equal(layer.getParams().mapillary, false);
// The form the app writes (the README and CHANGELOG quote it).
assert.equal(
layer.getParams().providerSwitches,
'google-0*kartaview-0*panoramax-1',
);
// Without the application's Street Level source there are none.
assert.deepEqual(createApplicationStreetLevel({}).providerIds, []);
});
test('every built-in provider is exported, so hosts can choose their own list', async () => {
const google = await import('gods-eye-view/layers/street-level/google');
const kartaview = await import('gods-eye-view/layers/street-level/kartaview');
const panoramax = await import('gods-eye-view/layers/street-level/panoramax');
const layer = createApplicationStreetLevel({
providers: [
google.createGoogleProvider({ getApiKey: () => null }),
kartaview.createKartaViewProvider(),
panoramax.createPanoramaxProvider(),
],
});
assert.deepEqual(layer.providerIds, ['google', 'kartaview', 'panoramax']);
});
+15 -6
View File
@@ -14,11 +14,13 @@ import {
governorRequestRender,
} from '../renderGovernor.js';
import { describeError } from './errors.js';
import { onLocaleChange, t } from '../i18n/index.js';
/** Construct the application globe using the caller's local configuration. */
export async function createApplicationScene({
requestServices,
googleApiKey,
googleTokens = null,
cesiumToken,
credits,
MapController = MapStackController,
@@ -41,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';
@@ -58,12 +60,14 @@ export async function createApplicationScene({
defer(installTrackpadPinchZoom(viewer));
registerDataCredits(viewer, credits);
configureCreditKeyboardAccess(document);
defer(onLocaleChange(() => configureCreditKeyboardAccess(document)));
loaderStatus.textContent =
googleApiKey || cesiumToken
? 'Loading Google 3D Tiles...'
: 'Loading the keyless globe...';
googleApiKey || googleTokens || cesiumToken
? t('Loading Google 3D Tiles...')
: t('Loading the keyless globe...');
const photoreal = await loadPhotorealisticTileset(Cesium, {
googleApiKey,
googleTokens,
cesiumToken,
});
const tileset = photoreal.tileset;
@@ -88,12 +92,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,
+116
View File
@@ -0,0 +1,116 @@
const contract = (layers, methods) =>
Object.freeze({
layers: Object.freeze(layers),
methods: Object.freeze(methods),
});
/** Acquisition contracts and their consumers; records remain family-specific. */
export const CATALOG_SOURCE_CONTRACTS = Object.freeze({
directions: contract(['directions'], ['getRoute']),
'recent-imagery': contract(
['recent-imagery'],
['searchHls', 'getThumbnail', 'getTileTemplate'],
),
flights: contract(['flights'], ['getSnapshot']),
military: contract(['military'], ['getSnapshot']),
vessels: contract(['ais-live-vessels'], ['getSnapshot']),
cctv: contract(
['cctv'],
['getCatalog', 'getHealth', 'getFrameUrl', 'getMediaUrl'],
),
radio: contract(['radio'], ['getDirectory', 'recordClick']),
traffic: contract(
['traffic'],
[
'requestRoads',
'getStatus',
'fetchFlowForBounds',
'getFlowSessionStats',
'resetFlowTileCache',
],
),
bikeshare: contract(['bikeshare'], ['getStations']),
installations: contract(
['military-installations'],
['getMappedSites', 'searchNearby'],
),
satellites: contract(['satellites'], ['readGroup']),
launches: contract(['rocket-launches'], ['getLaunches', 'getActiveTle']),
alpr: contract(['alpr-cameras'], ['fetch']),
firms: contract(['local-firms'], ['getSnapshot']),
wind: contract(['wind'], ['getSnapshot']),
weather: contract(
['weather-radar', 'weather-satellite', 'weather-lightning'],
['getSnapshot'],
),
cyclones: contract(['weather-cyclones'], ['getSnapshot']),
earthquakes: contract(['earthquakes'], ['getSnapshot']),
'fire-perimeters': contract(['fire-perimeters'], ['getSnapshot']),
'air-quality': contract(['air-quality'], ['getSnapshot']),
'gnss-integrity': contract(['gnss-integrity'], ['getSnapshot']),
's2-ghostmaps': contract(['s2-ghostmaps'], ['getCatalog', 'getFile']),
cables: contract(['telegeography-submarine-cables'], ['fetch']),
transit: contract(['transit'], ['requestSnapshot', 'getHistory']),
});
/** An omitted provider is unavailable, never an authoritative empty dataset. */
class SourceUnavailableError extends Error {
constructor(name) {
super(`Data source not configured: ${name}`);
this.name = 'SourceUnavailableError';
this.code = 'unavailable';
this.unavailable = true;
this.retryable = false;
}
}
/** Validate supplied contracts and keep omitted sources inert during construction. */
export function resolveCatalogSources(supplied = {}) {
if (!supplied || typeof supplied !== 'object' || Array.isArray(supplied))
throw new TypeError('Catalog sources must be an object');
const sources = { ...supplied };
const missing = new Set();
for (const [name, { methods }] of Object.entries(CATALOG_SOURCE_CONTRACTS)) {
const source = sources[name];
if (source == null) {
missing.add(name);
const unavailable = () => {
throw new SourceUnavailableError(name);
};
sources[name] = Object.freeze({
label: name,
...Object.fromEntries(methods.map((method) => [method, unavailable])),
});
} else if (methods.some((method) => typeof source[method] !== 'function')) {
throw new TypeError(`Invalid catalog source: ${name}`);
}
}
return {
sources,
isConfigured: (name) => !missing.has(name),
createAvailabilityLookup(layers) {
const byId = new Map(layers.map((layer) => [layer.id, layer]));
const availability = new Map();
for (const [name, { layers: ids }] of Object.entries(
CATALOG_SOURCE_CONTRACTS,
)) {
for (const id of ids) {
const layer = byId.get(id);
if (!layer) throw new Error(`Missing source consumer: ${id}`);
availability.set(
id,
Object.freeze({
available: !missing.has(name),
source: name,
reason: missing.has(name)
? `${layer.name || id}: data source not configured`
: null,
}),
);
}
}
return (id) =>
availability.get(id) ?? byId.get(id)?.getSourceAvailability?.();
},
};
}
+220
View File
@@ -0,0 +1,220 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
resolveCatalogSources,
CATALOG_SOURCE_CONTRACTS,
} from './sourceComposition.js';
import { createApplicationCatalog } from './constructCatalog.js';
import { createSurfaceServices } from './surfaceServices.js';
import { createStandaloneLayerSources } from '../standalone/layerSources.js';
import { LayerLifecycle } from '../data/lifecycle.js';
function catalog(sources, t) {
const lifetime = new AbortController();
t.after(() => lifetime.abort());
return createApplicationCatalog({
sources,
signal: lifetime.signal,
surface: createSurfaceServices({
terrainSource: { getHeights: async () => [] },
signal: lifetime.signal,
eventTarget: null,
}),
});
}
test('every acquisition source can be omitted without losing catalog membership', (t) => {
const baseline = catalog(createStandaloneLayerSources(), t);
for (const name of Object.keys(CATALOG_SOURCE_CONTRACTS)) {
const sources = createStandaloneLayerSources({ [name]: null });
const actual = catalog(sources, t);
assert.deepEqual(actual.metadata, baseline.metadata, name);
for (const id of CATALOG_SOURCE_CONTRACTS[name].layers) {
assert.equal(actual.getSourceAvailability(id).available, false, id);
}
}
});
test('all acquisition sources may be absent, and enable fails before initializing or fetching', async (t) => {
const actual = catalog({}, t);
assert.equal(actual.layers.length, 35);
const manager = new LayerLifecycle(
{},
{ getSourceAvailability: actual.getSourceAvailability },
);
for (const layer of actual.layers) manager.register(layer);
for (const { layers: ids } of Object.values(CATALOG_SOURCE_CONTRACTS)) {
for (const id of ids) {
assert.equal(await manager.setEnabled(id, true), false, id);
assert.equal(manager.layers.get(id).initialized, false, id);
const row = manager.getAll().find((entry) => entry.id === id);
assert.equal(row.enabled, false);
assert.equal(row.stats.status, 'unavailable');
assert.match(row.stats.error, /data source not configured/);
}
}
});
test('configured sources retain identity; invalid implementations never become unavailable silently', () => {
const source = { getSnapshot: async () => [] };
assert.equal(
resolveCatalogSources({ earthquakes: source }).sources.earthquakes,
source,
);
for (const name of Object.keys(CATALOG_SOURCE_CONTRACTS)) {
assert.throws(
() => resolveCatalogSources({ [name]: {} }),
/Invalid catalog source/,
);
}
const missing = resolveCatalogSources({}).sources.earthquakes;
assert.throws(() => missing.getSnapshot(), { code: 'unavailable' });
});
test('standalone defaults allow exact replacements and explicit removal, and reject typos', () => {
const source = { getSnapshot: async () => [] };
const sources = createStandaloneLayerSources({
flights: source,
military: null,
});
assert.equal(sources.flights, source);
assert.equal(sources.military, null);
assert.equal(typeof sources.cctv.getCatalog, 'function');
assert.throws(
() => createStandaloneLayerSources({ fligts: source }),
/Unknown source/,
);
});
test('GhostMaps obeys source omission, replacement and explicit removal', async (t) => {
let fetches = 0;
t.mock.method(globalThis, 'fetch', async () => {
fetches++;
throw new Error('Unexpected standalone acquisition');
});
const missing = catalog({}, t);
assert.equal(missing.getSourceAvailability('s2-ghostmaps')?.available, false);
const manager = new LayerLifecycle(
{},
{ getSourceAvailability: missing.getSourceAvailability },
);
manager.register(missing.get('s2-ghostmaps'));
assert.equal(await manager.setEnabled('s2-ghostmaps', true), false);
assert.equal(manager.layers.get('s2-ghostmaps').initialized, false);
assert.equal(
createStandaloneLayerSources({ 's2-ghostmaps': null })['s2-ghostmaps'],
null,
);
let reads = 0;
let subscriptions = 0;
let unsubscribed = 0;
const source = {
getCatalog: async () => {
reads++;
return { commit: 'a'.repeat(40), products: [] };
},
getFile: async () => {
throw new Error('Empty catalog must not read files');
},
subscribe: () => {
subscriptions++;
return () => {
unsubscribed++;
};
},
};
const supplied = createStandaloneLayerSources({ 's2-ghostmaps': source });
assert.equal(supplied['s2-ghostmaps'], source);
const configured = catalog(supplied, t);
assert.equal(
configured.getSourceAvailability('s2-ghostmaps').available,
true,
);
const previous = Object.getOwnPropertyDescriptor(globalThis, 'document');
globalThis.document = { addEventListener() {}, removeEventListener() {} };
t.after(() => {
if (previous) Object.defineProperty(globalThis, 'document', previous);
else delete globalThis.document;
});
const layer = configured.get('s2-ghostmaps');
t.after(() => layer.destroy());
layer.init({ scene: { requestRender() {} } });
layer.enable();
await layer.update();
layer.disable();
assert.equal(reads, 1);
assert.equal(subscriptions, 1);
assert.equal(unsubscribed, 1);
assert.equal(layer.getStats().commit, 'a'.repeat(40));
assert.equal(fetches, 0);
});
test('empty Street Level uses the shared unavailable lifecycle without initialization', async (t) => {
const actual = catalog({}, t);
const manager = new LayerLifecycle(
{},
{ getSourceAvailability: actual.getSourceAvailability },
);
manager.register(actual.get('street-level'));
assert.equal(await manager.setEnabled('street-level', true), false);
assert.equal(manager.layers.get('street-level').initialized, false);
const row = manager.getAll()[0];
assert.equal(row.stats.status, 'unavailable');
assert.equal(row.stats.error, 'No street-level imagery providers configured');
assert.equal(await manager.setEnabled('street-level', false), true);
const configured = catalog(createStandaloneLayerSources(), t);
assert.equal(
configured.get('street-level').getSourceAvailability().available,
true,
);
});
test('availability lookup accepts frozen layers and uses their display names', () => {
const composition = resolveCatalogSources({});
const layers = Object.values(CATALOG_SOURCE_CONTRACTS)
.flatMap(({ layers }) => layers)
.map((id) =>
Object.freeze({
id,
name: id === 'fire-perimeters' ? 'Fire perimeters' : id,
}),
);
const lookup = composition.createAvailabilityLookup(layers);
assert.equal(
lookup('fire-perimeters').reason,
'Fire perimeters: data source not configured',
);
assert.equal(Object.hasOwn(layers[0], 'getSourceAvailability'), false);
const manager = new LayerLifecycle({}, { getSourceAvailability: lookup });
manager.register(layers.find(({ id }) => id === 'fire-perimeters'));
assert.equal(
manager.getAll()[0].stats.error,
'Fire perimeters: data source not configured',
);
});
test('absent military acquisition does not poll, suppress flights, or erase supplied classification', async (t) => {
let fetches = 0;
t.mock.method(globalThis, 'fetch', async () => {
fetches++;
throw new Error('Unexpected fallback acquisition');
});
const actual = catalog(createStandaloneLayerSources({ military: null }), t);
const registry = actual.militaryRegistry;
assert.equal(actual.getSourceAvailability('flights').available, true);
assert.equal(registry.isMilitaryLayerActive(), false);
assert.equal(registry.isMilitaryIcao('abc123'), false);
registry.registerMilitaryIcaos([' ABC123 ']);
await registry.refreshMilitaryRegistryIfStale();
assert.equal(registry.isMilitaryIcao('abc123'), true);
assert.equal(registry.isMilitaryIcao('def456'), false);
const manager = new LayerLifecycle(
{},
{ getSourceAvailability: actual.getSourceAvailability },
);
manager.register(actual.get('military'));
assert.equal(await manager.setEnabled('military', true), false);
assert.equal(registry.isMilitaryLayerActive(), false);
assert.equal(fetches, 0);
});
-2
View File
@@ -1,2 +0,0 @@
// Compatibility entry; source contracts are owned by sources/.
export { createSourceSlot } from '../sources/sourceSlot.js';
-24
View File
@@ -1,24 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createSourceSlot } from './sourceSlot.js';
test('replacing a source rejects its late result and old cleanup cannot clear the new source', async () => {
let finish;
const slot = createSourceSlot({ read: async () => 'default' }, ['read']);
const old = slot.configure({ read: () => new Promise(resolve => { finish = resolve; }) });
const pending = slot.source.read();
const stop = slot.configure({ read: async () => 'replacement', attribution: { name: 'Replacement' } });
old();
finish('stale');
await assert.rejects(pending, { name: 'AbortError' });
assert.equal(await slot.source.read(), 'replacement');
assert.equal(slot.source.attribution.name, 'Replacement');
stop();
assert.throws(() => slot.source.read(), /not configured/);
});
test('an invalid replacement leaves the working source intact', async () => {
const slot = createSourceSlot({ read: async () => 42 }, ['read']);
assert.throws(() => slot.configure({}), TypeError);
assert.equal(await slot.source.read(), 42);
});
+55 -4
View File
@@ -1,10 +1,15 @@
import { SceneDirector } from '../scenes/director.js';
import { initAnnotations } from '../annotations/index.js';
import { initDrawTool } from '../annotations/drawTool.js';
import { initGeoFileImport } from '../ui/geoFileImport.js';
import { useDisplayStore } from '../ui/stores/display.js';
import { applicationPinia } from '../ui/vueRuntime.js';
import { initImageryBoxTool } from '../ui/imageryBoxTool.js';
import { createRecentImageryPanel } from '../ui/recentImagery.js';
import { initGevVoiceCommands } from '../voice/gevRealtime.js';
import { installViews, isEmbeddedInline } from './embed.js';
import { installScopeMask, destroyScopeMask } from '../scopeMask.js';
import { installLayerReleaseTrim } from '../sceneCommandRelease.js';
import {
installRenderGovernor,
getRenderGovernorDiagnostics,
@@ -50,11 +55,37 @@ export function createApplicationTools({
if (window.__gevAnnotations === annotations) delete window.__gevAnnotations;
annotations.destroy();
});
// DISPLAY ▸ Draw: the same whiteboard, drawn by hand. It claims the pointer
const displayStore = useDisplayStore(applicationPinia());
// The Display panel's controls answer through the store; returns the
// disconnect.
const connectDisplay = (actions) => {
const token = displayStore.connect(actions);
return () => displayStore.detach(token);
};
// UTILITIES ▸ Draw: the same whiteboard, drawn by hand. It claims the pointer
// while a session is open, so its teardown belongs to the application
// lifetime rather than to whoever last pressed the button.
const drawTool = initDrawTool({ viewer, annotations });
// lifetime rather than to whoever last pressed the button. A row's fly-to
// goes through the shell's navigation policy, as Import's does.
const drawTool = initDrawTool({
viewer,
annotations,
navigation: styleManager,
publish: (view) => displayStore.publishDraw(view),
connect: connectDisplay,
});
defer(() => drawTool?.destroy());
// UTILITIES ▸ Import: the person's own GeoJSON/KML/KMZ/GPX files, each its
// own data source. They live for the page session, so the application
// lifetime removes them and the drop listeners on the viewer container.
const geoFileImport = initGeoFileImport({
viewer,
navigation: styleManager,
ground: operations.surface?.groundFloor,
subscribeMapSource: (listener) => mapStackController?.subscribe?.(listener),
publish: (view) => displayStore.publishGeoImport(view),
connect: connectDisplay,
});
defer(() => geoFileImport?.destroy());
// DATA ▸ Recent Imagery: the box tool claims the pointer like Draw and the
// panel lives on the right rail, so both belong to the application
// lifetime. The tileset lets the layer drape while the globe is hidden.
@@ -98,6 +129,15 @@ export function createApplicationTools({
// nothing animates per frame. Installed AFTER every module above has had
// its chance to register pre-install holds. (perf wave 2)
installRenderGovernor(viewer);
// Cesium keeps a turned-off layer's draw commands (and so all its entities)
// alive in reused per-pass arrays; trim them after each disable.
defer(
installLayerReleaseTrim({
scene: viewer.scene,
dataManager,
requestRender: governorRequestRender,
}),
);
// Install the explicit scope mask used by the DISPLAY controls.
installScopeMask(viewer);
@@ -118,7 +158,9 @@ export function createApplicationTools({
// GPU. Holder/data state is untouched, so return is seamless: restore
// the loop, refresh the one DOM surface we gated, render a frame.
const syncVisibilitySuspension = () => {
const hidden = document.hidden;
// A panel's host may report it hidden while it is on screen; the panel
// keeps drawing itself (see keepPanelRendering in embed.js).
const hidden = document.hidden && !isEmbeddedInline();
viewer.useDefaultRenderLoop = !hidden;
cockpitCloudEffects?.setSuspended?.(hidden);
if (!hidden) {
@@ -176,5 +218,14 @@ export function createApplicationTools({
delete window.__gevVoiceCommands;
});
debug.voiceCommands = voiceCommands;
defer(
installViews({
shell: styleManager,
viewer,
dataManager,
run: (name, args) => voiceCommands.runner(name, args, { signal }),
signal,
}),
);
return { sceneDirector, annotations, voiceCommands };
}
+23 -6
View File
@@ -1,5 +1,6 @@
import * as Cesium from 'cesium';
import { applyModelAtmosphereWorkaround } from './atmosphereCompat.js';
import { StarrySky } from '../sky/starrySky.js';
const PINCH_ZOOM_MULTIPLIER = 8;
const MAX_PINCH_PIXEL_DELTA = 120;
@@ -102,11 +103,9 @@ export function installTrackpadPinchZoom(
};
}
/** Create the standard globe viewer in caller-owned, visible containers. */
export function createApplicationViewer({ container, creditContainer }) {
if (!container || !creditContainer)
throw new TypeError('Viewer and credit containers are required');
const viewer = new Cesium.Viewer(container, {
/** Cesium.Viewer options of the standard globe viewer. */
export function applicationViewerOptions(creditContainer) {
return {
timeline: false,
animation: false,
baseLayerPicker: false,
@@ -119,12 +118,30 @@ export function createApplicationViewer({ container, creditContainer }) {
selectionIndicator: false,
infoBox: false,
baseLayer: false,
// Replaces the blocky Tycho-2 default. An option, not `skyBox: false`,
// which would also stop CesiumWidget from creating the Sun and Moon.
skyBox: new StarrySky(),
creditContainer,
msaaSamples: 4,
contextOptions: { webgl: { preserveDrawingBuffer: true } },
});
};
}
/** Create the standard globe viewer in caller-owned, visible containers. */
export function createApplicationViewer({ container, creditContainer }) {
if (!container || !creditContainer)
throw new TypeError('Viewer and credit containers are required');
const viewer = new Cesium.Viewer(
container,
applicationViewerOptions(creditContainer),
);
try {
viewer.targetFrameRate = 60;
// A phone pinch spans fewer screen pixels than desktop zoom gestures.
// Keep Cesium's distance-aware zoom and collision limits, with less travel
// required on touch-first screens. Mouse/trackpad sensitivity stays native.
if (globalThis.matchMedia?.('(pointer: coarse)').matches)
viewer.scene.screenSpaceCameraController.zoomFactor *= 3;
// Before any tile builds a draw command: Cesium's per-vertex model
// atmosphere fails to LINK on Apple's Metal backend and kills the
// render loop. See app/atmosphereCompat.js.
-46
View File
@@ -1,51 +1,5 @@
import * as Cesium from 'cesium';
/**
* Camera presets for notable locations.
* Phase 1 default: fly to Austin, TX on load.
*/
export const CAMERA_PRESETS = {
austin: {
destination: Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672, 800),
orientation: {
heading: Cesium.Math.toRadians(0),
pitch: Cesium.Math.toRadians(-35),
roll: 0.0,
},
},
sf: {
destination: Cesium.Cartesian3.fromDegrees(-122.4194, 37.7749, 1000),
orientation: {
heading: Cesium.Math.toRadians(30),
pitch: Cesium.Math.toRadians(-30),
roll: 0.0,
},
},
nyc: {
destination: Cesium.Cartesian3.fromDegrees(-73.9857, 40.7484, 1200),
orientation: {
heading: Cesium.Math.toRadians(-20),
pitch: Cesium.Math.toRadians(-30),
roll: 0.0,
},
},
};
/**
* Fly the camera to a preset location with a smooth animation.
*/
export function flyToPreset(viewer, presetName, duration = 3.0) {
const preset = CAMERA_PRESETS[presetName];
if (!preset) return;
viewer.camera.flyTo({
destination: preset.destination,
orientation: preset.orientation,
duration,
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
});
}
/**
* Set camera to Austin on load with a cinematic fly-in.
* @returns {Function} Cancels the pending or active startup flight.
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -20,8 +20,8 @@ import { ownCameraArrival } from './data/cameraArrival.js';
/** Minimum eye height above the rendered surface for a usable view. */
export const MIN_EYE_CLEARANCE_M = 120;
/** Retries while tiles stream in; sampling fails until the mesh exists. */
export const GUARD_ATTEMPTS = 8;
export const GUARD_INTERVAL_MS = 600;
const GUARD_ATTEMPTS = 8;
const GUARD_INTERVAL_MS = 600;
/** Ignore sub-metre noise rather than nudging the camera forever. */
const GUARD_EPSILON_M = 2;
+4 -5
View File
@@ -1,7 +1,6 @@
import { LayerBindings } from './ui/layerBindings.js';
import { readShellSource, shellMethod } from './testSupport/readShellSource.mjs';
import { readLayerSource } from './testSupport/readLayerSource.mjs';
import { StyleManager } from './ui/applicationShell.js';
import { enter as cockpitEnter, navigateContext } from './ui/cockpitTrackingController.js';
import { CockpitViewController } from './ui/cockpitController.js';
import assert from 'node:assert/strict';
@@ -224,7 +223,7 @@ test('validated voice camera destinations share the UI navigation authority faca
'move camera',
);
ordered(move, [
"if (!['orbit', 'pan', 'tilt', 'rotate'].includes(motion))",
"if (!['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe'].includes(motion))",
'const start = () => {',
/const preserveCameraFlight =\s*motion === 'orbit'/,
'runNavigation(start, { preserveCameraFlight })',
@@ -262,7 +261,7 @@ test('deferred search releases only after its final authority check', () => {
assert.match(ui, /begin: \(\) => this\._beginDeferredNavigation\('location'\)/);
assert.match(ui, /beforeFly: \(generation\) => this\._reassertNavigationHandoff\(generation\)/);
assert.match(ui, /isCurrent: \(generation\) =>\s*!this\._disposed && generation === this\._navigationGeneration/);
assert.match(ui, /change\.type === 'settled'\)\s*this\._settleLocationSearchUi\(change\.generation\)/);
assert.match(ui, /change\.type === 'settled'\)\s*this\._settleLocationSearchUi\(change\.generation, \{\s*keepQuery: state\.status === 'missing' \|\| state\.status === 'failed',?\s*\}\)/);
assert.doesNotMatch(search.slice(0, search.indexOf('await this.search')), /_releaseFollowCamera/);
});
@@ -354,7 +353,7 @@ test('teardown refuses deferred location work before geocoding begins', () => {
'if (!query || this.destroyed) return;',
'const authority = this.begin();',
'if (authority === false)',
'this.input.blur();',
'this.input?.blur();',
'this.onStart?.(authority);',
'await this.search(query',
], 'disposed search refusal');
@@ -384,7 +383,7 @@ test('world-focus listener lifecycle is symmetric and idempotent', () => {
});
test('vessel and fire layers announce valid clicks and never fly cameras', () => {
for (const [label, source] of [['vessels', vessels], ['fires', firms]]) {
for (const source of [vessels, firms]) {
assert.match(source, /requestWorldFocus\(\{/);
assert.doesNotMatch(source, /camera\.flyTo/);
}
+306 -13
View File
@@ -4,7 +4,7 @@
*
* One motion at a time, driven per clock tick. `once` = bounded eased nudge;
* `continuous` runs until move_camera{stop}, ANY manual camera input on the
* canvas (pointerdown/wheel — the cancelFlight reflex), or a navigation tool
* canvas (pointerdown/wheel/touchstart — the cancelFlight reflex), or a navigation tool
* starts (the shared UI navigation policy releases this owner before the new
* destination mutates the camera). fly_route is a
* continuous motion in the same slot: a cinematic dolly along an existing
@@ -13,14 +13,24 @@
* Orbit circles the current screen-center ground target holding range and
* pitch while advancing heading (per-frame camera.lookAt; the lookAt
* transform is cleared on every stop so manual control returns cleanly).
* Globe rotation turns the world-frame camera about its screen-up axis through
* Earth's center, preserving range and framing. Left/right name the visible
* globe motion, opposite to the camera's travel around Earth.
*/
import * as Cesium from 'cesium';
import { GLOBE_VIEW } from './locations.js';
import {
projectEarthDiscToViewport,
earthDiscScreenRadius,
getKeyholeGeometry,
} from './celestialRing.js';
import {
holdContinuousRender,
releaseContinuousRender,
} from './renderGovernor.js';
let _isCockpitActive = null;
/** °/s by speed word — orbit; pan uses fractions of view height/s. */
const ORBIT_DEG_S = { slow: 2, normal: 6, fast: 15 };
const TILT_DEG_S = { slow: 4, normal: 10, fast: 20 };
@@ -952,6 +962,7 @@ let _viewer = null;
let _getTarget = null;
let _active = null; // { kind, motion, direction, speed, mode, motionId, ...state }
let _tickRemover = null;
let _trackingRemover = null;
let _inputRemovers = [];
/** Monotonic id for the motion currently running; 0 means "no motion". */
let _motionSeq = 0;
@@ -1162,7 +1173,11 @@ function onTick() {
else if (m.direction === 'right') cam.moveRight(step);
else if (m.direction === 'up') cam.moveUp(step);
else cam.moveDown(step);
} else if (m.kind === 'tilt' || m.kind === 'rotate') {
} else if (
m.kind === 'tilt' ||
m.kind === 'rotate' ||
m.kind === 'rotate_globe'
) {
const degS = TILT_DEG_S[m.speed];
const stepRad =
m.mode === 'once'
@@ -1172,7 +1187,38 @@ function onTick() {
(scale - (m.lastScale || 0))
: Cesium.Math.toRadians(degS) * dt;
m.lastScale = scale;
if (m.kind === 'tilt') {
if (m.kind === 'rotate_globe') {
// Tracking can acquire the camera without going through navigation.
// Yield without resetting its pose/frame: the world-space arc proof
// only describes Cesium's rotation while the frame is identity.
if (
_viewer.scene.mode !== Cesium.SceneMode.SCENE3D ||
_viewer.trackedEntity ||
_isCockpitActive?.() ||
!Cesium.Matrix4.equals(cam.transform, Cesium.Matrix4.IDENTITY)
) {
interruptCameraMotion('globe-camera-owner-changed');
return;
}
// Cesium rotates both position and basis about the reference-frame
// origin. The captured screen-up axis keeps left/right horizontal even
// in a tilted or rolled view; a positive angle moves the globe right.
const angle = m.direction === 'left' ? -stepRad : stepRad;
if (globeRotationFitsKeyhole() !== true) {
interruptCameraMotion('globe-framing-changed');
return;
}
if (
!globeRotationHasClearance(cam.positionWC) ||
Math.abs(
Cesium.Cartesian3.magnitude(cam.positionWC) - m.rotationRadius,
) > 0.01
) {
interruptCameraMotion('globe-surface-limit');
return;
}
cam.rotate(m.rotationAxis, angle);
} else if (m.kind === 'tilt') {
const up = m.direction === 'up';
// Clamp pitch to [-89°, -5°]: never through the ground, never at the sky.
const next = cam.pitch + (up ? stepRad : -stepRad);
@@ -1206,6 +1252,133 @@ function onTick() {
}
}
/** Rigid origin-centred rotation stays outside the actual ellipsoid's bounding sphere. */
function globeRotationHasClearance(position) {
const ellipsoid = _viewer.scene.globe?.ellipsoid ?? Cesium.Ellipsoid.WGS84;
const { minimumRadius, maximumRadius } = ellipsoid;
const radius = Cesium.Cartesian3.magnitude(position);
return (
Number.isFinite(minimumRadius) &&
minimumRadius > 0 &&
Number.isFinite(maximumRadius) &&
maximumRadius >= minimumRadius &&
Number.isFinite(radius) &&
radius > maximumRadius * (1 + 1e-12)
);
}
/** Use the existing WGS84/CSS-pixel projection; null means unsupported, not close-up. */
function globeRotationFitsKeyhole(camera = _viewer.camera) {
const ellipsoid = _viewer.scene.globe?.ellipsoid ?? Cesium.Ellipsoid.WGS84;
if (!Cesium.Cartesian3.equals(ellipsoid.radii, Cesium.Ellipsoid.WGS84.radii))
return null;
const canvas = _viewer.scene.canvas;
const width = canvas.clientWidth;
const height = canvas.clientHeight;
const frustum = camera.frustum;
if (
!Number.isFinite(width) ||
!Number.isFinite(height) ||
width <= 0 ||
height <= 0 ||
!(frustum instanceof Cesium.PerspectiveFrustum) ||
!Number.isFinite(frustum.near) ||
frustum.near <= 0 ||
!Number.isFinite(frustum.far) ||
frustum.far <= frustum.near ||
frustum.xOffset !== 0 ||
frustum.yOffset !== 0
)
return null;
try {
const cam = camera;
const distance = Cesium.Cartesian3.magnitude(cam.positionWC);
const sphereRadius = ellipsoid.maximumRadius;
if (!Number.isFinite(distance) || distance <= 0) return null;
if (distance <= sphereRadius) return false; // Near-surface polar views are close-up too.
const radiusPx = earthDiscScreenRadius(distance, height, frustum.fovy);
if (!radiusPx) return null;
const keyhole = getKeyholeGeometry(width, height);
// The known nadir reset probe uses the same exact cone below; projecting
// through SceneTransforms would read the live scene.camera instead.
if (
camera === _viewer.camera &&
!projectEarthDiscToViewport(_viewer, width, height)
) {
// A near-surface horizon view cannot fit even when pointed at Earth;
// an orbital camera looking away is unsupported, not a close-up offer.
return radiusPx > Math.min(keyhole.radius, width / 2, height / 2)
? false
: null;
}
// The shared disc is an overlay approximation off-axis. Bound the sphere
// against the existing keyhole cone. Canvas-edge clipping does not change
// framing during rigid globe rotation and is not a clearance condition,
// without changing the UI projection or defining another keyhole/mask.
const toCenter = Cesium.Cartesian3.negate(
cam.positionWC,
new Cesium.Cartesian3(),
);
const depth = Cesium.Cartesian3.dot(toCenter, cam.directionWC);
const right = Math.abs(Cesium.Cartesian3.dot(toCenter, cam.rightWC));
const up = Math.abs(Cesium.Cartesian3.dot(toCenter, cam.upWC));
const focalY = height / (2 * Math.tan(frustum.fovy / 2));
const focalX =
width / (2 * Math.tan(frustum.fovy / 2) * frustum.aspectRatio);
if (
![depth, right, up, focalX, focalY].every(Number.isFinite) ||
focalX <= 0 ||
focalY <= 0
)
return null;
const halfAngle = Math.asin(sphereRadius / distance);
const centerAngle = Math.atan2(Math.hypot(right, up), depth);
const keyholeAngle = Math.atan(keyhole.radius / Math.max(focalX, focalY));
// No overlay visibility hysteresis: a fully contained smaller globe needs no reset.
const framed = centerAngle + halfAngle <= keyholeAngle;
if (!framed) return false;
// Projection alone does not prove that a tiny globe is within depth planes.
if (
depth - sphereRadius < frustum.near ||
depth + sphereRadius > frustum.far
)
return null;
return true;
} catch {
return null;
}
}
/** Check the unchanged reset destination without moving the live camera. */
function globeResetFitsKeyhole() {
try {
const camera = Cesium.Camera.clone(_viewer.camera);
const { longitude, latitude } = _viewer.camera.positionCartographic;
camera.setView({
destination: Cesium.Cartesian3.fromRadians(
longitude,
latitude,
GLOBE_VIEW.heightM,
),
orientation: {
heading: 0,
pitch: Cesium.Math.toRadians(GLOBE_VIEW.pitchDeg),
roll: 0,
},
endTransform: Cesium.Matrix4.IDENTITY,
});
return globeRotationFitsKeyhole(camera) === true;
} catch {
return false;
}
}
/** Release only globe rotation when a new camera intent takes ownership. */
export function interruptGlobeRotation(reason = 'camera-intent') {
if (_active?.kind === 'rotate_globe') return interruptCameraMotion(reason);
return { wasActive: false, reason, leveled: false };
}
/**
* Wire the module to the viewer once (idempotent). The view-target getter is
* optional so a non-voice caller (the Directions FLY chip) can arm route
@@ -1218,24 +1391,42 @@ function onTick() {
* call brings none, so a callback from the old viewer can never answer for the
* new one.
*/
export function initCameraVerbs(viewer, getViewTargetCartesian = null) {
export function initCameraVerbs(
viewer,
getViewTargetCartesian = null,
{ isCockpitActive } = {},
) {
const nextTarget =
typeof getViewTargetCartesian === 'function'
? getViewTargetCartesian
: null;
if (_viewer === viewer) {
if (nextTarget) _getTarget = nextTarget;
if (typeof isCockpitActive === 'function')
_isCockpitActive = isCockpitActive;
return;
}
// A globe motion belongs to the viewer whose world pose it captured.
if (_active?.kind === 'rotate_globe')
interruptCameraMotion('viewer-replaced');
_getTarget = nextTarget;
_isCockpitActive =
typeof isCockpitActive === 'function' ? isCockpitActive : null;
_viewer = viewer;
if (_tickRemover) _tickRemover();
_tickRemover = viewer.clock.onTick.addEventListener(onTick);
if (_trackingRemover) _trackingRemover();
// Cockpit can acquire then clear tracking before the next clock tick.
// Yield on acquisition itself without changing legacy camera motions.
_trackingRemover = viewer.trackedEntityChanged?.addEventListener((entity) => {
if (entity && _active?.kind === 'rotate_globe')
interruptCameraMotion('globe-camera-owner-changed');
});
for (const rm of _inputRemovers) rm();
_inputRemovers = [];
const canvas = viewer.scene.canvas;
// ANY manual camera input reclaims control — the cancelFlight reflex.
for (const evt of ['pointerdown', 'wheel']) {
for (const evt of ['pointerdown', 'touchstart', 'wheel']) {
const h = () => interruptCameraMotion('manual-input');
canvas.addEventListener(evt, h, { passive: true });
_inputRemovers.push(() => canvas.removeEventListener(evt, h));
@@ -1256,7 +1447,11 @@ export function moveCamera(args = {}, runNavigation = null) {
const speed = ORBIT_DEG_S[String(args.speed || 'normal').toLowerCase()]
? String(args.speed || 'normal').toLowerCase()
: 'normal';
const mode = args.mode === 'continuous' ? 'continuous' : 'once';
const mode =
args.mode === 'continuous' ||
(motion === 'rotate_globe' && args.mode === undefined)
? 'continuous'
: 'once';
if (motion === 'stop') {
const wasActiveBeforeHandoff = Boolean(_active);
const stop = () => {
@@ -1270,15 +1465,16 @@ export function moveCamera(args = {}, runNavigation = null) {
};
return typeof runNavigation === 'function' ? runNavigation(stop) : stop();
}
if (!['orbit', 'pan', 'tilt', 'rotate'].includes(motion)) {
if (!['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe'].includes(motion)) {
return {
ok: false,
action: 'move_camera',
error: `Unknown motion "${args.motion}" — use orbit, pan, tilt, rotate, or stop.`,
error: `Unknown motion "${args.motion}" — use orbit, pan, tilt, rotate, rotate_globe, or stop.`,
};
}
if (
motion !== 'orbit' &&
motion !== 'rotate_globe' &&
!['left', 'right', 'up', 'down'].includes(direction)
) {
return {
@@ -1294,6 +1490,17 @@ export function moveCamera(args = {}, runNavigation = null) {
error: 'rotate needs left or right.',
};
}
if (
motion === 'rotate_globe' &&
direction !== null &&
!['left', 'right'].includes(direction)
) {
return {
ok: false,
action: 'move_camera',
error: 'rotate_globe needs left or right.',
};
}
if (motion === 'tilt') {
// Honest limits: at the clamp a tilt is a no-op — say so instead of
// reporting success and letting the model gaslight itself (field test).
@@ -1318,9 +1525,9 @@ export function moveCamera(args = {}, runNavigation = null) {
}
}
if (
motion === 'orbit' &&
_viewer?.trackedEntity &&
typeof runNavigation !== 'function'
(motion === 'rotate_globe' ||
(motion === 'orbit' && typeof runNavigation !== 'function')) &&
_viewer?.trackedEntity
) {
// The follow camera owns a tracked view; a lookAt orbit fights it frame
// by frame and stop rips the camera out (field finding). Honest refusal
@@ -1332,9 +1539,74 @@ export function moveCamera(args = {}, runNavigation = null) {
return {
ok: false,
action: 'move_camera',
error: `Already following ${label} — the follow camera owns the view while tracking. Say "stop tracking" first if you want a free orbit.`,
error: `Already following ${label} — the follow camera owns the view while tracking. Say "stop tracking" first if you want ${motion === 'rotate_globe' ? 'to rotate the globe' : 'a free orbit'}.`,
};
}
let globePose;
if (motion === 'rotate_globe') {
if (_viewer.scene.mode !== Cesium.SceneMode.SCENE3D) {
return {
ok: false,
action: 'move_camera',
error: 'Globe rotation requires a 3D view.',
};
}
const cam = _viewer.camera;
globePose = {
position: Cesium.Cartesian3.clone(cam.positionWC),
direction: Cesium.Cartesian3.clone(cam.directionWC),
up: Cesium.Cartesian3.normalize(cam.upWC, new Cesium.Cartesian3()),
right: Cesium.Cartesian3.clone(cam.rightWC),
};
if (_isCockpitActive?.())
return {
ok: false,
action: 'move_camera',
reason: 'camera-owned',
error: 'Exit Cockpit before rotating the globe.',
};
const ellipsoid = _viewer.scene.globe?.ellipsoid ?? Cesium.Ellipsoid.WGS84;
if (!(
Number.isFinite(ellipsoid.minimumRadius) &&
ellipsoid.minimumRadius > 0 &&
Number.isFinite(ellipsoid.maximumRadius) &&
ellipsoid.maximumRadius >= ellipsoid.minimumRadius &&
Number.isFinite(Cesium.Cartesian3.magnitude(globePose.position))
))
return {
ok: false,
action: 'move_camera',
reason: 'unsafe-ellipsoid',
error:
'Globe rotation requires a finite supported ellipsoid and a clear globe view.',
};
const fits = globeRotationFitsKeyhole();
if (fits === null)
return {
ok: false,
action: 'move_camera',
reason: 'unsupported-view',
error: 'Globe rotation requires a supported perspective view.',
};
if (!fits) {
const canReset = globeResetFitsKeyhole();
return {
ok: false,
action: 'move_camera',
reason: canReset ? 'globe-view-required' : 'globe-reset-unavailable',
error: canReset
? 'Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’'
: 'Zoom out further to fit the globe inside the keyhole; the Globe View preset does not fit this viewport.',
};
}
if (!globeRotationHasClearance(globePose.position))
return {
ok: false,
action: 'move_camera',
reason: 'unsafe-ellipsoid',
error: 'Globe rotation requires a clear ellipsoid bounding sphere.',
};
}
const start = () => {
interruptCameraMotion('replaced');
const state = {
@@ -1343,6 +1615,22 @@ export function moveCamera(args = {}, runNavigation = null) {
speed,
mode,
};
if (motion === 'rotate_globe') {
// Convert an inherited ground-target frame without changing the world
// pose. Rotation must pivot about Earth, never the previous local target.
const cam = _viewer.camera;
cam.cancelFlight();
cam.lookAtTransform(Cesium.Matrix4.IDENTITY);
// Handoff may level a route's bank. Keep the caller's exact world pose.
// Copy the basis directly: setView's heading/pitch conversion is singular
// at nadir and can change the captured orientation near the poles.
Cesium.Cartesian3.clone(globePose.position, cam.position);
Cesium.Cartesian3.clone(globePose.direction, cam.direction);
Cesium.Cartesian3.clone(globePose.up, cam.up);
Cesium.Cartesian3.clone(globePose.right, cam.right);
state.rotationAxis = globePose.up;
state.rotationRadius = Cesium.Cartesian3.magnitude(globePose.position);
}
if (motion === 'orbit') {
if (_viewer.scene.tweens.length > 0) {
// A camera flight is in progress ("fly to X and orbit it") — ALWAYS arm
@@ -1458,8 +1746,13 @@ export function flyRoute(
const speed = ROUTE_M_S[String(args.speed || 'normal').toLowerCase()]
? String(args.speed || 'normal').toLowerCase()
: 'normal';
// A route hidden from Draw's list is not on screen, so it is not flown.
const routes = (annoList || []).filter(
(a) => a.type === 'route' && Array.isArray(a.path) && a.path.length >= 2,
(a) =>
a.type === 'route' &&
!a.hidden &&
Array.isArray(a.path) &&
a.path.length >= 2,
);
if (!routes.length) {
return {
+1 -1
View File
@@ -27,7 +27,7 @@ export function runCctvLayerEnableFocus({
* @param {Object} [after={}] Ownership immediately after the await.
* @returns {{ trackedEntity: *, cockpitActive: boolean }} Conservative ownership.
*/
export function mergeCctvEnableOwnership(before = {}, after = {}) {
function mergeCctvEnableOwnership(before = {}, after = {}) {
return {
trackedEntity: before.trackedEntity || after.trackedEntity || null,
cockpitActive: !!(before.cockpitActive || after.cockpitActive),
-1
View File
@@ -1,7 +1,6 @@
import { readShellSource } from './testSupport/readShellSource.mjs';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import {
runCctvLayerEnableFocus,
runCctvLayerEnableTransition,
-1
View File
@@ -1,7 +1,6 @@
import { readShellSource } from './testSupport/readShellSource.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import {
CCTV_WORLD_CLICK_FOCUS_DURATION_SEC,
CCTV_FOCUS_REQUEST_EVENT,
+141 -84
View File
@@ -379,15 +379,23 @@ function drawMoonHaze(ctx, cx, cy, radius, angle) {
ctx.restore();
}
// Keys each ring's overlay, so a new ring never reuses an old one's canvases.
let rings = 0;
/**
* Screen-space ring and celestial direction overlay for the full-globe view.
* The overlay is presented to the celestial ring store, whose view renders it
* inside the viewer's container and hands back the two canvases this paints.
*/
export class CelestialRing {
/**
* @param {Cesium.Viewer} viewer
* @param {{enabled?:boolean,onAutoDisable?:Function}} [options]
* @param {{enabled?:boolean,onAutoDisable?:Function,store:object}} options
* `store` is the celestial ring store (src/ui/stores/celestialRing.js);
* the keyhole helpers here stay free of Vue for the packages that use
* them.
*/
constructor(viewer, { enabled = true, onAutoDisable = null } = {}) {
constructor(viewer, { enabled = true, onAutoDisable = null, store }) {
this.viewer = viewer;
this.enabled = !!enabled;
this.visible = false;
@@ -406,6 +414,10 @@ export class CelestialRing {
this._sunRenderKey = '';
this._moonRenderKey = '';
this._outlineRenderKey = '';
this._sunCanvas = null;
this._sunCtx = null;
this._moonCanvas = null;
this._moonCtx = null;
this._sunInertial = new Cesium.Cartesian3();
this._moonInertial = new Cesium.Cartesian3();
@@ -415,7 +427,7 @@ export class CelestialRing {
this._screenCenter = new Cesium.Cartesian2();
this._fixedMatrix = new Cesium.Matrix3();
this._buildDOM();
this._initView(store);
this._removePostRender = viewer.scene.postRender.addEventListener(() =>
this._draw(),
);
@@ -436,48 +448,68 @@ export class CelestialRing {
this.setEnabled(this.enabled);
}
/** Construct cached effect layers and Material Symbols celestial markers. */
_buildDOM() {
this._root = document.createElement('div');
this._root.id = 'celestial-ring-overlay';
this._root.className = 'celestial-ring-overlay';
this._root.setAttribute('aria-hidden', 'true');
this._ringOutline = document.createElement('div');
this._ringOutline.className = 'celestial-ring-outline';
this._sunCanvas = document.createElement('canvas');
this._sunCanvas.className = 'celestial-ring-canvas celestial-sun-canvas';
this._sunCtx = this._sunCanvas.getContext('2d', {
alpha: true,
desynchronized: true,
/** Present the overlay and take the canvases its view hands back. */
_initView(store) {
const owner = {};
this._store = store;
this._owner = owner;
this._view = {
id: ++rings,
owner,
container: this.viewer.container,
enabled: this.enabled,
visible: false,
globeVisible: null,
ephemerisUpdates: null,
size: null,
outline: null,
sun: null,
moon: null,
sunMarker: null,
moonMarker: null,
};
this._viewKey = '';
store.publish(this._view);
store.connect({
attach: (from, kind, canvas) => {
if (from === owner && this._shown()) this._attach(kind, canvas);
},
});
}
this._moonCanvas = document.createElement('canvas');
this._moonCanvas.className = 'celestial-ring-canvas celestial-moon-canvas';
this._moonCtx = this._moonCanvas.getContext('2d', {
alpha: true,
desynchronized: true,
});
/** Whether the store still shows this ring's overlay. */
_shown() {
return this._store.ring?.owner === this._owner;
}
this._sunMarker = document.createElement('span');
this._sunMarker.className =
'celestial-marker celestial-sun material-symbols-outlined';
this._sunMarker.textContent = 'light_mode';
this._moonMarker = document.createElement('span');
this._moonMarker.className =
'celestial-marker celestial-moon material-symbols-outlined';
this._moonMarker.textContent = 'dark_mode';
this._root.append(
this._ringOutline,
this._sunCanvas,
this._moonCanvas,
this._sunMarker,
this._moonMarker,
/** Publish a change to the overlay, skipping one that changes nothing. */
_present(patch) {
if (!this._shown()) return;
const next = { ...this._view, ...patch };
const key = JSON.stringify(next, (name, value) =>
name === 'owner' || name === 'container' ? undefined : value,
);
this.viewer.container.appendChild(this._root);
if (key === this._viewKey) return;
this._view = next;
this._viewKey = key;
this._store.publish(next);
}
/** Take (or, with null, drop) the canvas the view rendered for one layer. */
_attach(kind, canvas) {
const ctx =
canvas?.getContext('2d', { alpha: true, desynchronized: true }) ?? null;
// A new canvas starts blank, so the next frame repaints its layer.
if (kind === 'sun') {
this._sunCanvas = canvas;
this._sunCtx = ctx;
this._sunRenderKey = '';
} else if (kind === 'moon') {
this._moonCanvas = canvas;
this._moonCtx = ctx;
this._moonRenderKey = '';
} else return;
if (canvas && this.enabled) governorRequestRender('celestial-ring');
}
/** Enable or disable the user preference for the effect. */
@@ -491,12 +523,14 @@ export class CelestialRing {
// all. Camera motion covers every later repaint; the 60 s ephemeris
// timer requests its own. (perf wave 2 fix — owner playtest finding)
if (this.enabled !== wasEnabled) governorRequestRender('celestial-ring');
this._root.classList.toggle('disabled', !this.enabled);
if (!this.enabled) {
this.visible = false;
this._root.classList.remove('visible');
this._clear();
}
this._present({
enabled: this.enabled,
...(!this.enabled && { visible: false }),
});
}
/** Whether the current camera already frames the complete globe inside the keyhole. */
@@ -577,7 +611,7 @@ export class CelestialRing {
]) {
if (canvas && ctx) ctx.clearRect(0, 0, canvas.width, canvas.height);
}
this._ringOutline?.style.setProperty('display', 'none');
this._present({ outline: { ...this._view.outline, hidden: true } });
this._sunRenderKey = '';
this._moonRenderKey = '';
this._outlineRenderKey = '';
@@ -607,10 +641,9 @@ export class CelestialRing {
canvas.width = bw;
canvas.height = bh;
}
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
this._present({ size: { width, height } });
this._renderScale = dpr;
}
@@ -665,8 +698,8 @@ export class CelestialRing {
);
}
/** Position one icon-library marker along the keyhole circumference. */
_positionMarker(marker, cx, cy, radius, angle, opacity) {
/** Where one icon-library marker sits along the keyhole circumference. */
_markerPosition(cx, cy, radius, angle, opacity) {
const cos = Math.cos(angle);
const sin = Math.sin(angle);
const margin = 14;
@@ -679,20 +712,26 @@ export class CelestialRing {
? (cy - margin) / Math.abs(sin)
: Number.POSITIVE_INFINITY;
const markerRadius = Math.min(radius, maxRadiusX, maxRadiusY);
marker.style.left = `${cx + cos * markerRadius}px`;
marker.style.top = `${cy + sin * markerRadius}px`;
marker.style.opacity = String(opacity);
return {
left: cx + cos * markerRadius,
top: cy + sin * markerRadius,
opacity: String(opacity),
};
}
/** Update the cached effect geometry only when camera distance or viewport changes. */
_renderEffectLayers(cx, cy, radius, rayInnerRadius) {
const outlineKey = `${cx}:${cy}:${radius}`;
if (outlineKey !== this._outlineRenderKey) {
this._ringOutline.style.display = '';
this._ringOutline.style.left = `${cx - radius}px`;
this._ringOutline.style.top = `${cy - radius}px`;
this._ringOutline.style.width = `${radius * 2}px`;
this._ringOutline.style.height = `${radius * 2}px`;
this._present({
outline: {
hidden: false,
left: cx - radius,
top: cy - radius,
width: radius * 2,
height: radius * 2,
},
});
this._outlineRenderKey = outlineKey;
}
@@ -724,7 +763,11 @@ export class CelestialRing {
/** Per-frame camera projection with GPU-composited cached effect layers. */
_draw() {
if (!this.enabled || !this._sunCtx || !this._moonCtx) return;
// A ring the store no longer shows has handed its overlay to a later one.
if (!this.enabled || !this._shown()) return;
// Without its canvases (no view rendered them) the ring still tracks
// whether the globe is framed, so it turns itself off as before.
const painted = !!(this._sunCtx && this._moonCtx);
const now = performance.now();
if (now < this._nextDrawAt) return;
this._nextDrawAt = now + 1000 / CELESTIAL_MAX_FRAME_RATE;
@@ -732,7 +775,7 @@ export class CelestialRing {
const width = canvas.clientWidth || canvas.width;
const height = canvas.clientHeight || canvas.height;
if (!(width > 0 && height > 0)) return;
this._resize(width, height);
if (painted) this._resize(width, height);
const disc = this._projectedEarthDisc(width, height);
const wasVisible = this.visible;
@@ -740,9 +783,11 @@ export class CelestialRing {
? isFullGlobeInsideKeyhole(disc, wasVisible)
: false;
this.visible = nextVisible;
this._root.classList.toggle('visible', nextVisible);
this._root.dataset.globeVisible = String(nextVisible);
this._root.dataset.ephemerisUpdates = String(this._ephemerisUpdateCount);
this._present({
visible: nextVisible,
globeVisible: String(nextVisible),
ephemerisUpdates: String(this._ephemerisUpdateCount),
});
if (!nextVisible || !disc) {
this._clear();
this._debug = { enabled: true, visible: false, disc };
@@ -751,10 +796,14 @@ export class CelestialRing {
}
if (!wasVisible) this._ephemerisDirty = true;
if (!painted) {
this._debug = { enabled: true, visible: true, disc };
return;
}
const time = Cesium.JulianDate.now();
if (!this._updateEphemeris(time)) return;
this._root.dataset.ephemerisUpdates = String(this._ephemerisUpdateCount);
this._present({ ephemerisUpdates: String(this._ephemerisUpdateCount) });
const camera = this.viewer.camera;
const sunProjection = celestialScreenAngle(
Cesium.Cartesian3.dot(this._sunFixed, camera.rightWC),
@@ -779,10 +828,6 @@ export class CelestialRing {
disc.earthRadius + Math.max(38, radius * 0.065),
);
this._renderEffectLayers(cx, cy, radius, rayInnerRadius);
this._sunCanvas.style.transform = `rotate(${this._sunAngle}rad)`;
this._sunCanvas.style.opacity = String(this._sunOpacity);
this._moonCanvas.style.transform = `rotate(${this._moonAngle}rad)`;
this._moonCanvas.style.opacity = String(this._moonOpacity);
// Markers sit inside the illuminated band rather than straddling its outer
// stroke. Near conjunction (such as a new moon), keep the true bearings but
@@ -791,22 +836,30 @@ export class CelestialRing {
const markersCollide =
circularAngleDistance(this._sunAngle, this._moonAngle) <
MARKER_COLLISION_ANGLE;
this._positionMarker(
this._sunMarker,
cx,
cy,
markerRadius,
this._sunAngle,
this._sunOpacity,
);
this._positionMarker(
this._moonMarker,
cx,
cy,
markerRadius - (markersCollide ? COLLIDING_MARKER_EXTRA_INSET_PX : 0),
this._moonAngle,
this._moonOpacity,
);
this._present({
sun: {
transform: `rotate(${this._sunAngle}rad)`,
opacity: String(this._sunOpacity),
},
moon: {
transform: `rotate(${this._moonAngle}rad)`,
opacity: String(this._moonOpacity),
},
sunMarker: this._markerPosition(
cx,
cy,
markerRadius,
this._sunAngle,
this._sunOpacity,
),
moonMarker: this._markerPosition(
cx,
cy,
markerRadius - (markersCollide ? COLLIDING_MARKER_EXTRA_INSET_PX : 0),
this._moonAngle,
this._moonOpacity,
),
});
this._debug = {
enabled: true,
visible: true,
@@ -831,7 +884,7 @@ export class CelestialRing {
: null;
}
/** Detach the render hook and remove all overlay DOM. */
/** Detach the render hook and withdraw the overlay. */
destroy() {
if (this._ephemerisTimer) {
clearInterval(this._ephemerisTimer);
@@ -839,6 +892,10 @@ export class CelestialRing {
}
if (this._removePostRender) this._removePostRender();
this._removePostRender = null;
this._root?.remove();
if (this._shown()) {
this._store.connect(null);
this._store.publish(null);
}
this._sunCanvas = this._sunCtx = this._moonCanvas = this._moonCtx = null;
}
}
+1 -1
View File
@@ -87,7 +87,7 @@ test('circular angle distance remains small across the wrap point', () => {
test('celestial ring is available only in Normal style', () => {
assert.equal(isCelestialRingStyleSupported('normal'), true);
for (const style of ['retro', 'surveillance', 'thermal', 'anime', 'noir', 'snow']) {
for (const style of ['retro', 'surveillance', 'thermal', 'anime', 'noir']) {
assert.equal(isCelestialRingStyleSupported(style), false);
}
});
+251
View File
@@ -0,0 +1,251 @@
// src/celestialRingView.test.mjs — the full-globe ring renders in the
// viewer's container from what the ring owner presents, and the owner paints
// the canvases the view hands back.
import './testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { createApp, nextTick } from 'vue';
import { createPinia } from 'pinia';
import { useCelestialRingStore } from './ui/stores/celestialRing.js';
import CelestialRingView from './ui/views/panels/CelestialRing.vue';
import * as Cesium from 'cesium';
import { CelestialRing } from './celestialRing.js';
// happy-dom has no 2D context; a stand-in takes every drawing call.
const context = new Proxy(
{},
{ get: (target, name) => target[name] ?? (() => context), set: () => true },
);
globalThis.HTMLCanvasElement.prototype.getContext = () => context;
function fakeViewer(container) {
return {
container,
camera: {
rightWC: new Cesium.Cartesian3(1, 0, 0),
upWC: new Cesium.Cartesian3(0, 1, 0),
},
scene: {
canvas: { clientWidth: 400, clientHeight: 300, width: 400, height: 300 },
postRender: { addEventListener: () => () => {} },
requestRender() {},
},
};
}
function fixture(t) {
document.body.replaceChildren();
const container = document.body.appendChild(document.createElement('div'));
const pinia = createPinia();
const store = useCelestialRingStore(pinia);
const app = createApp(CelestialRingView).use(pinia);
app.mount(document.body.appendChild(document.createElement('div')));
const rings = [];
const create = (options = {}) => {
const ring = new CelestialRing(fakeViewer(container), {
enabled: false,
store,
...options,
});
rings.push(ring);
return ring;
};
t.after(() => {
for (const ring of rings) ring.destroy();
app.unmount();
});
const $ = (selector) => container.querySelector(selector);
const settle = async () => {
for (let flush = 0; flush < 4; flush += 1) await nextTick();
};
return { container, store, create, $, settle };
}
test('the ring renders its overlay in the viewer container', async (t) => {
const { create, $, settle } = fixture(t);
const ring = create();
await settle();
const root = $('#celestial-ring-overlay');
assert.equal(root.className, 'celestial-ring-overlay disabled');
assert.equal(root.getAttribute('aria-hidden'), 'true');
assert.equal(root.hasAttribute('data-globe-visible'), false);
assert.deepEqual(
[...root.children].map((child) => [child.tagName, child.className]),
[
['DIV', 'celestial-ring-outline'],
['CANVAS', 'celestial-ring-canvas celestial-sun-canvas'],
['CANVAS', 'celestial-ring-canvas celestial-moon-canvas'],
['SPAN', 'celestial-marker celestial-sun material-symbols-outlined'],
['SPAN', 'celestial-marker celestial-moon material-symbols-outlined'],
],
);
assert.equal(root.children[0].style.display, 'none');
assert.equal(root.children[3].textContent, 'light_mode');
assert.equal(root.children[4].textContent, 'dark_mode');
assert.ok(ring._sunCanvas === root.children[1]);
assert.ok(ring._moonCanvas === root.children[2]);
ring.setEnabled(true);
await settle();
assert.equal(root.className, 'celestial-ring-overlay');
ring.destroy();
await settle();
assert.equal($('#celestial-ring-overlay'), null);
assert.equal(ring._sunCanvas, null);
});
test('a drawn frame places the outline, layers and markers', async (t) => {
const { create, $, settle } = fixture(t);
const ring = create({ enabled: true });
await settle();
ring._present({ visible: true, globeVisible: 'true', ephemerisUpdates: '1' });
ring._resize(400, 300);
ring._renderEffectLayers(200, 150, 120, 80);
ring._present({
sun: { transform: 'rotate(1rad)', opacity: '0.5' },
sunMarker: ring._markerPosition(200, 150, 100, 0, 1),
});
await settle();
const root = $('#celestial-ring-overlay');
assert.equal(root.className, 'celestial-ring-overlay visible');
assert.equal(root.dataset.globeVisible, 'true');
assert.equal(root.dataset.ephemerisUpdates, '1');
const [outline, sun, moon, sunMarker] = root.children;
assert.equal(outline.style.display, '');
assert.equal(outline.style.left, '80px');
assert.equal(outline.style.width, '240px');
assert.equal(sun.style.width, '400px');
assert.equal(sun.style.transform, 'rotate(1rad)');
assert.equal(sun.style.opacity, '0.5');
assert.equal(moon.style.height, '300px');
assert.equal(sunMarker.style.left, '300px');
assert.equal(sunMarker.style.top, '150px');
ring.setEnabled(false);
await settle();
assert.equal(root.className, 'celestial-ring-overlay disabled');
assert.equal(outline.style.display, 'none');
});
test("an earlier ring leaves a later ring's overlay alone", async (t) => {
const { create, store, $, settle } = fixture(t);
const earlier = create();
await settle();
const later = create();
await settle();
const root = $('#celestial-ring-overlay');
assert.ok(later._sunCanvas === root.children[1]);
earlier.setEnabled(true);
earlier.destroy();
await settle();
assert.ok(store.ring.owner === later._owner);
assert.equal(
$('#celestial-ring-overlay').className,
'celestial-ring-overlay disabled',
);
assert.ok(later._sunCanvas === $('.celestial-sun-canvas'));
});
// A globe framed well inside the keyhole of a 400 x 300 view.
const FRAMED = {
earthCenterX: 200,
earthCenterY: 150,
earthRadius: 40,
keyholeCenterX: 200,
keyholeCenterY: 150,
keyholeRadius: 140,
};
/** Run one rendered frame, past the frame-rate throttle. */
function frame(ring, disc) {
ring._projectedEarthDisc = () => disc;
// Fixed sun and moon directions; Cesium's own model would fetch its
// orientation data.
ring._updateEphemeris = () => {
if (ring._ephemerisDirty) {
Cesium.Cartesian3.clone(new Cesium.Cartesian3(1, 0, 0), ring._sunFixed);
Cesium.Cartesian3.clone(new Cesium.Cartesian3(0, 1, 0), ring._moonFixed);
ring._ephemerisDirty = false;
ring._ephemerisUpdateCount += 1;
}
return true;
};
ring._nextDrawAt = 0;
ring._draw();
}
test('a rendered frame publishes the layers and markers it painted', async (t) => {
const { create, $, settle } = fixture(t);
const ring = create({ enabled: true });
await settle();
frame(ring, FRAMED);
await settle();
const root = $('#celestial-ring-overlay');
assert.equal(root.className, 'celestial-ring-overlay visible');
assert.equal(root.dataset.globeVisible, 'true');
assert.equal(root.dataset.ephemerisUpdates, '1');
const [outline, sun, moon, sunMarker, moonMarker] = root.children;
const debug = ring.getDebugState();
assert.equal(debug.visible, true);
assert.equal(outline.style.display, '');
// The outline is the keyhole circle, a fixed inset inside its radius.
const diameter = parseFloat(outline.style.width);
assert.ok(diameter > 0 && diameter < 280);
assert.equal(outline.style.height, outline.style.width);
assert.equal(outline.style.left, `${200 - diameter / 2}px`);
assert.equal(outline.style.top, `${150 - diameter / 2}px`);
assert.equal(sun.style.width, '400px');
assert.equal(moon.style.height, '300px');
assert.equal(sun.style.transform, `rotate(${debug.sunAngle}rad)`);
assert.equal(moon.style.transform, `rotate(${debug.moonAngle}rad)`);
assert.equal(sun.style.opacity, String(debug.sunOpacity));
assert.equal(moon.style.opacity, String(debug.moonOpacity));
assert.ok(sunMarker.style.left.endsWith('px'));
assert.ok(moonMarker.style.top.endsWith('px'));
assert.equal(moonMarker.style.opacity, String(debug.moonOpacity));
});
test('a frame without the globe framed turns the ring off', async (t) => {
const { create, $, settle } = fixture(t);
let disabled = 0;
const ring = create({ enabled: true, onAutoDisable: () => (disabled += 1) });
await settle();
frame(ring, FRAMED);
frame(ring, null);
await settle();
assert.equal(ring.enabled, false);
assert.equal(disabled, 1);
assert.equal(
$('#celestial-ring-overlay').className,
'celestial-ring-overlay disabled',
);
});
test('without a rendered view the ring still tracks the globe and turns off', () => {
const pinia = createPinia();
let disabled = 0;
const ring = new CelestialRing(fakeViewer({}), {
enabled: true,
store: useCelestialRingStore(pinia),
onAutoDisable: () => (disabled += 1),
});
frame(ring, FRAMED);
assert.equal(ring.visible, true);
frame(ring, null);
assert.equal(ring.enabled, false);
assert.equal(disabled, 1);
ring.destroy();
});
test('a ring the store no longer shows stops drawing', async (t) => {
const { create, settle } = fixture(t);
let disabled = 0;
const earlier = create({
enabled: true,
onAutoDisable: () => (disabled += 1),
});
await settle();
create();
await settle();
frame(earlier, null);
assert.equal(earlier.enabled, true);
assert.equal(disabled, 0);
});
+514
View File
@@ -0,0 +1,514 @@
/**
* @module cityPois
* @description The preset cities and their landmarks — plain data with no
* Cesium, so modules that only need the names (voice cards) can import it.
*/
/**
* Points of Interest per city.
* Each city has 5 POIs; the first is the default fly-to landmark.
*
* Field reference:
* alt — RANGE (distance from target in meters), NOT absolute altitude
* heading — optimal camera heading in degrees (0=N, 90=E, 180=S, 270=W)
* pitch — camera tilt in degrees (negative = looking down)
* buildingHeight — estimated height of landmark center above ground (meters)
*/
export const CITY_POIS = {
austin: {
name: 'Austin',
groundElevation: 150, // meters above WGS84 ellipsoid
viewBounds: {
southwest: { lat: 30.1, lng: -97.95 },
northeast: { lat: 30.52, lng: -97.55 },
},
pois: [
{
name: 'Texas State Capitol',
lat: 30.2747,
lon: -97.7403,
alt: 550,
pitch: -28,
heading: 180,
buildingHeight: 35,
},
{
name: 'Frost Bank Tower',
lat: 30.2674,
lon: -97.7434,
alt: 550,
pitch: -22,
heading: 30,
buildingHeight: 80,
},
{
name: 'Pennybacker Bridge',
lat: 30.3451,
lon: -97.7951,
alt: 500,
pitch: -25,
heading: 90,
buildingHeight: 40,
},
{
name: 'The Jenga Tower',
lat: 30.2642,
lon: -97.75,
alt: 500,
pitch: -18,
heading: 45,
buildingHeight: 60,
},
{
name: 'UT Tower',
lat: 30.2862,
lon: -97.7394,
alt: 500,
pitch: -22,
heading: 180,
buildingHeight: 50,
},
],
},
sf: {
name: 'San Francisco',
groundElevation: 15,
viewBounds: {
southwest: { lat: 37.7, lng: -122.53 },
northeast: { lat: 37.84, lng: -122.35 },
},
pois: [
{
name: 'Golden Gate Bridge',
lat: 37.8199,
lon: -122.4783,
alt: 1400,
pitch: -20,
heading: 45,
buildingHeight: 100,
},
{
name: 'Transamerica Pyramid',
lat: 37.7952,
lon: -122.4028,
alt: 500,
pitch: -25,
heading: 30,
buildingHeight: 85,
},
{
name: 'Salesforce Tower',
lat: 37.7897,
lon: -122.3972,
alt: 680,
pitch: -25,
heading: 330,
buildingHeight: 100,
},
{
name: 'Alcatraz Island',
lat: 37.8267,
lon: -122.423,
alt: 800,
pitch: -30,
heading: 0,
buildingHeight: 20,
},
{
name: 'Coit Tower',
lat: 37.8024,
lon: -122.4058,
alt: 420,
pitch: -30,
heading: 45,
buildingHeight: 30,
},
],
},
nyc: {
name: 'New York',
groundElevation: 10,
viewBounds: {
southwest: { lat: 40.477, lng: -74.259 },
northeast: { lat: 40.918, lng: -73.7 },
},
pois: [
{
name: 'Statue of Liberty',
lat: 40.6892,
lon: -74.0445,
alt: 450,
pitch: -25,
heading: 315,
buildingHeight: 45,
},
{
name: 'Empire State Building',
lat: 40.7484,
lon: -73.9857,
alt: 850,
pitch: -12,
heading: 30,
buildingHeight: 130,
buildingBounds: { height: 443, width: 130, depth: 75 },
},
{
name: 'One World Trade Center',
lat: 40.7127,
lon: -74.0134,
alt: 850,
pitch: -25,
heading: 0,
buildingHeight: 170,
},
{
name: 'Brooklyn Bridge',
lat: 40.7061,
lon: -73.9969,
alt: 850,
pitch: -25,
heading: 45,
buildingHeight: 40,
},
{
name: 'Chrysler Building',
lat: 40.7516,
lon: -73.9755,
alt: 700,
pitch: -20,
heading: 225,
buildingHeight: 100,
},
],
},
tokyo: {
name: 'Tokyo',
groundElevation: 40,
viewBounds: {
southwest: { lat: 35.52, lng: 139.55 },
northeast: { lat: 35.9, lng: 139.92 },
},
pois: [
{
name: 'Tokyo Tower',
lat: 35.6586,
lon: 139.7454,
alt: 850,
pitch: -25,
heading: 0,
buildingHeight: 110,
},
{
name: 'Tokyo Skytree',
lat: 35.7101,
lon: 139.8107,
alt: 900,
pitch: -25,
heading: 30,
buildingHeight: 200,
},
{
name: 'Imperial Palace',
lat: 35.6852,
lon: 139.7528,
alt: 900,
pitch: -35,
heading: 0,
buildingHeight: 20,
},
{
name: 'Senso-ji Temple',
lat: 35.7148,
lon: 139.7967,
alt: 400,
pitch: -30,
heading: 180,
buildingHeight: 25,
},
{
name: 'Mode Gakuen Cocoon Tower',
lat: 35.6929,
lon: 139.6925,
alt: 350,
pitch: -20,
heading: 30,
buildingHeight: 70,
},
],
},
london: {
name: 'London',
groundElevation: 15,
viewBounds: {
southwest: { lat: 51.28, lng: -0.51 },
northeast: { lat: 51.7, lng: 0.33 },
},
pois: [
{
name: 'Tower Bridge',
lat: 51.5055,
lon: -0.0754,
alt: 400,
pitch: -25,
heading: 270,
buildingHeight: 65,
},
{
name: 'The Shard',
lat: 51.5045,
lon: -0.0865,
alt: 850,
pitch: -20,
heading: 0,
buildingHeight: 100,
},
{
name: 'Big Ben / Parliament',
lat: 51.5007,
lon: -0.1246,
alt: 600,
pitch: -25,
heading: 180,
buildingHeight: 50,
},
{
name: "St. Paul's Cathedral",
lat: 51.5138,
lon: -0.0984,
alt: 400,
pitch: -30,
heading: 270,
buildingHeight: 55,
},
{
name: 'The Gherkin',
lat: 51.5145,
lon: -0.0803,
alt: 350,
pitch: -20,
heading: 30,
buildingHeight: 60,
},
],
},
paris: {
name: 'Paris',
groundElevation: 35,
viewBounds: {
southwest: { lat: 48.815, lng: 2.224 },
northeast: { lat: 48.902, lng: 2.47 },
},
pois: [
{
name: 'Eiffel Tower',
lat: 48.8584,
lon: 2.2945,
alt: 750,
pitch: -25,
heading: 315,
buildingHeight: 150,
},
{
name: 'Arc de Triomphe',
lat: 48.8738,
lon: 2.295,
alt: 400,
pitch: -28,
heading: 45,
buildingHeight: 25,
},
{
name: 'Notre-Dame',
lat: 48.853,
lon: 2.3499,
alt: 400,
pitch: -25,
heading: 225,
buildingHeight: 35,
},
{
name: 'Sacré-Cœur',
lat: 48.8867,
lon: 2.3431,
alt: 400,
pitch: -30,
heading: 180,
buildingHeight: 40,
},
{
name: 'Louvre Pyramid',
lat: 48.8606,
lon: 2.3376,
alt: 500,
pitch: -35,
heading: 0,
buildingHeight: 10,
},
],
},
dubai: {
name: 'Dubai',
groundElevation: 5,
viewBounds: {
southwest: { lat: 24.95, lng: 54.9 },
northeast: { lat: 25.35, lng: 55.55 },
},
pois: [
{
name: 'Burj Khalifa',
lat: 25.1972,
lon: 55.2744,
alt: 600,
pitch: -20,
heading: 200,
buildingHeight: 270,
},
{
name: 'Burj Al Arab',
lat: 25.1412,
lon: 55.1853,
alt: 500,
pitch: -25,
heading: 90,
buildingHeight: 100,
},
{
name: 'Palm Jumeirah',
lat: 25.1124,
lon: 55.139,
alt: 1200,
pitch: -40,
heading: 0,
buildingHeight: 20,
},
{
name: 'Dubai Frame',
lat: 25.235,
lon: 55.3003,
alt: 400,
pitch: -25,
heading: 270,
buildingHeight: 75,
},
{
name: 'Museum of the Future',
lat: 25.2197,
lon: 55.2806,
alt: 350,
pitch: -20,
heading: 30,
buildingHeight: 35,
},
],
},
dc: {
name: 'Washington DC',
groundElevation: 10,
viewBounds: {
southwest: { lat: 38.79, lng: -77.12 },
northeast: { lat: 38.995, lng: -76.91 },
},
pois: [
{
name: 'US Capitol',
lat: 38.8897,
lon: -77.0091,
alt: 550,
pitch: -25,
heading: 270,
buildingHeight: 45,
},
{
name: 'Washington Monument',
lat: 38.8895,
lon: -77.0353,
alt: 500,
pitch: -30,
heading: 0,
buildingHeight: 85,
},
{
name: 'Lincoln Memorial',
lat: 38.8893,
lon: -77.0502,
alt: 400,
pitch: -25,
heading: 90,
buildingHeight: 20,
},
{
name: 'Pentagon',
lat: 38.8711,
lon: -77.0559,
alt: 800,
pitch: -40,
heading: 0,
buildingHeight: 20,
},
{
name: 'Jefferson Memorial',
lat: 38.8814,
lon: -77.0365,
alt: 400,
pitch: -30,
heading: 0,
buildingHeight: 25,
},
],
},
tallinn: {
name: 'Tallinn',
groundElevation: 15,
viewBounds: {
southwest: { lat: 59.36, lng: 24.6 },
northeast: { lat: 59.52, lng: 24.92 },
},
pois: [
{
name: 'Viru Square',
lat: 59.4366,
lon: 24.7527,
alt: 450,
pitch: -28,
heading: 60,
buildingHeight: 25,
},
{
name: 'Old Town / Raekoja plats',
lat: 59.4372,
lon: 24.7452,
alt: 400,
pitch: -30,
heading: 180,
buildingHeight: 20,
},
{
name: 'Teatri väljak',
lat: 59.4344,
lon: 24.7514,
alt: 400,
pitch: -25,
heading: 220,
buildingHeight: 25,
},
{
name: 'Port of Tallinn',
lat: 59.4445,
lon: 24.7675,
alt: 700,
pitch: -30,
heading: 90,
buildingHeight: 20,
},
{
name: 'Ülemiste',
lat: 59.421,
lon: 24.792,
alt: 600,
pitch: -28,
heading: 45,
buildingHeight: 30,
},
],
},
};
+2 -1
View File
@@ -8,7 +8,8 @@
// say nothing about whether a destination is framed well.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { CITY_POIS, LOCATIONS } from './locations.js';
import { CITY_POIS } from './cityPois.js';
import { LOCATIONS } from './locations.js';
const DESTINATIONS = Object.entries(CITY_POIS);
const REQUIRED_POI_KEYS = ['name', 'lat', 'lon', 'alt', 'pitch', 'heading'];
+5 -18
View File
@@ -1,5 +1,6 @@
import { applicationServices } from './services/application.js';
import * as Cesium from 'cesium';
import { greatCircleMeters } from './geoDistance.js';
import {
deriveWeatherEffectProfile,
weatherAltitudeFactors,
@@ -127,23 +128,9 @@ function compileShader(gl, type, source) {
}
function greatCircleM(a, b) {
if (
![a?.latitude, a?.longitude, b?.latitude, b?.longitude].every(
Number.isFinite,
)
)
return Infinity;
const latitudeA = Cesium.Math.toRadians(a.latitude);
const latitudeB = Cesium.Math.toRadians(b.latitude);
const latitudeDelta = Cesium.Math.toRadians(b.latitude - a.latitude);
const longitudeDelta = Cesium.Math.toRadians(b.longitude - a.longitude);
const haversine =
Math.sin(latitudeDelta / 2) ** 2 +
Math.cos(latitudeA) *
Math.cos(latitudeB) *
Math.sin(longitudeDelta / 2) ** 2;
return (
6371000 * 2 * Math.atan2(Math.sqrt(haversine), Math.sqrt(1 - haversine))
return greatCircleMeters(
{ lat: a?.latitude, lon: a?.longitude },
{ lat: b?.latitude, lon: b?.longitude },
);
}
@@ -187,7 +174,7 @@ export function cockpitWeatherEnabledFromStoredValue(value) {
* Weather-backed volumetric cloud pass that exists only while cockpit mode is
* active. It owns no Cesium fog/post-process stages and cannot affect map mode.
*/
export class CockpitCloudEffectsController {
class CockpitCloudEffectsController {
constructor(viewer, { weatherService = applicationServices.weather } = {}) {
this.weatherService = weatherService;
this.viewer = viewer;
+747 -222
View File
File diff suppressed because it is too large. Load diff
+73 -10
View File
@@ -1,9 +1,18 @@
export { resolveHudRailLayout } from './ui/panelRailGeometry.js';
import {
normalizeHeading,
roundHeadingDegrees,
} from './data/headingDegrees.js';
/** Normalize a heading into the [0, 360) range. */
export function normalizeHeading(value) {
if (!Number.isFinite(value)) return 0;
return ((value % 360) + 360) % 360;
export { resolveHudRailLayout } from './ui/panelRailGeometry.js';
export { normalizeHeading };
/**
* Three-digit heading readout, 000–359.
* Rounding 359.5° and above lands on north (000), not 360.
*/
export function formatHeadingReadout(degrees) {
if (!Number.isFinite(degrees)) return '---';
return String(roundHeadingDegrees(degrees)).padStart(3, '0');
}
/** Advance a displayed heading along the shortest arc without exceeding a slew rate. */
@@ -83,11 +92,65 @@ export function cockpitAltitudeDisplayFt(altitudeM, onGround) {
return Number.isFinite(altitudeM) ? altitudeM * 3.28084 : null;
}
/** Format the cockpit Context scope without overstating installation coverage. */
/** 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).
* 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, 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 format.squawk({ name, label, squawk: emergency.squawk });
return format.adsb({
name,
label,
priority: emergency.severity === 'priority',
});
}
/** 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'
@@ -97,12 +160,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. */
+29
View File
@@ -13,7 +13,9 @@ import {
compassDivisions,
formatAltitudeRulerTick,
formatCockpitContextScope,
formatCockpitEmergency,
formatCompassDivision,
formatHeadingReadout,
formatSpeedRulerTick,
normalizeHeading,
relativeBearing,
@@ -154,6 +156,15 @@ test('compass labels use cardinals where exact and degrees elsewhere', () => {
assert.equal(formatCompassDivision(30), '030');
});
test('a heading that rounds to north reads 000, not 360', () => {
assert.equal(formatHeadingReadout(359.6), '000');
assert.equal(formatHeadingReadout(359.4), '359');
assert.equal(formatHeadingReadout(0), '000');
assert.equal(formatHeadingReadout(10.2), '010');
assert.equal(formatHeadingReadout(-0.6), '359');
assert.equal(formatHeadingReadout(Number.NaN), '---');
});
test('great-circle bearing resolves cardinal directions', () => {
assert.ok(Math.abs(bearingBetweenCoordinates(0, 0, 1, 0) - 0) < 0.001);
assert.ok(Math.abs(bearingBetweenCoordinates(0, 0, 0, 1) - 90) < 0.001);
@@ -364,3 +375,21 @@ test('tracked-identity resolution falls back to layer precedence', () => {
assert.equal(resolveTrackedAircraftInfo({ trackedId: 'flights:aaa077' }), null);
assert.equal(resolveTrackedAircraftInfo(), null);
});
test('cockpit emergency wording states the broadcast code, not a conclusion', () => {
assert.equal(
formatCockpitEmergency({ label: 'General emergency', squawk: '7700', source: 'squawk', severity: 'emergency' }),
'SQUAWK 7700 · GENERAL EMERGENCY',
);
assert.equal(
formatCockpitEmergency({ label: 'Downed aircraft', squawk: '1200', source: 'ads-b', severity: 'emergency' }),
'ADS-B EMERGENCY · DOWNED AIRCRAFT',
'an unrelated squawk is not quoted beside an ADS-B status',
);
assert.equal(
formatCockpitEmergency({ label: 'Minimum fuel', squawk: null, source: 'ads-b', severity: 'priority' }),
'ADS-B PRIORITY · MINIMUM FUEL',
);
assert.equal(formatCockpitEmergency(null), null);
assert.equal(formatCockpitEmergency({ label: '' }), null);
});
+341 -167
View File
@@ -1,219 +1,393 @@
// src/cockpitSignalFocus.test.mjs — the Cockpit signal rows keep keyboard
// focus while they refresh and reorder, over happy-dom with the real view.
import './testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { afterEach } from 'node:test';
import { createPinia } from 'pinia';
import { createApp, nextTick } from 'vue';
import { formatAwarenessLabel } from './data/militaryAwarenessEngine.js';
import { CockpitViewController } from './ui/cockpitController.js';
const previousDocument = globalThis.document;
afterEach(() => { globalThis.document = previousDocument; });
import { createBriefState } from './ui/cockpitBriefPages.js';
import {
handleSignalClick,
publishCockpitSignals,
pushCockpitSignal,
updateCockpitSignals,
} from './ui/cockpitSignals.js';
import { useCockpitStore } from './ui/stores/cockpit.js';
import CockpitBriefing from './ui/views/panels/CockpitBriefing.vue';
// Model native focus loss on removal AND on ordinary DOM moves. Keeping an
// object reference alone is insufficient if reconciliation disconnects it.
function fixture() {
const document = { activeElement: null };
let focusLosses = 0;
let focusCalls = 0;
class Node {
constructor(tagName) {
this.tagName = tagName.toUpperCase();
this.children = [];
this.parentNode = null;
this.dataset = {};
this.attributes = new Map();
this.className = '';
this._text = '';
this.classList = {
add: (...names) => { this.className = [...new Set([...this.className.split(/\s+/).filter(Boolean), ...names])].join(' '); },
};
/**
* A browser blurs an element it removes or moves. happy-dom only models the
* removal, so count both: any insert or removal of a node that holds focus
* is a loss, and a moved node gives its focus up as a browser's would.
*/
function trackFocus(t) {
const counts = { losses: 0, focusCalls: 0 };
const holdsFocus = (node) =>
node.isConnected && node.contains(document.activeElement);
const insertBefore = Node.prototype.insertBefore;
t.mock.method(Node.prototype, 'insertBefore', function (node, anchor) {
const lost = holdsFocus(node);
const result = insertBefore.call(this, node, anchor);
if (lost) {
counts.losses += 1;
document.activeElement.blur();
}
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');
if (node === anchor) return node;
node.remove();
const index = anchor === null ? this.children.length : this.children.indexOf(anchor);
this.children.splice(index, 0, node);
node.parentNode = this;
return node;
}
remove() {
if (!this.parentNode) return;
if (this.contains(document.activeElement)) {
document.activeElement = document.body;
focusLosses += 1;
}
this.parentNode.children.splice(this.parentNode.children.indexOf(this), 1);
this.parentNode = null;
}
replaceChildren(...nodes) {
for (const child of [...this.children]) child.remove();
this._text = '';
this.append(...nodes);
}
matches(selector) {
if (selector === 'button[data-signal-layer][data-signal-id]') {
return this.tagName === 'BUTTON' && this.dataset.signalLayer != null && this.dataset.signalId != null;
}
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;
return null;
}
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');
focusCalls += 1;
document.activeElement = this;
}
}
document.body = new Node('body');
document.createElement = (tag) => new Node(tag);
document.activeElement = document.body;
globalThis.document = document;
const controller = Object.create(CockpitViewController.prototype);
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.briefPageIndex = 0;
controller.signalItems = [];
controller.signalSignatures = new Map();
controller.scheduleContextLayout = () => {};
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);
return result;
});
const removeChild = Node.prototype.removeChild;
t.mock.method(Node.prototype, 'removeChild', function (node) {
if (holdsFocus(node)) counts.losses += 1;
return removeChild.call(this, node);
});
const focus = HTMLElement.prototype.focus;
t.mock.method(HTMLElement.prototype, 'focus', function (...args) {
assert.ok(this.isConnected, 'a focus destination must still be connected');
counts.focusCalls += 1;
return focus.apply(this, args);
});
return counts;
}
function fixture(t) {
document.body.replaceChildren();
const host = document.body.appendChild(document.createElement('div'));
const pinia = createPinia();
const app = createApp(CockpitBriefing).use(pinia);
app.mount(host);
const store = useCockpitStore(pinia);
const button = () => {
const element = document.createElement('button');
element.type = 'button';
return document.body.appendChild(element);
};
const display = button();
const radio = button();
const outside = button();
const counts = trackFocus(t);
t.after(() => app.unmount());
const selected = [];
controller.services.militaryAwarenessLayer = { focusTarget: (...args) => selected.push(args) };
const click = event => controller.handleSignalClick(event);
const buttons = () => controller.signalList.querySelectorAll('button');
// The signal presenters need only this much of the controller.
const owner = {
destroyed: false,
brief: createBriefState(),
signalItems: [],
signalSignatures: new Map(),
publishSignals: (rows) => store.publishSignals(rows),
publishCockpitSignals,
scheduleContextLayout() {},
services: {
formatAwarenessLabel,
militaryAwarenessLayer: {
focusTarget: (...args) => selected.push(args),
},
},
};
// The controller shows the card on entering Cockpit.
document.getElementById('cockpit-signal-stream').hidden = false;
const list = document.getElementById('cockpit-signal-list');
list.addEventListener('click', (event) =>
handleSignalClick.call(owner, event),
);
const buttons = () => [...list.querySelectorAll('button')];
// Tab visits the enabled buttons no hidden ancestor removes from view.
const tab = () => {
const nodes = document.body.querySelectorAll('button');
const index = nodes.indexOf(document.activeElement);
nodes[(index + 1) % nodes.length].focus();
const nodes = [...document.querySelectorAll('button')].filter(
(node) => !node.disabled && !node.closest('[hidden]'),
);
nodes[(nodes.indexOf(document.activeElement) + 1) % nodes.length].focus();
};
const pageTab = (index) =>
document.querySelector(`[data-cockpit-brief-index="${index}"]`);
const signalTab = () => pageTab(0);
return {
owner,
list,
buttons,
display,
radio,
outside,
selected,
tab,
pageTab,
signalTab,
store,
losses: () => counts.losses,
focusCalls: () => counts.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 = async (f, items) => {
f.owner.signalItems = items;
publishCockpitSignals.call(f.owner);
await nextTick();
};
const refresh = async (f, distanceM) => {
updateCockpitSignals.call(f.owner, snapshot(distanceM), 0);
await nextTick();
};
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();
test('rows render the signals the controller presents', async (t) => {
const f = fixture(t);
await render(f, [
item('a'),
item('status', { tone: 'warning', target: null, title: 'UNKNOWN' }),
]);
const [action, status] = f.list.children;
assert.equal(action.className, 'nearby actionable');
assert.equal(action.querySelector('time').textContent, '22:13:20Z');
const button = action.querySelector('button.cockpit-signal-target');
assert.equal(button.type, 'button');
assert.equal(button.dataset.signalLayer, 'flights');
assert.equal(button.dataset.signalId, 'a');
assert.equal(button.getAttribute('aria-label'), 'Select flight Flight a');
assert.equal(
button.querySelector('.cockpit-signal-target-label').textContent,
'Flight a',
);
assert.equal(
button.querySelector('.cockpit-signal-target-chevron').textContent,
'chevron_right',
);
assert.equal(
action.querySelector('div > span').textContent,
'COMMERCIAL FLIGHT · 2 KM',
);
assert.equal(status.className, 'warning');
assert.equal(status.querySelector('div > strong').textContent, 'UNKNOWN');
assert.equal(status.querySelector('button'), null);
});
test('actual Context refresh retains the focused signal button across unchanged and changed details', async (t) => {
const f = fixture(t);
await refresh(f);
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);
assert.equal(f.buttons()[1], target);
assert.equal(f.document.activeElement, target);
for (let tick = 0; tick < 8; tick += 1) await refresh(f, 2500);
assert.equal(f.buttons()[1] === target, true);
assert.equal(document.activeElement === target, true);
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.list.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 })]);
assert.equal(f.buttons()[0], target);
test('signal content and accessible name update without replacing the active target', async (t) => {
const f = fixture(t);
await render(f, [item('a')]);
const target = f.buttons()[0];
target.focus();
await render(f, [
item('a', {
title: 'RENAMED',
tone: 'track',
detail: 'CURRENT',
timestamp: 1_700_000_001_000,
}),
]);
assert.equal(f.buttons()[0] === target, true);
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.document.activeElement, target);
assert.match(f.list.textContent, /CURRENT/);
assert.equal(f.list.children[0].className, 'track actionable');
assert.equal(document.activeElement === target, true);
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();
test('signal ranking changes move surrounding rows without disconnecting the focused row', async (t) => {
const f = fixture(t);
await 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);
assert.equal(f.document.activeElement, target);
for (const order of [
['d', 'c', 'b', 'a'],
['b', 'c', 'd', 'a'],
['a', 'd', 'c', 'b'],
['c', 'a', 'b', 'd'],
['b', 'a', 'd', 'c'],
]) {
await render(
f,
order.map((id) => item(id)),
);
assert.deepEqual(
f.buttons().map((button) => button.dataset.signalId),
order,
);
assert.equal(document.activeElement === target, true);
assert.equal(f.losses(), 0);
}
assert.equal(f.focusCalls(), calls);
});
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');
assert.equal(f.document.activeElement, target);
test('a new pushed signal preserves existing focus and delegated selection reads the current target', async (t) => {
const f = fixture(t);
await render(f, [item('a'), item('b')]);
const target = f.buttons()[1];
target.focus();
pushCockpitSignal.call(
f.owner,
'status',
'warning',
'INPUT UNKNOWN',
'SOURCE UNAVAILABLE',
);
await nextTick();
assert.equal(f.list.children.length, 3);
assert.equal(document.activeElement === target, true);
assert.equal(f.losses(), 0);
const event = { target: target.children[0], prevented: false, preventDefault() { this.prevented = true; } };
f.click(event);
assert.equal(event.prevented, true);
const click = new MouseEvent('click', { bubbles: true, cancelable: true });
target.querySelector('.cockpit-signal-target-label').dispatchEvent(click);
assert.equal(click.defaultPrevented, 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();
test('removing the focused identity uses the shown briefing tab once and permits forward traversal', async (t) => {
const f = fixture(t);
await render(f, [item('a'), item('b')]);
const removed = f.buttons()[1];
removed.focus();
const calls = f.focusCalls();
render(f, [item('a')]);
await render(f, [item('a')]);
assert.equal(removed.isConnected, false);
assert.equal(f.document.activeElement, f.controller.briefTabs[0]);
assert.equal(document.activeElement === f.signalTab(), true);
assert.equal(f.focusCalls(), calls + 1);
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.losses(), 0);
assert.equal(f.signalTab().closest('[hidden]'), null);
// Traversal carries on forward from the footer tab.
f.tab();
assert.equal(f.document.activeElement, f.radio);
assert.equal(document.activeElement === f.pageTab(1), true);
while (document.activeElement !== f.display) f.tab();
await render(f, [item('a', { detail: 'UPDATED' })]);
assert.equal(
document.activeElement === f.display,
true,
'later refresh must not pull focus back',
);
f.tab();
assert.equal(document.activeElement === f.radio, true);
});
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' } })]);
test('a changed target cannot reuse a focused action for another flight', async (t) => {
const f = fixture(t);
await render(f, [item('a', { key: 'shared-status-key' })]);
const removed = f.buttons()[0];
removed.focus();
await render(f, [
item('b', {
key: 'shared-status-key',
target: { layerId: 'military', id: 'b' },
}),
]);
const target = f.buttons()[0];
assert.notEqual(target, removed);
assert.equal(target === removed, false);
assert.equal(removed.isConnected, false);
assert.equal(f.document.activeElement, f.controller.briefTabs[0]);
f.click({ target, preventDefault() {} });
assert.equal(document.activeElement === f.signalTab(), true);
target.click();
assert.deepEqual(f.selected, [['military', 'b', { origin: 'user' }]]);
});
test('empty, informational and duplicate-key rows have no stale or aliased actions', () => {
const f = fixture();
render(f, [item('a'), item('a')]);
test('empty, informational and duplicate-key rows have no stale or aliased actions', async (t) => {
const f = fixture(t);
await render(f, [item('a'), item('a')]);
const [first, second] = f.buttons();
assert.notEqual(first, second);
assert.equal(first === second, false);
second.focus();
render(f, [item('a'), item('a')]);
assert.deepEqual(f.buttons(), [first, second]);
assert.equal(f.document.activeElement, second);
render(f, [item('status', { target: null, title: 'UNKNOWN' })]);
await render(f, [item('a'), item('a')]);
const again = f.buttons();
assert.equal(again[0] === first && again[1] === second, true);
assert.equal(document.activeElement === second, true);
await render(f, [item('status', { target: null, title: 'UNKNOWN' })]);
assert.equal(f.buttons().length, 0);
assert.match(f.controller.signalList.textContent, /UNKNOWN/);
assert.equal(f.document.activeElement, f.controller.briefTabs[0]);
assert.match(f.list.textContent, /UNKNOWN/);
assert.equal(document.activeElement === f.signalTab(), true);
f.outside.focus();
render(f, []);
assert.equal(f.controller.signalList.children.length, 0);
render(f, [item('a')]);
assert.notEqual(f.buttons()[0], first);
assert.equal(f.document.activeElement, f.outside);
await render(f, []);
assert.equal(f.list.children.length, 0);
await render(f, [item('a')]);
assert.equal(f.buttons()[0] === first, false);
assert.equal(document.activeElement === f.outside, true);
assert.equal(f.losses(), 0);
});
test('any reordering of five rows keeps focus wherever it sits', async (t) => {
const f = fixture(t);
const ids = ['a', 'b', 'c', 'd', 'e'];
// A fixed pseudo-random sequence, so a failure reproduces.
let seed = 7;
const random = () => {
seed = (seed * 48271) % 2147483647;
return seed / 2147483647;
};
const shuffle = (list) => {
const next = [...list];
for (let index = next.length - 1; index > 0; index -= 1) {
const swap = Math.floor(random() * (index + 1));
[next[index], next[swap]] = [next[swap], next[index]];
}
return next;
};
await render(
f,
ids.map((id) => item(id)),
);
for (let round = 0; round < 60; round += 1) {
const target = f.buttons()[round % ids.length];
target.focus();
const order = shuffle(ids);
await render(
f,
order.map((id) => item(id)),
);
assert.deepEqual(
f.buttons().map((button) => button.dataset.signalId),
order,
);
assert.equal(document.activeElement === target, true);
}
assert.equal(f.losses(), 0);
});
test('the fallback follows the shown page, and the collapse toggle without tabs', async (t) => {
const f = fixture(t);
f.store.publishBrief({ ...createBriefState(), pageIndex: 2 });
await render(f, [item('a'), item('b')]);
f.buttons()[1].focus();
await render(f, [item('a')]);
assert.equal(document.activeElement === f.pageTab(2), true);
for (const index of [0, 1, 2]) f.pageTab(index).remove();
f.buttons()[0].focus();
await render(f, []);
assert.equal(
document.activeElement === document.getElementById('cockpit-signal-toggle'),
true,
);
});
+19 -5
View File
@@ -3,6 +3,7 @@ export const COCKPIT_VISION_MODES = Object.freeze([
'crt',
'nvg',
'thermal',
'anime',
'noir',
]);
@@ -10,14 +11,29 @@ const TARGET_STYLE_BY_MODE = Object.freeze({
crt: 'retro',
nvg: 'surveillance',
thermal: 'thermal',
anime: 'anime',
noir: 'noir',
});
/** Normalize a requested Cockpit vision mode to the inherited preset entry. */
const COCKPIT_MODE_BY_STYLE = Object.freeze({
normal: 'optical',
retro: 'crt',
surveillance: 'nvg',
thermal: 'thermal',
anime: 'anime',
noir: 'noir',
});
/** Normalize a requested Cockpit vision mode to the unfiltered Normal entry. */
export function normalizeCockpitVisionMode(mode) {
return COCKPIT_VISION_MODES.includes(mode) ? mode : 'optical';
}
/** Start Cockpit on the equivalent supported map preset. */
export function cockpitVisionModeForStyle(style) {
return COCKPIT_MODE_BY_STYLE[String(style || '').toLowerCase()] || 'optical';
}
/** Settle pending map-style crossfades and return their intended final intensities. */
export function captureCockpitVisionBaseline(stages, transitions) {
const baseline = {};
@@ -34,12 +50,10 @@ export function captureCockpitVisionBaseline(stages, transitions) {
* Apply Cockpit-only stage intensities without changing any shader parameters.
* Returns the temporary style whose parameters should be shown, or null.
*/
export function applyCockpitVisionStageIntensities(stages, mode, restore = {}) {
export function applyCockpitVisionStageIntensities(stages, mode) {
const next = normalizeCockpitVisionMode(mode);
if (next === 'optical') {
for (const [name, intensity] of Object.entries(restore)) {
if (stages[name]) stages[name].uniforms.intensity = intensity;
}
for (const stage of Object.values(stages)) stage.uniforms.intensity = 0;
return null;
}
+74 -23
View File
@@ -3,6 +3,7 @@ import test from 'node:test';
import {
applyCockpitVisionStageIntensities,
captureCockpitVisionBaseline,
cockpitVisionModeForStyle,
COCKPIT_VISION_MODES,
normalizeCockpitVisionMode,
} from './cockpitVisionPolicy.js';
@@ -14,12 +15,30 @@ const createStages = () => ({
thermal: { uniforms: { intensity: 0, heat: 0.8 } },
});
test('Cockpit vision order exposes inherited, CRT, NVG, FLIR, and NOIR modes', () => {
assert.deepEqual(COCKPIT_VISION_MODES, ['optical', 'crt', 'nvg', 'thermal', 'noir']);
test('Cockpit vision order mirrors the unique map preset order', () => {
assert.deepEqual(COCKPIT_VISION_MODES, [
'optical',
'crt',
'nvg',
'thermal',
'anime',
'noir',
]);
assert.equal(normalizeCockpitVisionMode('none'), 'optical');
assert.equal(normalizeCockpitVisionMode('unknown'), 'optical');
});
test('Cockpit entry selects the vision mode equivalent to the active map style', () => {
assert.equal(cockpitVisionModeForStyle('normal'), 'optical');
assert.equal(cockpitVisionModeForStyle('retro'), 'crt');
assert.equal(cockpitVisionModeForStyle('surveillance'), 'nvg');
assert.equal(cockpitVisionModeForStyle('thermal'), 'thermal');
assert.equal(cockpitVisionModeForStyle('anime'), 'anime');
assert.equal(cockpitVisionModeForStyle('noir'), 'noir');
assert.equal(cockpitVisionModeForStyle('snow'), 'optical');
assert.equal(cockpitVisionModeForStyle('unsupported'), 'optical');
});
test('Cockpit settles pending map crossfades before a temporary preset takes ownership', () => {
const stages = createStages();
const transitions = new Map([
@@ -29,49 +48,81 @@ test('Cockpit settles pending map crossfades before a temporary preset takes own
const restore = captureCockpitVisionBaseline(stages, transitions);
assert.deepEqual(restore, { noir: 1, retro: 0, surveillance: 0, thermal: 0 });
assert.equal(transitions.size, 0);
applyCockpitVisionStageIntensities(stages, 'nvg', restore);
applyCockpitVisionStageIntensities(stages, 'nvg');
assert.equal(stages.surveillance.uniforms.intensity, 1);
applyCockpitVisionStageIntensities(stages, 'optical', restore);
assert.equal(stages.noir.uniforms.intensity, 1);
applyCockpitVisionStageIntensities(stages, 'optical');
assert.equal(stages.noir.uniforms.intensity, 0);
assert.equal(stages.retro.uniforms.intensity, 0);
});
test('returning from a temporary preset restores the exact inherited intensities', () => {
test('Normal clears every temporary and inherited visual stage', () => {
const stages = createStages();
const restore = Object.fromEntries(
Object.entries(stages).map(([name, stage]) => [name, stage.uniforms.intensity]),
Object.entries(stages).map(([name, stage]) => [
name,
stage.uniforms.intensity,
]),
);
applyCockpitVisionStageIntensities(stages, 'thermal', restore);
applyCockpitVisionStageIntensities(stages, 'optical', restore);
applyCockpitVisionStageIntensities(stages, 'thermal');
applyCockpitVisionStageIntensities(stages, 'optical');
assert.deepEqual(
Object.fromEntries(Object.entries(stages).map(([name, stage]) => [name, stage.uniforms.intensity])),
restore,
Object.fromEntries(
Object.entries(stages).map(([name, stage]) => [
name,
stage.uniforms.intensity,
]),
),
{ noir: 0, retro: 0, surveillance: 0, thermal: 0 },
);
assert.deepEqual(restore, {
noir: 0.72,
retro: 0.18,
surveillance: 0,
thermal: 0,
});
});
test('temporary styles replace each other without changing the inherited restore snapshot', () => {
const stages = createStages();
const restore = Object.fromEntries(
Object.entries(stages).map(([name, stage]) => [name, stage.uniforms.intensity]),
Object.entries(stages).map(([name, stage]) => [
name,
stage.uniforms.intensity,
]),
);
applyCockpitVisionStageIntensities(stages, 'thermal', restore);
assert.equal(applyCockpitVisionStageIntensities(stages, 'crt', restore), 'retro');
applyCockpitVisionStageIntensities(stages, 'thermal');
assert.equal(applyCockpitVisionStageIntensities(stages, 'crt'), 'retro');
assert.equal(stages.retro.uniforms.intensity, 1);
assert.equal(stages.noir.uniforms.intensity, 0);
applyCockpitVisionStageIntensities(stages, 'optical', restore);
assert.equal(stages.noir.uniforms.intensity, 0.72);
assert.equal(stages.retro.uniforms.intensity, 0.18);
applyCockpitVisionStageIntensities(stages, 'optical');
assert.equal(stages.noir.uniforms.intensity, 0);
assert.equal(stages.retro.uniforms.intensity, 0);
assert.deepEqual(restore, {
noir: 0.72,
retro: 0.18,
surveillance: 0,
thermal: 0,
});
});
test('NOIR is a temporary Cockpit override and inherited restores the captured map style', () => {
test('NOIR is a temporary Cockpit override and Normal clears it', () => {
const stages = createStages();
const restore = Object.fromEntries(
Object.entries(stages).map(([name, stage]) => [name, stage.uniforms.intensity]),
Object.entries(stages).map(([name, stage]) => [
name,
stage.uniforms.intensity,
]),
);
assert.equal(applyCockpitVisionStageIntensities(stages, 'noir', restore), 'noir');
assert.equal(applyCockpitVisionStageIntensities(stages, 'noir'), 'noir');
assert.equal(stages.noir.uniforms.intensity, 1);
assert.equal(stages.retro.uniforms.intensity, 0);
applyCockpitVisionStageIntensities(stages, 'optical', restore);
assert.equal(stages.noir.uniforms.intensity, 0.72);
assert.equal(stages.retro.uniforms.intensity, 0.18);
applyCockpitVisionStageIntensities(stages, 'optical');
assert.equal(stages.noir.uniforms.intensity, 0);
assert.equal(stages.retro.uniforms.intensity, 0);
assert.deepEqual(restore, {
noir: 0.72,
retro: 0.18,
surveillance: 0,
thermal: 0,
});
});
+280
View File
@@ -0,0 +1,280 @@
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import test from 'node:test';
import path from 'node:path';
import {
codexAuthPath,
preferCodexOAuth,
readCodexOAuthAccessToken,
resolveCodexExecutable,
startCodexChatGptLogin,
} from '../server/providers/openai/codex-auth.js';
const nowMs = Date.UTC(2026, 8, 27);
const jwt = (payload) =>
`${Buffer.from('{}').toString('base64url')}.${Buffer.from(JSON.stringify(payload)).toString('base64url')}.signature`;
const auth = (token, extras = {}) =>
JSON.stringify({
auth_mode: 'chatgpt',
tokens: { access_token: token, refresh_token: 'refresh-fixture' },
...extras,
});
test('optional Codex OAuth pin fails closed on ambiguous values', () => {
assert.equal(preferCodexOAuth({}), false);
assert.equal(preferCodexOAuth({ GEV_PREFER_CODEX_OAUTH: 'true' }), true);
assert.equal(preferCodexOAuth({ GEV_PREFER_CODEX_OAUTH: 'off' }), false);
assert.throws(
() => preferCodexOAuth({ GEV_PREFER_CODEX_OAUTH: 'maybe' }),
/must be true or false/,
);
});
test('Codex auth path follows explicit auth file, CODEX_HOME, then home directory', () => {
assert.equal(
codexAuthPath({
environment: { CODEX_AUTH_JSON: '/tmp/custom-auth.json' },
home: '/home/fixture',
}),
path.resolve('/tmp/custom-auth.json'),
);
assert.equal(
codexAuthPath({
environment: { CODEX_HOME: '/tmp/codex-home' },
home: '/home/fixture',
}),
path.join(path.resolve('/tmp/codex-home'), 'auth.json'),
);
assert.equal(
codexAuthPath({ environment: {}, home: '/home/fixture' }),
path.join('/home/fixture', '.codex', 'auth.json'),
);
});
test('Codex OAuth reader accepts a current ChatGPT token and rejects unusable credentials', () => {
const current = jwt({ exp: nowMs / 1000 + 3600 });
assert.equal(
readCodexOAuthAccessToken({
authPath: '/unused',
nowMs,
readFile: () => auth(current),
}),
current,
);
assert.equal(
readCodexOAuthAccessToken({
authPath: '/unused',
nowMs,
readFile: () =>
JSON.stringify({
auth_mode: 'chatgpt',
tokens: { access_token: current },
}),
}),
current,
'GEV does not require or consume the vendor refresh token',
);
for (const readFile of [
() => '{bad json',
() => JSON.stringify({ tokens: {} }),
() => auth(current, { auth_mode: 'apikey' }),
() =>
JSON.stringify({
OPENAI_API_KEY: 'sk-fixture',
tokens: { access_token: current, refresh_token: 'refresh-fixture' },
}),
() => auth('opaque-token'),
() => auth('header.not-json.signature'),
() => auth(jwt({ exp: 'tomorrow' })),
() => {
throw new Error('missing');
},
]) {
assert.throws(
() => readCodexOAuthAccessToken({ authPath: '/unused', readFile, nowMs }),
/ChatGPT sign-in is unavailable/,
);
}
});
test('Codex OAuth reader enforces expiry margin and bounds tokens without exp by file age', () => {
for (const exp of [nowMs / 1000 - 1, nowMs / 1000 + 59]) {
assert.throws(
() => {
readCodexOAuthAccessToken({
authPath: '/unused',
nowMs,
readFile: () => auth(jwt({ exp })),
});
},
(error) => {
assert.match(error.message, /sign-in has expired/);
assert.equal(error.code, 'CODEX_OAUTH_REAUTH_REQUIRED');
return true;
},
);
}
const noExp = jwt({ sub: 'fixture' });
const base = { authPath: '/unused', nowMs, readFile: () => auth(noExp) };
assert.equal(
readCodexOAuthAccessToken({
...base,
statFile: () => ({ mtimeMs: nowMs }),
}),
noExp,
);
assert.throws(
() =>
readCodexOAuthAccessToken({
...base,
statFile: () => ({ mtimeMs: nowMs - 3600_000 }),
}),
/sign-in has expired/,
);
assert.throws(
() =>
readCodexOAuthAccessToken({
...base,
statFile: () => {
throw new Error('missing');
},
}),
/sign-in is unavailable/,
);
});
test('Codex login resolves an installed executable before PATH and starts browser auth detached', async () => {
const checked = [];
// Built with path.join so the stub matches the resolver on Windows too,
// where the separators are backslashes.
const installed = path.join('/home/fixture', '.local', 'bin', 'codex');
assert.equal(
resolveCodexExecutable({
environment: {},
home: '/home/fixture',
access(candidate) {
checked.push(candidate);
if (candidate === installed) return;
throw new Error('missing');
},
}),
installed,
);
assert.equal(checked.length, 1);
const child = new EventEmitter();
let unref = 0;
child.unref = () => {
unref += 1;
};
const calls = [];
const pending = startCodexChatGptLogin({
executable: '/fixture/codex',
home: '/home/fixture',
environment: {
PATH: '/fixture',
https_proxy: 'http://proxy.fixture:8080',
OPENAI_API_KEY: 'fixture-api-key',
GOOGLE_MAPS_SERVER_API_KEY: 'fixture-google-key',
OPENSKY_CLIENT_SECRET: 'fixture-opensky-secret',
},
spawnImpl(command, args, options) {
calls.push({ command, args, options });
queueMicrotask(() => child.emit('spawn'));
return child;
},
});
const login = await pending;
assert.equal(unref, 1);
assert.equal(calls[0].command, '/fixture/codex');
assert.deepEqual(calls[0].args, ['login']);
assert.equal(calls[0].options.detached, true);
assert.equal(calls[0].options.stdio, 'ignore');
assert.deepEqual(
calls[0].options.env,
{
PATH: '/fixture',
https_proxy: 'http://proxy.fixture:8080',
CODEX_HOME: path.join('/home/fixture', '.codex'),
},
'provider secrets stay out of the login process',
);
child.emit('exit', 1);
assert.deepEqual(await login.completion, { exitCode: 1 });
});
test('Codex login refuses a read/write storage mismatch before spawning', async () => {
await assert.rejects(
startCodexChatGptLogin({
environment: { CODEX_AUTH_JSON: '/tmp/another-auth.json' },
home: '/home/fixture',
executable: 'codex',
spawnImpl: () => assert.fail('mismatched storage must not launch login'),
}),
{ code: 'CODEX_OAUTH_STORAGE_MISMATCH' },
);
});
test('Codex login resolves relative CODEX_HOME before changing the child directory', async () => {
const child = new EventEmitter();
const environment = { CODEX_HOME: './fixture-codex' };
const login = await startCodexChatGptLogin({
environment,
executable: 'codex',
spawnImpl(_command, _args, options) {
assert.equal(
options.env.CODEX_HOME,
path.resolve(environment.CODEX_HOME),
);
queueMicrotask(() => child.emit('spawn'));
return child;
},
});
assert.equal(
environment.CODEX_HOME,
'./fixture-codex',
'caller environment is unchanged',
);
child.emit('exit', 0);
assert.deepEqual(await login.completion, { exitCode: 0 });
});
test('Codex login hides spawn errors and can cancel only its owned child', async () => {
const child = new EventEmitter();
await assert.rejects(
startCodexChatGptLogin({
environment: {},
home: '/home/fixture',
executable: 'codex',
spawnImpl() {
queueMicrotask(() =>
child.emit('error', new Error('private-token-fixture')),
);
return child;
},
}),
(error) =>
error.code === 'CODEX_OAUTH_LOGIN_FAILED' &&
!error.message.includes('private-token-fixture'),
);
const running = new EventEmitter();
let cancelled = 0;
running.kill = (signal) => {
assert.equal(signal, 'SIGTERM');
cancelled += 1;
running.emit('exit', null);
};
const login = await startCodexChatGptLogin({
environment: {},
home: '/home/fixture',
executable: 'codex',
spawnImpl() {
queueMicrotask(() => running.emit('spawn'));
return running;
},
});
login.cancel();
assert.equal(cancelled, 1);
assert.deepEqual(await login.completion, { exitCode: null });
});
+2 -2
View File
@@ -281,7 +281,7 @@ test('detection is wired to the Contacts transaction, and cockpit no longer touc
uiSource.indexOf('_syncContactsDetection() {'),
uiSource.indexOf('/** Apply a temporary cockpit-only'),
);
assert.ok(helper.length > 0, 'the Contacts detection helper is present in ui.js');
assert.ok(helper.length > 0, 'the Contacts detection helper is present in the UI shell source');
assert.match(helper, /if \(this\._contextModeChanging\) return;/, 'fires at settle, not at click');
assert.match(helper, /active: this\._contextMode === 'flights'/);
assert.match(helper, /styleOwnsDetection:\s*!this\._detectionUserOverridden/);
@@ -302,7 +302,7 @@ test('detection is wired to the Contacts transaction, and cockpit no longer touc
uiSource.indexOf('_setCockpitVision(mode, active, { revealParameters = false } = {}) {'),
uiSource.indexOf('_syncIrBoost() {'),
);
assert.ok(visionHook.length > 0, 'the cockpit vision hook is present in ui.js');
assert.ok(visionHook.length > 0, 'the cockpit vision hook is present in the UI shell source');
assert.doesNotMatch(visionHook, /etection/, 'cockpit transitions must not touch detection');
});
+32 -1
View File
@@ -1,3 +1,5 @@
import { msg } from './i18n/marker.js';
const CONTEXT_DEPENDENCIES = Object.freeze({
flights: new Set([
'military-awareness',
@@ -9,6 +11,12 @@ const CONTEXT_DEPENDENCIES = Object.freeze({
'space-missions': new Set(['rocket-launches', 'satellites']),
});
const CONTEXT_COMPANIONS = new Set(['radio']);
/** The Space Missions refusal, with the blocked layer's name as {layer}. */
export const SPACE_MISSIONS_LAYER_BLOCK_REASON = msg(
'Space Missions isolates replay data. Exit the mode to enable {layer}.',
);
const [BLOCK_REASON_PREFIX, BLOCK_REASON_SUFFIX] =
SPACE_MISSIONS_LAYER_BLOCK_REASON.split('{layer}');
/** Return whether an origin represents a direct user choice on this route. */
export function isExplicitUserIntentOrigin(origin, layerId = null) {
return origin === 'user' || origin === 'voice';
@@ -336,7 +344,30 @@ export function contextLayerEnableBlockReason({
return null;
}
const label = String(layerName || change.layerId || 'that layer');
return `Space Missions isolates replay data. Exit the mode to enable ${label}.`;
return SPACE_MISSIONS_LAYER_BLOCK_REASON.replace('{layer}', label);
}
/**
* The layer name from a Space Missions refusal, so the UI can rebuild the
* sentence in its language.
* @param {string} reason
* @returns {{layer: string}|null} The blocked layer's name, or null when the
* reason is not a Space Missions refusal.
*/
export function parseContextLayerBlockReason(reason) {
if (
typeof reason !== 'string' ||
!reason.startsWith(BLOCK_REASON_PREFIX) ||
!reason.endsWith(BLOCK_REASON_SUFFIX) ||
reason.length <= BLOCK_REASON_PREFIX.length + BLOCK_REASON_SUFFIX.length
)
return null;
return {
layer: reason.slice(
BLOCK_REASON_PREFIX.length,
reason.length - BLOCK_REASON_SUFFIX.length,
),
};
}
/**
+11 -5
View File
@@ -8,7 +8,6 @@ import { setContextMode as contextModeAction } from './ui/contextActions.js';
import { ContextControls } from './ui/contextControls.js';
import { readFileSync as readRadioSource } from 'node:fs';
const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8');
const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8');
const radioControlsSource = readRadioSource(new URL('./ui/radioControls.js', import.meta.url), 'utf8');
// Source-contract pins read the actual Context owners and root disposal wiring. Each pin guards a bug that shipped or nearly shipped:
// - session bookkeeping ran AFTER the exit early-return, so the compensating
@@ -21,8 +20,6 @@ const radioControlsSource = readRadioSource(new URL('./ui/radioControls.js', imp
// - the right-rail entry ignored the activation result entirely.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
const src = readShellSource();
@@ -97,6 +94,15 @@ test('context restore settles the Contacts coordinator before dependency fanout'
assert.match(restore, /excludeLayerIds: settleContactsCoordinator[\s\S]*?contactsCoordinatorId/);
});
test('Clear All during the Contacts settle stops the snapshot coming back', () => {
const restore = _restoreContextSession.toString();
const coordinatorOff = restore.indexOf('const coordinatorSettled = await this._dataManager.setEnabled(');
const cancelled = restore.indexOf('if (restoreState.cancelled) return;', coordinatorOff);
const fanout = restore.indexOf('await this._dataManager.restoreEnabledLayerIds(', coordinatorOff);
assert.ok(cancelled > coordinatorOff, 'the cancellation is checked after Contacts settles');
assert.ok(fanout > cancelled, 'and before the snapshot fans out');
});
test('context restore preserves its exact pending target after a failed transition', () => {
const restore = _restoreContextSession.toString();
assert.match(restore, /const replayError = await settleContextIntentReplay\(\{/);
@@ -162,7 +168,7 @@ test('every user-facing Context exit route settles through the failure surface',
test('the Radio chip catches lifecycle rejection and semantic false through the toast wrapper', () => {
const radioControls = radioBindings.slice(
radioBindings.indexOf('const toggleRadio = async (trigger) => {'),
radioBindings.indexOf("this.listen(this._radioFilter, 'change'"),
radioBindings.indexOf('this._storeToken = this.store?.connect?.('),
);
assert.match(radioControls, /await this\.actions\.runUserAction\(/);
assert.match(radioControls, /Radio could not \$\{enabling \? 'start' : 'stop'\} cleanly/);
@@ -172,7 +178,7 @@ test('the Radio chip catches lifecycle rejection and semantic false through the
test('only the expanded Radio Enable gesture requests the contained post-enable reveal', () => {
const radioControls = radioBindings.slice(
radioBindings.indexOf('const toggleRadio = async (trigger) => {'),
radioBindings.indexOf("this.listen(this._radioFilter, 'change'"),
radioBindings.indexOf('this._storeToken = this.store?.connect?.('),
);
assert.match(radioControls, /revealAfterEnable = enabling && trigger === this\._radioEnableBtn/);
assert.match(radioControls, /if \(revealAfterEnable\)\s*await this\._revealRadioControlsAfterExplicitEnable\(trigger\)/);
+68 -89
View File
@@ -1,20 +1,16 @@
import { readShellSource } from './testSupport/readShellSource.mjs';
import { expandApplicationHtml } from '../build/application-html.js';
import { renderApplicationHtml } from './testSupport/applicationHtml.mjs';
import { readStylesheet } from './testSupport/readStylesheet.mjs';
import { _selectContextMode } from './ui/contextTransactions.js';
import { _syncContextModeButtons } from './ui/contextPresentation.js';
import { _initGlobalContextPanel } from './ui/contextBindings.js';
import { clearSelectedLayers } from './ui/contextActions.js';
import { readFileSync as readRadioSource } from 'node:fs';
const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8');
const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8');
const radioControlsSource = readRadioSource(new URL('./ui/radioControls.js', import.meta.url), 'utf8');
const contextView = readRadioSource(new URL('./ui/views/ContextView.vue', import.meta.url), 'utf8');
import { readFileSync } from 'node:fs';
import { test } from 'node:test';
import assert from 'node:assert/strict';
const html = expandApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8'));
const ui = readShellSource();
const html = await renderApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8'));
const css = readStylesheet(new URL('../style.css', import.meta.url));
test('Contacts and Space Missions both participate in the ordinary Tab sequence', () => {
@@ -25,77 +21,12 @@ test('Contacts and Space Missions both participate in the ordinary Tab sequence'
assert.match(button[0], /tabindex="0"/);
}
const syncSource = _syncContextModeButtons.toString();
assert.match(syncSource, /\[\s*this\._globalContextFlightsBtn,\s*this\._globalContextMissionsBtn,?\s*\]/);
assert.match(syncSource, /button\.tabIndex = 0/);
assert.doesNotMatch(syncSource, /tabIndex\s*=\s*[^;]*\?\s*-1/);
});
test('Context transition state preserves focus and Tab availability until settle', () => {
const syncSource = _syncContextModeButtons.toString();
const attributes = () => new Map();
const makeButton = () => {
const attrs = attributes();
let disabled = false;
const button = {
attrs,
tabIndex: -1,
classList: { toggle() {} },
setAttribute(name, value) { attrs.set(name, String(value)); },
get disabled() { return disabled; },
set disabled(value) {
disabled = Boolean(value);
// Model the browser behavior that exposed this regression: native
// disabled drops focus immediately.
if (disabled && globalThis.document?.activeElement === button) {
globalThis.document.activeElement = null;
}
},
};
return button;
};
const contacts = makeButton();
const missions = makeButton();
const panel = { classList: { toggle() {} }, setAttribute() {} };
const priorDocument = globalThis.document;
globalThis.document = { activeElement: missions, getElementById: () => panel };
const owner = {
_globalContextPanel: panel,
actions: { syncDetection() {}, scheduleLayout() {} },
_contextMode: null,
_contextModeChanging: true,
_globalContextFlightsBtn: contacts,
_globalContextMissionsBtn: missions,
_contextModeStandby: {},
_contextFlightsView: {},
_contextMissionsView: {},
cockpitView: { syncEntry() {} },
_syncContactsDetection() {},
_scheduleRightPanelLayout() {},
};
try {
_syncContextModeButtons.call(owner);
assert.equal(globalThis.document.activeElement, missions, 'busy sync retains focused Space Missions');
for (const button of [contacts, missions]) {
assert.equal(button.disabled, false);
assert.equal(button.tabIndex, 0);
assert.equal(button.attrs.get('aria-disabled'), 'true');
assert.equal(button.attrs.get('aria-busy'), 'true');
}
owner._contextModeChanging = false;
owner._contextMode = 'space-missions';
_syncContextModeButtons.call(owner);
assert.equal(globalThis.document.activeElement, missions, 'settled sync retains focused Space Missions');
for (const button of [contacts, missions]) {
assert.equal(button.disabled, false);
assert.equal(button.tabIndex, 0);
assert.equal(button.attrs.get('aria-disabled'), 'false');
assert.equal(button.attrs.get('aria-busy'), 'false');
}
} finally {
globalThis.document = priorDocument;
// The view keeps both tabs' tabindex static and never disables them; the
// transition itself is covered over happy-dom in ui/contextPanel.test.mjs.
for (const id of ['global-context-flights-btn', 'global-context-missions-btn']) {
const tag = contextView.match(new RegExp(`<button id="${id}"[^>]*>`))?.[0];
assert.ok(tag, `${id} is missing from the view`);
assert.doesNotMatch(tag, /:tabindex|:disabled|\sdisabled/);
}
});
@@ -134,20 +65,68 @@ test('Context tabs draw a visible keyboard-focus outline including active tabs',
test('Context async action buttons remain focused while busy', () => {
const init = _initGlobalContextPanel.toString();
const radio = radioBindings.slice(radioBindings.indexOf('const toggleRadio = async (trigger) => {'), radioBindings.indexOf('this.listen(this._contextRadioToggleBtn,'));
const radioSync = radioPresentation.slice(0, radioPresentation.indexOf('if (this._radioFilter)'));
const radio = radioBindings.slice(radioBindings.indexOf('const toggleRadio = async (trigger) => {'), radioBindings.indexOf('this._storeToken = this.store?.connect?.('));
const radioPanel = readFileSync(new URL('./ui/views/panels/RadioPanel.vue', import.meta.url), 'utf8');
const clear = clearSelectedLayers.toString();
assert.match(init, /button\.getAttribute\('aria-busy'\) === 'true'/);
assert.doesNotMatch(init, /button\.disabled\s*=\s*true/);
assert.match(init, /if \(this\._installationsSearchBusy\) return;/);
assert.doesNotMatch(init, /\.disabled\s*=\s*true/);
const search = contextView.match(/<button id="installations-search-btn"[^>]*>/)?.[0];
assert.ok(search, 'nearby sites search is missing');
assert.doesNotMatch(search, /:disabled|\sdisabled/);
assert.match(search, /:aria-busy=/);
assert.match(radio, /trigger\.getAttribute\('aria-busy'\) === 'true'/);
assert.doesNotMatch(radio, /trigger\.disabled\s*=\s*true/);
for (const name of ['_radioEnableBtn', '_contextRadioMiniEnableBtn', '_cockpitRadioEnableBtn']) {
assert.match(radioSync, new RegExp(`${name}\\.disabled = false`));
}
assert.doesNotMatch(clear, /_clearSelectedLayersBtn\.disabled\s*=\s*true/);
const cockpitRadio = readFileSync(new URL('./ui/views/panels/CockpitUtilityControls.vue', import.meta.url), 'utf8');
const cockpitEnable = cockpitRadio.match(/<UiButton id="cockpit-radio-enable-btn"[^>]*>/)?.[0];
assert.ok(cockpitEnable, 'Cockpit Radio Enable button is missing');
assert.doesNotMatch(cockpitEnable, /disabled(?!=")|:disabled/);
assert.match(cockpitEnable, /:aria-busy=/);
// The Radio view never disables its Enable button; it marks it busy.
const enable = radioPanel.match(/<UiButton id="radio-enable-btn"[^>]*>/)?.[0];
assert.ok(enable, 'Radio Enable button is missing');
assert.doesNotMatch(enable, /disabled(?!=")|:disabled/);
assert.match(enable, /:aria-busy=/);
assert.match(clear, /this\.setClearBusy\(true\)/);
const control = readFileSync(new URL('./ui/clearLayersControl.js', import.meta.url), 'utf8');
assert.match(control, /button\.setAttribute\('aria-busy', String\(busy\)\)/);
assert.doesNotMatch(control, /button\.disabled\s*=/);
// The globe actions view marks the clear button busy, never disabled.
const globeActions = readFileSync(new URL('./ui/views/panels/GlobeActions.vue', import.meta.url), 'utf8');
const clearButton = globeActions.match(/<button id="clear-selected-layers"[^>]*>/)?.[0];
assert.ok(clearButton, 'Clear selected layers button is missing');
assert.doesNotMatch(clearButton, /disabled(?!=")|:disabled=/);
assert.match(clearButton, /:aria-busy=/);
});
test('Context header holds only its title and collapse control', () => {
const panel = html.match(/<div id="global-context-panel"[\s\S]*?<div class="cyber-panel-body[ "]/);
assert.ok(panel, 'Global Context panel is missing');
const header = panel[0].match(/<div class="ui-panel__head panel-header">([\s\S]*?)<\/div>\s*<div class="cyber-panel-body[ "]/);
assert.ok(header, 'Global Context header is missing');
// The kit's own ui-* classes carry no Core styles.
const tags = [...header[1].matchAll(/<(\w+)\s+(?:type="button"\s+)?class="([^"]+)"/g)].map(([, tag, cls]) =>
`${tag}.${cls.split(' ').filter((name) => !/^ui-/.test(name)).join(' ')}`);
assert.deepEqual(tags, ['span.panel-title', 'span.panel-divider', 'button.panel-collapse-btn']);
});
test('Context reads as one segmented control with legible type and no halo', () => {
const rules = [...css.matchAll(/([^{}]+)\{([^{}]*)\}/g)];
const clean = (selector) => selector.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\s+/g, ' ').trim();
const rule = (selector) => rules.find(([, s]) => clean(s) === selector)?.[2] || '';
const px = (body, prop) => Number(body.match(new RegExp(`${prop}:[^;]*?(\\d+(?:\\.\\d+)?)px`))?.[1] || 0);
assert.match(rule('#global-context-panel .panel-glow'), /display:\s*none/);
const modes = rule('.global-context-modes');
assert.match(modes, /border:\s*1px solid/);
assert.match(modes, /padding:\s*2px/);
assert.ok(px(rule('.context-mode-button'), 'font') >= 10, 'mode labels must stay at least 10px');
assert.doesNotMatch(rule('.context-mode-button.active'), /box-shadow/);
const standby = rule('.context-mode-standby');
assert.ok(px(standby, 'font') >= 10, 'standby guidance must stay at least 10px');
assert.doesNotMatch(standby, /dashed/);
assert.ok(px(rule('.global-context-actions .panel-layer-toggle'), 'font-size') >= 10);
// The shared rail title colour (brighter than --text-secondary) keeps the
// open Context title legible; no Context-only rule dims it again.
assert.equal(
rule(":root:not([data-ui-theme='cyber']) #global-context-panel:not(.collapsed) .panel-title"),
'',
);
});
+281
View File
@@ -0,0 +1,281 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { Readable } from 'node:stream';
import { readPinokioEnvironment } from '../scripts/pinokio-environment.mjs';
import { openAiRealtimeProxy } from 'gods-eye-view/server/providers/openai';
import {
makeCostRateLimiter,
resolvePerMinuteCap,
} from '../server/providers/common/rate-limit.js';
import { OPENAI_DEFAULT_PER_MIN } from '../server/providers/openai/rate-limit.js';
import { handleHudSummary } from '../server/providers/openai/hud-summary.js';
import {
googlePlacesContextProxy,
GOOGLE_DEFAULT_PER_MIN,
} from '../server/providers/places/google.js';
// Both limiters are cached at module scope on first use, so the env value that
// matters is the one in place when a route is first exercised — restoring the
// variable afterwards does not rebuild the limiter. Each route is therefore
// driven by exactly one test here; a second test wanting a different cap would
// need its own process, not just a different env value.
/** Set an env var for one test and put the old value back afterwards. */
function env(t, name, value) {
const old = process.env[name];
if (value === undefined) delete process.env[name];
else process.env[name] = value;
t.after(() => {
if (old === undefined) delete process.env[name];
else process.env[name] = old;
});
}
function install(plugin) {
const routes = new Map();
plugin.configureServer({
middlewares: { use: (route, handler) => routes.set(route, handler) },
restart: async () => {},
});
return routes;
}
function request(
handler,
{ url = '/', method = 'POST', remoteAddress = '127.0.0.1' } = {},
) {
return new Promise((resolve, reject) => {
const req = Readable.from([]);
Object.assign(req, {
method,
url,
headers: { host: 'localhost:4173', 'content-type': 'application/json' },
socket: { remoteAddress },
});
const headers = {};
const res = {
statusCode: 200,
setHeader(name, value) {
headers[name.toLowerCase()] = value;
},
end(body = '') {
resolve({ status: this.statusCode, headers, body: String(body) });
},
};
Promise.resolve(handler(req, res)).catch(reject);
});
}
test('an unset throttle resolves to the documented default, not to unlimited', () => {
// The defect #16 reports: the cap was opt-in, so an unconfigured server let
// anyone who could reach it mint tokens on the operator's key without bound.
assert.equal(resolvePerMinuteCap(undefined, 30), 30);
assert.equal(resolvePerMinuteCap('', 30), 30);
assert.equal(resolvePerMinuteCap(' ', 30), 30);
assert.ok(
typeof makeCostRateLimiter(undefined, 30) === 'function',
'an unconfigured cost endpoint must still be throttled',
);
});
test('an explicit 0 is the documented way to run unthrottled', () => {
assert.equal(resolvePerMinuteCap('0', 30), 0);
assert.equal(
makeCostRateLimiter('0', 30),
null,
'operators who mean "no throttle" must still be able to say so',
);
});
test('a configured value wins, and a fractional one floors', () => {
assert.equal(resolvePerMinuteCap('45', 30), 45);
assert.equal(resolvePerMinuteCap(45, 30), 45);
assert.equal(resolvePerMinuteCap('30.7', 30), 30);
});
test('a positive fraction keeps the guard rather than flooring to unlimited', () => {
for (const fraction of ['0.5', '0.99', 0.01, '1e-9']) {
assert.equal(resolvePerMinuteCap(fraction, 30), 1, String(fraction));
assert.equal(
typeof makeCostRateLimiter(fraction, 30),
'function',
`${fraction} must still throttle`,
);
}
// Only an explicit zero turns the limiter off.
assert.equal(makeCostRateLimiter('0', 30), null);
assert.equal(makeCostRateLimiter('0.0', 30), null);
});
test('an unreadable value keeps the guard instead of removing it', () => {
// `3O` (letter O) and `60/min` are the shapes a hand-edited .env produces.
// Reading either as "unlimited" would disarm the spend guard on a typo —
// the opposite of what the operator was reaching for when they typed it.
for (const typo of ['3O', '60/min', 'thirty', '-5', 'NaN']) {
assert.equal(
resolvePerMinuteCap(typo, 30),
30,
`${typo} must fall back to the default, not to unlimited`,
);
}
});
test('the default cap clears the app’s own demand with room to spare', () => {
// src/hud.js HUD_SUMMARY_INTERVAL_MS = 15000 -> 4 summaries/min per open
// tab, plus one token mint per voice session. The cap has to sit well above
// that or it would throttle the app rather than an abuser.
const hudRequestsPerMinutePerTab = 60_000 / 15_000;
assert.ok(
OPENAI_DEFAULT_PER_MIN >= hudRequestsPerMinutePerTab * 4,
`default ${OPENAI_DEFAULT_PER_MIN}/min leaves too little headroom over ${hudRequestsPerMinutePerTab}/min of ordinary use`,
);
});
test('the realtime token route throttles by default, and says so in a 429', async (t) => {
env(t, 'GEV_RATELIMIT_OPENAI_PER_MIN', undefined);
env(t, 'OPENAI_API_KEY', 'fixture-upstream-secret');
let upstreamCalls = 0;
t.mock.method(globalThis, 'fetch', async () => {
upstreamCalls += 1;
return Response.json({ value: 'fixture-ephemeral' });
});
const handler = install(openAiRealtimeProxy({})).get('/api/realtime/token');
const statuses = [];
for (let i = 0; i < OPENAI_DEFAULT_PER_MIN + 1; i += 1) {
statuses.push((await request(handler)).status);
}
assert.deepEqual(
statuses.slice(0, OPENAI_DEFAULT_PER_MIN),
Array(OPENAI_DEFAULT_PER_MIN).fill(200),
'the default must not throttle ordinary use',
);
const overLimit = await request(handler);
assert.equal(overLimit.status, 429);
assert.equal(overLimit.headers['retry-after'], '5');
assert.equal(JSON.parse(overLimit.body).error, 'Rate limit exceeded');
assert.equal(
overLimit.body.includes('fixture-upstream-secret'),
false,
'a throttled reply must not leak the server-held key',
);
assert.ok(
upstreamCalls <= OPENAI_DEFAULT_PER_MIN,
'a throttled request must be refused before it reaches OpenAI, or it still costs money',
);
});
test('the HUD summary route throttles the paid path, never the free one', async (t) => {
env(t, 'OPENAI_API_KEY', 'fixture-upstream-secret');
let upstreamCalls = 0;
t.mock.method(globalThis, 'fetch', async () => {
upstreamCalls += 1;
return Response.json({ output_text: 'five word hud summary here' });
});
// Its own client address: the limiter is cached at module scope and shared
// across the OpenAI routes, so the realtime case above has already spent
// 127.0.0.1's budget inside this process.
const from = '10.9.9.9';
const statuses = [];
for (let i = 0; i < OPENAI_DEFAULT_PER_MIN; i += 1)
statuses.push(
(await request(handleHudSummary, { remoteAddress: from })).status,
);
assert.deepEqual(
statuses,
Array(OPENAI_DEFAULT_PER_MIN).fill(200),
'the default must not throttle ordinary HUD use',
);
const overLimit = await request(handleHudSummary, { remoteAddress: from });
assert.equal(overLimit.status, 429);
assert.equal(overLimit.headers['retry-after'], '5');
assert.equal(JSON.parse(overLimit.body).error, 'Rate limit exceeded');
assert.equal(
overLimit.body.includes('fixture-upstream-secret'),
false,
'a throttled reply must not leak the server-held key',
);
assert.ok(
upstreamCalls <= OPENAI_DEFAULT_PER_MIN,
'a throttled request must be refused before it reaches OpenAI, or it still costs money',
);
// The keyless HUD answer is deliberately NOT throttled: it is composed
// locally, spends nothing upstream, and capping it would only take the free
// HUD away from an unconfigured install. Same exhausted client, no key.
delete process.env.OPENAI_API_KEY;
const free = await request(handleHudSummary, { remoteAddress: from });
assert.equal(free.status, 200, 'the free path answers past a spent budget');
assert.equal(JSON.parse(free.body).configured, false);
assert.equal(
upstreamCalls <= OPENAI_DEFAULT_PER_MIN,
true,
'the free path reaches no provider',
);
});
test('the Google places route throttles by default and keeps its places[] contract', async (t) => {
env(t, 'GEV_RATELIMIT_GOOGLE_PER_MIN', undefined);
let upstreamCalls = 0;
const handler = install(
googlePlacesContextProxy({
resolveApiKey: () => 'fixture-google-server-key',
fetchImpl: async () => {
upstreamCalls += 1;
return Response.json({ places: [] });
},
}),
).get('/api/google/nearby-places');
const url = '/?lat=37.77493&lon=-122.41942';
const statuses = [];
for (let i = 0; i < GOOGLE_DEFAULT_PER_MIN; i += 1) {
statuses.push((await request(handler, { url, method: 'GET' })).status);
}
assert.ok(
statuses.every((s) => s === 200),
'the default must not throttle ordinary use',
);
const overLimit = await request(handler, { url, method: 'GET' });
assert.equal(overLimit.status, 429);
assert.equal(overLimit.headers['retry-after'], '5');
// Every error response on this route carries places[]; the client reads it
// unconditionally, so a throttled reply must not be the one shape that omits it.
assert.deepEqual(JSON.parse(overLimit.body).places, []);
assert.equal(
upstreamCalls,
GOOGLE_DEFAULT_PER_MIN,
'a throttled request must be refused before it reaches Google, or it still costs money',
);
});
test('the code defaults match the caps the Pinokio build ships', () => {
// These two numbers used to live only in pinokio/_ENVIRONMENT, and the value
// this repo suggested elsewhere had already drifted away from them. Now that
// they are the server's defaults, the packaged app and an unconfigured
// checkout must agree, or a Pinokio user's throttle silently stops matching
// the one everybody else gets. If you deliberately want them to differ, this
// assertion is the place to say so.
const shipped = readPinokioEnvironment(
path.join(
path.dirname(fileURLToPath(import.meta.url)),
'..',
'pinokio',
'_ENVIRONMENT',
),
);
assert.equal(
Number(shipped.GEV_RATELIMIT_OPENAI_PER_MIN),
OPENAI_DEFAULT_PER_MIN,
);
assert.equal(
Number(shipped.GEV_RATELIMIT_GOOGLE_PER_MIN),
GOOGLE_DEFAULT_PER_MIN,
);
});
+3 -2
View File
@@ -1,4 +1,3 @@
import { readShellSource } from './testSupport/readShellSource.mjs';
import { readStylesheet } from './testSupport/readStylesheet.mjs';
import assert from 'node:assert/strict';
import fs from 'node:fs';
@@ -8,7 +7,6 @@ import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const css = readStylesheet(path.join(ROOT, 'style.css'));
const ui = readShellSource();
/*
* Required-attribution keep-out pin.
@@ -247,6 +245,9 @@ function ownBoxEntries() {
const compound = lastCompound(part);
if (!ELEMENT_KEYS.some((key) => compound.includes(key))) continue;
if (compound.includes('::')) continue; // a pseudo-element is its own box
// The phone shell is its own layout; mobileShell.test.mjs pins its
// attribution clearance.
if (part.startsWith('body.mobile-shell')) continue;
entries.push({ rule, part });
}
}
+12 -2
View File
@@ -2,6 +2,8 @@
* Adds the keyboard semantics omitted by Cesium's href-less attribution links.
* Cesium retains ownership of showing and hiding its required credit lightbox.
*/
import { t } from './i18n/index.js';
export function configureCreditKeyboardAccess(root = document) {
const expand = root?.querySelector?.(
'#cesium-credits .cesium-credit-expand-link',
@@ -11,15 +13,23 @@ 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 is Cesium's own text. Called
// again after a locale change, this words them again.
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');
expand.setAttribute('aria-haspopup', 'dialog');
expand.setAttribute('aria-controls', lightbox.id);
expand.setAttribute('aria-expanded', 'false');
if (!expand.hasAttribute?.('aria-expanded'))
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';
+48
View File
@@ -1,6 +1,7 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { configureCreditKeyboardAccess } from './creditKeyboard.js';
import { setLocale } from './i18n/index.js';
function fakeElement() {
const attributes = new Map();
@@ -186,3 +187,50 @@ 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');
}
});
test('setting up again after a switch back to English restores the English texts', () => {
const f = textFixture();
setLocale('ru');
try {
configureCreditKeyboardAccess(f.root);
} finally {
setLocale('en');
}
configureCreditKeyboardAccess(f.root);
assert.equal(f.expand.textContent, 'Data attribution');
assert.equal(f.title.textContent, 'Data provided by:');
});
+9 -3
View File
@@ -480,11 +480,16 @@ export function setCyberSonarEnabled(
return enabled;
}
/** Whether the map-mode sweep should alter flight presentation. */
/**
* Whether the map-mode sweep should alter flight presentation. `hudActive`
* is the HUD's visibility from its owner, which a caller acting on it in the
* same turn passes; otherwise the rendered `#intel-hud` class answers.
*/
export function isCyberSonarActive({
root = globalThis.document?.documentElement,
body = globalThis.document?.body,
hud,
hudActive,
reducedMotion,
} = {}) {
// Keep optional method calls in the body: production downleveling of nested
@@ -498,7 +503,7 @@ export function isCyberSonarActive({
return !!(
root?.dataset?.uiTheme === 'cyber' &&
isCyberSonarEnabled(root) &&
hud?.classList?.contains?.('active') &&
(hudActive ?? hud?.classList?.contains?.('active')) &&
!body?.classList?.contains?.('cockpit-mode') &&
!reducedMotion
);
@@ -508,12 +513,13 @@ export function isCyberMapThemeActive({
root = globalThis.document?.documentElement,
body = globalThis.document?.body,
hud,
hudActive,
} = {}) {
if (hud === undefined)
hud = globalThis.document?.getElementById?.('intel-hud');
return !!(
root?.dataset?.uiTheme === 'cyber' &&
hud?.classList?.contains?.('active') &&
(hudActive ?? hud?.classList?.contains?.('active')) &&
!body?.classList?.contains?.('cockpit-mode')
);
}
+97 -34
View File
@@ -58,30 +58,18 @@ export function normalizeAdminName(value) {
const PACKS = {
countries: {
url: new URL('./local_data/natural_earth/countries.json', import.meta.url),
importJson: () =>
import('./local_data/natural_earth/countries.json', {
with: { type: 'json' },
}),
},
admin1: {
url: new URL(
'./local_data/natural_earth/states_provinces.json',
import.meta.url,
),
importJson: () =>
import('./local_data/natural_earth/states_provinces.json', {
with: { type: 'json' },
}),
},
counties: {
url: new URL(
'./local_data/us_census_counties/counties.json',
import.meta.url,
),
importJson: () =>
import('./local_data/us_census_counties/counties.json', {
with: { type: 'json' },
}),
},
};
@@ -162,17 +150,60 @@ export function polygonsContain(polygons, lat, lon) {
return false;
}
/**
* Whether polygons contain a point. A part whose outer ring crosses the
* antimeridian ([179.99, …], [-179.99, …]) is read contiguously in its own
* longitude frame, with the point moved into that frame; every other part,
* including one genuinely wider than 180°, is tested as stored. Crossing is
* read from an edge jumping more than 180°, which assumes densely noded rings
* (OSM, Nominatim, tiles); a ring that is both wider than 180° and crossing, or
* a non-crossing ring with a single edge over 180°, is not supported.
*/
export function containsAnchor(polygons, lat, lon) {
if (!Number.isFinite(lon)) return false;
for (const part of polygons || []) {
const outer = part?.[0];
if (!Array.isArray(outer) || !outer.length) continue;
const reference = outer[0][0];
const shift = (x) => x - 360 * Math.round((x - reference) / 360);
const span = (ring, f) => {
let west = Infinity;
let east = -Infinity;
for (const [x] of ring) {
west = Math.min(west, f(x));
east = Math.max(east, f(x));
}
return east - west;
};
// An edge jumping more than 180° wraps the line; a polar ring whose
// edges run along ±180 collapses when reframed and is read as stored.
const jumps = outer.some(
([x], i) => i > 0 && Math.abs(x - outer[i - 1][0]) > 180,
);
const framedSpan = jumps ? span(outer, shift) : 0;
const crosses = framedSpan > 0 && framedSpan < 180;
const framed = crosses
? part.map((ring) => ring.map(([x, y]) => [shift(x), y]))
: part;
if (polygonsContain([framed], lat, crosses ? shift(lon) : lon)) return true;
}
return false;
}
/**
* A unit's geometry, decoded on first use. Longitudes are unwrapped around
* the largest part for the box so units cut at the antimeridian (Alaska,
* Chukotka) get a box on the right side of the globe.
* Chukotka) get a box on the right side of the globe. An entry from another
* encoding supplies `decode()` returning the same `[outer, ...holes][]`.
*/
function geometryOf(entry) {
export function geometryOf(entry) {
if (entry.geometry) return entry.geometry;
const decimals = entry.feature.d ?? entry.decimals;
const polygons = entry.feature.polygons.map((poly) =>
poly.map((ring) => decodeRing(ring, decimals)),
);
const polygons = entry.decode
? entry.decode()
: entry.feature.polygons.map((poly) =>
poly.map((ring) => decodeRing(ring, decimals)),
);
const ref = polygons[0]?.[0]?.[0]?.[0] ?? 0;
let west = Infinity;
let south = Infinity;
@@ -190,7 +221,7 @@ function geometryOf(entry) {
if (lat > north) north = lat;
}
}
const label = entry.feature.label
const label = Array.isArray(entry.feature.label)
? { lon: entry.feature.label[0], lat: entry.feature.label[1] }
: ringCentroid(polygons[0][0]);
entry.geometry = {
@@ -220,7 +251,7 @@ function ringCentroid(ring) {
}
/** Km from a point to a unit's box (0 inside it). */
function boxDistanceKm(bbox, lat, lon) {
export function boxDistanceKm(bbox, lat, lon) {
const [west, south, east, north] = bbox;
const mid = (west + east) / 2;
const x = lon - 360 * Math.round((lon - mid) / 360);
@@ -297,7 +328,7 @@ function addKey(index, key, entry, tier) {
const loadAdmin1 = createRetryableLoader(async () => {
packLoads.admin1 += 1;
const pack = await loadBundledJson(PACKS.admin1.url, PACKS.admin1.importJson);
const pack = await loadBundledJson(PACKS.admin1.url);
const decimals = pack.meta?.decimals ?? 3;
const index = new Map();
for (const feature of pack.features || []) {
@@ -323,10 +354,7 @@ const loadAdmin1 = createRetryableLoader(async () => {
const loadCounties = createRetryableLoader(async () => {
packLoads.counties += 1;
const pack = await loadBundledJson(
PACKS.counties.url,
PACKS.counties.importJson,
);
const pack = await loadBundledJson(PACKS.counties.url);
const decimals = pack.meta?.decimals ?? 4;
const byFull = new Map();
const byName = new Map();
@@ -348,10 +376,7 @@ const loadCounties = createRetryableLoader(async () => {
const loadCountries = createRetryableLoader(async () => {
packLoads.countries += 1;
const pack = await loadBundledJson(
PACKS.countries.url,
PACKS.countries.importJson,
);
const pack = await loadBundledJson(PACKS.countries.url);
const index = new Map();
for (const feature of pack.features || []) {
const entry = {
@@ -417,8 +442,8 @@ const COUNTY_WORDS = [
];
const COUNTY_WORD_RE = new RegExp(`\\b(${COUNTY_WORDS.join('|')})\\b`);
const STATE_PREFIX_RE =
/^(?:free state|state|commonwealth|province|territory|prefecture|region|land|canton|department) of (.+)$/;
const STATE_SUFFIX_RE = /^(.+) (?:state|province|prefecture|territory)$/;
/^(free state|state|commonwealth|province|territory|prefecture|region|land|canton|department) of (.+)$/;
const STATE_SUFFIX_RE = /^(.+) (state|province|prefecture|territory)$/;
/**
* Split an ask into what it names and how.
@@ -479,14 +504,17 @@ export function parseAdminQuery(text) {
if (prefix)
return {
kind: 'state',
name: prefix[1],
name: prefix[2],
qualifiers,
postal: null,
countyWord: null,
full: head,
form: 'prefix',
adminType: prefix[1],
};
const suffix = STATE_SUFFIX_RE.exec(head);
// South Africa's canonical province name is literally "Free State"; do not
// strip its final word as though the ask explicitly named an admin type.
const suffix = head === 'free state' ? null : STATE_SUFFIX_RE.exec(head);
if (suffix)
return {
kind: 'state',
@@ -496,6 +524,7 @@ export function parseAdminQuery(text) {
countyWord: null,
full: head,
form: 'suffix',
adminType: suffix[2],
};
return {
kind: 'any',
@@ -507,6 +536,30 @@ export function parseAdminQuery(text) {
};
}
/** Explicit wording is stronger than a stale same-name geometry. */
function explicitStateTypeFits(entry, parsed) {
if (!parsed.adminType) return true;
const words = new Set(
normalizeAdminName(entry.feature?.type)
.split(/[^a-z]+/)
.filter(Boolean),
);
const accepted =
{
'free state': ['state'],
state: ['state'],
commonwealth: ['state'],
province: ['province'],
territory: ['territory'],
prefecture: ['prefecture'],
region: ['region'],
land: ['state'],
canton: ['canton'],
department: ['department', 'departamento'],
}[parsed.adminType] || [];
return accepted.some((word) => words.has(word));
}
/** A qualifier names the unit's country ("Bavaria, Germany") or the unit
* itself ("Washington, DC"). */
function qualifierMatches(entry, qualifier) {
@@ -523,7 +576,7 @@ function qualifierMatches(entry, qualifier) {
* Rank candidates: a unit containing `near` first, then the match tier,
* then distance from `near`, then larger area. Returns the ranked list.
*/
function rank(candidates, near) {
export function rank(candidates, near) {
const hasNear = Number.isFinite(near?.lat) && Number.isFinite(near?.lon);
const scored = candidates.map(({ entry, tier }) => {
const geometry = geometryOf(entry);
@@ -644,6 +697,10 @@ async function stateCandidates(parsed, { allowAmbiguous = false } = {}) {
list = list.filter((i) =>
parsed.qualifiers.every((q) => qualifierMatches(i.entry, q)),
);
// Preserve explicit identity at the selector seam. In particular,
// "Bagmati Province" must not select Natural Earth's historical NP-BA
// Bagmati Zone merely because the point and normalized name overlap.
list = list.filter((i) => explicitStateTypeFits(i.entry, parsed));
if (allowAmbiguous) return list;
// A bare name answers alone only for a prominent unit — a US state, a
// Canadian province, Bavaria — never a French département, an English
@@ -756,6 +813,12 @@ export async function findAdminAreaAt(names, lat, lon, scope) {
.map(parseAdminQuery)
.filter((p) => p?.name);
if (!parsedList.length) return null;
// If the ask explicitly names an admin type, do not let the geocoder's
// untyped alias reopen an incompatible historical same-name boundary.
const explicitStates = parsedList.filter(
(parsed) => parsed.kind === 'state' && parsed.adminType,
);
const stateParses = explicitStates.length ? explicitStates : parsedList;
const containing = (items) => {
const out = [];
for (const item of items)
@@ -800,7 +863,7 @@ export async function findAdminAreaAt(names, lat, lon, scope) {
return toResult(rank(found, { lat, lon })[0], found.length);
}
const states = [];
for (const parsed of parsedList)
for (const parsed of stateParses)
states.push(
...(await stateCandidates(
{ ...parsed, qualifiers: [] },
+22
View File
@@ -225,6 +225,28 @@ test('geocoder-confirmed lookup: the named unit that contains the point', async
assert.equal(await findAdminAreaAt(['Georgia'], 41.7, 44.8, 'state'), null);
const newYork = await findAdminAreaAt(['New York'], 42.9, -75.5, 'state');
assert.equal(newYork?.id, 'US-NY');
assert.equal(
(
await findAdminAreaAt(
['Ontario Province', 'Ontario'],
43.6532,
-79.3832,
'state',
)
)?.id,
'CA-ON',
);
// Natural Earth still carries the historical NP-BA Bagmati Zone. An ask
// for today's province must not silently select that incompatible geometry.
assert.equal(
await findAdminAreaAt(
['Bagmati Province', 'Bagmati'],
27.7172,
85.324,
'state',
),
null,
);
// A county the geocoder typed, named without the county word.
const travis = await findAdminAreaAt(
['Travis', 'Travis County'],
+40 -10
View File
@@ -1,3 +1,5 @@
import { readAdsbVerticalRateFpm } from '../sources/adsbFields.js';
const KNOT_TO_MPS = 0.514444;
const FOOT_TO_M = 0.3048;
const FPM_TO_MPS = 0.00508;
@@ -43,27 +45,35 @@ export function normalizeAdsbLolAircraftState(aircraft, nowSeconds) {
.toLowerCase();
const latitude = finiteNumber(aircraft?.lat);
const longitude = finiteNumber(aircraft?.lon);
if (!hex || latitude === null || longitude === null) return null;
if (
!hex ||
latitude === null ||
longitude === null ||
Math.abs(latitude) > 90 ||
Math.abs(longitude) > 180
)
return null;
const seenPosition = Math.max(
0,
finiteNumber(aircraft?.seen_pos) ?? finiteNumber(aircraft?.seen) ?? 0,
finiteNumber(aircraft?.seen_pos) ?? finiteNumber(aircraft?.seen) ?? NaN,
);
const seen = Math.max(0, finiteNumber(aircraft?.seen) ?? seenPosition);
const onGround = aircraft?.alt_baro === 'ground';
const barometricFeet = onGround ? null : finiteNumber(aircraft?.alt_baro);
const geometricFeet = finiteNumber(aircraft?.alt_geom);
const groundSpeedKnots = finiteNumber(aircraft?.gs);
const verticalRateFpm =
finiteNumber(aircraft?.baro_rate) ?? finiteNumber(aircraft?.geom_rate);
const verticalRateFpm = readAdsbVerticalRateFpm(aircraft);
const track = finiteNumber(aircraft?.track);
return [
hex,
String(aircraft?.flight || aircraft?.r || '').trim() || null,
null,
Math.max(0, nowSeconds - seenPosition),
Math.max(0, nowSeconds - seen),
Number.isFinite(seenPosition)
? Math.max(0, nowSeconds - seenPosition)
: null,
Number.isFinite(seen) ? Math.max(0, nowSeconds - seen) : null,
longitude,
latitude,
barometricFeet === null ? null : barometricFeet * FOOT_TO_M,
@@ -94,8 +104,28 @@ export function normalizeAdsbLolPointResponse(payload) {
: Math.floor(
responseNow > 10_000_000_000 ? responseNow / 1000 : responseNow,
);
const states = (Array.isArray(payload?.ac) ? payload.ac : [])
.map((aircraft) => normalizeAdsbLolAircraftState(aircraft, nowSeconds))
.filter(Boolean);
return { time: nowSeconds, states };
const aircraft = Array.isArray(payload?.ac) ? payload.ac : [];
const states = [];
// Keep the OpenSky vectors unchanged. The regional fallback's extra readsb
// status travels alongside them, keyed only to admitted aircraft.
const admitted = new Set();
const statuses = [];
for (const row of aircraft) {
const state = normalizeAdsbLolAircraftState(row, nowSeconds);
if (!state) continue;
states.push(state);
// The live contract admits the first valid vector for each ICAO. Its
// status must come from that same row, never an invalid or later duplicate.
const id = state[0];
if (admitted.has(id)) continue;
admitted.add(id);
if (typeof row.emergency === 'string')
statuses.push([id, row.emergency.trim().toLowerCase()]);
}
const emergencyByIcao = Object.fromEntries(statuses);
return {
time: nowSeconds,
states,
...(Object.keys(emergencyByIcao).length ? { emergencyByIcao } : {}),
};
}
+13 -11
View File
@@ -7,6 +7,8 @@
* integer category (states index 17) and ADS-B emitter strings ("A1".."B1").
*/
import { msg } from '../i18n/marker.js';
const HELI = new Set([
'EC20',
'EC25',
@@ -438,18 +440,18 @@ export function classifyAircraft({ typeCode, category } = {}) {
return 'airliner';
}
/** Readable name of each class, for cards and readouts. */
/** Readable name of each class, for cards and readouts (English data). */
export const AIRCRAFT_CLASS_LABELS = Object.freeze({
light: 'Light aircraft',
glider: 'Glider',
turboprop: 'Turboprop',
airliner: 'Airliner',
widebody: 'Widebody',
quadjet: 'Four-engine jet',
helicopter: 'Helicopter',
fastjet: 'Fast jet',
bizjet: 'Business jet',
uav: 'Drone',
light: msg('Light aircraft'),
glider: msg('Glider'),
turboprop: msg('Turboprop'),
airliner: msg('Airliner'),
widebody: msg('Widebody'),
quadjet: msg('Four-engine jet'),
helicopter: msg('Helicopter'),
fastjet: msg('Fast jet'),
bizjet: msg('Business jet'),
uav: msg('Drone'),
});
/** Billboard scale multipliers (skylight GLYPH_SCALE, + fastjet). */
+98
View File
@@ -0,0 +1,98 @@
/**
* Transponder emergency status for one aircraft, from the Mode A squawk and
* the ADS-B emergency/priority field.
*
* Emergency squawks are ICAO-standard worldwide: 7500 unlawful interference,
* 7600 radio failure, 7700 general emergency. readsb (adsb.lol) also reports
* `emergency`, "a superset of the 7x00 squawks": none, general, lifeguard,
* minfuel, nordo, unlawful, downed, reserved. OpenSky state vectors carry the
* squawk only.
*
* The status is what the transponder broadcast in THIS poll, never a finding:
* crews pass through 7x00 codes while dialling. Highlighting follows the latest
* 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: 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',
},
};
/**
* A Mode A code as its four octal digits, else null.
* @param {unknown} value
* @returns {string|null}
*/
export function normalizeSquawk(value) {
const code = String(value ?? '').trim();
return /^[0-7]{4}$/.test(code) ? code : null;
}
/**
* Emergency status from one observation. Higher severity wins; an ADS-B
* emergency wins a tie. A
* 7x00 squawk counts even when the ADS-B field says `none`.
* @param {{squawk?: unknown, emergency?: unknown}} [observation]
* @returns {{kind: string, label: string, severity: 'emergency'|'priority',
* squawk: string|null, source: 'ads-b'|'squawk'}|null} `source` is
* 'squawk' when the Mode A code itself names the status (so it can be
* quoted), else 'ads-b'.
*/
export function aircraftEmergency({ squawk, emergency } = {}) {
const code = normalizeSquawk(squawk);
const reported = String(emergency ?? '')
.trim()
.toLowerCase();
const fromAdsb = Object.hasOwn(KINDS, reported);
const squawkKind = SQUAWK_KINDS[code] ?? null;
const kind =
fromAdsb && (KINDS[reported].severity === 'emergency' || !squawkKind)
? reported
: squawkKind;
if (!kind) return null;
return {
kind,
...KINDS[kind],
squawk: code,
source: SQUAWK_KINDS[code] === kind ? 'squawk' : 'ads-b',
};
}
/**
* How a summary may phrase the status without flattening it into an incident:
* quote the code when the Mode A squawk itself names the status, otherwise
* attribute it to the ADS-B emergency field.
* @param {{kind?: string, squawk?: string|null, source?: string}|null|undefined} status
* @returns {string|null} e.g. 'broadcasting squawk 7700 (general emergency)'
* or 'ADS-B reports general emergency'; null when there is no status.
*/
export function emergencyBroadcastPhrase(status) {
if (!status || !Object.hasOwn(KINDS, status.kind)) return null;
const label = KINDS[status.kind].label.toLowerCase();
return status.source === 'squawk' && status.squawk
? `broadcasting squawk ${status.squawk} (${label})`
: `ADS-B reports ${label}`;
}
/**
* True when the aircraft is broadcasting an emergency (not a priority status).
* @param {{severity?: string}|null|undefined} status
* @returns {boolean}
*/
export function isEmergency(status) {
return status?.severity === 'emergency';
}
+95
View File
@@ -0,0 +1,95 @@
// src/data/aircraftEmergency.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
aircraftEmergency,
emergencyBroadcastPhrase,
isEmergency,
normalizeSquawk,
} from './aircraftEmergency.js';
test('the three ICAO emergency squawks map to their kinds', () => {
assert.equal(aircraftEmergency({ squawk: '7500' }).kind, 'unlawful');
assert.equal(aircraftEmergency({ squawk: '7600' }).kind, 'nordo');
const general = aircraftEmergency({ squawk: ' 7700 ' });
assert.deepEqual(general, {
kind: 'general',
label: 'General emergency',
severity: 'emergency',
squawk: '7700',
source: 'squawk',
});
});
test('ordinary codes, blanks and non-octal values are not emergencies', () => {
for (const squawk of ['1200', '7000', '2000', '', null, undefined, '7800'])
assert.equal(aircraftEmergency({ squawk }), null, String(squawk));
assert.equal(aircraftEmergency(), null);
assert.equal(normalizeSquawk('7800'), null);
assert.equal(normalizeSquawk(7700), '7700');
assert.equal(normalizeSquawk('770'), null);
});
test('the ADS-B emergency field wins, and `none` defers to the squawk', () => {
const minfuel = aircraftEmergency({ squawk: '4521', emergency: 'MinFuel' });
assert.equal(minfuel.kind, 'minfuel');
assert.equal(minfuel.severity, 'priority');
assert.equal(minfuel.source, 'ads-b');
assert.equal(minfuel.squawk, '4521');
assert.equal(
aircraftEmergency({ squawk: '7700', emergency: 'none' }).source,
'squawk',
);
assert.equal(
aircraftEmergency({ squawk: '7700', emergency: 'general' }).source,
'squawk',
'a code that names the status stays quotable',
);
assert.equal(
aircraftEmergency({ squawk: '1200', emergency: 'general' }).source,
'ads-b',
);
assert.equal(aircraftEmergency({ emergency: 'downed' }).kind, 'downed');
assert.equal(aircraftEmergency({ emergency: 'none' }), null);
assert.equal(aircraftEmergency({ emergency: 'reserved' }), null);
});
test('priority statuses are reported but are not emergencies', () => {
assert.equal(isEmergency(aircraftEmergency({ squawk: '7700' })), true);
assert.equal(
isEmergency(aircraftEmergency({ emergency: 'lifeguard' })),
false,
);
assert.equal(isEmergency(aircraftEmergency({ emergency: 'minfuel' })), false);
assert.equal(isEmergency(null), false);
});
test('the broadcast phrase quotes a naming code, else attributes ADS-B', () => {
assert.equal(
emergencyBroadcastPhrase(aircraftEmergency({ squawk: '7700' })),
'broadcasting squawk 7700 (general emergency)',
);
assert.equal(
emergencyBroadcastPhrase(
aircraftEmergency({ squawk: '1200', emergency: 'general' }),
),
'ADS-B reports general emergency',
);
assert.equal(
emergencyBroadcastPhrase(aircraftEmergency({ emergency: 'lifeguard' })),
'ADS-B reports lifeguard (medical priority)',
);
assert.equal(emergencyBroadcastPhrase(null), null);
assert.equal(emergencyBroadcastPhrase({ kind: 'reserved' }), null);
});
test('an emergency squawk outranks an ADS-B priority', () => {
assert.equal(
aircraftEmergency({ squawk: '7700', emergency: 'lifeguard' }).kind,
'general',
);
assert.equal(
aircraftEmergency({ squawk: '7500', emergency: 'minfuel' }).kind,
'unlawful',
);
});
+524
View File
@@ -0,0 +1,524 @@
import * as Cesium from 'cesium';
import { TRACKED_ICON_COLOR } from '../layers/military/policy.js';
import { createRendering as civilRendering } from '../layers/flights/rendering.js';
import { createRendering as militaryRendering } from '../layers/military/rendering.js';
import { createMilitarySnapshotRenderer } from '../layers/military/snapshotRenderer.js';
import { aircraftEmergency } from './aircraftEmergency.js';
// Source-to-voice provenance, status retention/clearing, contact-time contracts,
// and civil/military billboard, Cockpit and model tint regressions.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { openSkySnapshot, readsbSnapshot } from '../sources/live/index.js';
import { FlightRecords } from '../layers/flights/records.js';
import { MilitaryFlightRecords } from '../layers/military/records.js';
import { mapAnalystRecord as mapFlight } from './flights.js';
import { mapAnalystRecord as mapMilitary } from './militaryFlights.js';
import { createAnalystEngine } from './analystEngine.js';
import { normalizeAdsbLolPointResponse } from './adsbLolFallback.js';
import { createGevActionRunner } from '../voice/gevActions.js';
const NOW_MS = 1800000000000;
test('fallback status belongs to the first valid admitted aircraft row', () => {
const valid = { hex: 'a1b2c3', lat: 30, lon: -97, squawk: '1200' };
for (const status of [undefined, 'general', 'none']) {
const payload = normalizeAdsbLolPointResponse({
now: NOW_MS,
ac: [
{ ...valid, lat: 100, emergency: 'general' },
{ ...valid, emergency: status },
{ ...valid, lat: null, emergency: 'general' },
{ ...valid, emergency: status === 'general' ? 'none' : 'general' },
],
});
const snapshot = openSkySnapshot(payload, { now: NOW_MS });
assert.equal(snapshot.records.length, 1);
assert.equal(snapshot.records[0].emergency, status);
assert.equal(snapshot.records[0].latitude, 30);
}
});
function civilStore() {
return new FlightRecords({
geoidHeight: () => 0,
cachedGroundFloor: () => null,
floorAltitudeM: (value) => value,
approxDistanceKm: () => 0,
});
}
const receiveContext = {
viewerLatDeg: null,
viewerLonDeg: null,
trackedId: null,
floorWarmPoints: [],
};
test('real civil fallback preserves ADS-B emergency and priority, then clears', () => {
const store = civilStore();
const aircraft = {
hex: 'a1b2c3',
flight: 'TEST1',
lat: 30,
lon: -97,
alt_baro: 10000,
gs: 150,
track: 90,
squawk: '1200',
seen: 3,
seen_pos: 1,
};
for (const [status, expectedKind, flagged] of [
['general', 'general', true],
['minfuel', 'minfuel', false],
['none', null, false],
]) {
const payload = normalizeAdsbLolPointResponse({
now: NOW_MS,
ac: [{ ...aircraft, emergency: status }],
});
assert.equal(
payload.states[0].length,
18,
'fallback vectors retain the OpenSky shape',
);
const [observation] = openSkySnapshot(payload, { now: NOW_MS }).records;
const result = mapFlight(
aircraft.hex,
store.receive(observation, receiveContext).meta,
);
assert.equal(result.emergency, flagged);
assert.equal(result.emergencyKind, expectedKind);
assert.equal(result.emergencySource, expectedKind ? 'ads-b' : null);
assert.equal(result.lastContactAtMs, NOW_MS - 3000);
}
});
test('changed status without contact time retains general contact, including voice projection', async () => {
const store = civilStore();
const [observation] = readsbSnapshot(
{
ac: [
{
hex: 'a1b2c3',
flight: 'TEST1',
lat: 30,
lon: -97,
alt_baro: 10000,
gs: 150,
track: 90,
squawk: '1200',
emergency: 'general',
seen: 3,
seen_pos: 1,
},
],
},
{ observedAtMs: NOW_MS, now: NOW_MS },
).records;
store.receive(observation, receiveContext);
const meta = store.receive(
{ ...observation, contactTimeMs: null, squawk: '7600', emergency: 'none' },
receiveContext,
).meta;
const record = mapFlight(observation.id, meta);
assert.equal(record.emergencyKind, 'nordo');
assert.equal(record.lastContactAtMs, NOW_MS - 3000);
assert.equal(Object.hasOwn(record, 'observedAtMs'), false);
globalThis.window = globalThis.window || {
clearTimeout,
setTimeout,
requestIdleCallback: null,
};
const flights = {
id: 'flights',
source: 'fixture',
getStats: () => ({ count: 1 }),
getAnalystRecords: () => [record],
};
const runner = createGevActionRunner({
viewer: {
clock: { onTick: { addEventListener: () => () => {} } },
scene: { canvas: { addEventListener() {}, removeEventListener() {} } },
camera: {
moveEnd: { addEventListener() {} },
positionCartographic: {
height: 300000,
latitude: 0.52,
longitude: -1.71,
},
},
},
styleManager: {
getContextModeState: () => ({ mode: null, active: false }),
getCockpitState: () => ({ active: false }),
},
dataManager: {
layers: new Map([['flights', { module: flights }]]),
isEnabled: (id) => id === 'flights',
getAll: () => [
{ id: 'flights', name: 'Flights', enabled: true, stats: { count: 1 } },
],
},
});
try {
const result = await runner('analyst_query', {
layers: ['flights'],
scope: { kind: 'anywhere' },
filters: [{ field: 'emergency', op: 'eq', value: true }],
});
assert.equal(result.ok, true);
assert.equal(result.items[0].emergency, true);
assert.equal(result.items[0].lastContactAtMs, NOW_MS - 3000);
assert.equal(result.items[0].emergencySource, 'squawk');
assert.equal(
result.items[0].emergencyBroadcast,
'broadcasting squawk 7600 (radio failure)',
);
assert.equal(
Object.hasOwn(result.items[0], 'time'),
false,
'general contact is not a generic incident timestamp',
);
} finally {
runner.dispose?.();
}
});
// OpenSky /states/all: [icao24, callsign, origin_country, time_position,
// last_contact, lon, lat, baro_alt, on_ground, velocity, true_track,
// vertical_rate, sensors, geo_alt, squawk, spi, position_source, category].
const OPENSKY = {
time: NOW_MS / 1000,
states: [
[
'a1b2c3',
'SWA696 ',
'United States',
NOW_MS / 1000 - 6,
NOW_MS / 1000 - 3,
-97.67,
30.19,
3000,
false,
150,
180,
-5,
null,
3040,
'7700',
false,
0,
4,
],
],
};
// adsb.lol /v2/mil: readsb aircraft with `seen`/`seen_pos` ages in seconds.
const ADSB_LOL = {
ac: [
{
hex: 'ae01ce',
flight: 'RCH123 ',
t: 'C17',
lat: 31.1,
lon: -97.4,
alt_baro: 18000,
gs: 300,
track: 90,
squawk: '1200',
emergency: 'general',
seen: 1.5,
seen_pos: 2,
},
],
};
function civilAnalystRecord() {
const [row] = openSkySnapshot(OPENSKY, { now: NOW_MS }).records;
const store = new FlightRecords({
geoidHeight: () => 0,
cachedGroundFloor: () => null,
floorAltitudeM: (altitude) => altitude,
approxDistanceKm: () => 0,
});
const { meta } = store.receive(row, {
viewerLatDeg: null,
viewerLonDeg: null,
trackedId: null,
floorWarmPoints: [],
});
return mapFlight(row.id, meta);
}
function militaryAnalystRecord() {
const [row] = readsbSnapshot(ADSB_LOL, {
observedAtMs: NOW_MS,
now: NOW_MS,
}).records;
const store = new MilitaryFlightRecords({
geoidHeight: () => 0,
cachedGroundFloor: () => null,
floorAltitudeM: (altitude) => altitude,
});
const { meta } = store.receive(row, {
observedAtMs: NOW_MS,
floorWarmPoints: [],
modelOwnsVisual: false,
});
return mapMilitary(row.id, meta);
}
test('OpenSky 7700 and adsb.lol emergency=general differ only in provenance', () => {
const civil = civilAnalystRecord();
const military = militaryAnalystRecord();
assert.equal(civil.emergency, true);
assert.equal(civil.emergencyKind, 'general');
assert.equal(civil.squawk, '7700');
assert.equal(civil.emergencySource, 'squawk');
assert.equal(
civil.emergencyBroadcast,
'broadcasting squawk 7700 (general emergency)',
);
assert.equal(civil.lastContactAtMs, NOW_MS - 3000, 'OpenSky last_contact');
assert.equal(military.emergency, true);
assert.equal(military.emergencyKind, 'general');
assert.equal(military.squawk, '1200', 'the code is not an emergency code');
assert.equal(military.emergencySource, 'ads-b');
assert.equal(military.emergencyBroadcast, 'ADS-B reports general emergency');
assert.equal(military.lastContactAtMs, NOW_MS - 1500, 'snapshot time - seen');
});
test('the analyst can filter emergencies by source', async () => {
const records = {
flights: [civilAnalystRecord()],
military: [militaryAnalystRecord()],
};
const engine = createAnalystEngine({
getRecords: (key) => records[key] || [],
resolveRegionRing: async () => null,
getViewContext: () => ({ lat: 30.5, lon: -97.5, viewRadiusKm: 500 }),
});
const query = (value) =>
engine.query({
layers: ['flights', 'military'],
scope: { kind: 'anywhere' },
filters: [
{ field: 'emergency', op: 'eq', value: true },
{ field: 'emergencySource', op: 'eq', value },
],
});
const bySquawk = await query('squawk');
assert.deepEqual(
bySquawk.items.map((item) => [item.icao24, item.emergencyBroadcast]),
[['a1b2c3', 'broadcasting squawk 7700 (general emergency)']],
);
const byAdsb = await query('ads-b');
assert.deepEqual(
byAdsb.items.map((item) => [item.icao24, item.emergencyBroadcast]),
[['ae01ce', 'ADS-B reports general emergency']],
);
});
test('military status changes retain only the latest known general contact', () => {
const store = new MilitaryFlightRecords({
geoidHeight: () => 0,
cachedGroundFloor: () => null,
floorAltitudeM: (v) => v,
});
const [observation] = readsbSnapshot(ADSB_LOL, {
observedAtMs: NOW_MS,
now: NOW_MS,
}).records;
const context = {
observedAtMs: NOW_MS,
floorWarmPoints: [],
modelOwnsVisual: false,
};
const unknown = mapMilitary(
observation.id,
store.receive({ ...observation, contactTimeMs: null }, context).meta,
);
assert.equal(unknown.lastContactAtMs, null);
store.receive(observation, context);
const changed = mapMilitary(
observation.id,
store.receive(
{
...observation,
contactTimeMs: null,
squawk: '7600',
emergency: 'none',
},
context,
).meta,
);
assert.equal(changed.emergencyKind, 'nordo');
assert.equal(changed.lastContactAtMs, NOW_MS - 1500);
});
test('fallback missing contact age retains known contact through the real converter', () => {
const store = civilStore();
const row = {
hex: 'a1b2c3',
lat: 30,
lon: -97,
squawk: '1200',
emergency: 'general',
seen: 3,
seen_pos: 1,
};
const receive = (row) =>
store.receive(
openSkySnapshot(
normalizeAdsbLolPointResponse({ now: NOW_MS, ac: [row] }),
{ now: NOW_MS },
).records[0],
receiveContext,
).meta;
const first = receive(row);
const second = receive({ ...row, seen: undefined, seen_pos: undefined });
assert.equal(second.lastContactEpochMs, first.lastContactEpochMs);
});
test('one omitted ADS-B status poll is retained; explicit none and changed squawk clear immediately', () => {
const store = civilStore();
const row = {
hex: 'a1b2c3',
lat: 30,
lon: -97,
squawk: '1200',
emergency: 'general',
seen: 3,
};
const receive = (row) =>
store.receive(
openSkySnapshot(
normalizeAdsbLolPointResponse({ now: NOW_MS, ac: [row] }),
{ now: NOW_MS },
).records[0],
receiveContext,
).meta;
assert.equal(receive(row).emergency.kind, 'general');
assert.equal(
receive({ ...row, emergency: undefined }).emergency?.kind,
'general',
);
assert.equal(receive({ ...row, emergency: undefined }).emergency, null);
receive(row);
assert.equal(receive({ ...row, emergency: 'none' }).emergency, null);
receive(row);
assert.equal(
receive({ ...row, emergency: undefined, squawk: '2000' }).emergency,
null,
);
});
function renderHarness(factory) {
const flightState = { records: { data: new Map() }, _trackedIcao: null };
const rendering = factory({
flightState,
services: {
militaryRegistry: { isMilitaryIcao: (id) => id === 'mil' },
aircraftPresentation: {},
camera: {},
focus: {},
recession: {},
},
parts: {},
});
return { flightState, rendering };
}
test('tint priorities cover normal, priority, emergency, tracking and Cockpit dots', () => {
for (const factory of [civilRendering, militaryRendering]) {
const { flightState, rendering } = renderHarness(factory);
const normal = rendering._fleetBillboardColor('civil', {});
assert.equal(
rendering._fleetBillboardColor('civil', {
emergency: aircraftEmergency({ emergency: 'minfuel' }),
}),
normal,
);
const red = rendering._fleetBillboardColor('civil', {
emergency: aircraftEmergency({ squawk: '7700' }),
});
assert.ok(red.red > 0.9 && red.green < 0.5);
flightState.records.data.set('civil', {
emergency: aircraftEmergency({ squawk: '7700' }),
});
assert.equal(rendering._modelColor('civil'), red);
if (rendering._cockpitContactColor)
assert.equal(rendering._cockpitContactColor('civil'), red);
flightState._trackedIcao = 'civil';
assert.equal(
rendering._modelColor('civil'),
factory === civilRendering ? Cesium.Color.CYAN : TRACKED_ICON_COLOR,
);
flightState._trackedIcao = null;
flightState.records.data.set('civil', { emergency: null });
assert.equal(rendering._modelColor('civil'), normal);
}
});
test('GERMAN: an existing untracked military billboard repaints each poll without alpha loss', () => {
const { flightState, rendering } = renderHarness(militaryRendering);
const bb = {
color: rendering._fleetBillboardColor('abc123').withAlpha(0.65),
};
Object.assign(flightState, {
_billboards: new Map([['abc123', bb]]),
_models: new Map(),
_positionHistory: new Map(),
_cullPositions: new Map(),
_groundSnap: { forget() {} },
_cockpitContactMode: false,
});
const records = new MilitaryFlightRecords({
geoidHeight: () => 0,
cachedGroundFloor: () => null,
floorAltitudeM: (value) => value,
});
flightState.records = records;
const apply = createMilitarySnapshotRenderer({
flightState,
records,
groundFloor: { warmGroundFloor() {}, cachedGroundFloor: () => null },
meshFloor: { sampleMeshFloorCells() {} },
militaryRegistry: { registerMilitaryIcaos() {} },
rendering: {
...rendering,
_syncModelToClass() {},
_applyFleetBillboardPresentation() {},
},
motion: {},
tracking: {},
queries: {},
});
for (const [emergency, red] of [
['general', true],
['none', false],
['general', true],
]) {
const snapshot = readsbSnapshot(
{
ac: [
{
hex: 'abc123',
lat: 30,
lon: -97,
alt_baro: 10000,
squawk: '1200',
emergency,
seen_pos: 1,
},
],
},
{ observedAtMs: NOW_MS },
);
apply(snapshot, {});
assert.equal(bb.color.green < 0.5, red);
assert.equal(bb.color.alpha, 0.65);
}
});
+17
View File
@@ -0,0 +1,17 @@
/**
* Lighting options shared by every aircraft 3D model (Flights, Military
* Flights, Local ADS-B).
*
* Cesium gives each Model a dynamic environment map: it renders the sky around
* the model and reads the result back from the GPU (`readPixels`) to light it,
* when the model is first placed and again when it moves more than 1 km, when
* the scene's atmosphere settings change, or every scene hour under sunlight
* lighting. Aircraft models take 94% of their surface from the class colour
* (`colorBlendAmount`), so that lighting contributes little; without it they
* are lit from Cesium's default spherical harmonics. Measured 2026-10-24
* (Intel Arc, Google 3D, ~150 models in the scene): GPU per frame at rest
* 14.3-14.8 -> 12.1-12.5 ms.
*/
export const AIRCRAFT_ENVIRONMENT_MAP_OPTIONS = Object.freeze({
enabled: false,
});
+51
View File
@@ -0,0 +1,51 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from './aircraftModelLighting.js';
/**
* Every aircraft model drawn in the scene skips Cesium's dynamic environment
* map (a sky render and GPU readback per model). The preload calls only warm
* the glTF cache and are never added to the scene, so they are left out:
* drawn models are the calls that set a minimum pixel size.
*/
const SOURCES = [
{
name: 'flights',
source: readLayerSource(new URL('./flights.js', import.meta.url)),
},
{
name: 'militaryFlights',
source: readLayerSource(new URL('./militaryFlights.js', import.meta.url)),
},
{
name: 'localAdsb',
source: readFileSync(
new URL('../layers/localAdsb/models.js', import.meta.url),
'utf8',
),
},
];
test('the shared option turns the dynamic environment map off', () => {
assert.deepEqual({ ...AIRCRAFT_ENVIRONMENT_MAP_OPTIONS }, { enabled: false });
assert.ok(Object.isFrozen(AIRCRAFT_ENVIRONMENT_MAP_OPTIONS));
});
for (const { name, source } of SOURCES) {
test(`${name}: every drawn aircraft model skips the dynamic environment map`, () => {
const calls = [
...source.matchAll(/(?:fromGltfAsync|loadModel)\(\{([\s\S]*?)\}\)/g),
]
.map((match) => match[1])
.filter((options) => /\bminimumPixelSize:/.test(options));
assert.ok(calls.length >= 1, `expected drawn model calls in ${name}`);
for (const [index, options] of calls.entries())
assert.match(
options,
/\benvironmentMapOptions:\s*AIRCRAFT_ENVIRONMENT_MAP_OPTIONS\b/,
`${name} model call ${index + 1} must pass AIRCRAFT_ENVIRONMENT_MAP_OPTIONS`,
);
});
}
+5 -2
View File
@@ -242,7 +242,7 @@ export function applyAircraftBillboardTreatment({
}
/**
* Apply the already-composed aircraft alpha to an ambient glTF model without
* Apply layer-owned hue and composed aircraft alpha to an ambient glTF model without
* disturbing Cesium's existing MIX/colorBlendAmount presentation semantics.
* @param {object} input
* @param {object} input.model
@@ -261,7 +261,10 @@ export function applyAircraftModelTreatment({
if (!model || !baseColor || !Number.isFinite(alpha)) return 0;
if (
Number.isFinite(model.color?.alpha) &&
Math.abs(model.color.alpha - alpha) <= tuning.writeEpsilon
Math.abs(model.color.alpha - alpha) <= tuning.writeEpsilon &&
model.color.red === baseColor.red &&
model.color.green === baseColor.green &&
model.color.blue === baseColor.blue
)
return 0;
model.color = baseColor.withAlpha(alpha);
+12
View File
@@ -142,3 +142,15 @@ test('ambient model presentation receives composed alpha without changing blend
assert.equal(model.colorBlendAmount, 0.9);
assert.equal(applyAircraftModelTreatment({ model, baseColor: color, alpha: 0.2, params }), 0);
});
test('model hue changes at constant alpha, then clears without redundant writes', () => {
const color = (red,green,blue) => ({red,green,blue,withAlpha(alpha){return {red,green,blue,alpha};}});
const model = {color:color(1,1,1).withAlpha(0.65)};
assert.equal(applyAircraftModelTreatment({model,baseColor:color(1,0.2,0.2),alpha:0.65}),1);
assert.equal(model.color.green,0.2);
assert.equal(model.color.alpha,0.65);
assert.equal(applyAircraftModelTreatment({model,baseColor:color(1,0.2,0.2),alpha:0.65}),0);
assert.equal(applyAircraftModelTreatment({model,baseColor:color(1,1,1),alpha:0.65}),1);
assert.equal(model.color.green,1);
});
+1 -1
View File
@@ -28,7 +28,7 @@ test('ais analyst record: full record maps every contract field', () => {
courseDeg: 214.0,
shipType: 'Cargo',
destination: 'OAKLAND',
navStatus: null, // /api/ais-live does not surface NavigationalStatus
navStatus: null, // /api/vessels does not surface NavigationalStatus
});
});
+6 -3
View File
@@ -1,10 +1,13 @@
import { createApplicationVessels } from '../app/layers/aisLiveVessels.js';
import { createAisStreamSource } from '../sources/live/standalone.js';
import { createVesselSource } from '../sources/live/standalone.js';
const aisLiveVesselsLayer = createApplicationVessels({
source: createAisStreamSource({
apiUrl: import.meta.env?.VITE_AIS_LIVE_API_URL || '/api/ais-live',
source: createVesselSource({
apiUrl: import.meta.env?.VITE_AIS_LIVE_API_URL || '/api/vessels',
// Resolved against the document's address, which a panel host may serve
// from its own scheme.
origin: () => globalThis.document?.baseURI ?? 'http://localhost',
}),
options: {
maxRows: import.meta.env?.VITE_AIS_LIVE_MAX_ROWS,
+115 -20
View File
@@ -35,6 +35,10 @@ import { ensureGeoidReady, geoidHeight } from './geoid.js';
import { registerPickOwner, unregisterPickOwner } from './pickRegistry.js';
import { applyVesselOverlayPolicy } from './vesselLabels.js';
import { layerFeedState } from './manager.js';
import {
presentSelectedVessel,
subscribeSelectedVessel,
} from '../hudReadouts.js';
test('open feed with vessels is healthy (null)', () => {
assert.equal(deriveAisFeedError({ status: 'open', lastMessageAt: 1, error: null }, 42), null);
@@ -856,15 +860,6 @@ function makeTrailSpy() {
};
}
function makeClassList(...initial) {
const classes = new Set(initial);
return {
add(value) { classes.add(value); },
remove(value) { classes.delete(value); },
contains(value) { return classes.has(value); },
};
}
function makeInteractionHandler() {
return {
click: null,
@@ -934,14 +929,20 @@ function installWireHarness(picked, stateOverrides = {}) {
};
const record = makeRecord();
const trail = makeTrailSpy();
presentSelectedVessel({ text: `AIS: ${record.name}` });
let vesselReadout;
const stopReadout = subscribeSelectedVessel(
(readout) => (vesselReadout = readout),
);
const hud = {
textContent: `AIS: ${record.name}`,
classList: makeClassList('active'),
get readout() {
return vesselReadout;
},
};
globalThis.window = windowTarget;
globalThis.document = {
getElementById: (id) => (id === 'hud-ais-vessel' ? hud : null),
getElementById: () => null,
};
_setVesselStateForTest({
records: [record],
@@ -966,6 +967,7 @@ function installWireHarness(picked, stateOverrides = {}) {
reinstalledHandlers,
interactionHandlerFactory,
cleanup() {
stopReadout();
aisLiveVesselsLayer.destroy();
if (hadWindow) globalThis.window = priorWindow;
else delete globalThis.window;
@@ -1011,6 +1013,34 @@ test('vessel interaction wire: flights-owned pick preserves vessel selection', (
}
});
test('vessel touch interaction yields to the first nearby flight behind map tiles', () => {
const previous = Object.getOwnPropertyDescriptor(globalThis, 'matchMedia');
Object.defineProperty(globalThis, 'matchMedia', {
configurable: true,
value: () => ({ matches: true }),
});
registerPickOwner('flights', (pickedId) => pickedId === 'a1b2c3');
const tile = { primitive: { isCesium3DTileset: true } };
const harness = installWireHarness(tile);
let drills = 0;
harness.viewer.scene.drillPick = (_position, limit, width, height) => {
drills++;
assert.deepEqual([limit, width, height], [8, 14, 14]);
return [tile, { id: 'a1b2c3' }];
};
try {
harness.handler.click({ position: { x: 10, y: 20 } });
assert.equal(drills, 1);
assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.mmsi, harness.record.mmsi);
assert.equal(harness.trail.clearCalls, 0);
} finally {
harness.cleanup();
unregisterPickOwner('flights');
if (previous) Object.defineProperty(globalThis, 'matchMedia', previous);
else delete globalThis.matchMedia;
}
});
test('vessel interaction wire: CCTV-owned pick preserves vessel selection', () => {
registerPickOwner('cctv', (pickedId) => pickedId === 'atx-cam-3');
const harness = installWireHarness({ id: 'atx-cam-3' });
@@ -1063,8 +1093,7 @@ test('vessel interaction wire: id-less 3D Tiles pick deselects and resets the HU
harness.handler.click({ position: { x: 10, y: 20 } });
assert.equal(aisLiveVesselsLayer.getSelectedInfo(), null);
assert.equal(harness.hud.textContent, 'AIS: --');
assert.equal(harness.hud.classList.contains('active'), false);
assert.equal(harness.hud.readout, null);
// Clicking away is a deliberate deselect, not the vessel aging out of the
// feed. Consumers that keep a readout on screen (the Cockpit Contact
// panel) tear down on 'deliberate' and hold last-known on 'evicted'.
@@ -1091,8 +1120,7 @@ test('vessel interaction wire: empty pick deselects and emits gev:entity-selecti
harness.handler.click({ position: { x: 10, y: 20 } });
assert.equal(aisLiveVesselsLayer.getSelectedInfo(), null);
assert.equal(harness.hud.textContent, 'AIS: --');
assert.equal(harness.hud.classList.contains('active'), false);
assert.equal(harness.hud.readout, null);
// Empty pick is a deliberate deselect — see the note above.
assert.deepEqual(cleared, [{ layerId: 'ais-live-vessels', reason: 'deliberate' }]);
} finally {
@@ -1105,8 +1133,7 @@ test('vessel interaction wire: Escape deselects the selected vessel', () => {
try {
harness.keyTarget.dispatch({ key: 'Escape' });
assert.equal(aisLiveVesselsLayer.getSelectedInfo(), null);
assert.equal(harness.hud.textContent, 'AIS: --');
assert.equal(harness.hud.classList.contains('active'), false);
assert.equal(harness.hud.readout, null);
} finally {
harness.cleanup();
}
@@ -1294,6 +1321,27 @@ test('buildVesselCard: heading falls back to course; missing parts are omitted',
assert.deepEqual(bare.details, []);
});
test('vessel cards read a course that rounds to north as 0°, not 360°', () => {
const card = (course) => buildVesselCard(makeRecord({ heading: null, course })).details[0];
assert.equal(card(359.4), 'CONTAINER SHIP · 14.5KT · 359°');
assert.equal(card(359.7), 'CONTAINER SHIP · 14.5KT · 0°');
assert.equal(
buildSelectedVesselCard(makeRecord({ heading: null, course: 359.7 })).details[0],
'CONTAINER SHIP · 14.5KT · 0°',
);
});
test('the vessel HUD reads a course that rounds to north as HDG 0DEG', () => {
const harness = installWireHarness(undefined, { selectedRecord: null });
try {
Object.assign(harness.record, { heading: null, course: 359.7 });
assert.equal(aisLiveVesselsLayer.selectById(harness.record.mmsi), true);
assert.match(harness.hud.readout.text, /HDG: 0DEG/);
} finally {
harness.cleanup();
}
});
test('buildVesselCard: unnamed vessels title as MMSI; long names truncate', () => {
const unnamed = buildVesselCard(makeRecord({ name: 'VESSEL' }));
assert.equal(unnamed.title, 'MMSI 353136000');
@@ -1668,7 +1716,7 @@ test('a vessel analyst record carries the MMSI the tracker keys on', () => {
});
test('vessel selection passes the opaque source reference to optional history', async () => {
const { createAisStreamSource } = await import('../sources/live/standalone.js');
const { createVesselSource } = await import('../sources/live/standalone.js');
_setVesselStateForTest({ enabled: false });
const requests = [];
aisLiveVesselsLayer.setSource({
@@ -1688,6 +1736,53 @@ test('vessel selection passes the opaque source reference to optional history',
} finally {
harness.cleanup();
_setVesselStateForTest({ enabled: false });
aisLiveVesselsLayer.setSource(createAisStreamSource());
aisLiveVesselsLayer.setSource(createVesselSource());
}
});
test('vessel focus wire skips hidden sprites at rest while a target is tracked', () => {
const makeColor = (alpha) => ({ alpha, withAlpha: (next) => makeColor(next) });
const sprite = (show) => ({
position: { x: 1, y: 2, z: 3 },
show,
color: makeColor(1),
});
const visible = sprite(true);
const hidden = Array.from({ length: 50 }, () => sprite(false));
const records = [visible, ...hidden].map((billboard) => ({
position: billboard.position,
billboard,
}));
const target = {
screenRect: { left: 40, top: 40, right: 60, bottom: 60 },
paddingPx: 0,
cameraDistance: 1000,
};
let projected = 0;
let measured = 0;
const apply = (nowMs) =>
applyVesselFocusDeemphasis({
records,
target,
nowMs,
screenPositionFor: () => {
projected += 1;
return { x: 50, y: 50 };
},
cameraDistanceFor: () => {
measured += 1;
return 1200;
},
params: { paddingPx: 0, dimFloor: 0.25, attackMs: 300 },
});
apply(0);
const result = apply(300);
// Only the on-screen sprite pays for projection and distance.
assert.equal(projected, 2);
assert.equal(measured, 2);
assert.equal(visible.color.alpha, 0.25);
assert.equal(result.activeCount, 1);
for (const billboard of hidden) assert.equal(billboard.color.alpha, 1);
});
+1 -1
View File
@@ -215,7 +215,7 @@ export const AIS_RECOGNIZED_MESSAGE_TYPES = Object.freeze(
* @param {Object} body
* @returns {string|null}
*/
export function aisEnvelopeMmsi(envelope, body = {}) {
function aisEnvelopeMmsi(envelope, body = {}) {
const metadata = envelope?.MetaData || envelope?.Metadata || {};
const raw = metadata.MMSI ?? body.UserID ?? body.UserId ?? body.Mmsi;
if (raw === null || raw === undefined) return null;
+195
View File
@@ -1,8 +1,10 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
AISSTREAM_STALE_MS,
aisStreamRows,
ingestAisStreamEnvelope,
readAisTrack,
} from '../../server/providers/vessels/ais-store.js';
// ITU-R M.1371 encodes speed over ground in 0.1-knot units and course over
@@ -63,3 +65,196 @@ test('the fastest encodable reading below the unavailable code is kept', () => {
assert.equal(row.speed, 102.2);
assert.equal(row.course, 359.9);
});
test('a static data report keeps the motion and fix time of a tracked vessel', () => {
ingestAisStreamEnvelope(
positionReport('244690000', { Sog: 12.4, Cog: 86.7, TrueHeading: 87 }),
);
// AISStream's MetaData carries the last known position on every message,
// static data included.
assert.equal(
ingestAisStreamEnvelope({
MessageType: 'ShipStaticData',
Message: {
ShipStaticData: {
UserID: 244690000,
Name: 'EXAMPLE VESSEL',
Type: 70,
Destination: 'ROTTERDAM',
ImoNumber: 9876543,
},
},
MetaData: {
MMSI: '244690000',
latitude: 53.40879,
longitude: 6.19741,
time_utc: '2026-09-17 20:05:00.000000000 +0000 UTC',
},
}),
true,
);
const row = rowFor('244690000');
assert.equal(row.speed, 12.4);
assert.equal(row.course, 86.7);
assert.equal(row.heading, 87);
assert.equal(row.last_position_UTC, '2026-09-17T20:00:00.000Z');
assert.equal(row.destination, 'ROTTERDAM');
assert.equal(row.imo, '9876543');
});
// AISStream carries both Class B parts; the unused part has Valid: false.
const classBStatic = (part, value) => ({
MessageID: 24,
RepeatIndicator: 0,
Valid: true,
Reserved: 0,
PartNumber: part === 'B',
ReportA: { Valid: part === 'A', Name: part === 'A' ? value : '' },
ReportB: {
Valid: part === 'B',
ShipType: part === 'B' ? value : 0,
VendorIDName: '',
VenderIDModel: 0,
VenderIDSerial: 0,
CallSign: '',
Dimension: { A: 0, B: 0, C: 0, D: 0 },
FixType: 0,
Spare: 0,
},
});
for (const [index, scenario] of [
{
type: 'ShipStaticData',
initial: [
{
Name: 'OLD VESSEL',
Type: 70,
Destination: 'ROTTERDAM',
ImoNumber: 9876543,
},
],
updates: [
{
Name: 'NEW VESSEL',
Type: 80,
Destination: 'HAMBURG',
ImoNumber: 9876544,
},
],
empty: { Name: ' ', Type: 0, Destination: '', ImoNumber: 0 },
expected: {
name: 'NEW VESSEL',
type: '80',
destination: 'HAMBURG',
imo: '9876544',
},
},
{
type: 'StaticDataReport',
initial: [classBStatic('A', 'OLD VESSEL'), classBStatic('B', 36)],
updates: [classBStatic('A', 'NEW VESSEL'), classBStatic('B', 37)],
empty: classBStatic('A', ''),
expected: { name: 'NEW VESSEL', type: '37', destination: '', imo: '' },
},
].entries()) {
test(`${scenario.type} replaces static fields without inventing a position fix`, (t) => {
const mmsi = String(244690100 + index);
const start = Date.parse('2026-09-17T20:00:00Z');
let now = start;
t.mock.method(Date, 'now', () => now);
const position = (
lat,
body = { Sog: 12.4, Cog: 86.7, TrueHeading: 87 },
) => {
const envelope = positionReport(mmsi, body);
Object.assign(envelope.MetaData, {
latitude: lat,
time_utc: new Date(now).toISOString(),
});
return ingestAisStreamEnvelope(envelope);
};
const sendStatic = (body) =>
ingestAisStreamEnvelope({
MessageType: scenario.type,
Message: { [scenario.type]: { UserID: Number(mmsi), ...body } },
MetaData: {
MMSI: mmsi,
ShipName: 'STALE HEADER',
latitude: 54,
longitude: 7,
time_utc: new Date(now).toISOString(),
},
});
position(53.40879);
for (const body of scenario.initial) assert.equal(sendStatic(body), true);
now += 60_000;
position(53.41879);
const before = rowFor(mmsi);
const track = readAisTrack(mmsi);
assert.equal(track.length, 2);
now += 5 * 60_000;
for (const body of scenario.updates) assert.equal(sendStatic(body), true);
assert.deepEqual(rowFor(mmsi), { ...before, ...scenario.expected });
assert.deepEqual(readAisTrack(mmsi), track);
// Empty or omitted static fields must not erase known metadata, including
// the cached values used when the next actual position arrives.
assert.equal(sendStatic(scenario.empty), true);
assert.deepEqual(rowFor(mmsi), { ...before, ...scenario.expected });
assert.deepEqual(readAisTrack(mmsi), track);
if (scenario.type === 'StaticDataReport') {
sendStatic({
...classBStatic('A', ''),
ReportA: { Valid: false, Name: 'INVALID NAME' },
ReportB: { ...classBStatic('B', 99).ReportB, Valid: false },
});
assert.deepEqual(rowFor(mmsi), { ...before, ...scenario.expected });
}
now = start + 60_000 + AISSTREAM_STALE_MS + 1;
assert.equal(
rowFor(mmsi),
undefined,
'static traffic must not renew position freshness',
);
position(53.42879, { Sog: 0, Cog: 0, TrueHeading: 0 });
const next = rowFor(mmsi);
for (const [key, value] of Object.entries(scenario.expected))
assert.equal(next[key], value, key);
assert.equal(next.speed, 0);
assert.equal(next.course, 0);
assert.equal(next.heading, 0);
assert.equal(next.last_position_epoch, Math.floor(now / 1000));
assert.equal(readAisTrack(mmsi).length, 3);
});
}
test('partial static data keeps newer live metadata ahead of the old static cache', () => {
const mmsi = '244690200';
const staticReport = (body) => ({
MessageType: 'ShipStaticData',
Message: { ShipStaticData: { UserID: Number(mmsi), ...body } },
MetaData: { MMSI: mmsi },
});
ingestAisStreamEnvelope(staticReport({ Name: 'OLD VESSEL', Type: 70 }));
const extended = positionReport(mmsi, {});
extended.MessageType = 'ExtendedClassBPositionReport';
extended.Message = {
ExtendedClassBPositionReport: {
UserID: Number(mmsi),
Type: 80,
Sog: 12,
Cog: 86,
},
};
extended.MetaData.ShipName = 'NEW VESSEL';
ingestAisStreamEnvelope(extended);
const before = rowFor(mmsi);
ingestAisStreamEnvelope(staticReport({ Destination: 'HAMBURG' }));
assert.deepEqual(rowFor(mmsi), { ...before, destination: 'HAMBURG' });
ingestAisStreamEnvelope(positionReport(mmsi, { Sog: 12, Cog: 86 }));
assert.equal(rowFor(mmsi).name, 'NEW VESSEL');
assert.equal(rowFor(mmsi).type, '80');
assert.equal(rowFor(mmsi).destination, 'HAMBURG');
});
+6 -22
View File
@@ -46,28 +46,8 @@ export const AIS_WATCHDOG_DEFAULTS = Object.freeze({
authProbeMs: 3_600_000,
});
/**
* Statuses reported to the browser. 'missing-key' and 'unsupported' keep their
* pre-watchdog meaning so a keyless install still reads as "feature off".
*/
export const AIS_WATCHDOG_STATUSES = Object.freeze([
'idle',
'missing-key',
'unsupported',
'connecting',
'live',
'stale',
'reconnecting',
'down',
'auth-failed',
]);
/** Failure classes. Only 'transport' is allowed to walk the fast ladder. */
export const AIS_FAILURE_KINDS = Object.freeze([
'transport',
'auth',
'rate-limit',
]);
const AIS_FAILURE_KINDS = Object.freeze(['transport', 'auth', 'rate-limit']);
/** Statuses that must not flip back to a hopeful 'connecting' on a retry. */
const QUIET_TERMINAL = new Set(['down', 'auth-failed']);
@@ -249,6 +229,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 +238,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 +311,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 +384,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 [];
@@ -450,7 +434,7 @@ export function createAisWatchdog(options = {}) {
return generation;
}
/** Status metadata for /api/ais-live. */
/** Status metadata for /api/vessels. */
function snapshot() {
const monoNow = clock.mono();
const wallNow = clock.wall();
Loaded 100 of 2198 files, more files were not shown because too many files have changed in this diff. Show more