Merge branch 'main' into upstream/traffic-layer-stale

This commit is contained in:
quanticsoul4772 authored and GitHub committed 2026-10-27 16:51:08 -07:00
commit eef64cfb4f
145 files changed
+17257 -457

No files matched your search

+2
View File
@@ -27,6 +27,7 @@ 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 { createApplicationStreetLevel } from './layers/streetLevel.js';
import { createInfrastructureLayers } from '../data/infrastructure.js';
@@ -126,6 +127,7 @@ export function createApplicationCatalog({
createApplicationGnssIntegrity({
source: sources['gnss-integrity'],
}),
createApplicationGhostMaps({ source: sources['s2-ghostmaps'] }),
createApplicationAlpr({ surface, source: sources.alpr }),
createApplicationStreetLevel({
surface,
+3 -2
View File
@@ -40,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, 32);
assert.equal(first.layers.length, 33);
assert.ok(first.get('local-adsb'), 'Local ADS-B is registered');
assert.deepEqual(
first.metadata.find(({ id }) => id === 'local-adsb'),
@@ -61,6 +61,7 @@ test('catalogs construct distinct layers and classification from their supplied
);
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(
@@ -154,7 +155,7 @@ test('Mapillary can be omitted or replaced without changing catalog membership',
surface: fixtureSurface(lifetime.signal),
};
const catalog = createApplicationCatalog(options);
assert.equal(catalog.layers.length, 32);
assert.equal(catalog.layers.length, 33);
assert.deepEqual(catalog.get('street-level').providerIds, []);
assert.equal(
catalogControlServices(catalog).streetLevelLayer,
+8 -1
View File
@@ -4,7 +4,11 @@ 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.
*/
export function createApplicationControls({
scene: { viewer, mapStackController, operations },
loaderStatus,
@@ -13,6 +17,7 @@ export function createApplicationControls({
catalog,
placeSearch,
defer,
initialShareCode = null,
}) {
// Initialize the style manager (post-processing, HUD, locations, share links)
const styleManager = new Controls(viewer, {
@@ -27,6 +32,8 @@ export function createApplicationControls({
requestServices: operations.requests,
mapStackController,
placeSearch,
// A share code to open at start-up instead of the page's address.
initialShareCode,
});
defer(() => styleManager.orbitController.stop());
defer(() => styleManager.hud.destroy());
+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,
});
}
+1
View File
@@ -48,6 +48,7 @@ export const CATALOG_SOURCE_CONTRACTS = Object.freeze({
'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']),
});
+65 -1
View File
@@ -37,7 +37,7 @@ test('every acquisition source can be omitted without losing catalog membership'
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, 32);
assert.equal(actual.layers.length, 33);
const manager = new LayerLifecycle(
{},
{ getSourceAvailability: actual.getSourceAvailability },
@@ -86,6 +86,70 @@ test('standalone defaults allow exact replacements and explicit removal, and rej
);
});
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(
+1 -1
View File
@@ -261,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/);
});
+9
View File
@@ -94,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\(\{/);
+8
View File
@@ -448,6 +448,14 @@ export const DATA_CREDITS = [
'(<a href="https://earthdata.nasa.gov/firms" target="_blank" rel="noopener">earthdata.nasa.gov/firms</a>), ' +
'part of NASA’s Earth Observing System Data and Information System (EOSDIS)',
},
{
key: 's2-ghostmaps',
html:
'S2 GhostMaps intel products: S2 Underground © by S2A1, ' +
'<a href="https://github.com/s2underground/GhostMaps" target="_blank" rel="noopener">github.com/s2underground/GhostMaps</a>, ' +
'licensed under <a href="https://creativecommons.org/licenses/by-nc-sa/4.0/" target="_blank" rel="noopener">CC BY-NC-SA 4.0</a>. ' +
'Shown as published; reports are S2 assessments, not verified fact.',
},
{
key: 'drivebc-cctv',
html:
+4 -2
View File
@@ -1134,7 +1134,8 @@ export function repairKmlRootNamespaces(value, what = 'KML') {
return text.slice(0, start) + root + text.slice(end);
}
function parseXml(text, what) {
/** Parse KML or GPX with the shared namespace repairs and XML validation. */
export function parseXml(text, what) {
// Match the namespace repairs Cesium applies to ordinary exported KML.
if (what !== 'GPX') text = repairKmlRootNamespaces(text, what);
const doc = new DOMParser().parseFromString(
@@ -1150,7 +1151,8 @@ function parseXml(text, what) {
return doc;
}
function decodeXml(bytes) {
/** Decode XML bytes, honouring a UTF-16 byte-order mark. */
export function decodeXml(bytes) {
const encoding =
bytes[0] === 0xff && bytes[1] === 0xfe
? 'utf-16le'
+6 -1
View File
@@ -23,7 +23,7 @@ const NEW_SINGLE_CHARACTER_TOKEN_PATTERN = /^[0-9]$/;
* out-of-grammar ID is rejected outright, because half an address is a
* DIFFERENT aircraft, not a shorter name for the same one.
*/
const TRACKING_ID_GRAMMAR = /^[0-9a-z~_-]{1,16}$/;
export const TRACKING_ID_GRAMMAR = /^[0-9a-z~_-]{1,16}$/;
/**
* Ceilings for the untrusted v2 layer fields. The layer ceiling covers the
* complete reserved one-character space plus every two-character base-36
@@ -655,6 +655,11 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
token: 'x',
disposition: 'enabled-only',
}),
Object.freeze({
id: 's2-ghostmaps',
token: '5',
disposition: 'enabled-only',
}),
Object.freeze({
id: 'satellites',
token: 's',
+145 -13
View File
@@ -202,8 +202,8 @@ function encode(state) {
test('production registry is exact, canonical, and rejects incomplete contracts', async () => {
assert.equal(validateLayerStateRegistry(), true);
assert.equal(REGISTERED_LAYER_IDS.length, 31);
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 31);
assert.equal(REGISTERED_LAYER_IDS.length, 32);
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 32);
assert.ok(REGISTERED_LAYER_IDS.includes('transit'));
assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort());
assert.deepEqual(LEGACY_LAYER_STATE_TOKENS, {
@@ -242,19 +242,19 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
for (const [id, token] of Object.entries(LEGACY_LAYER_STATE_TOKENS)) {
assert.equal(LAYER_STATE_TOKEN_RESERVATIONS[id], token);
}
assert.equal(nextLayerStateToken(), '5');
assert.equal(nextLayerStateToken(), '6');
assert.equal(
nextLayerStateToken({
...LAYER_STATE_TOKEN_RESERVATIONS,
alpha: '5',
bravo: '6',
alpha: '6',
bravo: '7',
}),
'7',
'8',
);
const digitsExhausted = {
...LAYER_STATE_TOKEN_RESERVATIONS,
...Object.fromEntries(
[...'56789'].map((digit) => [`prior-${digit}`, digit]),
[...'6789'].map((digit) => [`prior-${digit}`, digit]),
),
};
assert.equal(nextLayerStateToken(digitsExhausted), '00');
@@ -307,8 +307,8 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
assert.equal(
validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
...LAYER_STATE_TOKEN_RESERVATIONS,
future: '5',
next: '6',
future: '6',
next: '7',
}),
true,
);
@@ -318,7 +318,7 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
...LAYER_STATE_TOKEN_RESERVATIONS,
future: '00',
}),
/next free token 5/,
/next free token 6/,
);
const beforeLastDigit = { ...digitsExhausted };
delete beforeLastDigit['prior-9'];
@@ -341,10 +341,10 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
assert.throws(
() =>
validateLayerStateAllocations(
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '5' },
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '5', competing: '5' },
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '6' },
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '6', competing: '6' },
),
/next free token 6/,
/next free token 7/,
);
assert.throws(
() => validateLayerStateAllocations({ future: '00' }, { future: '01' }),
@@ -2858,3 +2858,135 @@ test('explicit provider switches reach durable state without adopting unrelated
coordinator.destroy();
}
});
function enabledLayerParam(sink) {
return new URLSearchParams(encode(sink.provider())).get('l');
}
test('Clear All drops a saved layer a context mode had already switched off', async () => {
const manager = productionManager();
const storage = memoryStorage();
const sink = shareSink();
const coordinator = new LayerStateCoordinator(manager, sink, { storage });
await coordinator.start();
await manager.setEnabled('earthquakes', true, { origin: 'user' });
await manager.setEnabled('flights', true, { origin: 'user' });
assert.deepEqual(coordinator.getDurableState().enabledLayerIds, [
'earthquakes',
'flights',
]);
// Context isolation is a temporary, passive takeover: the saved choice stays.
await manager.setEnabled('earthquakes', false, { origin: 'programmatic' });
assert.deepEqual(coordinator.getDurableState().enabledLayerIds, [
'earthquakes',
'flights',
]);
const updatesBefore = sink.updates;
const result = await manager.clearSelectedLayers({ origin: 'user' });
assert.deepEqual(result.targetIds, ['flights']);
assert.ok(sink.updates > updatesBefore, 'the URL is told to refresh');
assert.deepEqual([...manager.getEnabledLayerIds()], []);
assert.deepEqual(coordinator.getDurableState().enabledLayerIds, []);
assert.equal(enabledLayerParam(sink), '', 'the URL carries no layers');
assert.deepEqual(
parseStoredLayerState(storage.getItem(LAYER_STATE_STORAGE_KEY))
.enabledLayerIds,
[],
'a reload restores nothing',
);
coordinator.destroy();
});
test('Clear All drops a saved layer a dependency release superseded mid-batch', async () => {
let manager;
manager = productionManager({
// Contacts-style owner: its own teardown releases a borrowed dependency
// without awaiting it, which supersedes Clear All's turn for that layer.
'military-awareness': {
disable() {
void manager.setEnabled('flights', false, { origin: 'programmatic' });
return true;
},
},
});
const storage = memoryStorage();
const sink = shareSink();
const coordinator = new LayerStateCoordinator(manager, sink, { storage });
await coordinator.start();
await manager.setEnabled('earthquakes', true, { origin: 'user' });
await manager.setEnabled('flights', true, { origin: 'user' });
await manager.setEnabled('military-awareness', true, { origin: 'user' });
const result = await manager.clearSelectedLayers({ origin: 'user' });
assert.equal(
result.items.find((item) => item.id === 'flights')?.superseded,
true,
'the fixture reproduces the superseded flights turn',
);
assert.deepEqual([...manager.getEnabledLayerIds()], []);
assert.deepEqual(coordinator.getDurableState().enabledLayerIds, []);
assert.equal(enabledLayerParam(sink), '');
coordinator.destroy();
});
test('Clear All keeps a layer a newer explicit request turned back on', async () => {
const manager = productionManager();
const sink = shareSink();
const coordinator = new LayerStateCoordinator(manager, sink, {
storage: memoryStorage(),
});
await coordinator.start();
await manager.setEnabled('earthquakes', true, { origin: 'user' });
await manager.setEnabled('flights', true, { origin: 'user' });
const clearing = manager.clearSelectedLayers({ origin: 'user' });
// A direct request issued during the batch owns its layer.
const reenable = manager.setEnabled('earthquakes', true, { origin: 'user' });
await Promise.all([clearing, reenable]);
assert.deepEqual([...manager.getEnabledLayerIds()], ['earthquakes']);
assert.deepEqual(coordinator.getDurableState().enabledLayerIds, [
'earthquakes',
]);
assert.equal(enabledLayerParam(sink), 'e');
coordinator.destroy();
});
test('a passive clear never rewrites saved layers', async () => {
const manager = productionManager();
const storage = memoryStorage();
const coordinator = new LayerStateCoordinator(manager, shareSink(), {
storage,
});
await coordinator.start();
await manager.setEnabled('flights', true, { origin: 'user' });
await manager.setEnabled('flights', false, { origin: 'programmatic' });
const writes = storage.writes.length;
await manager.clearSelectedLayers({ origin: 'programmatic' });
assert.deepEqual(coordinator.getDurableState().enabledLayerIds, ['flights']);
assert.equal(storage.writes.length, writes);
coordinator.destroy();
});
test('Clear All cancels a saved restore still waiting to start', async () => {
const gate = deferred();
const manager = productionManager();
const sink = shareSink();
const coordinator = new LayerStateCoordinator(manager, sink, {
storage: memoryStorage(),
restoreGate: gate.promise,
});
const params = new URLSearchParams('v=2&l=e.f');
const restore = coordinator.start({
shareLayerState: decodeLayerStateParams(params),
});
await manager.clearSelectedLayers({ origin: 'user' });
assert.deepEqual(coordinator.getDurableState().enabledLayerIds, []);
gate.resolve();
const results = await restore;
for (const id of ['earthquakes', 'flights']) {
assert.equal(results.find((result) => result.layerId === id).succeeded, false);
}
assert.deepEqual([...manager.getEnabledLayerIds()], []);
assert.equal(enabledLayerParam(sink), '');
coordinator.destroy();
});
+67 -5
View File
@@ -148,6 +148,36 @@ export class LayerStateCoordinator {
return this.restorePromise;
}
/**
* Replace the running layer state with a share opened later in this tab
* (hash navigation). Every in-flight restore and pending shared Follow is
* superseded; the new state restores through the same passive share origin
* as start-up, so recipient-local preferences are never written. A share
* without a layer payload restores the defaults, as a reload of it would.
*/
applyShareLayerState({
shareLayerState = null,
shareCreatedAtMs = null,
} = {}) {
if (this._destroyed) return Promise.resolve([]);
for (const controller of this._restoreControllers.values())
controller.abort('superseded-by-share-navigation');
this._revokePendingTrackingWatch('superseded-by-share-navigation');
const selected = shareLayerState
? normalizeLayerState(shareLayerState)
: null;
this._source = selected ? 'share' : 'legacy-share';
this._shareCreatedAtMs =
selected && Number.isFinite(shareCreatedAtMs) ? shareCreatedAtMs : null;
this._durableState = selected || createDefaultLayerState();
this.shareLinkManager?.onLayerStateChange?.();
this._notifyDurableState();
this.restorePromise = this._restoreSelectedState(
LAYER_RESTORE_ORIGINS.share,
);
return this.restorePromise;
}
get source() {
return this._source;
}
@@ -220,6 +250,30 @@ export class LayerStateCoordinator {
this._revokePendingTrackingWatch('owner-layer-disabled');
}
if (!isExplicitLayerStateOrigin(change.origin)) return;
if (change.type === 'selection-cleared') {
// Only layers a newer request turned back on survive Clear All. Any
// other saved layer would reappear from the share link or a reload.
const stillEnabled = new Set(change.enabledLayerIds || []);
const dropped = this._durableState.enabledLayerIds.filter(
(id) => !stillEnabled.has(id),
);
if (dropped.length === 0) return;
for (const layerId of dropped) {
this._restoreControllers
.get(layerId)
?.abort('superseded-by-explicit-visibility');
}
if (dropped.some((id) => SHARE_TRACKING_RESTORE_POLICIES[id])) {
this._revokePendingTrackingWatch('superseded-by-explicit-visibility');
}
this._commitExplicit({
...this._durableState,
enabledLayerIds: this._durableState.enabledLayerIds.filter((id) =>
stillEnabled.has(id),
),
});
return;
}
if (change.type === 'visibility') {
this._restoreControllers
.get(change.layerId)
@@ -312,15 +366,19 @@ export class LayerStateCoordinator {
}
async _restoreSelectedState(origin) {
// One controller set per run: a superseded run must not clear the newer
// run's controllers when it settles.
const controllers = new Map();
for (const entry of LAYER_STATE_REGISTRY) {
this._restoreControllers.set(entry.id, new AbortController());
controllers.set(entry.id, new AbortController());
}
this._restoreControllers = controllers;
try {
await this._waitForRestoreGate();
const enabled = new Set(this._durableState.enabledLayerIds);
const settled = await Promise.allSettled(
LAYER_STATE_REGISTRY.map(async (entry) => {
const controller = this._restoreControllers.get(entry.id);
const controller = controllers.get(entry.id);
const targetEnabled = enabled.has(entry.id);
const options = layerOptionsForRestore(this._durableState, entry.id);
if (origin === LAYER_RESTORE_ORIGINS.share && options) {
@@ -370,7 +428,7 @@ export class LayerStateCoordinator {
}));
}),
);
this.lastRestoreResults = settled.map((result, index) => {
const results = settled.map((result, index) => {
if (result.status === 'fulfilled') return result.value;
const entry = LAYER_STATE_REGISTRY[index];
return {
@@ -387,9 +445,13 @@ export class LayerStateCoordinator {
succeeded: false,
};
});
return this.lastRestoreResults.map((result) => ({ ...result }));
// A run superseded by a newer share keeps the newer run's results.
if (this._restoreControllers === controllers)
this.lastRestoreResults = results;
return results.map((result) => ({ ...result }));
} finally {
this._restoreControllers.clear();
if (this._restoreControllers === controllers)
this._restoreControllers.clear();
this._notifyDurableState();
}
}
+29 -28
View File
@@ -128,7 +128,7 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
{ ...parseLayerStateTokenReservations(reservationRows) },
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
);
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 31);
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 32);
assert.deepEqual(
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
{
@@ -136,6 +136,7 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
'street-level': '0',
'gnss-integrity': '3',
'air-quality': '4',
's2-ghostmaps': '5',
},
);
assert.throws(
@@ -161,9 +162,9 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
/Duplicate layer-state token reservation/,
);
assert.equal(
parseLayerStateTokenReservations([...reservationRows, ['future', '5']])
parseLayerStateTokenReservations([...reservationRows, ['future', '6']])
.future,
'5',
'6',
);
assert.throws(
() =>
@@ -181,14 +182,14 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
test('published retired digits and competing PRs advance the merge-time allocation', () => {
withPublishedBase(
{ rows: [...reservationRows, ['retired-layer', '5']] },
{ rows: [...reservationRows, ['retired-layer', '6']] },
(cwd) => {
const published = readPublishedLayerStateReservations('HEAD', cwd);
assert.equal(nextLayerStateToken(published), '6');
assert.equal(nextLayerStateToken(published), '7');
assert.equal(
validateLayerStateAllocations(published, {
...published,
newcomer: '6',
newcomer: '7',
}),
true,
);
@@ -196,15 +197,15 @@ test('published retired digits and competing PRs advance the merge-time allocati
() =>
validateLayerStateAllocations(published, {
...published,
competing: '5',
competing: '6',
}),
/next free token 6/,
/next free token 7/,
);
assert.throws(
() =>
validateLayerStateAllocations(published, {
...LAYER_STATE_TOKEN_RESERVATIONS,
newcomer: '5',
newcomer: '6',
}),
/changed or removed: retired-layer/,
);
@@ -212,8 +213,8 @@ test('published retired digits and competing PRs advance the merge-time allocati
);
});
test('PR B manually replaces provisional 5 with 6 after PR A publishes 5', () => {
withPublishedBase({ rows: [...reservationRows, ['pr-a', '5']] }, (cwd) => {
test('PR B manually replaces provisional 6 with 7 after PR A publishes 6', () => {
withPublishedBase({ rows: [...reservationRows, ['pr-a', '6']] }, (cwd) => {
mkdirSync(path.join(cwd, 'scripts'));
const fixtureChecker = path.join(
cwd,
@@ -234,8 +235,8 @@ test('PR B manually replaces provisional 5 with 6 after PR A publishes 5', () =>
writeCodecFixture(
cwd,
[...reservationRows, ['pr-b', '5']],
[{ id: 'pr-b', token: '5' }],
[...reservationRows, ['pr-b', '6']],
[{ id: 'pr-b', token: '6' }],
);
const beforeRebase = runCheck();
assert.equal(beforeRebase.status, 1);
@@ -243,10 +244,10 @@ test('PR B manually replaces provisional 5 with 6 after PR A publishes 5', () =>
writeCodecFixture(
cwd,
[...reservationRows, ['pr-a', '5'], ['pr-b', '5']],
[...reservationRows, ['pr-a', '6'], ['pr-b', '6']],
[
{ id: 'pr-a', token: '5' },
{ id: 'pr-b', token: '5' },
{ id: 'pr-a', token: '6' },
{ id: 'pr-b', token: '6' },
],
);
const staleRebase = runCheck();
@@ -254,16 +255,16 @@ test('PR B manually replaces provisional 5 with 6 after PR A publishes 5', () =>
assert.match(staleRebase.stderr, /Duplicate layer-state token reservation/);
const published = readPublishedLayerStateReservations('HEAD', cwd);
assert.equal(nextLayerStateToken(published), '6');
const validBaselineRows = [...reservationRows, ['pr-a', '5']];
writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '5' }]);
assert.equal(nextLayerStateToken(published), '7');
const validBaselineRows = [...reservationRows, ['pr-a', '6']];
writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '6' }]);
const helper = spawnSync(
process.execPath,
[realpathSync(fixtureNext), 'pr-b'],
{ cwd, encoding: 'utf8' },
);
assert.equal(helper.status, 0, helper.stderr);
assert.match(helper.stdout, /pr-b: 6/);
assert.match(helper.stdout, /pr-b: 7/);
assert.deepEqual(
JSON.parse(readFileSync(path.join(cwd, LEDGER_PATH), 'utf8')),
validBaselineRows,
@@ -271,20 +272,20 @@ test('PR B manually replaces provisional 5 with 6 after PR A publishes 5', () =>
);
writeCodecFixture(
cwd,
[...reservationRows, ['pr-a', '5'], ['pr-b', '6']],
[...reservationRows, ['pr-a', '6'], ['pr-b', '7']],
[
{ id: 'pr-a', token: '5' },
{ id: 'pr-b', token: '6' },
{ id: 'pr-a', token: '6' },
{ id: 'pr-b', token: '7' },
],
);
const corrected = runCheck();
assert.equal(corrected.status, 0, corrected.stderr);
assert.match(corrected.stdout, /32 published, 1 new/);
assert.match(corrected.stdout, /33 published, 1 new/);
});
});
test('allocation batches cross the last digit and base-36 pair boundaries in order', () => {
const digitRows = [...'56789'].map((digit) => [`retired-${digit}`, digit]);
const digitRows = [...'6789'].map((digit) => [`retired-${digit}`, digit]);
const allDigits = parseLayerStateTokenReservations([
...reservationRows,
...digitRows,
@@ -336,7 +337,7 @@ test('allocation batches cross the last digit and base-36 pair boundaries in ord
test('an isolated valid two-character fixture round-trips an l field beyond the old 64-character cap', async () => {
const cwd = mkdtempSync(path.join(tmpdir(), 'gev-layer-token-width-'));
try {
const priorDigits = [...'56789'].map((digit) => [
const priorDigits = [...'6789'].map((digit) => [
`qa-prior-digit-${digit}`,
digit,
]);
@@ -373,7 +374,7 @@ test('an isolated valid two-character fixture round-trips an l field beyond the
const params = new URLSearchParams([['v', '2']]);
codec.encodeLayerStateParams(params, state);
assert.ok(params.get('l').length > 64, 'fixture must cross the old cap');
assert.equal(params.get('l').length, 73);
assert.equal(params.get('l').length, 75);
const restored = codec.decodeLayerStateParams(params);
assert.deepEqual(restored?.enabledLayerIds, expectedLayerIds);
assert.deepEqual(restored?.options, state.options);
@@ -391,7 +392,7 @@ test('checker reads a complete future base ledger and rejects retired-token reus
},
(cwd) => {
const published = readPublishedLayerStateReservations('HEAD', cwd);
assert.equal(Object.keys(published).length, 32);
assert.equal(Object.keys(published).length, 33);
assert.equal(published['retired-layer'], '00');
assert.throws(
() =>
@@ -20,6 +20,7 @@
["radio", "r"],
["recent-imagery", "1"],
["rocket-launches", "x"],
["s2-ghostmaps", "5"],
["satellites", "s"],
["street-level", "0"],
["telegeography-submarine-cables", "u"],
+9
View File
@@ -1650,6 +1650,15 @@ export class LayerLifecycle {
}
}
}
// Clear All owns the whole selection, not only the layers it switched off
// itself. A layer already off for a passive reason (isolated by a context
// mode, released by a dependency owner, or superseded mid-batch) gets no
// OFF of its own here, so publish the set that is still on afterwards.
this._notifyListeners({
type: 'selection-cleared',
origin,
enabledLayerIds: [...this.getEnabledLayerIds()],
});
const items = targetIds.map((id) => {
const state = this.getLayerLifecycleState(id) || {
enabled: false,
+5 -3
View File
@@ -1052,11 +1052,13 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
// Globe rotation adds one camera motion and its descriptions; Globe View
// framing/default guidance updates only move_camera and mode descriptions.
// Radio markup and the complete production-request comparison pin locality.
// Restore the base camera/Globe View wording with the globe-rotation append.
assert.equal(block.length, 30878, 'serialized tool schema length drifted');
// Panel adds timeout and named voice/provider targets; consolidated guidance
// preserves the mission mapping and globe-rotation arguments.
// S2 GhostMaps extends the generated layer enums; both feature contracts remain.
assert.equal(block.length, 31206, 'serialized tool schema length drifted');
assert.equal(
crypto.createHash('sha256').update(block).digest('hex'),
'7b95f0f4258896fe5c3316265a19f3a6602809d4e7e681ee7ecd8f2df514ef1f',
'f772df95404494bf492e02fde1beabffdfe61986940bffd0cf9ad5a38d768746',
'the first-run missions must ride EXISTING tools: no schema edit, no cache bust',
);
const instructions = fs.readFileSync(
+21 -21
View File
@@ -282,28 +282,28 @@ export class IntelHUD {
<div class="hud-sonar" aria-hidden="true"></div>
<div class="hud-top-bar">
<span class="hud-top-bar-left">TOP SECRET // SI-TK // NOFORN</span>
<span class="hud-top-bar-center">${this._missionId}</span>
<span class="hud-top-bar-right">PAGE 1/1</span>
<span class="hud-readout"><span class="hud-top-bar-left">TOP SECRET // SI-TK // NOFORN</span></span>
<span class="hud-readout"><span class="hud-top-bar-center">${this._missionId}</span></span>
<span class="hud-readout"><span class="hud-top-bar-right">PAGE 1/1</span></span>
</div>
<div class="hud-corner hud-top-left">
<div class="hud-bracket">┌</div>
<div class="hud-content">
<div class="hud-classification">TOP SECRET // SI-TK // NOFORN</div>
<div class="hud-system">${this._missionId} ${this._sensorId}</div>
<div class="hud-mode" id="hud-mode">${t(MODE_LABELS.normal)}</div>
<div class="hud-readout"><div class="hud-classification">TOP SECRET // SI-TK // NOFORN</div></div>
<div class="hud-readout"><div class="hud-system">${this._missionId} ${this._sensorId}</div></div>
<div class="hud-readout"><div class="hud-mode" id="hud-mode">${t(MODE_LABELS.normal)}</div></div>
<div class="hud-summary-wrap">
<div class="hud-summary-label">${t('SUMMARY')}</div>
<div class="hud-summary" id="hud-summary">${t('Awaiting telemetry...')}</div>
<div class="hud-readout"><div class="hud-summary-label">${t('SUMMARY')}</div></div>
<div class="hud-readout"><div class="hud-summary" id="hud-summary">${t('Awaiting telemetry...')}</div></div>
</div>
</div>
</div>
<div class="hud-corner hud-top-right">
<div class="hud-content" style="text-align:right">
<div class="hud-rec"><span id="hud-rec-dot">●</span> ${t('REC')} <span id="hud-timestamp">2026-01-01 00:00:00Z</span></div>
<div class="hud-orbital">ORB: ${this._orbitNum} PASS: DESC-${this._passNum}</div>
<div class="hud-readout"><div class="hud-rec"><span id="hud-rec-dot">●</span> ${t('REC')} <span id="hud-timestamp">2026-01-01 00:00:00Z</span></div></div>
<div class="hud-readout"><div class="hud-orbital">ORB: ${this._orbitNum} PASS: DESC-${this._passNum}</div></div>
</div>
<div class="hud-bracket">┐</div>
</div>
@@ -311,33 +311,33 @@ export class IntelHUD {
<div class="hud-corner hud-bottom-left">
<div class="hud-bracket">└</div>
<div class="hud-content">
<div id="hud-mgrs">MGRS: ---</div>
<div id="hud-latlon">--°--'--"N ---°--'--"W</div>
<div class="hud-readout"><div id="hud-mgrs">MGRS: ---</div></div>
<div class="hud-readout"><div id="hud-latlon">--°--'--"N ---°--'--"W</div></div>
</div>
</div>
<div class="hud-corner hud-bottom-right">
<div class="hud-content" style="text-align:right">
<div id="hud-gsd">GSD: --m NIIRS: --</div>
<div id="hud-alt">ALT: --m SUN: --° EL</div>
<div id="hud-ais-vessel" class="hud-ais-vessel">${t('AIS: --')}</div>
<div class="hud-readout"><div id="hud-gsd">GSD: --m NIIRS: --</div></div>
<div class="hud-readout"><div id="hud-alt">ALT: --m SUN: --° EL</div></div>
<div class="hud-readout"><div id="hud-ais-vessel" class="hud-ais-vessel">${t('AIS: --')}</div></div>
</div>
<div class="hud-bracket">┘</div>
</div>
<div class="hud-edge hud-left-edge">
<div id="hud-coll">COLL: --:--:--Z</div>
<div id="hud-ona">ONA: --°</div>
<div class="hud-readout"><div id="hud-coll">COLL: --:--:--Z</div></div>
<div class="hud-readout"><div id="hud-ona">ONA: --°</div></div>
</div>
<div class="hud-edge hud-right-edge">
<div>BAND: PAN</div>
<div>BITS: 11</div>
<div>LVL: 1A</div>
<div class="hud-readout"><div>BAND: PAN</div></div>
<div class="hud-readout"><div>BITS: 11</div></div>
<div class="hud-readout"><div>LVL: 1A</div></div>
</div>
<div class="hud-bottom-bar">
<span id="hud-bottom-line">LAT: -- LON: -- MGRS: ---</span>
<span class="hud-readout"><span id="hud-bottom-line">LAT: -- LON: -- MGRS: ---</span></span>
</div>
`;
this._el.dataset.variant = this._variant;
+50 -1
View File
@@ -302,9 +302,16 @@
"CLOUD UNKNOWN": "NUBES DESCONOCIDAS",
"Panel layout updated — positions reset to new defaults": "Disposición de paneles actualizada — posiciones restablecidas a los nuevos valores predeterminados",
"Link copied!": "¡Enlace copiado!",
"Link shared": "Enlace compartido",
"This share link can't be opened": "Este enlace compartido no se puede abrir",
"{id} isn't reporting now — showing where it was at {time}": "{id} no está transmitiendo ahora; se muestra dónde estaba a las {time}",
"{id} isn't reporting now — showing where it was last seen": "{id} no está transmitiendo ahora; se muestra dónde se vio por última vez",
"Shared vessel is unavailable": "El buque compartido no está disponible",
"Copy failed": "No se pudo copiar",
"Location not found": "Ubicación no encontrada",
"Search failed": "No se pudo completar la búsqueda",
"Check the spelling and press Enter to search again.": "Revisa la ortografía y pulsa Intro para buscar de nuevo.",
"Press Enter to try again.": "Pulsa Intro para intentarlo de nuevo.",
"Fly to a POI first": "Ve primero a un punto de interés",
"Clearing selected data layers": "Limpiando las capas de datos seleccionadas",
"Selected data layers could not be cleared": "No se pudieron limpiar las capas de datos seleccionadas",
@@ -2943,6 +2950,34 @@
"Gemini Live is unavailable for the configured model.": "Gemini Live no está disponible para el modelo configurado.",
"Token request is too large": "La solicitud de credencial es demasiado grande",
"Use an empty JSON token request": "Usa una solicitud de credencial con un objeto JSON vacío",
"S2 GhostMaps": "S2 GhostMaps",
"Intel Picture": "Panorama de inteligencia",
"Kinetic 30d": "Cinético 30 d",
"Border Crisis": "Crisis fronteriza",
"Iran Picture": "Panorama de Irán",
"Iran Kinetic": "Irán cinético",
"Yemen FLOT": "FLOT de Yemen",
"Latest Drop": "Última publicación",
"GhostMaps file could not be read": "No se pudo leer el archivo de GhostMaps",
"GhostMaps catalog unavailable": "Catálogo de GhostMaps no disponible",
"Malformed GhostMaps catalog": "Catálogo de GhostMaps mal formado",
"{count} source links ↗ · click card to open the first": {
"one": "{count} enlace a la fuente ↗ · haz clic en la tarjeta para abrirlo",
"many": "{count} de enlaces a fuentes ↗ · haz clic en la tarjeta para abrir el primero",
"other": "{count} enlaces a fuentes ↗ · haz clic en la tarjeta para abrir el primero"
},
"S2 · {name}": "S2 · {name}",
"Report": "Informe",
"Open the source for {title}": "Abrir la fuente de {title}",
"LIVE · GitHub {commit} · {message} · checked every {seconds}s": "EN VIVO · GitHub {commit} · {message} · comprobado cada {seconds} s",
"GitHub {commit} · {message} · reconnecting for live updates": "GitHub {commit} · {message} · reconectando para recibir actualizaciones en vivo",
"Connecting to the S2 GhostMaps repository…": "Conectando con el repositorio S2 GhostMaps…",
"{count} placemarks": {
"one": "{count} marcador",
"many": "{count} de marcadores",
"other": "{count} marcadores"
},
"S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground publica estos productos KMZ en GitHub. Las nuevas publicaciones se detectan en segundos y sustituyen automáticamente a la edición anterior. Haz clic en un marcador para ver su informe.",
" Clouds are preserved from the source imagery.": " Las nubes provienen de las imágenes de origen.",
"hemisphere|N": "N",
"hemisphere|S": "S",
@@ -2953,5 +2988,19 @@
"Layers": "Capas",
"Search": "Buscar",
"More": "Más",
"Panels": "Paneles"
"Panels": "Paneles",
"Recent speech": "Habla reciente",
"Voice Settings": "Ajustes de voz",
"Preview": "Vista previa",
"Settings": "Ajustes",
"Close voice panel": "Cerrar panel de voz",
"Show Voice Preview": "Mostrar vista previa de voz",
"Voice inactivity timeout": "Tiempo de inactividad de voz",
"Custom": "Personalizado",
"Minutes": "Minutos",
"These preferences stay in this browser.": "Estas preferencias se guardan en este navegador.",
"Your choice applies now, but could not be saved.": "Tu elección se aplica ahora, pero no se pudo guardar.",
"Voice replies appear here.": "Las respuestas de voz aparecen aquí.",
"Preview is off.": "La vista previa está desactivada.",
"Automatic stop is unavailable for this voice connection.": "La parada automática no está disponible para esta conexión de voz."
}
+52 -1
View File
@@ -295,6 +295,11 @@
"Scene run complete": "Воспроизведение сцены завершено",
"CCTV layer unavailable": "Слой CCTV недоступен",
"Link copied!": "Ссылка скопирована!",
"Link shared": "Ссылка отправлена",
"This share link can't be opened": "Не удаётся открыть эту ссылку",
"{id} isn't reporting now — showing where it was at {time}": "{id} сейчас не передаёт данные — показано последнее известное место ({time})",
"{id} isn't reporting now — showing where it was last seen": "{id} сейчас не передаёт данные — показано последнее известное место",
"Shared vessel is unavailable": "Судно по ссылке недоступно",
"Copy failed": "Не удалось скопировать",
"VISUAL PRESETS": "ПРЕСЕТЫ",
"LOCATION": "МЕСТО",
@@ -526,6 +531,8 @@
"Resetting cockpit to full globe view": "Сброс кабины к виду всего глобуса",
"Location not found": "Место не найдено",
"Search failed": "Не удалось выполнить поиск",
"Check the spelling and press Enter to search again.": "Проверьте написание и нажмите Enter, чтобы повторить поиск.",
"Press Enter to try again.": "Нажмите Enter, чтобы попробовать снова.",
"MAP": "КАРТА",
"panel": "панель",
"Expand {name}": "Развернуть {name}",
@@ -3010,6 +3017,36 @@
"Gemini Live is unavailable for the configured model.": "Gemini Live недоступен для выбранной модели.",
"Token request is too large": "Запрос токена слишком велик",
"Use an empty JSON token request": "Используйте запрос токена с пустым объектом JSON",
"S2 GhostMaps": "S2 GhostMaps",
"Intel Picture": "Разведкартина",
"Kinetic 30d": "Боевые 30 дн.",
"Border Crisis": "Кризис на границе",
"Iran Picture": "Иран: картина",
"Iran Kinetic": "Иран: боевые",
"Yemen FLOT": "Йемен: линия фронта",
"Latest Drop": "Новый выпуск",
"GhostMaps file could not be read": "Не удалось прочитать файл GhostMaps",
"GhostMaps catalog unavailable": "Каталог GhostMaps недоступен",
"Malformed GhostMaps catalog": "Повреждённый каталог GhostMaps",
"{count} source links ↗ · click card to open the first": {
"one": "{count} ссылка на источник ↗ · нажмите на карточку, чтобы открыть",
"few": "{count} ссылки на источники ↗ · нажмите на карточку, чтобы открыть первую",
"many": "{count} ссылок на источники ↗ · нажмите на карточку, чтобы открыть первую",
"other": "{count} ссылки на источники ↗ · нажмите на карточку, чтобы открыть первую"
},
"S2 · {name}": "S2 · {name}",
"Report": "Сводка",
"Open the source for {title}": "Открыть источник: {title}",
"LIVE · GitHub {commit} · {message} · checked every {seconds}s": "В ЭФИРЕ · GitHub {commit} · {message} · проверка каждые {seconds} с",
"GitHub {commit} · {message} · reconnecting for live updates": "GitHub {commit} · {message} · переподключение для обновлений в реальном времени",
"Connecting to the S2 GhostMaps repository…": "Подключение к репозиторию S2 GhostMaps…",
"{count} placemarks": {
"one": "{count} метка",
"few": "{count} метки",
"many": "{count} меток",
"other": "{count} метки"
},
"S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground публикует эти продукты KMZ на GitHub. Новые файлы обнаруживаются за секунды и автоматически заменяют предыдущий выпуск. Нажмите на метку, чтобы открыть её сводку.",
" Clouds are preserved from the source imagery.": " Облака оставлены такими, как на исходных снимках.",
"hemisphere|N": "С",
"hemisphere|S": "Ю",
@@ -3020,5 +3057,19 @@
"Layers": "Слои",
"Search": "Поиск",
"More": "Ещё",
"Panels": "Панели"
"Panels": "Панели",
"Recent speech": "Недавняя речь",
"Voice Settings": "Настройки голоса",
"Preview": "Предпросмотр",
"Settings": "Настройки",
"Close voice panel": "Закрыть голосовую панель",
"Show Voice Preview": "Показывать предпросмотр голоса",
"Voice inactivity timeout": "Тайм-аут бездействия голоса",
"Custom": "Другое",
"Minutes": "Минуты",
"These preferences stay in this browser.": "Эти настройки сохраняются в этом браузере.",
"Your choice applies now, but could not be saved.": "Ваш выбор применён, но сохранить его не удалось.",
"Voice replies appear here.": "Здесь появляются голосовые ответы.",
"Preview is off.": "Предпросмотр отключён.",
"Automatic stop is unavailable for this voice connection.": "Автоматическая остановка недоступна для этого голосового подключения."
}
+46 -1
View File
@@ -1478,6 +1478,11 @@
"LIVE SIGNALS": "实时信号",
"CCTV layer unavailable": "CCTV 图层不可用",
"Link copied!": "链接已复制!",
"Link shared": "链接已分享",
"This share link can't be opened": "无法打开此分享链接",
"{id} isn't reporting now — showing where it was at {time}": "{id} 目前没有发送信号——显示其在 {time} 的位置",
"{id} isn't reporting now — showing where it was last seen": "{id} 目前没有发送信号——显示其最后出现的位置",
"Shared vessel is unavailable": "分享的船只不可用",
"Copy failed": "复制失败",
"Return map to straight-down view": "将地图恢复为垂直俯视",
"Tilt map to oblique view": "将地图倾斜至斜视角",
@@ -1815,6 +1820,8 @@
"ORBIT": "环绕",
"Location not found": "未找到位置",
"Search failed": "搜索失败",
"Check the spelling and press Enter to search again.": "请检查拼写,然后按 Enter 重新搜索。",
"Press Enter to try again.": "按 Enter 重试。",
"Detailed outline unavailable": "无法获取详细轮廓",
"Fly to a POI first": "请先飞往一个 POI",
"Reset to full globe view": "重置为完整地球视图",
@@ -2819,5 +2826,43 @@
"Layers": "图层",
"Search": "搜索",
"More": "更多",
"Panels": "面板"
"Panels": "面板",
"Recent speech": "最近语音",
"Voice Settings": "语音设置",
"Preview": "预览",
"Settings": "设置",
"Close voice panel": "关闭语音面板",
"Show Voice Preview": "显示语音预览",
"Voice inactivity timeout": "语音空闲超时",
"Custom": "自定义",
"Minutes": "分钟",
"These preferences stay in this browser.": "这些偏好设置仅保存在此浏览器中。",
"Your choice applies now, but could not be saved.": "选择已生效,但无法保存。",
"Voice replies appear here.": "语音回复显示在此处。",
"Preview is off.": "预览已关闭。",
"Automatic stop is unavailable for this voice connection.": "此语音连接不支持自动停止。",
"S2 GhostMaps": "S2 GhostMaps",
"Intel Picture": "情报态势图",
"Kinetic 30d": "近 30 天军事行动",
"Border Crisis": "边境危机",
"Iran Picture": "伊朗态势图",
"Iran Kinetic": "伊朗军事行动",
"Yemen FLOT": "也门前线",
"Latest Drop": "最新发布",
"GhostMaps file could not be read": "无法读取 GhostMaps 文件",
"GhostMaps catalog unavailable": "GhostMaps 目录不可用",
"Malformed GhostMaps catalog": "GhostMaps 目录格式无效",
"{count} source links ↗ · click card to open the first": {
"other": "{count} 个来源链接 ↗ · 点击卡片打开第一个"
},
"S2 · {name}": "S2 · {name}",
"Report": "报告",
"Open the source for {title}": "打开 {title} 的来源",
"LIVE · GitHub {commit} · {message} · checked every {seconds}s": "实时 · GitHub {commit} · {message} · 每 {seconds} 秒检查一次",
"GitHub {commit} · {message} · reconnecting for live updates": "GitHub {commit} · {message} · 正在重新连接以获取实时更新",
"Connecting to the S2 GhostMaps repository…": "正在连接 S2 GhostMaps 仓库…",
"{count} placemarks": {
"other": "{count} 个地标"
},
"S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground 在 GitHub 发布这些 KMZ 产品。新上传的内容会在数秒内被检测到,并自动替换旧版本。点击标记查看报告。"
}
+6
View File
@@ -75,6 +75,8 @@
"Radio",
"Reggae",
"Rock",
"S2 GhostMaps",
"S2 · {name}",
"SAR",
"SE",
"SECTOR {lat} {lon}",
@@ -135,6 +137,8 @@
"NVG",
"R&B",
"REC",
"S2 GhostMaps",
"S2 · {name}",
"VS",
"WHOT/BHOT"
],
@@ -165,6 +169,8 @@
"NVG",
"REC",
"SRC {value}",
"S2 GhostMaps",
"S2 · {name}",
"Sentinel-2",
"Techno",
"Trance",
+2
View File
@@ -395,6 +395,7 @@ export async function initKeySetup({
const destroy = () => {
if (disposed) return;
disposed = true;
delete root.dataset.ready;
unregisterUnsavedWork();
lifetime.abort();
signal?.removeEventListener('abort', destroy);
@@ -591,6 +592,7 @@ export async function initKeySetup({
chip.addEventListener('click', openDialog);
closeButton?.addEventListener('click', close);
applyButton?.addEventListener('click', onApply);
root.dataset.ready = 'true';
// Remove buttons are rendered per row; delegate so re-renders stay wired.
rowsHost?.addEventListener('click', (event) => {
const authButton = event.target?.closest?.(
+245
View File
@@ -0,0 +1,245 @@
// S2 Underground GhostMaps catalog: which KMZ in the public repository is the
// current edition of each product. Pure and portable (the server builds the
// catalog, tests pin the rules); no i18n, no DOM, no Cesium.
export const GHOSTMAPS_REPO = Object.freeze({
owner: 's2underground',
name: 'GhostMaps',
branch: 'main',
url: 'https://github.com/s2underground/GhostMaps',
});
/** Largest KMZ the proxy downloads; matches the browser's import ceiling. */
export const GHOSTMAPS_MAX_FILE_BYTES = 50 * 1024 * 1024;
const ARCGIS = 'ArcGIS Data for ATAK (KMZs)/';
const INCIDENTS = 'Incident Response Products for ATAK (KMZs and Documents)/';
/**
* Curated products, in panel order. Each names a series of dated uploads; the
* newest edition wins. Patterns match whole repository paths, so a renamed
* folder fails closed (the product drops out) instead of matching elsewhere.
*/
export const GHOSTMAPS_PRODUCTS = Object.freeze([
Object.freeze({
id: 'cip',
// i18n-ignore: catalog data; the panel shows its own translated label
title: 'Common Intelligence Picture',
defaultOn: true,
pattern: new RegExp(
`^${escape(ARCGIS)}Common Intelligence Picture/Master Database/[^/]+\\.kmz$`,
'i',
),
}),
Object.freeze({
id: 'kinetic-30d',
// i18n-ignore: catalog data; the panel shows its own translated label
title: 'Kinetic Activity (30 days)',
defaultOn: true,
pattern: new RegExp(
`^${escape(ARCGIS)}Common Intelligence Picture/30-Day Pulls/Kinetic[^/]*\\.kmz$`,
'i',
),
}),
Object.freeze({
id: 'border-crisis',
// i18n-ignore: catalog data; the panel shows its own translated label
title: 'Border Crisis Map',
defaultOn: false,
pattern: new RegExp(
`^${escape(ARCGIS)}Border Crisis Map/S2Underground_Border_Crisis_Map[^/]*\\.kmz$`,
'i',
),
}),
Object.freeze({
id: 'iran-cip',
// i18n-ignore: catalog data; the panel shows its own translated label
title: 'Iran Common Intelligence Picture',
defaultOn: false,
pattern: new RegExp(
`^${escape(INCIDENTS)}Iran[^/]*/Iran Common Intelligence Picture/[^/]+\\.kmz$`,
'i',
),
}),
Object.freeze({
id: 'iran-kinetic',
// i18n-ignore: catalog data; the panel shows its own translated label
title: 'Iran Kinetic Events',
defaultOn: false,
pattern: new RegExp(
`^${escape(INCIDENTS)}Iran[^/]*/Iran Kinetic Events/[^/]+\\.kmz$`,
'i',
),
}),
Object.freeze({
id: 'yemen-flot',
// i18n-ignore: catalog data; the panel shows its own translated label
title: 'Yemen FLOT',
defaultOn: false,
pattern: new RegExp(
`^${escape(INCIDENTS)}Yemen[^/]*/Yemen FLOT[^/]*\\.kmz$`,
'i',
),
}),
]);
/** The newest incident product outside the curated series and the archive. */
export const GHOSTMAPS_LATEST_DROP = Object.freeze({
id: 'latest-drop',
// i18n-ignore: catalog data; the panel shows its own translated label
title: 'Latest incident drop',
defaultOn: false,
});
const LATEST_DROP_EXCLUDED =
/\/(?:0_Archive|Basedata|Reference Material)(?:\/|$)/i;
function escape(text) {
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
const MONTHS = [
'jan',
'feb',
'mar',
'apr',
'may',
'jun',
'jul',
'aug',
'sep',
'oct',
'nov',
'dec',
];
const DATE_IN_NAME =
/(january|february|march|april|may|june|july|august|september|sept|october|november|december|jan|feb|mar|apr|jun|jul|aug|sep|oct|nov|dec)[ _-]?(\d{1,2})(?:st|nd|rd|th)?[ ,_-]+(20\d\d)/i;
const EDITION_IN_NAME = /(?:[ _-]export)?[ _-]v?(\d{1,3})$/i;
/**
* The date and edition written into an S2 file name, e.g.
* `..._October_16_2026_Export_1.kmz` → 2026-10-16, edition 1. Undated names
* report `dateMs: null` and sort below every dated edition.
* @param {string} fileName
* @returns {{dateMs: ?number, edition: number}}
*/
export function parseGhostMapsEdition(fileName) {
const stem = String(fileName || '').replace(/\.km[lz]$/i, '');
const match = DATE_IN_NAME.exec(stem);
let dateMs = null;
if (match) {
const month = MONTHS.indexOf(match[1].slice(0, 3).toLowerCase());
const day = Number(match[2]);
const year = Number(match[3]);
const at = Date.UTC(year, month, day);
// Date.UTC rolls 31 Sep into 1 Oct; only an exact calendar date counts.
if (month >= 0 && new Date(at).getUTCDate() === day) dateMs = at;
}
const tail = match ? stem.slice(match.index + match[0].length) : stem;
const edition = Number(EDITION_IN_NAME.exec(tail)?.[1] ?? 0);
return { dateMs, edition };
}
function newer(a, b) {
if (!b) return true;
const ad = a.dateMs ?? -Infinity;
const bd = b.dateMs ?? -Infinity;
if (ad !== bd) return ad > bd;
if (a.edition !== b.edition) return a.edition > b.edition;
return a.path > b.path;
}
/** `S2Underground_Border_Crisis_Map_June_02_2026_v1.kmz` → readable words. */
export function ghostMapsFileTitle(fileName) {
return String(fileName || '')
.replace(/\.km[lz]$/i, '')
.replace(/^S2Underground_/i, '')
.replace(/_/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
/**
* Build the product catalog from a recursive git tree listing.
* @param {Array<{path: string, type: string, sha: string, size?: number}>} tree
* @returns {Array<{id: string, title: string, defaultOn: boolean,
* path: string, fileName: string, fileTitle: string, version: string,
* size: number, dateMs: ?number, edition: number}>}
*/
export function buildGhostMapsCatalog(tree) {
const best = new Map();
let drop = null;
for (const entry of Array.isArray(tree) ? tree : []) {
if (entry?.type !== 'blob' || typeof entry.path !== 'string') continue;
if (!/\.kmz$/i.test(entry.path)) continue;
if (!/^[0-9a-f]{40}$/.test(entry.sha || '')) continue;
const size = Number(entry.size);
if (!Number.isFinite(size) || size <= 0 || size > GHOSTMAPS_MAX_FILE_BYTES)
continue;
const fileName = entry.path.slice(entry.path.lastIndexOf('/') + 1);
const candidate = {
path: entry.path,
fileName,
version: entry.sha,
size,
...parseGhostMapsEdition(fileName),
};
const product = GHOSTMAPS_PRODUCTS.find(({ pattern }) =>
pattern.test(entry.path),
);
if (product) {
if (newer(candidate, best.get(product.id)))
best.set(product.id, candidate);
} else if (
entry.path.startsWith(INCIDENTS) &&
!LATEST_DROP_EXCLUDED.test(`/${entry.path}`) &&
candidate.dateMs != null &&
newer(candidate, drop)
) {
drop = candidate;
}
}
const rows = [];
for (const product of [...GHOSTMAPS_PRODUCTS, GHOSTMAPS_LATEST_DROP]) {
const file =
product === GHOSTMAPS_LATEST_DROP ? drop : best.get(product.id);
if (!file) continue;
rows.push({
id: product.id,
title: product.title,
defaultOn: product.defaultOn,
...file,
fileTitle: ghostMapsFileTitle(file.fileName),
});
}
return rows;
}
/**
* Raw download URL pinned to a commit, so a CDN can never hand back an older
* edition for a newer catalog.
* @param {string} commit 40-hex commit sha.
* @param {string} path Repository path.
*/
export function ghostMapsRawUrl(commit, path) {
const encoded = path.split('/').map(encodeURIComponent).join('/');
return `https://raw.githubusercontent.com/${GHOSTMAPS_REPO.owner}/${GHOSTMAPS_REPO.name}/${commit}/${encoded}`;
}
/**
* The branch head from a git smart-HTTP ref advertisement
* (`/info/refs?service=git-upload-pack`), or null.
* @param {string} text
* @param {string} [branch]
* @returns {?string}
*/
export function parseGitRefAdvertisement(text, branch = GHOSTMAPS_REPO.branch) {
const ref = `refs/heads/${branch}`;
for (const line of String(text || '').split('\n')) {
// pkt-line: 4 hex length digits, then "<sha> <ref>" (the first ref line
// also carries a NUL and the capability list).
const match = /^[0-9a-f]{4}([0-9a-f]{40}) ([^\0\s]+)/.exec(line);
if (match && match[2] === ref) return match[1];
}
return null;
}
+165
View File
@@ -0,0 +1,165 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
buildGhostMapsCatalog,
ghostMapsFileTitle,
ghostMapsRawUrl,
parseGhostMapsEdition,
parseGitRefAdvertisement,
} from './catalog.js';
const ARCGIS = 'ArcGIS Data for ATAK (KMZs)/';
const INCIDENTS = 'Incident Response Products for ATAK (KMZs and Documents)/';
let n = 0;
const blob = (path, size = 1000) => ({
path,
type: 'blob',
size,
sha: (++n).toString(16).padStart(40, '0'),
});
test('editions read the date and export/version number written in the name', () => {
assert.deepEqual(
parseGhostMapsEdition(
'S2Underground_Common_Intelligence_Picture_October_16_2026_Export_1.kmz',
),
{ dateMs: Date.UTC(2026, 9, 16), edition: 1 },
);
assert.deepEqual(
parseGhostMapsEdition(
'S2Underground_Border_Crisis_Map_September_21_2026_Export_v2.kmz',
),
{ dateMs: Date.UTC(2026, 8, 21), edition: 2 },
);
assert.deepEqual(
parseGhostMapsEdition('Reno Wildfires August 24, 2026.kmz'),
{
dateMs: Date.UTC(2026, 7, 24),
edition: 0,
},
);
assert.deepEqual(
parseGhostMapsEdition('Venezuelan_Crisis_Sep_5_2025_v2.kmz'),
{
dateMs: Date.UTC(2025, 8, 5),
edition: 2,
},
);
assert.deepEqual(
parseGhostMapsEdition('Kinetic_Activities_Last_30_Days.kmz'),
{
dateMs: null,
edition: 0,
},
);
// An impossible date is not a date.
assert.equal(parseGhostMapsEdition('X_September_31_2026.kmz').dateMs, null);
});
test('each product keeps its newest edition by date, then edition number', () => {
const master = `${ARCGIS}Common Intelligence Picture/Master Database/`;
const tree = [
blob(
`${master}S2Underground_Common_Intelligence_Picture_October_9_2026_Export_1.kmz`,
),
blob(
`${master}S2Underground_Common_Intelligence_Picture_October_16_2026_Export_1.kmz`,
),
blob(
`${master}S2Underground_Common_Intelligence_Picture_October_1_2026_Export_1.kmz`,
),
blob(
`${master}S2Underground_Common_Intelligence_Picture_September_30_2026_Export_2.kmz`,
),
blob(
`${ARCGIS}Common Intelligence Picture/30-Day Pulls/Kinetic_Activities_Last_30_Days.kmz`,
),
blob(
`${ARCGIS}Border Crisis Map/S2Underground_Border_Crisis_Map_September_21_2026_Export_v1.kmz`,
),
blob(
`${ARCGIS}Border Crisis Map/S2Underground_Border_Crisis_Map_September_21_2026_Export_v2.kmz`,
),
// A European-only subset in a subfolder is not the border map.
blob(
`${ARCGIS}Border Crisis Map/European Layers Only/Europe_Migration_Crisis_June_02_2026_v1.kmz`,
),
blob(`${INCIDENTS}Yemen 2026/Yemen FLOT.kmz`),
blob(`${INCIDENTS}Yemen 2026/Yemen FLOT_October_10_2026.kmz`),
{ path: `${INCIDENTS}Yemen 2026`, type: 'tree', sha: 'f'.repeat(40) },
];
const rows = buildGhostMapsCatalog(tree);
const byId = Object.fromEntries(rows.map((row) => [row.id, row]));
assert.deepEqual(
rows.map(({ id }) => id),
['cip', 'kinetic-30d', 'border-crisis', 'yemen-flot'],
);
assert.match(byId.cip.fileName, /October_16_2026/);
assert.equal(byId.cip.defaultOn, true);
assert.equal(byId['kinetic-30d'].defaultOn, true);
assert.match(byId['border-crisis'].fileName, /Export_v2/);
assert.equal(byId['yemen-flot'].fileName, 'Yemen FLOT_October_10_2026.kmz');
assert.equal(byId.cip.version, tree[1].sha);
assert.equal(
byId.cip.fileTitle,
'Common Intelligence Picture October 16 2026 Export 1',
);
});
test('latest drop is the newest dated incident file outside curated, archived and base data', () => {
const rows = buildGhostMapsCatalog([
blob(`${INCIDENTS}Reno Wildfires August 24, 2026.kmz`),
blob(
`${INCIDENTS}Spokane Fire Disaster - August, 2026/SpokaneDisasterAid_August_8_2026.kmz`,
),
blob(
`${INCIDENTS}0_Archive/Texas Floods/TexasFloods_December_1_2026_v1.kmz`,
),
blob(`${INCIDENTS}Iran 2026/Basedata/Pipelines_December_1_2026.kmz`),
blob(
`${INCIDENTS}Iran 2026/Iran Kinetic Events/Iran_Kinetic_Events_December_2_2026_v1.kmz`,
),
blob(`${INCIDENTS}Undated Product.kmz`),
]);
const drop = rows.find(({ id }) => id === 'latest-drop');
assert.equal(drop.fileName, 'Reno Wildfires August 24, 2026.kmz');
assert.ok(rows.some(({ id }) => id === 'iran-kinetic'));
});
test('oversized, empty, non-KMZ and malformed entries are skipped', () => {
const path = `${ARCGIS}Common Intelligence Picture/Master Database/S2Underground_Common_Intelligence_Picture_October_16_2026_Export_1.kmz`;
assert.deepEqual(
buildGhostMapsCatalog([
{ ...blob(path), size: 51 * 1024 * 1024 },
{ ...blob(path), size: 0 },
{ ...blob(path), sha: 'not-a-sha' },
blob(path.replace(/\.kmz$/, '.zip')),
null,
]),
[],
);
assert.deepEqual(buildGhostMapsCatalog(undefined), []);
});
test('raw URLs are pinned to the commit and encode every path segment', () => {
assert.equal(
ghostMapsRawUrl('a'.repeat(40), 'Folder (KMZs)/Yemen FLOT.kmz'),
`https://raw.githubusercontent.com/s2underground/GhostMaps/${'a'.repeat(40)}/Folder%20(KMZs)/Yemen%20FLOT.kmz`,
);
assert.equal(ghostMapsFileTitle('S2Underground_A__B.kmz'), 'A B');
});
test('the branch head comes from the smart-HTTP ref advertisement', () => {
const head = '142d7630bc8f1f2c4b96e16ee865ca50d1ab578d';
const other = 'b'.repeat(40);
const text = [
'001e# service=git-upload-pack',
`0159${head} HEAD\0multi_ack symref=HEAD:refs/heads/main`,
`003f${other} refs/heads/main-old`,
`003d${head} refs/heads/main`,
'0000',
].join('\n');
assert.equal(parseGitRefAdvertisement(text), head);
assert.equal(parseGitRefAdvertisement(text, 'dev'), null);
assert.equal(parseGitRefAdvertisement(''), null);
});
+711
View File
@@ -0,0 +1,711 @@
import * as Cesium from 'cesium';
import {
decodeXml,
kmzImageResolver,
kmzMainDocument,
parseXml,
readZipEntries,
resolveKmzPath,
} from '../../data/geoFileImport.js';
import {
formatNumber,
formatUtcDayMonth,
msg,
t,
tn,
} from '../../i18n/index.js';
import { createHorizonCull } from '../streetLevel/view.js';
import { readGhostMapsKml } from './kml.js';
import { fieldLabel, parseDescriptionFields, wrapCardText } from './records.js';
export * from './catalog.js';
export * from './records.js';
export { readGhostMapsKml } from './kml.js';
export { createGhostMapsSource } from './source.js';
export const GHOSTMAPS_LAYER_ID = 's2-ghostmaps';
export const GHOSTMAPS_OVERLAY_SOURCE_ID = 's2-ghostmaps';
const PICK_PREFIX = 'ghostmaps:';
const STORAGE_KEY = 'gev.ghostmaps.products';
const ACCENT = '#c8ff00';
const MAX_ICON_BYTES = 512 * 1024;
const CARD_HOST_OPTIONS = Object.freeze({
cohortLimit: 1,
collisionCapacity: 1,
moving: false,
});
const MAX_CARD_LINES = 12;
/** Panel names for the catalog's product ids (the catalog sends English). */
const PRODUCT_LABELS = Object.freeze({
cip: msg('Intel Picture'),
'kinetic-30d': msg('Kinetic 30d'),
'border-crisis': msg('Border Crisis'),
'iran-cip': msg('Iran Picture'),
'iran-kinetic': msg('Iran Kinetic'),
'yemen-flot': msg('Yemen FLOT'),
'latest-drop': msg('Latest Drop'),
});
/** One distinct fallback marker color per product. */
const PRODUCT_COLORS = Object.freeze({
cip: '#c8ff00',
'kinetic-30d': '#ff5a36',
'border-crisis': '#ffb547',
'iran-cip': '#39d0ff',
'iran-kinetic': '#ff3b8d',
'yemen-flot': '#5dff9f',
'latest-drop': '#b48cff',
});
function defaultStorage() {
try {
return globalThis.localStorage ?? null;
} catch {
return null;
}
}
function readStoredSelection(storage) {
try {
const value = JSON.parse(storage?.getItem(STORAGE_KEY) ?? 'null');
return Array.isArray(value) ? new Set(value.map(String)) : null;
} catch {
return null;
}
}
function writeStoredSelection(storage, selected) {
try {
storage?.setItem(STORAGE_KEY, JSON.stringify([...selected]));
} catch {
/* private window or blocked storage: the selection lasts this session */
}
}
/** A round marker for placemarks whose icon is missing or remote. */
const markerCache = new Map();
function markerImage(color) {
if (markerCache.has(color)) return markerCache.get(color);
let url = null;
try {
const canvas = document.createElement('canvas');
canvas.width = canvas.height = 20;
const ctx = canvas.getContext('2d');
ctx.beginPath();
ctx.arc(10, 10, 7, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = 'rgba(0,0,0,0.85)';
ctx.stroke();
url = canvas.toDataURL();
} catch {
url = null;
}
markerCache.set(color, url);
return url;
}
const cesiumColor = (rgba, fallback, alpha) =>
rgba
? new Cesium.Color(rgba[0], rgba[1], rgba[2], alpha ?? rgba[3])
: Cesium.Color.fromCssColorString(fallback).withAlpha(alpha ?? 1);
function anchorOf(geometry) {
if (geometry.type === 'point') return geometry.position;
const ring =
geometry.type === 'polygon' ? geometry.outer : geometry.positions;
let lon = 0;
let lat = 0;
for (const [x, y] of ring) {
lon += x;
lat += y;
}
return [lon / ring.length, lat / ring.length];
}
/**
* S2 Underground GhostMaps: the current editions of S2's public KMZ intel
* products, kept in step with the GitHub repository. The proxy watches the
* branch head and pushes each new commit; only changed products reload.
*/
export function createGhostMapsLayer({
source,
overlayHost = null,
screenSpaceEventHandlerFactory = null,
picking = null,
pointer = null,
openExternal = null,
storage = defaultStorage(),
} = {}) {
if (typeof source?.getCatalog !== 'function')
throw new TypeError('GhostMaps layer requires a catalog source');
let _viewer = null;
let _enabled = false;
let _catalog = null;
let _lastError = null;
let _unsubscribe = null;
let _stream = 'closed';
let _queue = Promise.resolve();
let _clickHandler = null;
let _rowListener = null;
let _selectedKey = null;
let _selectedCardId = null;
let _selectedLink = null;
let _lifetime = new AbortController();
let _selection = readStoredSelection(storage);
/** productId → {version, dataSource, urls, count, features} */
const _loaded = new Map();
/** productId → in-flight version */
const _loading = new Map();
/** productId → error text */
const _failed = new Map();
const notifyRow = () => _rowListener?.();
const render = () => _viewer?.scene?.requestRender?.();
// Pins skip the depth test, and with Google 3D tiles the globe is hidden,
// so nothing else stops far-side pins drawing through the planet.
const _horizon = createHorizonCull({
getViewer: () => _viewer,
items: function* () {
for (const item of _loaded.values()) yield* item.pins;
},
onChange: render,
});
const canSelect = () =>
overlayHost && screenSpaceEventHandlerFactory && picking;
function wanted(product) {
return _selection ? _selection.has(product.id) : product.defaultOn;
}
function selectedIds() {
return (_catalog?.products || []).filter(wanted).map(({ id }) => id);
}
function unload(id) {
const item = _loaded.get(id);
if (!item) return;
_loaded.delete(id);
_viewer?.dataSources?.remove(item.dataSource, true);
item.urls.forEach((url) => URL.revokeObjectURL(url));
if (_selectedKey?.startsWith(`${id}:`)) clearSelection();
}
/** Build one product's data source off-scene, then swap it in. */
async function load(product, signal) {
const bytes = await source.getFile(product.id, product.version, { signal });
signal.throwIfAborted();
const entries = await readZipEntries(bytes);
signal.throwIfAborted();
const main = kmzMainDocument(entries);
if (!main) throw new Error(msg('That KMZ holds no KML document.'));
const urls = [];
const resolvePacked = kmzImageResolver(entries, main, (url) =>
urls.push(url),
);
// Icons only: ArcGIS exports also pack multi-megabyte photo attachments,
// which fall back to the product marker instead of being decoded.
const resolveImage = (href) => {
const path = resolveKmzPath(href, main, entries);
return path && entries.get(path).length <= MAX_ICON_BYTES
? resolvePacked(href)
: null;
};
try {
const parsed = readGhostMapsKml(
parseXml(decodeXml(entries.get(main)), 'KML'),
resolveImage,
);
entries.clear();
signal.throwIfAborted();
const color = PRODUCT_COLORS[product.id] || ACCENT;
const dataSource = new Cesium.CustomDataSource(
`${GHOSTMAPS_LAYER_ID}:${product.id}`,
);
const features = [];
const pins = [];
dataSource.entities.suspendEvents();
parsed.features.forEach((feature, index) => {
const key = `${product.id}:${index}`;
const { style } = feature;
feature.geometries.forEach((geometry, part) => {
const id = `${PICK_PREFIX}${key}:${part}`;
if (geometry.type === 'point') {
const position = Cesium.Cartesian3.fromDegrees(
...geometry.position,
);
const entity = dataSource.entities.add({
id,
position,
billboard: {
image: style.icon || markerImage(color),
scale: Math.min(
1.5,
Math.max(0.35, (style.iconScale ?? 1) * 0.7),
),
color:
style.icon && style.iconColor
? cesiumColor(style.iconColor)
: undefined,
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
verticalOrigin: Cesium.VerticalOrigin.CENTER,
// Ground-clamped icons would sink into terrain and 3D tiles; the
// horizon cull hides the far side instead (see `_horizon`).
disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
});
pins.push({
position,
get show() {
return entity.show;
},
set show(value) {
entity.show = value;
},
});
} else if (geometry.type === 'line') {
dataSource.entities.add({
id,
polyline: {
positions: Cesium.Cartesian3.fromDegreesArray(
geometry.positions.flat(),
),
width: Math.min(8, Math.max(1.5, style.lineWidth ?? 2)),
material: cesiumColor(style.lineColor, color),
clampToGround: true,
},
});
} else {
const ring = (points) =>
Cesium.Cartesian3.fromDegreesArray(points.flat());
dataSource.entities.add({
id,
polygon: {
hierarchy: new Cesium.PolygonHierarchy(
ring(geometry.outer),
geometry.holes.map(
(hole) => new Cesium.PolygonHierarchy(ring(hole)),
),
),
material:
style.fill === false
? Cesium.Color.TRANSPARENT
: cesiumColor(
style.polyColor,
color,
style.polyColor ? undefined : 0.25,
),
classificationType: Cesium.ClassificationType.BOTH,
},
});
if (style.outline !== false)
dataSource.entities.add({
id: `${id}:outline`,
polyline: {
positions: ring([...geometry.outer, geometry.outer[0]]),
width: Math.min(6, Math.max(1.5, style.lineWidth ?? 2)),
material: cesiumColor(style.lineColor, color),
clampToGround: true,
},
});
}
});
features.push({
name: feature.name,
folder: feature.folder,
description: feature.description,
anchor: anchorOf(feature.geometries[0]),
});
});
dataSource.entities.resumeEvents();
return { version: product.version, dataSource, urls, features, pins };
} catch (error) {
urls.forEach((url) => URL.revokeObjectURL(url));
throw error;
}
}
/** Bring the globe in line with the catalog and the chip selection. */
function sync() {
const run = _queue.then(async () => {
if (!_enabled || !_viewer || !_catalog) return;
const signal = _lifetime.signal;
const products = new Map(_catalog.products.map((p) => [p.id, p]));
for (const id of [..._loaded.keys()]) {
const product = products.get(id);
if (!product || !wanted(product)) unload(id);
}
for (const product of _catalog.products) {
if (!wanted(product) || signal.aborted) continue;
if (_loaded.get(product.id)?.version === product.version) continue;
_loading.set(product.id, product.version);
_failed.delete(product.id);
notifyRow();
try {
const next = await load(product, signal);
if (signal.aborted || !_enabled) {
next.urls.forEach((url) => URL.revokeObjectURL(url));
return;
}
// Cull before the first frame so far-side pins never flash.
_horizon.update(next.pins);
await _viewer.dataSources.add(next.dataSource);
// The new edition is on the globe before the old one leaves.
unload(product.id);
_loaded.set(product.id, next);
} catch (error) {
if (signal.aborted) return;
console.warn(`[Data:GhostMaps] ${product.id}:`, error);
_failed.set(
product.id,
error?.template ||
error?.message ||
msg('GhostMaps file could not be read'),
);
} finally {
_loading.delete(product.id);
notifyRow();
render();
}
}
});
_queue = run.catch(() => {});
return run;
}
function applyCatalog(catalog) {
_catalog = catalog;
_lastError = catalog.error || null;
notifyRow();
return sync();
}
function toggleProduct(id) {
// The first change pins the whole current selection, defaults included.
if (!_selection) _selection = new Set(selectedIds());
if (_selection.has(id)) _selection.delete(id);
else _selection.add(id);
writeStoredSelection(storage, _selection);
notifyRow();
void sync();
}
// ---- selection card ----------------------------------------------------
function featureFor(key) {
const [productId, index] = key.split(':');
return {
product: _catalog?.products.find((p) => p.id === productId) || null,
feature: _loaded.get(productId)?.features[Number(index)] || null,
};
}
function publishCard() {
if (!canSelect()) return;
const { product, feature } = _selectedKey ? featureFor(_selectedKey) : {};
if (!feature) {
_selectedKey = null;
_selectedCardId = null;
_selectedLink = null;
overlayHost.setEntries(
GHOSTMAPS_OVERLAY_SOURCE_ID,
[],
CARD_HOST_OPTIONS,
);
return;
}
const { fields, links } = parseDescriptionFields(feature.description);
_selectedLink = links[0] || null;
const heading = [t(PRODUCT_LABELS[product?.id] || product?.title || '')];
if (feature.folder) heading.push(fieldLabel(feature.folder));
if (product?.dateMs) heading.push(formatUtcDayMonth(product.dateMs));
const details = [heading.filter(Boolean).join(' · ')];
for (const { label, value } of fields) {
if (details.length >= MAX_CARD_LINES) break;
// ArcGIS repeats the feature type as the first row; the title has it.
if (value === feature.name) continue;
const line = label ? `${fieldLabel(label)}: ${value}` : value;
details.push(
...wrapCardText(line, 60, Math.min(4, MAX_CARD_LINES - details.length)),
);
}
if (_selectedLink && openExternal)
details.push(
tn(
links.length,
'{count} source link ↗ · click card to open',
'{count} source links ↗ · click card to open the first',
{ count: formatNumber(links.length) },
),
);
const title = t('S2 · {name}', {
name: fieldLabel(feature.name || feature.folder) || t('Report'),
});
const card = {
id: `ghostmaps-card:${_selectedKey}`,
selected: true,
interactive: Boolean(_selectedLink && openExternal),
title,
details,
accent: PRODUCT_COLORS[product?.id] || ACCENT,
priority: Number.MAX_SAFE_INTEGER,
gapPx: 15,
verticalOnly: true,
placement: 'above',
// A click card comes and goes with the click, as CCTV cards do, rather
// than holding the label arbiter's 2.5 s minimum lifetime; and it hides
// when its marker turns behind the planet.
stateless: true,
horizonCull: true,
position: Cesium.Cartesian3.fromDegrees(...feature.anchor),
};
if (card.interactive) {
const link = _selectedLink;
card.accessibilityLabel = t('Open the source for {title}', { title });
card.activate = () => {
openExternal(link);
return true;
};
}
_selectedCardId = card.id;
overlayHost.setEntries(
GHOSTMAPS_OVERLAY_SOURCE_ID,
[card],
CARD_HOST_OPTIONS,
);
overlayHost.setVisible?.(GHOSTMAPS_OVERLAY_SOURCE_ID, true);
}
function clearSelection() {
_selectedKey = null;
_selectedCardId = null;
_selectedLink = null;
// An empty publish, as the perimeter cards deselect: it releases the
// selected card's paint lane, which clearing the source alone left drawn.
overlayHost?.setEntries(GHOSTMAPS_OVERLAY_SOURCE_ID, [], CARD_HOST_OPTIONS);
}
function pickedKey(picked) {
const pickId = picking.resolvePickId(picked);
if (typeof pickId !== 'string' || !pickId.startsWith(PICK_PREFIX))
return null;
const [productId, index] = pickId.slice(PICK_PREFIX.length).split(':');
return _loaded.get(productId)?.features[Number(index)]
? `${productId}:${index}`
: null;
}
function installClickHandler() {
if (!canSelect() || _clickHandler || !_viewer) return;
picking.registerPickOwner?.(
GHOSTMAPS_LAYER_ID,
(pickedId) =>
typeof pickedId === 'string' && pickedId.startsWith(PICK_PREFIX),
);
_clickHandler = screenSpaceEventHandlerFactory(_viewer);
_clickHandler.setInputAction((click) => {
if (pointer && !pointer.isPointerFree()) return;
const cardHit = overlayHost.hitTest?.(
click.position?.x,
click.position?.y,
{ sourceId: GHOSTMAPS_OVERLAY_SOURCE_ID },
);
if (cardHit && cardHit.entryId === _selectedCardId) {
if (_selectedLink && openExternal) openExternal(_selectedLink);
return;
}
const picked = _viewer.scene.pick(click.position);
const key = picked ? pickedKey(picked) : null;
if (key) {
_selectedKey = key;
publishCard();
render();
return;
}
if (picked) {
const pickId = picking.resolvePickId(picked);
if (pickId && picking.isOwnedByOtherLayer(GHOSTMAPS_LAYER_ID, pickId))
return;
}
if (_selectedKey) {
clearSelection();
render();
}
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
}
function removeClickHandler() {
_clickHandler?.destroy();
_clickHandler = null;
picking?.unregisterPickOwner?.(GHOSTMAPS_LAYER_ID);
}
function featureCount() {
let count = 0;
for (const item of _loaded.values()) count += item.features.length;
return count;
}
const layer = {
id: GHOSTMAPS_LAYER_ID,
name: 'S2 GhostMaps',
icon: '👻',
source: 'S2 Underground',
// Pushes arrive within seconds over the event stream; this interval is
// the backstop if the stream is down.
updateInterval: 300000,
init(viewer) {
if (_viewer) throw new Error('GhostMaps layer is already initialized');
_viewer = viewer;
},
enable() {
_enabled = true;
_lifetime = new AbortController();
for (const item of _loaded.values()) item.dataSource.show = true;
installClickHandler();
if (!_unsubscribe && typeof source.subscribe === 'function')
_unsubscribe = source.subscribe(
(catalog) => {
if (_enabled) void applyCatalog(catalog);
},
(state) => {
if (_stream === state) return;
_stream = state;
notifyRow();
},
);
},
disable() {
_enabled = false;
_lifetime.abort();
_unsubscribe?.();
_unsubscribe = null;
_stream = 'closed';
removeClickHandler();
clearSelection();
_horizon.stop();
for (const id of [..._loaded.keys()]) unload(id);
render();
},
async update(_viewerArg, { signal } = {}) {
if (!_enabled || signal?.aborted) return false;
try {
const catalog = await source.getCatalog({ signal });
if (!_enabled) return false;
await applyCatalog(catalog);
return true;
} catch (error) {
if (signal?.aborted || !_enabled) return false;
console.warn('[Data:GhostMaps] catalog error:', error);
_lastError = error?.message || msg('GhostMaps catalog unavailable');
notifyRow();
// Keep the layer on; the event stream or the next interval recovers.
return true;
}
},
destroy() {
layer.disable();
_viewer = null;
_catalog = null;
_rowListener = null;
},
setRowControlsListener(listener) {
_rowListener = listener;
},
getRowControls() {
const products = _catalog?.products || [];
const commit = _catalog?.commit;
const info = commit
? _stream === 'open'
? t('LIVE · GitHub {commit} · {message} · checked every {seconds}s', {
commit: commit.slice(0, 7),
message: _catalog.message || '—',
seconds: formatNumber(
Math.round((_catalog.pollMs || 15000) / 1000),
),
})
: t('GitHub {commit} · {message} · reconnecting for live updates', {
commit: commit.slice(0, 7),
message: _catalog.message || '—',
})
: t('Connecting to the S2 GhostMaps repository…');
return {
chips: products.map((product) => {
const loaded = _loaded.get(product.id);
const failed = _failed.get(product.id);
return {
id: product.id,
label: t(PRODUCT_LABELS[product.id] || product.title),
active: wanted(product),
busy: _loading.has(product.id),
state: failed ? 'error' : undefined,
color: PRODUCT_COLORS[product.id],
title: [
product.fileTitle,
loaded
? tn(
loaded.features.length,
'{count} placemark',
'{count} placemarks',
{
count: formatNumber(loaded.features.length),
},
)
: null,
failed ? t(failed) : null,
]
.filter(Boolean)
.join(' · '),
onClick: () => toggleProduct(product.id),
};
}),
legend: [],
info,
infoTitle: t(
'S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.',
),
};
},
getStats() {
return {
count: featureCount(),
products: [..._loaded.keys()],
commit: _catalog?.commit || null,
lastUpdate: _catalog?.committedAt || _catalog?.changedAt || null,
checkedAt: _catalog?.checkedAt || null,
stream: _stream,
loading: _loading.size > 0,
error: _lastError || [..._failed.values()][0] || null,
fallback: false,
};
},
/** Loaded placemarks as plain records, for tools and QA. */
getAnalystRecords(maxCount = Infinity) {
if (!_enabled) return [];
const out = [];
for (const [productId, item] of _loaded) {
for (const feature of item.features) {
if (out.length >= maxCount) return out;
out.push({
product: productId,
name: feature.name,
folder: feature.folder,
lon: feature.anchor[0],
lat: feature.anchor[1],
});
}
}
return out;
},
};
return layer;
}
+198
View File
@@ -0,0 +1,198 @@
// Read the drawable content of a parsed GhostMaps KML document: placemarks
// with their folder, geometry, resolved style and raw description. Network
// links, overlays, models and remote icons are never followed; an icon is
// either an image packed in the same KMZ or the layer's own marker.
import { kmlColor } from './records.js';
export const MAX_GHOSTMAPS_FEATURES = 20_000;
export const MAX_GHOSTMAPS_POSITIONS = 250_000;
const childrenNamed = (el, name) =>
[...(el?.children || [])].filter((child) => child.localName === name);
const firstChild = (el, name) =>
[...(el?.children || [])].find((child) => child.localName === name) || null;
const descendants = (el, name) =>
[...(el?.getElementsByTagName?.('*') || [])].filter(
(child) => child.localName === name,
);
const text = (el) => (el?.textContent ?? '').trim();
function number(el, fallback) {
const value = Number(text(el));
return el && Number.isFinite(value) ? value : fallback;
}
/** `lon,lat[,alt] lon,lat ...` → [[lon, lat], ...]; invalid tuples are dropped. */
export function parseKmlCoordinates(value) {
const out = [];
for (const tuple of String(value ?? '')
.trim()
.split(/\s+/)) {
const [lon, lat] = tuple.split(',').map(Number);
if (
Number.isFinite(lon) &&
Number.isFinite(lat) &&
Math.abs(lat) <= 90 &&
Math.abs(lon) <= 180
)
out.push([lon, lat]);
}
return out;
}
function readStyle(styleEl, resolveImage) {
const style = {};
const icon = firstChild(styleEl, 'IconStyle');
if (icon) {
const href = text(firstChild(firstChild(icon, 'Icon'), 'href'));
style.icon = href ? resolveImage(href) : null;
style.iconScale = number(firstChild(icon, 'scale'), 1);
style.iconColor = kmlColor(text(firstChild(icon, 'color')));
}
const line = firstChild(styleEl, 'LineStyle');
if (line) {
style.lineColor = kmlColor(text(firstChild(line, 'color')));
style.lineWidth = number(firstChild(line, 'width'), 1);
}
const poly = firstChild(styleEl, 'PolyStyle');
if (poly) {
style.polyColor = kmlColor(text(firstChild(poly, 'color')));
style.fill = text(firstChild(poly, 'fill')) !== '0';
style.outline = text(firstChild(poly, 'outline')) !== '0';
}
return style;
}
/** Shared styles by id; a StyleMap resolves to its `normal` pair. */
function collectStyles(doc, resolveImage) {
const styles = new Map();
for (const el of descendants(doc, 'Style')) {
const id = el.getAttribute('id');
if (id) styles.set(id, readStyle(el, resolveImage));
}
const maps = new Map();
for (const el of descendants(doc, 'StyleMap')) {
const id = el.getAttribute('id');
if (!id) continue;
const normal = childrenNamed(el, 'Pair').find(
(pair) => text(firstChild(pair, 'key')) === 'normal',
);
if (!normal) continue;
const inline = firstChild(normal, 'Style');
maps.set(
id,
inline
? readStyle(inline, resolveImage)
: text(firstChild(normal, 'styleUrl')),
);
}
const resolve = (url, depth = 0) => {
const id = String(url || '').replace(/^#/, '');
if (!id || url.includes('/') || depth > 2) return {};
if (styles.has(id)) return styles.get(id);
const mapped = maps.get(id);
if (typeof mapped === 'string') return resolve(mapped, depth + 1);
return mapped || {};
};
return resolve;
}
function readGeometry(el, out, budget) {
switch (el.localName) {
case 'Point': {
const [position] = parseKmlCoordinates(
text(firstChild(el, 'coordinates')),
);
if (position) {
budget.positions += 1;
out.push({ type: 'point', position });
}
break;
}
case 'LineString':
case 'LinearRing': {
const positions = parseKmlCoordinates(
text(firstChild(el, 'coordinates')),
);
if (positions.length >= 2) {
budget.positions += positions.length;
out.push({ type: 'line', positions });
}
break;
}
case 'Polygon': {
const ring = (boundary) =>
parseKmlCoordinates(
text(firstChild(firstChild(boundary, 'LinearRing'), 'coordinates')),
);
const outer = ring(firstChild(el, 'outerBoundaryIs'));
if (outer.length >= 3) {
const holes = childrenNamed(el, 'innerBoundaryIs')
.map(ring)
.filter((hole) => hole.length >= 3);
budget.positions +=
outer.length + holes.reduce((n, hole) => n + hole.length, 0);
out.push({ type: 'polygon', outer, holes });
}
break;
}
case 'MultiGeometry':
for (const child of el.children) readGeometry(child, out, budget);
break;
default:
// Models, tracks and anything newer are not drawn.
break;
}
}
/** Name of the nearest enclosing Folder (or Document), for grouping. */
function folderName(el) {
for (let node = el.parentElement; node; node = node.parentElement) {
if (node.localName === 'Folder' || node.localName === 'Document') {
const name = text(firstChild(node, 'name'));
if (name) return name;
}
}
return '';
}
/**
* @param {Document} doc Parsed KML.
* @param {(href: string) => ?string} resolveImage URL for a KMZ-packed image.
* @returns {{features: Array<{name: string, folder: string,
* description: string, style: object, geometries: Array<object>}>,
* positions: number, over: boolean}}
*/
export function readGhostMapsKml(doc, resolveImage = () => null) {
const styleFor = collectStyles(doc, resolveImage);
const features = [];
const budget = { positions: 0 };
let over = false;
for (const placemark of descendants(doc, 'Placemark')) {
// A hidden placemark (visibility 0) is drawn hidden by Google Earth too.
if (text(firstChild(placemark, 'visibility')) === '0') continue;
const geometries = [];
for (const child of placemark.children)
readGeometry(child, geometries, budget);
if (!geometries.length) continue;
if (
features.length >= MAX_GHOSTMAPS_FEATURES ||
budget.positions > MAX_GHOSTMAPS_POSITIONS
) {
over = true;
break;
}
const inline = firstChild(placemark, 'Style');
features.push({
name: text(firstChild(placemark, 'name')).slice(0, 200),
folder: folderName(placemark).slice(0, 120),
description: text(firstChild(placemark, 'description')),
style: {
...styleFor(text(firstChild(placemark, 'styleUrl'))),
...(inline ? readStyle(inline, resolveImage) : {}),
},
geometries,
});
}
return { features, positions: budget.positions, over };
}
+180
View File
@@ -0,0 +1,180 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import { createGhostMapsLayer } from './index.js';
function element(localName, children = [], value = '', attributes = {}) {
const node = {
localName,
children,
get textContent() {
return value + children.map((child) => child.textContent).join('');
},
getAttribute: (key) => attributes[key] ?? null,
getElementsByTagName(name) {
return children.flatMap((child) => [
...(name === '*' || name === child.localName ? [child] : []),
...child.getElementsByTagName(name),
]);
},
};
for (const child of children) child.parentElement = node;
return node;
}
function documentFixture() {
return element('document', [
element('kml', [
element('Document', [
element(
'Style',
[
element('IconStyle', [
element('Icon', [element('href', [], 'icon.png')]),
]),
],
'',
{ id: 'pin' },
),
element('Placemark', [
element('name', [], 'Test report'),
element('styleUrl', [], '#pin'),
element('Point', [element('coordinates', [], '12,34')]),
]),
]),
]),
]);
}
// A stored KMZ with a local icon; the XML DOM double supplies its one point.
function fixtureKmz() {
const files = [
['doc.kml', '<kml/>'],
['icon.png', 'packed icon'],
];
const local = [];
const central = [];
let offset = 0;
for (const [name, value] of files) {
const key = Buffer.from(name);
const bytes = Buffer.from(value);
const header = Buffer.alloc(30);
header.writeUInt32LE(0x04034b50);
header.writeUInt32LE(bytes.length, 18);
header.writeUInt32LE(bytes.length, 22);
header.writeUInt16LE(key.length, 26);
const directory = Buffer.alloc(46);
directory.writeUInt32LE(0x02014b50);
directory.writeUInt32LE(bytes.length, 20);
directory.writeUInt32LE(bytes.length, 24);
directory.writeUInt16LE(key.length, 28);
directory.writeUInt32LE(offset, 42);
local.push(header, key, bytes);
central.push(directory, key);
offset += header.length + key.length + bytes.length;
}
const directory = Buffer.concat(central);
const end = Buffer.alloc(22);
end.writeUInt32LE(0x06054b50);
end.writeUInt16LE(files.length, 8);
end.writeUInt16LE(files.length, 10);
end.writeUInt32LE(directory.length, 12);
end.writeUInt32LE(offset, 16);
return new Uint8Array(Buffer.concat([...local, directory, end]));
}
function fixture(t, { beforeFileReturns, onCreateUrl } = {}) {
const previous = Object.getOwnPropertyDescriptor(globalThis, 'DOMParser');
globalThis.DOMParser = class {
parseFromString() {
return documentFixture();
}
};
t.after(() => {
if (previous) Object.defineProperty(globalThis, 'DOMParser', previous);
else delete globalThis.DOMParser;
});
const created = [];
const revoked = [];
t.mock.method(URL, 'createObjectURL', () => {
const url = `blob:local-${created.length}`;
created.push(url);
onCreateUrl?.(layer);
return url;
});
t.mock.method(URL, 'revokeObjectURL', (url) => revoked.push(url));
t.mock.method(console, 'warn', () => {});
const mounted = new Set();
const viewer = {
scene: { requestRender() {} },
dataSources: {
async add(dataSource) {
mounted.add(dataSource);
return dataSource;
},
remove(dataSource) {
return mounted.delete(dataSource);
},
},
};
const layer = createGhostMapsLayer({
storage: null,
source: {
getCatalog: async () => ({
products: [{ id: 'cip', version: 'a'.repeat(40), defaultOn: true }],
}),
getFile: async () => {
beforeFileReturns?.(layer);
return fixtureKmz();
},
},
});
layer.init(viewer);
layer.enable();
t.after(() => layer.destroy());
return { layer, viewer, mounted, created, revoked };
}
test('cancelling a file read does not allocate packed-image URLs', async (t) => {
const { layer, viewer, mounted, created, revoked } = fixture(t, {
beforeFileReturns: (layer) => queueMicrotask(() => layer.disable()),
});
await layer.update(viewer);
assert.equal(created.length, 0);
assert.equal(revoked.length, 0);
assert.equal(mounted.size, 0);
});
test('cancellation during parsing releases every packed-image URL', async (t) => {
const { layer, viewer, mounted, created, revoked } = fixture(t, {
onCreateUrl: (layer) => layer.disable(),
});
await layer.update(viewer);
assert.equal(created.length, 1);
assert.deepEqual(revoked, created);
assert.equal(mounted.size, 0);
});
test('entity construction failure releases parsed image URLs', async (t) => {
const { layer, viewer, mounted, created, revoked } = fixture(t);
t.mock.method(Cesium.EntityCollection.prototype, 'add', () => {
throw new Error('fixture entity failure');
});
await layer.update(viewer);
assert.equal(created.length, 1);
assert.deepEqual(revoked, created);
assert.equal(mounted.size, 0);
assert.equal(layer.getStats().error, 'fixture entity failure');
});
test('successful load keeps icons until the product is disabled', async (t) => {
const { layer, viewer, mounted, created, revoked } = fixture(t);
await layer.update(viewer);
assert.equal(created.length, 1);
assert.equal(revoked.length, 0);
assert.equal(mounted.size, 1);
assert.equal(layer.getStats().count, 1);
layer.disable();
assert.deepEqual(revoked, created);
assert.equal(mounted.size, 0);
});
+165
View File
@@ -0,0 +1,165 @@
// Pure helpers for GhostMaps placemarks: description tables to plain fields,
// KML colors, and card text wrapping. Strings in, data out — description
// markup is only ever read as text, never inserted into the page.
const ENTITIES = Object.freeze({
amp: '&',
lt: '<',
gt: '>',
quot: '"',
apos: "'",
nbsp: ' ',
});
/** Decode the HTML character references ArcGIS and Google Earth emit. */
export function decodeHtmlEntities(text) {
return String(text ?? '').replace(
/&(#x[0-9a-f]{1,6}|#\d{1,7}|[a-z]{2,8});/gi,
(whole, ref) => {
if (ref[0] === '#') {
const code =
ref[1] === 'x' || ref[1] === 'X'
? parseInt(ref.slice(2), 16)
: parseInt(ref.slice(1), 10);
return code > 0 &&
code <= 0x10ffff &&
!(code >= 0xd800 && code <= 0xdfff)
? String.fromCodePoint(code)
: '';
}
return ENTITIES[ref.toLowerCase()] ?? whole;
},
);
}
/** Markup to one line of plain text. */
export function htmlToText(html) {
return decodeHtmlEntities(
String(html ?? '')
.replace(/<(script|style|head)\b[\s\S]*?<\/\1\s*>/gi, ' ')
.replace(/<br\s*\/?>/gi, ' ')
.replace(/<[^>]*>/g, ' '),
)
.replace(/\s+/g, ' ')
.trim();
}
const EMPTY_VALUES = new Set(['', '<null>', 'null', 'n/a', '-', '<blank>']);
// ArcGIS writes geometry type and internal ids into every popup table.
const SKIPPED_LABELS = new Set(['shape', 'objectid', 'fid', 'globalid']);
const MAX_FIELDS = 24;
const MAX_VALUE = 600;
const MAX_LINKS = 6;
/**
* Fields and links from a placemark description. ArcGIS "popup" exports are a
* two-column table (label, value); anything else becomes one unlabelled field.
* @param {string} html Description as written in the KML (CDATA text).
* @returns {{fields: Array<{label: string, value: string}>, links: string[]}}
*/
export function parseDescriptionFields(html) {
const source = String(html ?? '').replace(
/<(script|style|head)\b[\s\S]*?<\/\1\s*>/gi,
' ',
);
const fields = [];
const links = [];
const addLinks = (markup) => {
for (const match of markup.matchAll(/href\s*=\s*["']([^"']+)["']/gi)) {
const url = decodeHtmlEntities(match[1]).trim();
if (/^https?:\/\/[^\s]+$/i.test(url) && !links.includes(url))
links.push(url);
}
};
// Innermost rows only: ArcGIS nests the field table inside an outer row,
// and only leaf rows with exactly two cells are label/value pairs.
for (const row of source.matchAll(
/<tr\b[^>]*>((?:(?!<tr\b)[\s\S])*?)<\/tr\s*>/gi,
)) {
const cells = [
...row[1].matchAll(/<t[dh]\b[^>]*>([\s\S]*?)<\/t[dh]\s*>/gi),
].map((cell) => cell[1]);
if (cells.length !== 2) continue;
const label = htmlToText(cells[0]);
const value = htmlToText(cells[1]);
if (!label || SKIPPED_LABELS.has(label.toLowerCase())) continue;
if (EMPTY_VALUES.has(value.toLowerCase())) continue;
addLinks(cells[1]);
// A bare research link is carried by `links`; the card does not need
// the URL twice.
if (/^https?:\/\/\S+$/i.test(value)) continue;
if (fields.length < MAX_FIELDS)
fields.push({ label, value: value.slice(0, MAX_VALUE) });
}
if (!fields.length && !links.length) {
const text = htmlToText(source);
if (text) fields.push({ label: '', value: text.slice(0, MAX_VALUE) });
addLinks(source);
}
return { fields, links: links.slice(0, MAX_LINKS) };
}
/**
* KML `aabbggrr` to `[r, g, b, a]` in 0..1, or null.
* @param {string} text
* @returns {?number[]}
*/
export function kmlColor(text) {
const hex = String(text ?? '')
.trim()
.replace(/^#/, '');
if (!/^[0-9a-f]{8}$/i.test(hex)) return null;
const byte = (at) => parseInt(hex.slice(at, at + 2), 16) / 255;
return [byte(6), byte(4), byte(2), byte(0)];
}
/**
* Greedy word wrap for canvas card lines; overlong words are cut.
* @param {string} text
* @param {number} width Characters per line.
* @param {number} maxLines Lines kept; the last ends with an ellipsis when cut.
* @returns {string[]}
*/
export function wrapCardText(text, width = 56, maxLines = 4) {
const lines = [];
let line = '';
let cut = false;
for (let word of String(text ?? '')
.split(/\s+/)
.filter(Boolean)) {
while (word.length > width) {
if (line) {
lines.push(line);
line = '';
}
lines.push(word.slice(0, width));
word = word.slice(width);
}
if (!line) line = word;
else if (line.length + 1 + word.length <= width) line += ` ${word}`;
else {
lines.push(line);
line = word;
}
if (lines.length >= maxLines) {
cut = true;
break;
}
}
if (line && lines.length < maxLines) lines.push(line);
else if (line) cut = true;
const kept = lines.slice(0, maxLines);
if (cut && kept.length) {
const last = kept[kept.length - 1];
kept[kept.length - 1] = `${last.slice(0, width - 1)}…`;
}
return kept;
}
/** Field labels ArcGIS writes CamelCase; show them as words. */
export function fieldLabel(label) {
return String(label ?? '')
.replace(/([a-z])([A-Z0-9])/g, '$1 $2')
.replace(/_/g, ' ')
.trim();
}
+106
View File
@@ -0,0 +1,106 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
decodeHtmlEntities,
fieldLabel,
htmlToText,
kmlColor,
parseDescriptionFields,
wrapCardText,
} from './records.js';
// The shape ArcGIS Pro writes into every S2 placemark description.
const ARCGIS = `<html xmlns:fo="http://www.w3.org/1999/XSL/Format"><head>
<META http-equiv="Content-Type" content="text/html"></head>
<body style="margin:0px"><table style="font-family:Arial">
<tr style="text-align:center;font-weight:bold;background:#9CBCE2"><td>HistoricProtestSite</td></tr>
<tr><td><table style="font-family:Arial">
<tr><td>SHAPE</td><td>Point</td></tr>
<tr bgcolor="#D4E4F3"><td>InstallationType</td><td>HistoricProtestSite</td></tr>
<tr><td>OperationalStatus</td><td>ACTIVE</td></tr>
<tr bgcolor="#D4E4F3"><td>Affiliation</td><td>&lt;Null&gt;</td></tr>
<tr><td>Description</td><td>Locals gather here &amp; protest &#8212; weekly.</td></tr>
<tr><td>Research1</td><td><a target="_blank" href="https://x.com/a/status/1">https://x.com/a/status/1</a></td></tr>
<tr><td>Research2</td><td><a href="javascript:alert(1)">bad</a></td></tr>
<tr><td>Research3</td><td>&lt;Null&gt;</td></tr>
</table></td></tr></table></body><script type="text/javascript">
function changeImage(a, b) { document.getElementById('x').innerHTML = b; }
</script></html>`;
test('ArcGIS popup tables become plain label/value fields and http links', () => {
const { fields, links } = parseDescriptionFields(ARCGIS);
assert.deepEqual(fields, [
{ label: 'InstallationType', value: 'HistoricProtestSite' },
{ label: 'OperationalStatus', value: 'ACTIVE' },
{ label: 'Description', value: 'Locals gather here & protest — weekly.' },
{ label: 'Research2', value: 'bad' },
]);
// javascript: and other schemes never become links.
assert.deepEqual(links, ['https://x.com/a/status/1']);
});
test('free-form descriptions become one unlabelled field; scripts are dropped', () => {
assert.deepEqual(
parseDescriptionFields(
'<p>Front line<br>as of 10 Oct</p><script>steal()</script><a href="https://s2.example/x">more</a>',
),
{
fields: [{ label: '', value: 'Front line as of 10 Oct more' }],
links: ['https://s2.example/x'],
},
);
assert.deepEqual(parseDescriptionFields(''), { fields: [], links: [] });
assert.deepEqual(parseDescriptionFields(undefined), {
fields: [],
links: [],
});
});
test('field values and counts are bounded', () => {
const rows = Array.from(
{ length: 40 },
(_, i) => `<tr><td>K${i}</td><td>${'x'.repeat(900)}</td></tr>`,
).join('');
const { fields } = parseDescriptionFields(`<table>${rows}</table>`);
assert.equal(fields.length, 24);
assert.equal(fields[0].value.length, 600);
});
test('entities and tags decode to text', () => {
assert.equal(
decodeHtmlEntities('&lt;b&gt; &#x41;&#66; &nbsp;&bogus;'),
'<b> AB &bogus;',
);
assert.equal(decodeHtmlEntities('&#xD800;&#0;'), '');
assert.equal(htmlToText('<style>a{}</style><b>One</b>\n two'), 'One two');
});
test('KML aabbggrr colors convert to rgba', () => {
assert.deepEqual(kmlColor('ff0000ff'), [1, 0, 0, 1]);
assert.deepEqual(kmlColor('8000ff00'), [0, 1, 0, 128 / 255]);
assert.equal(kmlColor('red'), null);
assert.equal(kmlColor(''), null);
});
test('card text wraps on words, cuts long words and marks truncation', () => {
assert.deepEqual(wrapCardText('one two three four', 9, 4), [
'one two',
'three',
'four',
]);
assert.deepEqual(wrapCardText('abcdefghijkl', 5, 4), [
'abcde',
'fghij',
'kl',
]);
assert.deepEqual(wrapCardText('aa bb cc dd ee', 5, 2), ['aa bb', 'cc d…']);
assert.deepEqual(wrapCardText('', 5, 2), []);
for (const line of wrapCardText('word '.repeat(200), 20, 3))
assert.ok(line.length <= 20);
});
test('CamelCase field labels read as words', () => {
assert.equal(fieldLabel('OperationalStatus'), 'Operational Status');
assert.equal(fieldLabel('Research1'), 'Research 1');
assert.equal(fieldLabel('Kinetic_Activity'), 'Kinetic Activity');
});
+81
View File
@@ -0,0 +1,81 @@
import {
readResponseBytesCapped,
readResponseJsonCapped,
} from '../../sources/httpBody.js';
import { GHOSTMAPS_MAX_FILE_BYTES } from './catalog.js';
// A portable export (package.json): no i18n here. Failures stay English data;
// the Layers panel shows them translated.
const msg = (text) => text;
/**
* Same-origin GhostMaps source: the catalog, one product's KMZ bytes, and a
* push subscription that fires whenever the watcher sees a new commit.
*/
export function createGhostMapsSource({
fetchImpl = (...args) => globalThis.fetch(...args),
EventSourceImpl = globalThis.EventSource,
} = {}) {
return {
async getCatalog({ signal } = {}) {
const response = await fetchImpl('/api/ghostmaps/catalog', { signal });
if (!response.ok)
throw new Error(
`GhostMaps catalog unavailable (HTTP ${response.status})`,
);
const catalog = await readResponseJsonCapped(
response,
1024 * 1024,
signal,
);
if (!Array.isArray(catalog?.products))
throw new Error(msg('Malformed GhostMaps catalog'));
return catalog;
},
async getFile(id, version, { signal } = {}) {
const response = await fetchImpl(
`/api/ghostmaps/file/${encodeURIComponent(id)}?v=${encodeURIComponent(version)}`,
{ signal },
);
if (!response.ok)
throw new Error(`GhostMaps file unavailable (HTTP ${response.status})`);
return readResponseBytesCapped(
response,
GHOSTMAPS_MAX_FILE_BYTES,
signal,
);
},
/**
* Call `onCatalog(catalog)` for every catalog the server pushes, and
* `onState('open' | 'connecting' | 'closed')` as the stream changes. The
* browser's EventSource reconnects by itself. Returns an unsubscribe.
*/
subscribe(onCatalog, onState = () => {}) {
if (typeof EventSourceImpl !== 'function') {
onState('closed');
return () => {};
}
const events = new EventSourceImpl('/api/ghostmaps/events');
const states = ['connecting', 'open', 'closed'];
const report = () => onState(states[events.readyState] ?? 'connecting');
events.onopen = report;
events.onerror = report;
const listener = (event) => {
try {
const catalog = JSON.parse(event.data);
if (Array.isArray(catalog?.products)) onCatalog(catalog);
} catch {
/* ignore a malformed frame; the next one replaces it */
}
};
events.addEventListener('catalog', listener);
report();
return () => {
events.removeEventListener('catalog', listener);
events.close();
};
},
};
}
+16 -9
View File
@@ -94,20 +94,27 @@ test('deferred terminal notices lose ownership to newer acquisition epochs and d
test('share-follow failures use the universal top-center status instead of the bottom toast', () => {
const handler = shellMethod('_handleShareTrackingRestoreStatus').toString();
assert.match(handler, /this\.showStatus\(message\)/);
assert.match(handler, /this\.initialRestorePromise\.then\(showAfterStartupCover\)/);
assert.match(handler, /this\._lifetime\.frame\(\(\) => \{/);
assert.match(handler, /this\._lifetime\.listen\(\s*startupCover,\s*'transitionend',\s*showOnce,\s*\{ once: true \},?\s*\)/);
assert.match(handler, /fallbackTimer = this\._lifetime\.timeout\(showOnce, 1000\)/);
assert.doesNotMatch(handler, /this\._showToast\(message\)/);
assert.doesNotMatch(handler, /pushCockpitSignal/);
const present = shellMethod('_presentAfterStartupCover').toString();
assert.match(
handler,
/this\.initialRestorePromise\.then\(\(\) =>\s*this\._presentAfterStartupCover\(message, noticeGeneration\),?\s*\)/,
);
assert.match(present, /this\.showStatus\(message\)/);
assert.match(present, /this\._lifetime\.frame\(\(\) => \{/);
assert.match(present, /this\._lifetime\.listen\(\s*startupCover,\s*'transitionend',\s*showOnce,\s*\{ once: true \},?\s*\)/);
assert.match(present, /fallbackTimer = this\._lifetime\.timeout\(showOnce, 1000\)/);
assert.match(present, /canPresentDeferredStatusNotice\(/);
for (const source of [handler, present]) {
assert.doesNotMatch(source, /this\._showToast\(message\)/);
assert.doesNotMatch(source, /pushCockpitSignal/);
}
assert.match(handler, /result\.classification === 'pending'/);
assert.match(handler, /state: 'acquiring'/);
assert.match(handler, /persistent: true/);
assert.match(handler, /this\._shareTrackingNoticeGeneration \+= 1/);
assert.match(handler, /canPresentDeferredStatusNotice\(/);
assert.match(handler, /if \(this\._shareTrackingAcquiringKey\) return/);
assert.match(handler, /result\.classification === 'followed'\s*\|\|\s*result\.classification === 'cancelled'/);
assert.match(handler, /result\.classification === 'followed'/);
assert.match(handler, /result\.classification === 'cancelled'\) return/);
});
test('universal notice masks active loading only for its own fixed dwell', () => {
+4 -2
View File
@@ -94,11 +94,13 @@ test('a deferred lookup that never flies leaves the readout standing', () => {
/stamp: \(\) =>\s*this\._stampNavigation\(\{\s*cancelPendingSelection,\s*clearSearchedLocation: false,?\s*\}\)/,
);
const reassert = ui.indexOf(' _reassertNavigationHandoff(generation) {');
const reassert = ui.indexOf(
" _reassertNavigationHandoff(generation, { trackingOrigin = 'tool' } = {}) {",
);
assert.ok(reassert > 0, '_reassertNavigationHandoff is missing');
assert.match(
ui.slice(reassert, reassert + 900),
/release: \(\) => \{[\s\S]{0,320}?this\.clearLocation\(\);[\s\S]{0,200}?this\._releaseFollowCamera\(\)/,
/release: \(\) => \{[\s\S]{0,320}?this\.clearLocation\(\);[\s\S]{0,200}?this\._releaseFollowCamera\(\{ trackingOrigin \}\)/,
);
// …and the policy only reaches `release` after its authority checks pass.
+3 -2
View File
@@ -193,7 +193,8 @@ test('desktop panel lanes use per-panel allocations and presentation-only auto-c
test('share-panel state excludes responsive collapse and preserves recipient preferences', () => {
const ui = readShellSource();
const sharelink = readFileSync(new URL('./sharelink.js', import.meta.url), 'utf8');
// The long form spells the share format's panel table (one source of truth).
const panels = readFileSync(new URL('./share/viewCodec.js', import.meta.url), 'utf8');
assert.match(
ui,
@@ -211,7 +212,7 @@ test('share-panel state excludes responsive collapse and preserves recipient pre
'pin and unpin must update the share hash even when collapse state is unchanged',
);
assert.match(ui, /\{ id: 'param-slider-panel' \}/);
assert.match(sharelink, /\{ id: 'param-slider-panel', token: 'm', pinnable: false \}/);
assert.match(panels, /id: 'param-slider-panel',\s*token: 'm',\s*pinnable: false,/);
});
test('parameterized Display presets keep one stable scroll owner', () => {
+18 -17
View File
@@ -1,5 +1,6 @@
import { readRealtimeSource } from './testSupport/readRealtimeSource.mjs';
import { GEV_REALTIME_TOOLS } from '../server/providers/openai/tools.js';
import { realtimeInstructions } from '../server/providers/openai/instructions.js';
import { readShellSource } from './testSupport/readShellSource.mjs';
import { expandApplicationHtml } from '../build/application-html.js';
import { readLayerSource } from './testSupport/readLayerSource.mjs';
@@ -25,13 +26,14 @@ const css = readStylesheet(new URL('../style.css', import.meta.url));
function realtimeTools() { return GEV_REALTIME_TOOLS; }
test('Realtime schema exposes the authoritative 30-tool inventory', () => {
test('Realtime schema exposes the authoritative 31-tool inventory', () => {
const tools = realtimeTools();
assert.equal(tools.length, 30);
assert.equal(tools.length, 31);
const names = tools.map((tool) => tool.name);
assert.equal(new Set(names).size, 30, 'tool names are unique');
assert.equal(new Set(names).size, 31, 'tool names are unique');
assert.ok(names.includes('set_context_mode'));
assert.ok(names.includes('control_cockpit'));
assert.ok(names.includes('set_voice_inactivity_timeout'));
assert.ok(names.includes('select_nearest_aircraft'));
assert.ok(names.includes('control_radio'));
assert.ok(names.includes('next_satellite_pass'));
@@ -77,13 +79,12 @@ test('the counting contract is stated in the Realtime instructions', () => {
});
test('Context panel opening stays distinct from Contacts activation', () => {
const start = voice.indexOf("'For requests to open, show, reveal, or focus a menu/panel");
assert.ok(start >= 0, 'panel-routing instruction is missing');
const text = voice.slice(start, voice.indexOf('\n', start));
assert.match(text, /"Open Context" means only set_panel_open/);
assert.match(text, /does not activate a Context sub-mode/);
assert.match(text, /"Open Contacts" means set_context_mode\{mode:"contacts"\}/);
assert.match(text, /expands the parent Context panel before activating Contacts/);
const text = realtimeInstructions().split('\n').find(line => line.startsWith('Panels:'));
assert.ok(text, 'panel-routing instruction is missing');
assert.match(text, /"Open Context" is only set_panel_open\{panelId:"global-context-panel",open:true\}/);
assert.match(text, /opening Context or selecting\/viewing an aircraft never enters them/);
assert.match(text, /"Open Contacts" is set_context_mode contacts/);
assert.match(text, /expands Context itself/);
});
test('nearest-aircraft selection stays out of Contacts and Cockpit', () => {
@@ -99,14 +100,14 @@ test('nearest-aircraft selection stays out of Contacts and Cockpit', () => {
assert.match(text, /nearest airborne result/);
assert.match(text, /healthy fallback feed is valid data/i);
assert.match(text, /Do not also call fly_to_location, set_layer_visibility, analyst_query, track_entity/);
assert.match(text, /SELECT\/FIND never implies Contacts or Cockpit/);
const routing = realtimeInstructions().split('\n').find(line => line.startsWith('Panels:'));
assert.match(routing, /Contacts, Space Missions and Cockpit need explicit naming/);
assert.match(text, /set_context_mode, or control_cockpit/);
const byName = new Map(realtimeTools().map((tool) => [tool.name, tool]));
assert.match(byName.get('set_context_mode').description, /explicitly requests/);
assert.match(byName.get('set_context_mode').description, /selecting an aircraft does not imply Context/i);
assert.match(byName.get('control_cockpit').description, /explicitly requests Cockpit/);
assert.match(byName.get('control_cockpit').description, /must not enter Cockpit/);
assert.match(byName.get('set_context_mode').description, /only when explicitly requested/);
assert.match(routing, /selecting\/viewing an aircraft never enters them/);
assert.match(byName.get('control_cockpit').description, /Explicit Cockpit requests only/);
assert.equal(
byName.get('fly_to_location').parameters.properties.waitForArrival.type,
'boolean',
@@ -117,7 +118,6 @@ test('nearest-aircraft selection stays out of Contacts and Cockpit', () => {
assert.match(nearest.description, /Atomically/);
assert.match(nearest.description, /exclude on-ground records/);
assert.match(nearest.description, /fallback feeds remain usable/);
assert.match(nearest.description, /does not open Contacts or Cockpit/);
});
test('the two Context/Cockpit tools pin their enums and required arguments', () => {
@@ -153,7 +153,7 @@ test('the edited existing tools changed exactly as intended', () => {
const panel = byName.get('set_panel_open');
assert.deepEqual(
panel.parameters.properties.panelId.enum,
['data-panel', 'location-bar', 'control-panel', 'cctv-panel', 'radio-panel', 'scene-panel', 'pp-toggles', 'global-context-panel', 'weather-panel', 'recent-imagery-panel'],
['data-panel', 'location-bar', 'control-panel', 'cctv-panel', 'radio-panel', 'scene-panel', 'pp-toggles', 'global-context-panel', 'weather-panel', 'recent-imagery-panel', 'voice-settings', 'voice-preview', 'provider-settings'],
);
assert.deepEqual(panel.parameters.required, ['panelId', 'open']);
@@ -183,6 +183,7 @@ test('no unchanged Realtime tool definition drifts silently', () => {
'set_context_mode',
'control_cockpit',
'set_panel_open',
'set_voice_inactivity_timeout',
'get_current_view_state',
'fly_to_location',
'select_nearest_aircraft',
+31 -18
View File
@@ -11,9 +11,8 @@ import { expandApplicationHtml } from '../build/application-html.js';
// actually decides fresh-boot layer state — including the early return that
// makes each layer's own initializer the operative default.
// 2. Scope feather. Owner: "I like to hide feather" set it to 0% on
// 2026-08-22; the owner revised that to 8% on 2026-08-23 and locked 11%
// on 2026-08-24, a soft
// edge. The hard crop is still one drag away, and that is pinned too.
// 2026-08-22; later revisions were 8%, then 11%, then 100% on
// 2026-10-25. Scope stays ON on a fresh boot. The hard crop remains available.
// 3. Detection ON (Dense @ 75%) for EVERY style, Normal included.
// Owner: "detect should also be on by default."
// 4. Detection OUTSIDE opacity 1% (owner final lock, 2026-08-24; 3% on 08-23, 5% before), with
@@ -38,6 +37,7 @@ import { AIRCRAFT_BRACKET_FLOOR_ANCHOR } from './data/detectionPolicy.js';
import {
SCOPE_FEATHER_RATIO_DEFAULT,
getScopeMaskFeather,
isScopeMaskEnabled,
scopeMaskGeometry,
setScopeMaskFeather,
} from './scopeMask.js';
@@ -75,32 +75,40 @@ function managerForHash(hash) {
}
// ---------------------------------------------------------------------------
// 2. Scope feather — a subtle soft edge on a first run
// 2. Scope ON with 100% feather on a first run
// ---------------------------------------------------------------------------
test('first run opens with a subtle scope feather, at every surface that decides it', () => {
assert.equal(SCOPE_FEATHER_RATIO_DEFAULT, 0.11,
'owner final lock 2026-08-24, superseding the 08-22 hard-crop and 08-23 8% rulings');
assert.equal(getScopeMaskFeather(), 0.11,
test('first run opens with Scope ON and 100% feather at every default surface', () => {
assert.equal(isScopeMaskEnabled(), true, 'the live mask starts enabled');
assert.equal(SCOPE_FEATHER_RATIO_DEFAULT, 1, 'owner preference 2026-10-25');
assert.equal(getScopeMaskFeather(), 1,
'and the live module starts there, not merely documents it');
// The slider and its readout are the same default rendered as markup — a
// fresh boot applies no restore, so a stale value here would show one number
// over a mask drawn at another.
assert.match(indexHtml, /id="scope-feather-slider"[^>]*\svalue="11"/,
'index.html: the feather slider ships at 11');
assert.match(indexHtml, /id="scope-feather-value"[^>]*>11%</,
assert.match(indexHtml, /id="scope-feather-slider"[^>]*\svalue="100"/,
'index.html: the feather slider ships at 100');
assert.match(indexHtml, /id="scope-feather-value"[^>]*>100%</,
'index.html: and its readout agrees with the handle');
assert.match(indexHtml, /<input type="checkbox" id="scope-toggle"[^>]*\schecked\s*\/>/,
'the native Scope checkbox starts checked');
// The link this session generates must describe the mask this session draws,
// for the window before the first _syncShareState.
assert.match(shareSource, /this\._scopeFeatherPct = 11;/,
assert.match(shareSource, /this\._scopeFeatherPct = 100;/,
'sharelink.js: the generator starts from the same value the mask starts at');
const params = managerForHash('')._buildHashParams();
assert.equal(params.get('sc'), '1');
assert.equal(params.get('scf'), '100');
});
test('an explicit feather still wins over the subtle default', () => {
test('an explicit feather still wins over the 100% default', () => {
// A link is authored state. The new default governs a session that said
// nothing; it must never overwrite one that said something.
assert.equal(managerForHash('#lat=10&lon=20&scf=11').parseInitialHash().scopeFeatherPct, 11,
'links carrying the previous default preserve their authored feather');
assert.equal(managerForHash('#lat=10&lon=20&scf=35').parseInitialHash().scopeFeatherPct, 35);
assert.equal(managerForHash('#lat=10&lon=20&scf=64').parseInitialHash().scopeFeatherPct, 64);
assert.equal(managerForHash('#lat=10&lon=20&scf=0').parseInitialHash().scopeFeatherPct, 0,
@@ -113,23 +121,28 @@ test('an explicit feather still wins over the subtle default', () => {
'a pre-scf link restores the author\'s view, not the new default');
});
test('the subtle default did not weaken the feather control, and 0 is still reachable', () => {
test('share links preserve explicit Scope OFF and feather choices over the defaults', () => {
const shared = managerForHash('#lat=10&lon=20&sc=0&scf=11').parseInitialHash();
assert.equal(shared.scopeEnabled, false);
assert.equal(shared.scopeFeatherPct, 11);
});
test('the 100% default retains the expanded feather range and hard edge', () => {
// The cheap way to move a default would be to nerf the control. Prove the
// slider still spans its full range and the geometry is still DERIVED from
// the ratio — a check that would pass vacuously if it only ever saw one value.
assert.match(indexHtml, /id="scope-feather-slider"[^>]*\smin="0"[^>]*\smax="100"/,
assert.match(indexHtml, /id="scope-feather-slider"[^>]*\smin="0"[^>]*\smax="200"/,
'the slider still offers the whole range');
const previous = getScopeMaskFeather();
try {
for (const ratio of [0.35, 0.7, 1]) {
for (const ratio of [0.35, 0.7, 1, 1.75, 2]) {
setScopeMaskFeather(ratio);
const geo = scopeMaskGeometry(1200, 900);
const keyholeR = 900 * 0.5 * KEYHOLE_OUTER_RADIUS;
assert.ok(Math.abs((geo.outerR - geo.innerR) - keyholeR * ratio) < 1e-9,
`feather ${ratio} must still widen the band to that fraction of the keyhole`);
}
// The new default is a real, narrow band — not the hard crop, and nowhere
// near the retired 35 % halo.
// The first-run value draws a band derived from the same keyhole radius.
setScopeMaskFeather(SCOPE_FEATHER_RATIO_DEFAULT);
const soft = scopeMaskGeometry(1200, 900);
const keyholeR = 900 * 0.5 * KEYHOLE_OUTER_RADIUS;
+25 -21
View File
@@ -1,4 +1,5 @@
import { getKeyholeGeometry } from './celestialRing.js';
import { SCOPE_FEATHER_RATIO_MAX } from './scopeMaskPolicy.js';
/**
* Scope mask — the app's signature circular viewport treatment, made real.
@@ -41,25 +42,10 @@ import { getKeyholeGeometry } from './celestialRing.js';
/** Matches the page background the emergent scope faded into. */
const SCOPE_OUTSIDE_COLOR = { r: 5, g: 5, b: 8 };
/**
* Default edge feather as a fraction of the keyhole radius.
*
* 0.11 since 2026-08-24 (owner final lock; 0.08 on 08-23, hard-crop 0 on
* 08-22 — this supersedes both), REVISING the 2026-08-22 ruling that
* set it to zero: a subtle soft edge rather than either the hard crop or the
* retired 35 % halo. The slider is untouched and still spans 0..100; this is
* only where it STARTS. `setScopeMaskFeather` is unchanged, so any feather a
* share link carries, or the operator dials in, overrides this immediately, and
* the hard-crop path at 0 is still reachable from the handle.
*
* Keep in lockstep with `#scope-feather-slider`'s markup value AND readout in
* index.html and `_scopeFeatherPct` in sharelink.js — a fresh boot applies no
* restore, so those literals ARE the first-run state. NOT the `scf` PARSE
* fallback, which stays at 35 on purpose: a link predating that field was
* authored when 35 was what its author saw, and a link from the feather-0 era
* carries `scf=0` explicitly because the generator always writes the field.
* Pinned in reasonableDefaults.test.mjs.
* First-run feather ratio; the Display slider and share defaults use 100%.
* The historical missing-scf parse fallback remains 35%.
*/
export const SCOPE_FEATHER_RATIO_DEFAULT = 0.11;
export const SCOPE_FEATHER_RATIO_DEFAULT = 1;
/**
* Terminus opacity at/above SCOPE_TERMINUS_FAR_M — slightly translucent so
* faint stars survive in the corners at globe scale.
@@ -344,7 +330,10 @@ function teardownDevicePixelRatioWatch() {
export function scopeMaskGeometry(width, height, featherRatio = _featherRatio) {
const keyhole = getKeyholeGeometry(width, height);
if (!(keyhole.radius > 0)) return null;
const feather = Math.max(0, Math.min(1, Number(featherRatio) || 0));
const feather = Math.max(
0,
Math.min(SCOPE_FEATHER_RATIO_MAX, Number(featherRatio) || 0),
);
// Feather straddles the keyhole edge so the visible radius stays anchored
// to the shared geometry every keyhole consumer (labels, cards) fades on.
const half = keyhole.radius * feather * 0.5;
@@ -423,6 +412,14 @@ function draw() {
_painted = true;
}
/** Expose only the OFF state; an absent attribute preserves the original HUD. */
function syncScopeMaskAppearance(enabled = _enabled) {
const dataset = globalThis.document?.documentElement?.dataset;
if (!dataset) return;
if (enabled) delete dataset.scopeMask;
else dataset.scopeMask = 'off';
}
/**
* Install the scope mask into the viewer container. Idempotent.
* @param {Cesium.Viewer} viewer
@@ -430,6 +427,7 @@ function draw() {
*/
export function installScopeMask(viewer) {
if (_canvas || !viewer?.container) return;
syncScopeMaskAppearance();
_container = viewer.container;
_viewer = viewer;
_canvas = document.createElement('canvas');
@@ -505,6 +503,7 @@ export function setScopeMaskEnabled(enabled) {
const next = Boolean(enabled);
const reEnabled = next && !_enabled;
_enabled = next;
syncScopeMaskAppearance();
if (reEnabled) {
// The camera moved freely while the scope was off and nothing sampled it,
// so the painted terminus can be a whole altitude band stale. Re-sync once
@@ -522,11 +521,14 @@ export function isScopeMaskEnabled() {
}
/**
* @param {number} ratio - Edge feather as a fraction of keyhole radius [0,1].
* @param {number} ratio - Edge feather as a fraction of keyhole radius [0,2].
* @returns {void}
*/
export function setScopeMaskFeather(ratio) {
_featherRatio = Math.max(0, Math.min(1, Number(ratio) || 0));
_featherRatio = Math.max(
0,
Math.min(SCOPE_FEATHER_RATIO_MAX, Number(ratio) || 0),
);
draw();
}
@@ -540,6 +542,7 @@ export function getScopeMaskFeather() {
* @returns {void}
*/
export function destroyScopeMask() {
syncScopeMaskAppearance(true);
_resizeObserver?.disconnect();
_resizeObserver = null;
teardownDevicePixelRatioWatch();
@@ -553,6 +556,7 @@ export function destroyScopeMask() {
/** Test seam. */
export function _resetScopeMaskForTest() {
syncScopeMaskAppearance(true);
_resizeObserver?.disconnect();
_resizeObserver = null;
teardownDevicePixelRatioWatch();
+50 -5
View File
@@ -18,6 +18,7 @@ import {
SCOPE_TERMINUS_MIN_PCT,
SCOPE_TERMINUS_MAX_PCT,
SCOPE_FEATHER_RATIO_DEFAULT,
getScopeMaskFeather,
SCOPE_OUTSIDE_ALPHA,
SCOPE_TERMINUS_ALPHA_NEAR,
SCOPE_TERMINUS_FAR_M,
@@ -26,6 +27,7 @@ import {
_resetScopeMaskForTest,
} from './scopeMask.js';
import { KEYHOLE_OUTER_RADIUS } from './celestialRing.js';
import { SCOPE_FEATHER_RATIO_MAX, clampScopeFeatherPct } from './scopeMaskPolicy.js';
beforeEach(() => _resetScopeMaskForTest());
@@ -47,16 +49,34 @@ test('zero feather produces a hard edge exactly at the keyhole radius', () => {
assert.equal(geo.outerR, keyholeR);
});
test('feather ratio is clamped to [0,1] and bad input falls back to 0', () => {
test('feather ratio reaches the center at 200% and clamps larger input', () => {
const wide = scopeMaskGeometry(1000, 800, 7);
const one = scopeMaskGeometry(1000, 800, 1);
assert.equal(wide.innerR, one.innerR);
assert.equal(wide.outerR, one.outerR);
const maximum = scopeMaskGeometry(1000, 800, SCOPE_FEATHER_RATIO_MAX);
const keyholeR = 800 * 0.5 * KEYHOLE_OUTER_RADIUS;
assert.equal(maximum.innerR, 0);
assert.equal(maximum.outerR, keyholeR * 2);
assert.equal(wide.innerR, maximum.innerR);
assert.equal(wide.outerR, maximum.outerR);
for (const ratio of [1, 1.5, 2]) {
setScopeMaskFeather(ratio);
assert.equal(getScopeMaskFeather(), ratio);
const geometry = scopeMaskGeometry(1000, 800);
assert.equal((geometry.innerR + geometry.outerR) / 2, keyholeR);
assert.equal(geometry.outerR - geometry.innerR, keyholeR * ratio);
}
setScopeMaskFeather(7);
assert.equal(getScopeMaskFeather(), SCOPE_FEATHER_RATIO_MAX);
const nan = scopeMaskGeometry(1000, 800, Number.NaN);
const zero = scopeMaskGeometry(1000, 800, 0);
assert.equal(nan.innerR, zero.innerR);
});
test('UI and share feather percentages share one bounded integer range', () => {
for (const [value, expected] of [[0, 0], [11, 11], [175.6, 176], [200, 200], [350, 200], [-4, 0], [Number.NaN, 0]]) {
assert.equal(clampScopeFeatherPct(value), expected);
}
});
test('degenerate viewport yields null instead of a broken gradient', () => {
assert.equal(scopeMaskGeometry(0, 0), null);
assert.equal(scopeMaskGeometry(-5, 100), null);
@@ -136,12 +156,14 @@ function stubScopeMaskDom({ width = 1000, height = 800, dpr = 1 } = {}) {
removeEventListener: (type, fn) => listeners.delete(fn),
}),
};
globalThis.document = { createElement: () => canvas };
const documentElement = { dataset: {} };
globalThis.document = { documentElement, createElement: () => canvas };
globalThis.ResizeObserver = class { observe() {} disconnect() {} };
const container = { clientWidth: width, clientHeight: height, appendChild() {} };
return {
canvas,
container,
documentElement,
/** Canvas work counters: backing-store resizes, clears, fills. */
ops,
/** Every fillStyle string assigned since install (hard-crop path). */
@@ -160,6 +182,29 @@ function stubScopeMaskDom({ width = 1000, height = 800, dpr = 1 } = {}) {
};
}
test('scope appearance follows direct state changes and install/destroy lifecycles', () => {
const dom = stubScopeMaskDom();
try {
setScopeMaskEnabled(false);
assert.equal(dom.documentElement.dataset.scopeMask, 'off');
delete dom.documentElement.dataset.scopeMask;
installScopeMask({ container: dom.container });
assert.equal(dom.documentElement.dataset.scopeMask, 'off', 'install publishes retained OFF state');
destroyScopeMask();
assert.equal(dom.documentElement.dataset.scopeMask, undefined, 'destroy clears the document marker');
installScopeMask({ container: dom.container });
assert.equal(dom.documentElement.dataset.scopeMask, 'off', 'reinstall retains the preference');
setScopeMaskEnabled(true);
assert.equal(dom.documentElement.dataset.scopeMask, undefined, 'ON uses the original presentation');
setScopeMaskEnabled(false);
_resetScopeMaskForTest();
assert.equal(dom.documentElement.dataset.scopeMask, undefined, 'test reset clears the marker');
} finally {
destroyScopeMask();
dom.restore();
}
});
test('a DPR change with no resize still repaints the backing store', () => {
const dom = stubScopeMaskDom({ width: 1000, height: 800, dpr: 1 });
try {
+11
View File
@@ -0,0 +1,11 @@
/** A 200% feather reaches the center while keeping its midpoint on the keyhole. */
export const SCOPE_FEATHER_RATIO_MAX = 2;
const SCOPE_FEATHER_MAX_PCT = SCOPE_FEATHER_RATIO_MAX * 100;
/** Normalize a UI/share-link feather percentage to the supported integer range. */
export function clampScopeFeatherPct(value) {
return Math.max(
0,
Math.min(SCOPE_FEATHER_MAX_PCT, Math.round(Number(value) || 0)),
);
}
+155
View File
@@ -0,0 +1,155 @@
/**
* What a share link's thing view follows, and where its camera is: the
* entity kinds and the follow-offset geometry of the share format
* (docs/SHARE-LINK-FORMAT.md). Like the rest of the format, every code,
* default and formula here is permanent.
*
* Pure and dependency-free, so the app's follow camera and the codec
* (viewCodec.js) share one definition.
*/
const freezeAll = (rows) =>
Object.freeze(rows.map((row) => Object.freeze(row)));
/**
* Things a view can follow. `code` is permanent and is also the number of the
* thing-message field that carries this kind's id, so the kind costs nothing
* on the wire. `idForm` picks the compact numeric spelling (a 24-bit ICAO
* address or a decimal number); any other identifier travels as text.
* `tracking` names the layer-state slot that holds the same selection in the
* long form.
*/
export const SHARE_VIEW_ENTITY_KINDS = freezeAll([
{
code: 1,
kind: 'aircraft',
layerId: 'flights',
idForm: 'hex24',
tracking: 'flights',
},
{
code: 2,
kind: 'military_aircraft',
layerId: 'military',
idForm: 'hex24',
tracking: 'military',
},
{
code: 3,
kind: 'vessel',
layerId: 'ais-live-vessels',
idForm: 'decimal',
tracking: null,
},
{
code: 4,
kind: 'satellite',
layerId: 'satellites',
idForm: 'decimal',
tracking: 'satellites',
},
]);
/**
* The frozen meaning of an omitted follow-offset field. An offset whose
* fields all equal these is written as nothing, so it decodes as these.
*/
export const SHARE_VIEW_FOLLOW_OFFSET_DEFAULT = Object.freeze({
range: 2000,
heading: 0,
pitch: -35,
});
const EARTH_RADIUS_M = 6_371_000;
const RAD = Math.PI / 180;
/**
* The camera's offset from a followed entity in the entity's local east,
* north and up metres: the camera looks along (heading, pitch) at the entity
* from `range` metres away. Heading is clockwise from north; a negative pitch
* looks down, so the camera sits above the entity.
*/
export function followOffsetToEnu({ range, heading, pitch }) {
const horizontal = range * Math.cos(pitch * RAD);
return {
east: -horizontal * Math.sin(heading * RAD),
north: -horizontal * Math.cos(heading * RAD),
up: -range * Math.sin(pitch * RAD),
};
}
/** The inverse of {@link followOffsetToEnu}, in whole metres and degrees. */
export function followOffsetFromEnu({ east, north, up }) {
const range = Math.hypot(east, north, up);
if (!(range > 0)) return null;
return {
range: Math.round(range),
heading: (Math.round(Math.atan2(-east, -north) / RAD) + 360) % 360,
pitch: Math.round(Math.asin(Math.max(-1, Math.min(1, -up / range))) / RAD),
};
}
/** Earth-centred position of (lat, lon, alt) on the format's sphere. */
function toEcef(lat, lon, alt) {
const r = EARTH_RADIUS_M + alt;
const cosLat = Math.cos(lat * RAD);
return [
r * cosLat * Math.cos(lon * RAD),
r * cosLat * Math.sin(lon * RAD),
r * Math.sin(lat * RAD),
];
}
/** The local east, north and up unit vectors at (lat, lon). */
function enuBasis(lat, lon) {
const sinLat = Math.sin(lat * RAD);
const cosLat = Math.cos(lat * RAD);
const sinLon = Math.sin(lon * RAD);
const cosLon = Math.cos(lon * RAD);
return {
east: [-sinLon, cosLon, 0],
north: [-sinLat * cosLon, -sinLat * sinLon, cosLat],
up: [cosLat * cosLon, cosLat * sinLon, sinLat],
};
}
const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
/**
* Where the camera sits while following an entity at `fix` from `offset`,
* and how it looks back at the entity, on a sphere of radius 6 371 000 m. A
* thing view with a fix stores no camera of its own: this is its camera, and
* the place a decoder falls back to. Pure, and computed only from values at
* the format's precision, so encoder and decoder agree.
*/
export function followCameraFor(fix, offset) {
const local = followOffsetToEnu(offset);
const at = toEcef(fix.lat, fix.lon, fix.alt);
const basis = enuBasis(fix.lat, fix.lon);
const camera = [0, 1, 2].map(
(i) =>
at[i] +
local.east * basis.east[i] +
local.north * basis.north[i] +
local.up * basis.up[i],
);
const horizontal = Math.hypot(camera[0], camera[1]);
const lat = Math.atan2(camera[2], horizontal) / RAD;
const lon = Math.atan2(camera[1], camera[0]) / RAD;
const alt = Math.hypot(horizontal, camera[2]) - EARTH_RADIUS_M;
// The view direction, camera to entity, in the camera's own local frame.
const view = [0, 1, 2].map((i) => at[i] - camera[i]);
const own = enuBasis(lat, lon);
const length = Math.hypot(...view);
if (!(length > 0))
return { ...fix, heading: offset.heading, pitch: offset.pitch, roll: 0 };
return {
lat,
lon,
alt,
heading:
(Math.atan2(dot(view, own.east), dot(view, own.north)) / RAD + 360) % 360,
pitch:
Math.asin(Math.max(-1, Math.min(1, dot(view, own.up) / length))) / RAD,
roll: 0,
};
}
+119
View File
@@ -0,0 +1,119 @@
/**
* Conversion between the long share-link form's parsed state (what
* `ShareLinkManager.parseInitialHash()` returns for `#v=2&lat=…`) and the
* compact share-view state of viewCodec.js.
*
* The two use the same field names, so the conversion is small: the compact
* form adds `view`, and the long form keeps a followed entity as a tracking
* selection inside the layer options instead.
*/
import {
SHARE_TRACKING_RESTORE_POLICIES,
createDefaultLayerState,
normalizeLayerState,
} from '../data/layerState.js';
import {
SHARE_VIEW_ENTITY_KINDS,
normalizeShareViewState,
} from './viewCodec.js';
/** Fields of the long form's parsed state, in its own order. */
export const LEGACY_SHARE_STATE_KEYS = Object.freeze([
'lat',
'lon',
'alt',
'heading',
'pitch',
'roll',
'style',
'styleParams',
'bloom',
'sharpen',
'bloomIntensity',
'bloomVersion',
'sharpenIntensity',
'hudVariant',
'hudVisible',
'detectionMode',
'detectionDensity',
'detectionAllocation',
'detectionFadePct',
'detectionOutsideOpacityPct',
'celestialRing',
'scopeEnabled',
'scopeFeatherPct',
'scopeTerminusPct',
'mapStack',
'layerState',
'layerStateInvalid',
'panelState',
'sharedAtMs',
]);
/**
* A compact share-view state from a parsed long-form state. A tracking
* selection in the layer options becomes a thing view with no offset or
* last-known fix, since the long form records neither. Restore bookkeeping
* (`restoreAuthority`, `layerStateInvalid`) is not part of a view.
*/
export function fromLegacyShareState(parsed) {
if (!parsed || typeof parsed !== 'object') return null;
const picked = {};
for (const key of LEGACY_SHARE_STATE_KEYS) {
if (key !== 'layerStateInvalid' && Object.hasOwn(parsed, key))
picked[key] = parsed[key];
}
return normalizeShareViewState(picked);
}
/**
* The parsed long-form state for a compact share-view state, ready for
* `ShareLinkManager.applyState`. A thing view's layer is on (in a fresh layer
* set when the view has none), and a followed aircraft, military aircraft or
* satellite goes back into its layer's tracking selection. The long form
* cannot express the rest of a thing view (a vessel, the follow offset, the
* last-known fix); callers that need them read `state.view` directly.
*/
export function toLegacyShareState(input) {
const state = normalizeShareViewState(input);
const entry =
state.view.kind === 'thing'
? SHARE_VIEW_ENTITY_KINDS.find((e) => e.kind === state.view.entity.kind)
: null;
let layerState =
entry && !state.layerState
? normalizeLayerState({
...createDefaultLayerState(),
enabledLayerIds: [entry.layerId],
})
: state.layerState;
const policy = entry?.tracking
? SHARE_TRACKING_RESTORE_POLICIES[entry.tracking]
: null;
if (
layerState &&
policy &&
layerState.enabledLayerIds.includes(entry.layerId)
) {
const id =
entry.kind === 'satellite'
? Number(state.view.entity.id)
: state.view.entity.id;
layerState = normalizeLayerState({
...layerState,
options: {
...layerState.options,
[policy.optionOwner]: {
...layerState.options[policy.optionOwner],
[policy.optionKey]: id,
},
},
});
}
const legacy = {};
for (const key of LEGACY_SHARE_STATE_KEYS) legacy[key] = state[key];
legacy.layerState = layerState;
legacy.layerStateInvalid = false;
return legacy;
}
+308
View File
@@ -0,0 +1,308 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { ShareLinkManager } from '../sharelink.js';
import { createDefaultLayerState } from '../data/layerState.js';
import { HUD_LAYOUTS } from '../hudLayoutPolicy.js';
import { VIEW_MAPS, VIEW_STYLES } from '../view/index.js';
import {
HAL_EXAMPLE_HASH,
pick,
randomLayerState,
rng,
} from '../testSupport/shareViewFixtures.mjs';
import {
SHARE_VIEW_PANELS,
SHARE_VIEW_STYLE_PARAMS,
decodeShareView,
encodeShareView,
} from './viewCodec.js';
import { SHARE_VIEW_STYLE_DEFAULTS } from './viewCodec.js';
import {
LEGACY_SHARE_STATE_KEYS,
fromLegacyShareState,
toLegacyShareState,
} from './legacyShareState.js';
const DEG = Math.PI / 180;
function stubViewer(camera = {}) {
return {
camera: {
changed: { addEventListener: () => () => {} },
positionCartographic: camera.position ?? {
latitude: 0,
longitude: 0,
height: 0,
},
heading: camera.heading ?? 0,
pitch: camera.pitch ?? 0,
roll: camera.roll ?? 0,
},
};
}
/** The long form's own parser, run on `hash`. */
function parseLegacy(hash) {
const body = hash.startsWith('#') ? hash : `#${hash}`;
globalThis.window = {
location: { hash: body, href: `http://localhost/${body}` },
};
const manager = new ShareLinkManager(stubViewer());
const parsed = manager.parseInitialHash();
manager.destroy();
return parsed;
}
/**
* The parsed state as a view: restore bookkeeping removed, and headings and
* rolls wrapped into [0, 360) — the codec's one normalization of values the
* long-form generator writes (Cesium can round 359.6° up to `roll=360`).
*/
function comparable(parsed) {
const out = {};
for (const key of LEGACY_SHARE_STATE_KEYS) out[key] = parsed[key];
// A long link without `sp` leaves the style's parameters as they are; the
// compact form says what they are, the style's own unless changed.
const ownParams = SHARE_VIEW_STYLE_DEFAULTS[out.style].styleParams;
out.styleParams = ownParams ? { ...ownParams, ...out.styleParams } : null;
for (const key of ['heading', 'roll'])
out[key] = ((out[key] % 360) + 360) % 360;
return out;
}
/** Legacy hash → parsed state → codec → parsed state, asserting equality. */
function assertLegacyRoundTrip(hash) {
const parsed = parseLegacy(hash);
assert.ok(parsed, `long form parses: ${hash}`);
const state = fromLegacyShareState(parsed);
const encoded = encodeShareView(state);
const decoded = decodeShareView(encoded, { nowMs: Date.now() });
assert.equal(decoded.ok, true, decoded.reason);
assert.deepEqual(decoded.state, state, 'codec state survives');
assert.deepEqual(
toLegacyShareState(decoded.state),
comparable(parsed),
'parsed long-form state survives',
);
return { parsed, state, encoded };
}
test('the HAL example survives the round trip and shrinks to 39 characters', () => {
const { parsed, state, encoded } = assertLegacyRoundTrip(HAL_EXAMPLE_HASH);
assert.equal(HAL_EXAMPLE_HASH.length, 302);
assert.equal(encoded.length, 39);
assert.equal(encoded, 'ARCg-SQYrah3ILAJKB4wO1IEAIQAEGIBNMgBC1o');
assert.equal(parsed.roll, 360);
assert.equal(state.roll, 0);
assert.deepEqual(state.layerState.enabledLayerIds, [
'ais-live-vessels',
'flights',
'satellites',
]);
assert.equal(state.layerState.options.flights.models3d, true);
assert.equal(state.layerState.options.wind.overlay, 'none');
assert.deepEqual(
state.panelState.specs.find((spec) => spec.id === 'cctv-panel'),
{ id: 'cctv-panel', collapsed: false, pinned: null },
);
});
test('links from earlier long-form versions keep their historical meaning', () => {
for (const hash of [
'lat=10&lon=20',
'lat=37.7749&lon=-122.4194&alt=800&heading=0&pitch=-35&style=nvg&bloom=1&bi=84&sharpen=0&si=65&hud=tactical&hv=1&dm=BALANCED&dd=50&da=elastic&kf=16&ko=0&cr=0&map=photoreal',
'v=2&lat=1&lon=2&l=f&lo=f.m.a',
'v=2&lat=1&lon=2&l=',
'v=2&lat=1&lon=2&l=k.r&lo=r.f.g-jazz_r.v.35',
'v=2&lat=1&lon=2&ui=v.c.0',
'v=2&lat=1&lon=2&style=crt&sp=p.500_d.25_i.100',
'v=2&lat=1&lon=2&sce=0&scf=999&dm=GOD&dd=10&at=1767225600',
]) {
assertLegacyRoundTrip(hash);
}
// A pre-v2 link's omitted settings are written explicitly, so the compact
// form keeps the fallbacks the long form gives them (ko=5, kf=16, scf=35).
const old = fromLegacyShareState(parseLegacy('lat=10&lon=20'));
assert.deepEqual(
[old.detectionOutsideOpacityPct, old.detectionFadePct, old.scopeFeatherPct],
[5, 16, 35],
);
assert.deepEqual(
[old.bloomIntensity, old.bloomVersion],
[50, 1],
'legacy bloom scale',
);
// `l=f` without `e` is a v2 link with 3D models OFF; the codec writes that.
const off = fromLegacyShareState(parseLegacy('v=2&lat=1&lon=2&l=f'));
assert.equal(off.layerState.options.flights.models3d, false);
});
test('a tracking selection becomes a thing view and back', () => {
const flights = assertLegacyRoundTrip('v=2&lat=1&lon=2&l=f&lo=f.t.a1b2c3');
assert.deepEqual(flights.state.view.entity, {
kind: 'aircraft',
id: 'a1b2c3',
});
assert.equal(
flights.state.layerState.options.flights.selectedFlightsTrackingId,
null,
);
const military = assertLegacyRoundTrip('v=2&lat=1&lon=2&l=m&lo=f.u.~ae1234');
assert.deepEqual(military.state.view.entity, {
kind: 'military_aircraft',
id: '~ae1234',
});
const satellite = assertLegacyRoundTrip('v=2&lat=1&lon=2&l=s&lo=s.t.25544');
assert.deepEqual(satellite.state.view.entity, {
kind: 'satellite',
id: '25544',
});
assert.equal(
toLegacyShareState(satellite.state).layerState.options.satellites
.selectedSatTrackingId,
25544,
);
});
test('thing views the long form cannot express convert without one', () => {
const layerState = createDefaultLayerState();
layerState.enabledLayerIds = ['ais-live-vessels'];
const vessel = toLegacyShareState({
lat: 1,
lon: 2,
layerState,
view: { kind: 'thing', entity: { kind: 'vessel', id: '366999712' } },
});
assert.deepEqual(vessel.layerState, layerState);
});
test('a thing view turns its layer on, with or without a layer set', () => {
for (const layerState of [createDefaultLayerState(), null]) {
const legacy = toLegacyShareState({
layerState,
view: { kind: 'thing', entity: { kind: 'aircraft', id: 'a1b2c3' } },
});
assert.deepEqual(legacy.layerState.enabledLayerIds, ['flights']);
assert.equal(
legacy.layerState.options.flights.selectedFlightsTrackingId,
'a1b2c3',
);
assert.equal(legacy.layerStateInvalid, false);
}
const vessel = toLegacyShareState({
view: { kind: 'thing', entity: { kind: 'vessel', id: '366999712' } },
});
assert.deepEqual(vessel.layerState.enabledLayerIds, ['ais-live-vessels']);
// A place view without a layer set leaves the recipient's layers alone.
assert.equal(toLegacyShareState({ lat: 1, lon: 2 }).layerState, null);
});
/** A long-form link written by the real generator from random live state. */
function generatedLegacyHash(random) {
const int = (min, max) => min + Math.floor(random() * (max - min + 1));
globalThis.window = { location: { hash: '', href: 'http://localhost/' } };
const manager = new ShareLinkManager(
stubViewer({
position: {
latitude: (int(-899999, 899999) / 1e4) * DEG,
longitude: (int(-1799999, 1799999) / 1e4) * DEG,
height: pick(random, [int(-50, 3000), int(0, 30_000_000)]),
},
heading: int(0, 359) * DEG + (random() < 0.1 ? 0.99 * DEG : 0),
pitch: int(-90, 0) * DEG,
roll: pick(random, [0, 359.7 * DEG, int(0, 359) * DEG]),
}),
);
const style = pick(random, VIEW_STYLES);
manager.onStyleChange(style);
const density = pick(random, [0, 25, 50, 75, 100]);
manager.onToggleChange(random() < 0.5, random() < 0.5, {
bloomIntensity: int(0, 100),
bloomVersion: pick(random, [1, 2]),
sharpenIntensity: int(0, 100),
hudVariant: pick(random, HUD_LAYOUTS),
hudVisible: random() < 0.5,
detectionMode: pick(random, ['OFF', 'SPARSE', 'BALANCED', 'DENSE']),
detectionDensity: density,
detectionAllocation: pick(random, ['ELASTIC', 'WEIGHTED']),
detectionFadePct: int(0, 40),
detectionOutsideOpacityPct: int(0, 100),
celestialRingEnabled: random() < 0.5,
scopeEnabled: random() < 0.5,
scopeFeatherPct: int(0, 200),
scopeTerminusPct: random() < 0.3 ? int(94, 100) : null,
mapStack: pick(random, VIEW_MAPS),
});
if (random() < 0.8) {
const layerState = randomLayerState(random);
manager.setLayerStateProvider(() => layerState);
}
if (random() < 0.8) {
const panelState = {
specs: SHARE_VIEW_PANELS.filter(() => random() < 0.9).map((panel) => ({
id: panel.id,
collapsed: random() < 0.7,
...(panel.pinnable ? { pinned: random() < 0.3 } : {}),
})),
};
manager.setPanelStateProvider(() => panelState);
}
const specs = SHARE_VIEW_STYLE_PARAMS[style];
if (specs && random() < 0.7) {
const values = Object.fromEntries(
specs.map((spec) => [
spec.key,
spec.min + (spec.max - spec.min) * random(),
]),
);
manager.setStyleParamStateProvider(() => values);
}
const params = manager._buildHashParams();
if (random() < 0.7)
params.set('at', String(int(1_600_000_000, Math.floor(Date.now() / 1000))));
manager.destroy();
return params.toString();
}
test('links written by the long-form generator survive the compact form', () => {
const random = rng(0xc0dec);
let totalLegacy = 0;
let totalCompact = 0;
for (let i = 0; i < 400; i += 1) {
const hash = generatedLegacyHash(random);
const { encoded } = assertLegacyRoundTrip(hash);
totalLegacy += hash.length;
totalCompact += encoded.length;
}
// Even random states, far from defaults, are under half the length.
assert.ok(
totalCompact * 2 < totalLegacy,
`${totalCompact} vs ${totalLegacy}`,
);
});
test('the converter ignores non-objects and restore bookkeeping', () => {
// A malformed v2 layer list is reported by the long-form parser; the view
// itself simply has no layer set.
const invalid = parseLegacy('v=2&lat=1&lon=2&l=f.unknown');
assert.equal(invalid.layerStateInvalid, true);
assert.equal(fromLegacyShareState(invalid).layerState, null);
assert.equal(fromLegacyShareState(null), null);
const parsed = parseLegacy(HAL_EXAMPLE_HASH);
assert.ok(parsed.restoreAuthority);
const state = fromLegacyShareState(parsed);
assert.equal(Object.hasOwn(state, 'restoreAuthority'), false);
assert.equal(Object.hasOwn(state, 'layerStateInvalid'), false);
});
test('the converter knows every key of the long form’s parsed state', () => {
// A key the converter does not list would silently drop out of share codes.
globalThis.window = { location: { hash: '', href: 'http://localhost/' } };
const manager = new ShareLinkManager(stubViewer({}));
const parsed = manager._parseShareHash('v=2&lat=1&lon=2');
assert.deepEqual(
Object.keys(parsed).sort(),
[...LEGACY_SHARE_STATE_KEYS].sort(),
);
manager.destroy();
});
+56
View File
@@ -0,0 +1,56 @@
/**
* Share-link addresses: a share code travels in the path, `<app>/share/<code>`,
* where `<app>` is wherever the app is served (the origin's root, or a base
* path). The same code opens on any copy of the app.
*
* Pure: no DOM. Callers pass the addresses they have.
*/
/** The path segment that introduces a share code. */
export const SHARE_PATH_SEGMENT = 'share';
// Any last segment counts as a code here, so a mangled one is refused by the
// decoder (and said so) rather than opening the app at a wrong address.
const SHARE_PATH = new RegExp(`^(.*/)${SHARE_PATH_SEGMENT}/([^/]+)/?$`);
/**
* The share code and the app's base path for a share-link pathname, or null
* when the pathname is not a share link. `/share/AQc` gives
* `{ code: 'AQc', basePath: '/' }`; `/gev/share/AQc` gives basePath `/gev/`.
*/
export function parseSharePath(pathname) {
if (typeof pathname !== 'string') return null;
const match = SHARE_PATH.exec(pathname);
if (!match) return null;
let code = match[2];
try {
code = decodeURIComponent(code);
} catch {
// Left as written; the decoder refuses it.
}
return { code, basePath: match[1] };
}
/**
* The app's root address for `href`: the base path of a share link, or the
* directory of any other page, with no hash. The query is kept.
*/
export function appRootUrl(href) {
const url = new URL(href);
url.hash = '';
const share = parseSharePath(url.pathname);
url.pathname = share ? share.basePath : new URL('./', url).pathname;
return url;
}
/**
* The share link for `code` on the app at `href`. The sender's `?lang` stays
* behind: the recipient keeps their own language.
*/
export function shareLinkUrl(code, href) {
const root = appRootUrl(href);
const url = new URL(`${SHARE_PATH_SEGMENT}/${code}`, root);
url.search = root.search;
url.searchParams.delete('lang');
return url.href;
}
+45
View File
@@ -0,0 +1,45 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { appRootUrl, parseSharePath, shareLinkUrl } from './shareLinkPath.js';
test('a share path names its code and the app base path', () => {
assert.deepEqual(parseSharePath('/share/AQc'), {
code: 'AQc',
basePath: '/',
});
assert.deepEqual(parseSharePath('/gev/share/AQc/'), {
code: 'AQc',
basePath: '/gev/',
});
// A mangled code is still a share link, so the app refuses it out loud.
assert.equal(parseSharePath('/share/AQ.c').code, 'AQ.c');
for (const path of ['/', '/share', '/share/', '/share/a/b', '/sharex/AQc'])
assert.equal(parseSharePath(path), null, path);
assert.equal(parseSharePath(undefined), null);
});
test('the app root drops a share path and the hash, and keeps the query', () => {
assert.equal(
appRootUrl('https://gev.example/share/AQc?qa=1#v=2').href,
'https://gev.example/?qa=1',
);
assert.equal(
appRootUrl('http://localhost:4173/gev/share/AQc').href,
'http://localhost:4173/gev/',
);
assert.equal(
appRootUrl('https://gev.example/index.html#v=2&lat=1').href,
'https://gev.example/',
);
});
test('a share link sits under the app root without the sender ?lang', () => {
assert.equal(
shareLinkUrl('AQc', 'https://gev.example/?lang=ru#v=2&lat=1'),
'https://gev.example/share/AQc',
);
assert.equal(
shareLinkUrl('AQc', 'https://gev.example/gev/share/old?qa=1&lang=es'),
'https://gev.example/gev/share/AQc?qa=1',
);
});
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+259
View File
@@ -0,0 +1,259 @@
/**
* Byte-level primitives for the compact share-view format: base64url text,
* protobuf-style varints, and a tag-length-value reader and writer.
*
* Pure: no DOM, no Cesium, no platform buffers. Every integrity failure is a
* {@link ShareWireError} with a stable `reason`, so the view codec can turn it
* into a rejected link instead of an exception at app start.
*/
/** Wire types. Field numbers and their wire types are permanent. */
export const WIRE_VARINT = 0;
export const WIRE_FIXED64 = 1;
export const WIRE_LEN = 2;
export const WIRE_FIXED32 = 5;
/** Field numbers stay inside protobuf's range so tags fit in five bytes. */
export const MAX_FIELD_NUMBER = 2 ** 29 - 1;
const MAX_VARINT_BYTES = 8; // 56 bits covers every safe integer.
/** A malformed or out-of-bounds payload. `reason` is a stable code. */
export class ShareWireError extends Error {
constructor(reason, detail = '') {
super(detail ? `${reason}: ${detail}` : reason);
this.name = 'ShareWireError';
this.reason = reason;
}
}
const BASE64URL =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
const BASE64URL_INDEX = new Map(
[...BASE64URL].map((char, index) => [char, index]),
);
/** Unpadded base64url text for `bytes`. */
export function encodeBase64Url(bytes) {
let out = '';
for (let i = 0; i < bytes.length; i += 3) {
const a = bytes[i];
const b = i + 1 < bytes.length ? bytes[i + 1] : 0;
const c = i + 2 < bytes.length ? bytes[i + 2] : 0;
const triple = (a << 16) | (b << 8) | c;
out += BASE64URL[(triple >> 18) & 63] + BASE64URL[(triple >> 12) & 63];
if (i + 1 < bytes.length) out += BASE64URL[(triple >> 6) & 63];
if (i + 2 < bytes.length) out += BASE64URL[triple & 63];
}
return out;
}
/**
* Bytes for unpadded base64url text. Rejects padding, foreign characters, an
* impossible length and non-zero trailing bits, so every payload has exactly
* one spelling.
*/
export function decodeBase64Url(text) {
if (typeof text !== 'string' || text.length === 0)
throw new ShareWireError('empty');
if (text.length % 4 === 1) throw new ShareWireError('bad-base64');
const bytes = new Uint8Array(Math.floor((text.length * 3) / 4));
let buffer = 0;
let bits = 0;
let out = 0;
for (const char of text) {
const value = BASE64URL_INDEX.get(char);
if (value === undefined) throw new ShareWireError('bad-base64');
buffer = ((buffer << 6) | value) & 0xffffff;
bits += 6;
if (bits >= 8) {
bits -= 8;
bytes[out++] = (buffer >> bits) & 0xff;
}
}
if (bits > 0 && (buffer & ((1 << bits) - 1)) !== 0)
throw new ShareWireError('bad-base64', 'non-zero trailing bits');
return bytes;
}
/**
* CRC-8 (polynomial x⁸ + x² + x + 1, 0x07; initial value 0; no reflection;
* no final XOR) of `bytes`. A degree-8 CRC detects every error burst of up to
* 8 bits, so any one mistyped base64url character (6 bits) is caught.
*/
export function crc8(bytes) {
let crc = 0;
for (const byte of bytes) {
crc ^= byte;
for (let bit = 0; bit < 8; bit += 1)
crc = crc & 0x80 ? ((crc << 1) ^ 0x07) & 0xff : (crc << 1) & 0xff;
}
return crc;
}
/** Map a signed integer onto the unsigned varint space (0,-1,1,-2 → 0,1,2,3). */
export function zigzag(value) {
return value >= 0 ? value * 2 : -value * 2 - 1;
}
/** Inverse of {@link zigzag}. */
export function unzigzag(value) {
return value % 2 === 0 ? value / 2 : -(value + 1) / 2;
}
/** Largest magnitude a zigzag field can carry without losing precision. */
export const MAX_ZIGZAG_MAGNITUDE = Math.floor(Number.MAX_SAFE_INTEGER / 2);
/** Append-only byte sink with protobuf-style field helpers. */
export class WireWriter {
constructor() {
this.bytes = [];
}
varint(value) {
if (!Number.isSafeInteger(value) || value < 0)
throw new RangeError(`varint out of range: ${value}`);
let rest = value;
while (rest >= 0x80) {
this.bytes.push((rest % 0x80) | 0x80);
rest = Math.floor(rest / 0x80);
}
this.bytes.push(rest);
return this;
}
tag(field, wireType) {
if (!Number.isInteger(field) || field < 1 || field > MAX_FIELD_NUMBER)
throw new RangeError(`field number out of range: ${field}`);
return this.varint(field * 8 + wireType);
}
uintField(field, value) {
return this.tag(field, WIRE_VARINT).varint(value);
}
sintField(field, value) {
if (!Number.isSafeInteger(value) || Math.abs(value) > MAX_ZIGZAG_MAGNITUDE)
throw new RangeError(`signed value out of range: ${value}`);
return this.tag(field, WIRE_VARINT).varint(zigzag(value));
}
bytesField(field, bytes) {
this.tag(field, WIRE_LEN).varint(bytes.length);
for (const byte of bytes) this.bytes.push(byte);
return this;
}
/** ASCII-only text; every share-view string is drawn from ASCII grammars. */
textField(field, text) {
return this.bytesField(field, asciiBytes(text));
}
finish() {
return Uint8Array.from(this.bytes);
}
}
/** Printable-ASCII bytes for `text`; anything else is a programming error. */
export function asciiBytes(text) {
const bytes = [];
for (let i = 0; i < text.length; i += 1) {
const code = text.charCodeAt(i);
if (code < 0x20 || code > 0x7e)
throw new RangeError('share-view text must be printable ASCII');
bytes.push(code);
}
return bytes;
}
/** Printable-ASCII text from bytes; anything else is a malformed payload. */
export function asciiText(bytes) {
let out = '';
for (const byte of bytes) {
if (byte < 0x20 || byte > 0x7e) throw new ShareWireError('bad-text');
out += String.fromCharCode(byte);
}
return out;
}
/** Bounds-checked cursor over one message (or one packed blob). */
export class WireReader {
constructor(bytes, start = 0, end = bytes.length) {
this.bytes = bytes;
this.pos = start;
this.end = end;
}
get done() {
return this.pos >= this.end;
}
/** One canonical varint no larger than Number.MAX_SAFE_INTEGER. */
varint() {
let value = 0;
let scale = 1;
for (let count = 0; count < MAX_VARINT_BYTES; count += 1) {
if (this.pos >= this.end) throw new ShareWireError('truncated');
const byte = this.bytes[this.pos++];
value += (byte & 0x7f) * scale;
if ((byte & 0x80) === 0) {
// A zero final byte after a continuation is an overlong spelling.
if (count > 0 && byte === 0)
throw new ShareWireError('non-canonical-varint');
if (!Number.isSafeInteger(value))
throw new ShareWireError('varint-overflow');
return value;
}
scale *= 0x80;
}
throw new ShareWireError('varint-overflow');
}
svarint() {
return unzigzag(this.varint());
}
/** The next field's number and wire type, rejecting reserved wire types. */
tag() {
const key = this.varint();
const field = Math.floor(key / 8);
const wireType = key % 8;
if (field < 1 || field > MAX_FIELD_NUMBER)
throw new ShareWireError('bad-field-number');
if (
wireType !== WIRE_VARINT &&
wireType !== WIRE_FIXED64 &&
wireType !== WIRE_LEN &&
wireType !== WIRE_FIXED32
)
throw new ShareWireError('bad-wire-type', String(wireType));
return { field, wireType };
}
/** A length-delimited body as a sub-reader over the same bytes. */
lengthDelimited() {
const length = this.varint();
if (length > this.end - this.pos)
throw new ShareWireError('truncated', 'length exceeds payload');
const reader = new WireReader(this.bytes, this.pos, this.pos + length);
this.pos += length;
return reader;
}
/** Remaining bytes of this reader as a fresh array. */
rest() {
const out = this.bytes.slice(this.pos, this.end);
this.pos = this.end;
return out;
}
/** Skip one value of `wireType`, so newer fields cost older decoders nothing. */
skip(wireType) {
if (wireType === WIRE_VARINT) this.varint();
else if (wireType === WIRE_LEN) this.lengthDelimited();
else {
const size = wireType === WIRE_FIXED64 ? 8 : 4;
if (size > this.end - this.pos) throw new ShareWireError('truncated');
this.pos += size;
}
}
}
+188
View File
@@ -0,0 +1,188 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
ShareWireError,
WIRE_FIXED32,
WIRE_FIXED64,
WIRE_LEN,
WIRE_VARINT,
WireReader,
WireWriter,
crc8,
decodeBase64Url,
encodeBase64Url,
unzigzag,
zigzag,
} from './wire.js';
const reasonOf = (fn) => {
try {
fn();
} catch (error) {
assert.ok(error instanceof ShareWireError, String(error));
return error.reason;
}
assert.fail('expected a ShareWireError');
};
test('base64url round-trips every length and byte value without padding', () => {
for (let length = 0; length < 70; length += 1) {
const bytes = Uint8Array.from(
{ length },
(_, i) => (i * 97 + length) & 0xff,
);
const text = encodeBase64Url(bytes);
assert.match(text, /^[A-Za-z0-9_-]*$/);
if (length) assert.deepEqual(decodeBase64Url(text), bytes);
}
const all = Uint8Array.from({ length: 256 }, (_, i) => i);
assert.deepEqual(decodeBase64Url(encodeBase64Url(all)), all);
assert.equal(encodeBase64Url(Uint8Array.of(0xfb, 0xff)), '-_8');
});
test('base64url rejects padding, foreign characters, bad lengths and stray bits', () => {
for (const text of ['', 'AQ==', 'A+Q', 'A/Q', 'A Q', 'AQ%', 'A', 'AQAAA']) {
assert.ok(
['empty', 'bad-base64'].includes(reasonOf(() => decodeBase64Url(text))),
text,
);
}
// "AR" carries one byte (0x01) plus four trailing bits that must be zero.
assert.equal(
reasonOf(() => decodeBase64Url('AR')),
'bad-base64',
);
assert.deepEqual(decodeBase64Url('AQ'), Uint8Array.of(1));
});
test('zigzag maps small magnitudes of either sign to small codes', () => {
assert.deepEqual([0, -1, 1, -2, 2].map(zigzag), [0, 1, 2, 3, 4]);
for (const value of [0, 1, -1, 63, -64, 1e9, -1e9, 2 ** 51, -(2 ** 51)]) {
assert.equal(unzigzag(zigzag(value)), value);
}
});
test('varints round-trip up to the largest safe integer', () => {
const values = [
0,
1,
127,
128,
300,
2 ** 21,
2 ** 31,
2 ** 32 + 5,
Number.MAX_SAFE_INTEGER,
];
const writer = new WireWriter();
for (const value of values) writer.varint(value);
const reader = new WireReader(writer.finish());
for (const value of values) assert.equal(reader.varint(), value);
assert.ok(reader.done);
assert.deepEqual(
new WireWriter().varint(300).finish(),
Uint8Array.of(0xac, 0x02),
);
});
test('the writer refuses values the format cannot carry', () => {
assert.throws(() => new WireWriter().varint(-1), RangeError);
assert.throws(() => new WireWriter().varint(1.5), RangeError);
assert.throws(() => new WireWriter().varint(2 ** 53), RangeError);
assert.throws(() => new WireWriter().tag(0, WIRE_VARINT), RangeError);
assert.throws(() => new WireWriter().textField(1, 'é'), RangeError);
});
test('the reader rejects truncated, overlong and overflowing varints', () => {
assert.equal(
reasonOf(() => new WireReader(Uint8Array.of(0x80)).varint()),
'truncated',
);
assert.equal(
reasonOf(() => new WireReader(Uint8Array.of(0x81, 0x00)).varint()),
'non-canonical-varint',
);
const tooLong = new Uint8Array(9).fill(0xff);
assert.equal(
reasonOf(() => new WireReader(tooLong).varint()),
'varint-overflow',
);
const beyondSafe = Uint8Array.of(
0xff,
0xff,
0xff,
0xff,
0xff,
0xff,
0xff,
0x7f,
);
assert.equal(
reasonOf(() => new WireReader(beyondSafe).varint()),
'varint-overflow',
);
});
test('the reader bounds lengths and rejects group and reserved wire types', () => {
const absurd = new WireWriter().tag(3, WIRE_LEN).varint(1_000_000).finish();
const reader = new WireReader(absurd);
reader.tag();
assert.equal(
reasonOf(() => reader.lengthDelimited()),
'truncated',
);
for (const wireType of [3, 4, 6, 7]) {
const bytes = new WireWriter().varint(8 + wireType).finish();
assert.equal(
reasonOf(() => new WireReader(bytes).tag()),
'bad-wire-type',
);
}
assert.equal(
reasonOf(() => new WireReader(Uint8Array.of(0x00)).tag()),
'bad-field-number',
);
});
test('skip steps over each wire type and stops at a truncated value', () => {
const writer = new WireWriter()
.uintField(1, 300)
.bytesField(2, [1, 2, 3])
.tag(3, WIRE_FIXED32);
writer.bytes.push(1, 2, 3, 4);
writer.tag(4, WIRE_FIXED64);
writer.bytes.push(1, 2, 3, 4, 5, 6, 7, 8);
writer.uintField(5, 9);
const reader = new WireReader(writer.finish());
for (let field = 1; field <= 4; field += 1)
reader.skip(reader.tag().wireType);
assert.deepEqual(reader.tag(), { field: 5, wireType: WIRE_VARINT });
assert.equal(reader.varint(), 9);
const short = new WireWriter().tag(1, WIRE_FIXED64).finish();
const shortReader = new WireReader(Uint8Array.of(...short, 1, 2));
assert.equal(
reasonOf(() => shortReader.skip(shortReader.tag().wireType)),
'truncated',
);
});
test('crc8 is CRC-8/SMBUS and catches every burst of up to 8 bits', () => {
// The catalogued check value for the ASCII digits 1–9.
assert.equal(crc8(new TextEncoder().encode('123456789')), 0xf4);
assert.equal(crc8([]), 0);
const bytes = [1, 0x10, 0x82, 0x40, 0x7f, 0xff, 0x00, 0x33];
const sum = crc8(bytes);
for (let bit = 0; bit + 8 <= bytes.length * 8; bit += 1) {
for (let pattern = 1; pattern < 256; pattern += 1) {
// Flip a burst starting at `bit` (its first bit always set).
if (!(pattern & 1)) continue;
const flipped = [...bytes];
for (let k = 0; k < 8; k += 1) {
if (!(pattern & (1 << k))) continue;
const at = bit + k;
flipped[at >> 3] ^= 0x80 >> (at & 7);
}
assert.notEqual(crc8(flipped), sum, `burst ${pattern} at bit ${bit}`);
}
}
});
+27 -6
View File
@@ -8,6 +8,12 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import { ShareLinkManager, decodeShareCreatedAtMs } from './sharelink.js';
import { createDefaultLayerState } from './data/layerState.js';
import { parseSharePath } from './share/shareLinkPath.js';
import { decodeShareView } from './share/viewCodec.js';
/** The decoded view of a copied `/share/<code>` link. */
const sharedState = (href) =>
decodeShareView(parseSharePath(new URL(href).pathname).code).state;
const uiSource = readShellSource();
@@ -99,6 +105,22 @@ test('Nepal locator token is valid in v2 share links', () => {
assert.equal(parsed.layerStateInvalid, false);
});
test('scope feather above 100% survives share parsing and serialization', () => {
const manager = makeManager('#v=2&lat=10&lon=20&sc=0&scf=200');
const parsed = manager.parseInitialHash();
assert.equal(parsed.scopeEnabled, false);
assert.equal(parsed.scopeFeatherPct, 200);
assert.equal(makeManager('#lat=10&lon=20&scf=175').parseInitialHash().scopeFeatherPct, 175);
assert.equal(makeManager('#lat=10&lon=20&scf=999').parseInitialHash().scopeFeatherPct, 200);
manager.onToggleChange(false, true, { scopeEnabled: false, scopeFeatherPct: 200 });
const params = manager._buildHashParams();
assert.equal(params.get('sc'), '0');
assert.equal(params.get('scf'), '200');
manager.onToggleChange(false, true, { scopeFeatherPct: 999 });
assert.equal(manager._buildHashParams().get('scf'), '200');
clearTimeout(manager._debounceTimer);
});
test('share-link serialization emits the current celestial state', () => {
const manager = makeManager();
manager.onToggleChange(false, false, { celestialRingEnabled: false });
@@ -294,8 +316,8 @@ test('copy adds a fresh ephemeral timestamp without aging the live URL', async (
assert.equal(await manager.copyLink({ nowMs: 2_000_000 }), true);
assert.equal(await manager.copyLink({ nowMs: 2_002_000 }), true);
assert.equal(new URL(copied[0]).hash.includes('at=2000'), true);
assert.equal(new URL(copied[1]).hash.includes('at=2002'), true);
assert.equal(sharedState(copied[0]).sharedAtMs, 2_000_000);
assert.equal(sharedState(copied[1]).sharedAtMs, 2_002_000);
assert.equal(window.location.hash, liveHash);
assert.equal(new URLSearchParams(window.location.hash.slice(1)).has('at'), false);
});
@@ -307,8 +329,7 @@ test('copy snapshots current state while incoming hash writes are still suppress
manager.parseInitialHash();
assert.equal(manager._initialRestorePending, true);
assert.equal(await manager.copyLink({ nowMs: 3_000_000 }), true);
const params = new URL(copied).hash.slice(1);
assert.equal(new URLSearchParams(params).get('at'), '3000');
assert.equal(sharedState(copied).sharedAtMs, 3_000_000);
assert.equal(window.location.hash, '#v=2&lat=10&lon=20&l=e&at=100');
});
@@ -498,8 +519,8 @@ test('every explicit visual UI gesture claims restore authority before it mutate
['cycleDetection: () => {', 'toggleCctv: () =>', 'cycleDetectionMode()', 'detection hotkey'],
['toggleBloom:', 'setBloomIntensity:', 'this._setBloomEnabled(', 'toggleBloom control'],
['setBloomIntensity:', 'toggleSharpen:', 'this._setBloomIntensity(', 'setBloomIntensity control'],
['toggleSharpen:', 'toggleScope:', 'this._setSharpenEnabled(', 'toggleSharpen control'],
['toggleScope:', 'setScopeFeather:', 'setScopeMaskEnabled(', 'toggleScope control'],
['toggleSharpen:', 'setScopeEnabled:', 'this._setSharpenEnabled(', 'toggleSharpen control'],
['setScopeEnabled:', 'setScopeFeather:', 'setScopeMaskEnabled(', 'setScopeEnabled control'],
['setScopeFeather:', 'setSharpenIntensity:', 'setScopeMaskFeather(', 'setScopeFeather control'],
['setSharpenIntensity:', 'setHudLayout:', 'this._applySharpenIntensity(', 'setSharpenIntensity control'],
['setHudLayout:', 'toggleCleanView:', 'this._setHudVariant(', 'setHudLayout control'],
+186 -84
View File
@@ -5,17 +5,36 @@ import {
normalizeAllocationStrategy,
} from './data/detectionPolicy.js';
import { clampScopeTerminusPct } from './scopeMask.js';
import { clampScopeFeatherPct } from './scopeMaskPolicy.js';
import {
decodeLayerStateParams,
encodeLayerStateParams,
} from './data/layerState.js';
import { STYLE_URL_NAMES } from './view/index.js';
import {
SHARE_VIEW_PANELS,
SHARE_VIEW_STYLE_PARAMS,
decodeShareView,
encodeShareView,
} from './share/viewCodec.js';
import {
fromLegacyShareState,
toLegacyShareState,
} from './share/legacyShareState.js';
import {
appRootUrl,
parseSharePath,
shareLinkUrl,
} from './share/shareLinkPath.js';
/**
* Share Links — URL Hash State Management
*
* Encodes camera position + style into the URL hash so links can be shared.
* Format: #lat=37.77&lon=-122.42&alt=800&heading=0&pitch=-35&style=nvg&bloom=1&bi=84&bv=2&sharpen=0&si=65&hud=tactical&hv=1&dm=BALANCED&dd=50&da=elastic&kf=16&ko=0&cr=0&map=photoreal
* The address bar follows the live view in the long hash form:
* #lat=37.77&lon=-122.42&alt=800&heading=0&pitch=-35&style=nvg&bloom=1&bi=84&bv=2&sharpen=0&si=65&hud=tactical&hv=1&dm=BALANCED&dd=50&da=elastic&kf=16&ko=0&cr=0&map=photoreal
*
* A shared link is the short form, `<app>/share/<code>`, with the code from
* src/share/viewCodec.js (docs/SHARE-LINK-FORMAT.md). Both forms open here.
*/
const DEBOUNCE_MS = 500;
@@ -28,20 +47,9 @@ const SHARE_UI_STATE_PARAM = 'ui';
const SHARE_STYLE_PARAMS_PARAM = 'sp';
const SHARE_CREATED_AT_PARAM = 'at';
const SHARE_PANEL_STATE_REGISTRY = Object.freeze([
{ id: 'control-panel', token: 'c', pinnable: true },
{ id: 'location-bar', token: 'l', pinnable: true },
{ id: 'data-panel', token: 'd', pinnable: false },
{ id: 'cctv-panel', token: 'v', pinnable: false },
{ id: 'recent-imagery-panel', token: 'i', pinnable: false },
{ id: 'street-level-panel', token: 't', pinnable: false },
{ id: 'radio-panel', token: 'r', pinnable: false },
{ id: 'scene-panel', token: 's', pinnable: false },
{ id: 'global-context-panel', token: 'g', pinnable: false },
{ id: 'pp-toggles', token: 'p', pinnable: false },
{ id: 'param-slider-panel', token: 'm', pinnable: false },
]);
// The panel and visual-preset parameter tables are the share format's own
// (src/share/viewCodec.js); the long form spells their entries by token.
const SHARE_PANEL_STATE_REGISTRY = SHARE_VIEW_PANELS;
const SHARE_PANEL_STATE_BY_TOKEN = Object.freeze(
new Map(SHARE_PANEL_STATE_REGISTRY.map((entry) => [entry.token, entry])),
);
@@ -50,46 +58,25 @@ const URL_TO_STYLE = Object.fromEntries(
Object.entries(STYLE_TO_URL).map(([k, v]) => [v, k]),
);
const SHARE_STYLE_PARAM_REGISTRY = Object.freeze({
retro: Object.freeze([
{ key: 'pixelation', token: 'p', min: 1, max: 10 },
{ key: 'distortion', token: 'd', min: 0, max: 1 },
{ key: 'instability', token: 'i', min: 0, max: 1 },
]),
surveillance: Object.freeze([
{ key: 'gain', token: 'g', min: 0, max: 1 },
{ key: 'bloom', token: 'b', min: 0, max: 1 },
{ key: 'scanlineStr', token: 's', min: 0, max: 1 },
{ key: 'pixelation', token: 'p', min: 1, max: 6 },
]),
thermal: Object.freeze([
{ key: 'sensitivity', token: 's', min: 0, max: 1 },
{ key: 'bloom', token: 'b', min: 0, max: 1 },
{ key: 'mode', token: 'm', min: 0, max: 1 },
{ key: 'pixelation', token: 'p', min: 1, max: 6 },
{ key: 'palette', token: 'a', min: 0, max: 1 },
]),
anime: Object.freeze([
{ key: 'saturation', token: 's', min: 0, max: 2 },
{ key: 'edgeThick', token: 'e', min: 0, max: 1 },
]),
noir: Object.freeze([
{ key: 'contrastAmt', token: 'c', min: 0, max: 2 },
{ key: 'grainAmt', token: 'g', min: 0, max: 1 },
{ key: 'vignetteAmt', token: 'v', min: 0, max: 1 },
]),
snow: Object.freeze([
{ key: 'density', token: 'd', min: 0, max: 1 },
{ key: 'wind', token: 'w', min: 0, max: 1 },
]),
});
const SHARE_STYLE_PARAM_REGISTRY = SHARE_VIEW_STYLE_PARAMS;
export class ShareLinkManager {
constructor(
viewer,
{ onRestore, isNavigationCurrent, cancelOwnedNavigation } = {},
{
onRestore,
isNavigationCurrent,
cancelOwnedNavigation,
initialShareCode = null,
} = {},
) {
this.viewer = viewer;
// A host may hand over the share code to open instead of the address.
this._initialShareCode =
typeof initialShareCode === 'string' ? initialShareCode : null;
// Why the share code this page opened with was refused, if it was.
this.rejectedShareCode = null;
this._followSubjectProvider = null;
this._onRestore = onRestore; // callback: ({ style, bloom, sharpen }) => void
this._debounceTimer = null;
this._currentStyle = 'normal';
@@ -112,10 +99,7 @@ export class ShareLinkManager {
this._detectionOutsideOpacityPct = 1;
this._celestialRingEnabled = false;
this._scopeEnabled = true;
// Feather opens on a soft 11% scope-mask edge (owner final lock 2026-08-24,
// superseding the 08-22 hard-crop and 08-23 8% rulings) — mirrors
// SCOPE_FEATHER_RATIO_DEFAULT in scopeMask.js and the slider's markup value.
this._scopeFeatherPct = 11;
this._scopeFeatherPct = 100;
// null = the altitude-adaptive terminus (the default). A number pins the
// outside-fill opacity as a percent, 94..100. (`sce`, 2026-08-17)
this._scopeTerminusPct = null;
@@ -124,6 +108,13 @@ export class ShareLinkManager {
this._panelStateProvider = null;
this._styleParamStateProvider = null;
this._initialRestorePending = false;
// Each incoming share (start-up or a later hash navigation) gets a token;
// only the newest one may release URL-write suppression.
this._restoreToken = 0;
// The hash this page last read or wrote. A location change back to it is
// the app's own state, never an incoming share.
this._knownHash = null;
this._unwatchNavigation = null;
this._restoreAuthority = {
visual: 0,
map: 0,
@@ -150,10 +141,44 @@ export class ShareLinkManager {
}
/**
* Parse URL hash on page load. Returns parsed state or null.
* Parse the share this page opened with: the host's `initialShareCode`, a
* `/share/<code>` address, or a long-form hash. Returns the parsed state, or
* null for none. A share code that cannot be opened is recorded in
* `rejectedShareCode` and restores nothing.
*/
parseInitialHash() {
const hash = window.location.hash.slice(1);
this._knownHash = hash;
const code =
this._initialShareCode ??
parseSharePath(window.location.pathname)?.code ??
null;
this._initialShareCode = null;
const state =
code === null ? this._parseShareHash(hash) : this._parseShareCode(code);
if (!state) return null;
this._beginIncomingRestore(state);
return state;
}
/**
* The restore state for a share code, in the long form's parsed shape plus
* `shareView` (what the view follows, with its offset and last-known fix),
* or null when the code is refused.
*/
_parseShareCode(code) {
const result = decodeShareView(code);
if (!result.ok) {
this.rejectedShareCode = result.reason;
return null;
}
const state = toLegacyShareState(result.state);
state.shareView = result.state.view;
return state;
}
/** Parse one hash without side effects. Returns null unless it is a share. */
_parseShareHash(hash) {
if (!hash) return null;
const params = new URLSearchParams(hash);
@@ -227,10 +252,7 @@ export class ShareLinkManager {
// (`_scopeFeatherPct` in the constructor tracks the default: that one
// mirrors live state for the link this session generates, so it must match
// the mask, not the archive.)
scopeFeatherPct: Math.max(
0,
Math.min(100, Math.round(parseOr(params.get('scf'), 35))),
),
scopeFeatherPct: clampScopeFeatherPct(parseOr(params.get('scf'), 35)),
// Absent (or non-numeric) `sce` = adaptive (null), the default behavior;
// a value pins the terminus opacity percent, clamped into the SUPPORTED
// 94..100 band. `sce=0` used to survive as a sub-94 terminus — a hole in
@@ -247,15 +269,55 @@ export class ShareLinkManager {
panelState: decodePanelStateParams(params),
sharedAtMs: decodeShareCreatedAtMs(params),
};
return state;
}
/** Reserve restore lanes and hold URL writes for one incoming share. */
_beginIncomingRestore(state) {
state.restoreAuthority = {
visual: this._restoreAuthority.visual,
map: this._restoreAuthority.map,
panels: new Map(this._restoreAuthority.panels),
};
// Hold URL writes until the complete incoming state has been restored.
state.restoreToken = ++this._restoreToken;
// Hold URL writes until the complete incoming state has been restored. A
// debounced write of the previous view must not land over the new link.
this._initialRestorePending = true;
return state;
clearTimeout(this._debounceTimer);
this._debounceTimer = null;
}
/**
* Restore a share link opened in this tab after start-up: a pasted address,
* a followed link, or Back/Forward. The app's own replaceState writes never
* fire these events, and a hash equal to the last one read or written is
* ignored anyway, so only a genuinely different incoming share restores.
*/
watchNavigation(onNavigate) {
this._unwatchNavigation?.();
if (this._destroyed || typeof onNavigate !== 'function') return;
const target = globalThis.window;
if (typeof target?.addEventListener !== 'function') return;
const handler = () => this._handleLocationChange(onNavigate);
target.addEventListener('hashchange', handler);
target.addEventListener('popstate', handler);
this._unwatchNavigation = () => {
target.removeEventListener('hashchange', handler);
target.removeEventListener('popstate', handler);
this._unwatchNavigation = null;
};
}
_handleLocationChange(onNavigate) {
if (this._destroyed) return;
const hash = window.location.hash.slice(1);
// Back/Forward fires both popstate and hashchange for one entry.
if (hash === this._knownHash) return;
this._knownHash = hash;
const state = this._parseShareHash(hash);
if (!state) return;
this._beginIncomingRestore(state);
onNavigate(state);
}
/**
@@ -379,9 +441,13 @@ export class ShareLinkManager {
};
}
/** Release initial hash suppression only after every restore owner settles. */
completeInitialRestore() {
/**
* Release hash suppression only after every restore owner settles. A token
* from a superseded share restore leaves the newer one holding the URL.
*/
completeInitialRestore(restoreToken = this._restoreToken) {
if (!this._initialRestorePending) return;
if (restoreToken !== this._restoreToken) return;
this._initialRestorePending = false;
this._scheduleUpdate();
}
@@ -404,6 +470,16 @@ export class ShareLinkManager {
this._layerStateProvider = typeof provider === 'function' ? provider : null;
}
/**
* Install the source of the entity the camera follows, used for share links:
* `({ nowMs }) => ({ kind, id, offset, lastKnown }) | null`, in the share
* format's terms (src/share/follow.js), with the fix as of `nowMs`.
*/
setFollowSubjectProvider(provider) {
this._followSubjectProvider =
typeof provider === 'function' ? provider : null;
}
/** Install the finalized panel-state source used by URL generation. */
setPanelStateProvider(provider) {
this._panelStateProvider = typeof provider === 'function' ? provider : null;
@@ -494,10 +570,7 @@ export class ShareLinkManager {
if (typeof extras.scopeEnabled === 'boolean')
this._scopeEnabled = extras.scopeEnabled;
if (typeof extras.scopeFeatherPct === 'number') {
this._scopeFeatherPct = Math.max(
0,
Math.min(100, Math.round(extras.scopeFeatherPct)),
);
this._scopeFeatherPct = clampScopeFeatherPct(extras.scopeFeatherPct);
}
if (extras.scopeTerminusPct === null) this._scopeTerminusPct = null;
else if (typeof extras.scopeTerminusPct === 'number') {
@@ -507,17 +580,41 @@ export class ShareLinkManager {
this._scheduleUpdate();
}
/**
* The share link for the current state, `<app>/share/<code>`, stamped with
* its creation time, or null before the camera has a position. While the
* camera follows an entity, the link follows it too.
*/
buildLink({ nowMs = Date.now() } = {}) {
const params = this._buildHashParams();
if (!params) return null;
// The long form is the one description of the live state; the code is
// that state in the share format.
const state = fromLegacyShareState(this._parseShareHash(params.toString()));
state.sharedAtMs = Math.floor(nowMs / 1000) * 1000;
let subject = null;
try {
subject = this._followSubjectProvider?.({ nowMs }) ?? null;
} catch {
subject = null;
}
if (subject) {
state.view = {
kind: 'thing',
entity: { kind: subject.kind, id: subject.id },
offset: subject.offset ?? null,
lastKnown: subject.lastKnown ?? null,
};
}
return shareLinkUrl(encodeShareView(state), window.location.href);
}
/** Copy a current-state snapshot with a copy-time timestamp. Returns true on success. */
async copyLink({ nowMs = Date.now() } = {}) {
const params = this._buildHashParams();
if (!params) return false;
params.set(SHARE_CREATED_AT_PARAM, String(Math.floor(nowMs / 1000)));
const copiedUrl = new URL(window.location.href);
// ?lang is the sender's language override; the recipient keeps their own.
copiedUrl.searchParams.delete('lang');
copiedUrl.hash = params.toString();
try {
await navigator.clipboard.writeText(copiedUrl.href);
const href = this.buildLink({ nowMs });
if (!href) return false;
await navigator.clipboard.writeText(href);
return true;
} catch {
return false;
@@ -535,12 +632,15 @@ export class ShareLinkManager {
const params = this._buildHashParams();
if (!params) return;
// The hash belongs to this page's address, not the document base the
// app may load under.
history.replaceState(
null,
'',
new URL(`#${params.toString()}`, window.location.href).href,
);
// app may load under. A `/share/<code>` address gives way to the app's
// own address, so a reload follows the live view; replacing it adds no
// history entry.
const url = parseSharePath(new URL(window.location.href).pathname)
? appRootUrl(window.location.href)
: new URL(window.location.href);
url.hash = params.toString();
this._knownHash = url.hash.slice(1);
history.replaceState(null, '', url.href);
}
/** Build a deterministic snapshot without mutating history. */
@@ -599,8 +699,8 @@ export class ShareLinkManager {
this._styleParamStateProvider?.(this._currentStyle),
);
// Copy-time metadata is intentionally absent here. `copyLink()` adds a
// fresh timestamp to its ephemeral URL without aging the live address.
// Creation-time metadata is intentionally absent here. `buildLink()`
// stamps its own link without aging the live address.
params.delete(SHARE_CREATED_AT_PARAM);
return params;
}
@@ -623,9 +723,11 @@ export class ShareLinkManager {
this._debounceTimer = null;
this._removeCameraChanged?.();
this._removeCameraChanged = null;
this._unwatchNavigation?.();
this._layerStateProvider = null;
this._panelStateProvider = null;
this._styleParamStateProvider = null;
this._followSubjectProvider = null;
this._onRestore = null;
}
}
+425
View File
@@ -0,0 +1,425 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { ShareLinkManager } from './sharelink.js';
import { ShareRestoration } from './ui/shareRestoration.js';
import { DataLayerManager } from './data/manager.js';
import {
LAYER_STATE_REGISTRY,
REGISTERED_LAYER_IDS,
createDefaultLayerState,
decodeLayerStateParams,
} from './data/layerState.js';
import { LayerStateCoordinator } from './data/layerStateCoordinator.js';
const SHARE_A = 'v=2&lat=10.0000&lon=20.0000&alt=800&style=nvg&l=e';
const SHARE_B = 'v=2&lat=48.8584&lon=2.2945&alt=500&style=normal&l=f';
/**
* A window whose address behaves like a tab's: `navigate` is a hash-only
* navigation (new history entry plus hashchange), `back`/`forward` traverse
* entries (popstate plus hashchange), and `history.replaceState` rewrites the
* current entry without firing anything.
*/
function installTab(t, initialHash = '') {
const prior = {
window: globalThis.window,
history: globalThis.history,
};
const target = new EventTarget();
const location = new URL(
`http://localhost/${initialHash ? `#${initialHash}` : ''}`,
);
const entries = [location.hash];
let index = 0;
const listeners = { hashchange: 0, popstate: 0 };
const add = target.addEventListener.bind(target);
const remove = target.removeEventListener.bind(target);
target.addEventListener = (type, handler, options) => {
if (type in listeners) listeners[type] += 1;
add(type, handler, options);
};
target.removeEventListener = (type, handler, options) => {
if (type in listeners) listeners[type] -= 1;
remove(type, handler, options);
};
target.location = location;
globalThis.window = target;
globalThis.history = {
replaceState(_state, _title, next) {
location.hash = new URL(next, location.href).hash;
entries[index] = location.hash;
},
};
t.after(() => Object.assign(globalThis, prior));
const go = (nextIndex) => {
index = nextIndex;
location.hash = entries[index];
target.dispatchEvent(new Event('popstate'));
target.dispatchEvent(new Event('hashchange'));
};
return {
listeners,
navigate(hash) {
entries.splice(index + 1);
entries.push(`#${hash}`);
index = entries.length - 1;
location.hash = hash;
target.dispatchEvent(new Event('hashchange'));
},
back: () => go(index - 1),
forward: () => go(index + 1),
};
}
/** Queue timers instead of running them, so delayed work runs only on demand. */
function installManualTimers(t) {
const prior = {
setTimeout: globalThis.setTimeout,
clearTimeout: globalThis.clearTimeout,
};
const timers = new Map();
let timerId = 0;
globalThis.setTimeout = (fn) => {
timers.set(++timerId, fn);
return timerId;
};
globalThis.clearTimeout = (id) => timers.delete(id);
t.after(() => Object.assign(globalThis, prior));
return timers;
}
function fakeViewer() {
return {
camera: {
changed: { addEventListener: () => () => {} },
positionCartographic: { latitude: 0, longitude: 0, height: 1000 },
heading: 0,
pitch: -Math.PI / 2,
roll: 0,
},
};
}
test('a hash navigation to a different share restores it and holds URL writes', (t) => {
const tab = installTab(t, SHARE_A);
const manager = new ShareLinkManager(fakeViewer());
const initial = manager.parseInitialHash();
manager.completeInitialRestore(initial.restoreToken);
const navigated = [];
manager.watchNavigation((state) => navigated.push(state));
tab.navigate(SHARE_B);
assert.equal(navigated.length, 1);
assert.equal(navigated[0].lat, 48.8584);
assert.equal(navigated[0].style, 'normal');
assert.deepEqual(navigated[0].layerState.enabledLayerIds, ['flights']);
assert.equal(manager._initialRestorePending, true);
// Nothing from the previous view may overwrite the incoming link.
manager._updateHash();
assert.equal(window.location.hash, `#${SHARE_B}`);
manager.completeInitialRestore(navigated[0].restoreToken);
assert.equal(manager._initialRestorePending, false);
manager.destroy();
});
test("the app's own hash writes never trigger a restore", (t) => {
installTab(t, SHARE_A);
const manager = new ShareLinkManager(fakeViewer());
manager.completeInitialRestore(manager.parseInitialHash().restoreToken);
let restores = 0;
manager.watchNavigation(() => {
restores += 1;
});
manager._updateHash();
assert.notEqual(window.location.hash, `#${SHARE_A}`);
// replaceState fires nothing, but a stray event for our own hash is ignored.
window.dispatchEvent(new Event('hashchange'));
window.dispatchEvent(new Event('popstate'));
assert.equal(restores, 0);
assert.equal(manager._initialRestorePending, false);
manager.destroy();
});
test('a non-share hash is ignored and Back/Forward restores each entry once', (t) => {
const tab = installTab(t, SHARE_A);
const manager = new ShareLinkManager(fakeViewer());
manager.completeInitialRestore(manager.parseInitialHash().restoreToken);
// The app rewrites entry 0 with its own state; Back must return to that.
manager._updateHash();
const ownHash = window.location.hash.slice(1);
const navigated = [];
manager.watchNavigation((state) => navigated.push(state));
tab.navigate('not-a-share');
assert.equal(navigated.length, 0);
assert.equal(manager._initialRestorePending, false);
tab.navigate(SHARE_B);
assert.equal(navigated.length, 1);
manager.completeInitialRestore(navigated[0].restoreToken);
tab.back(); // not-a-share
tab.back(); // the app's own rewritten first entry
assert.equal(navigated.length, 2, 'popstate and hashchange restore once');
assert.equal(window.location.hash.slice(1), ownHash);
assert.equal(navigated[1].lat, 0);
manager.completeInitialRestore(navigated[1].restoreToken);
tab.forward();
tab.forward();
assert.equal(navigated.length, 3);
assert.equal(navigated[2].lat, 48.8584);
manager.destroy();
});
test('a newer navigation supersedes an older restore and its pending write', (t) => {
const tab = installTab(t, SHARE_A);
const timers = installManualTimers(t);
const manager = new ShareLinkManager(fakeViewer());
manager.completeInitialRestore(manager.parseInitialHash().restoreToken);
assert.equal(timers.size, 1, 'releasing suppression schedules a write');
const navigated = [];
manager.watchNavigation((state) => navigated.push(state));
tab.navigate(SHARE_B);
assert.equal(
timers.size,
0,
'the pending write of the old view is cancelled',
);
tab.navigate(SHARE_A);
const [older, newer] = navigated;
assert.ok(newer.restoreToken > older.restoreToken);
manager.completeInitialRestore(older.restoreToken);
assert.equal(
manager._initialRestorePending,
true,
'a superseded restore cannot release writes',
);
manager.completeInitialRestore(newer.restoreToken);
assert.equal(manager._initialRestorePending, false);
manager.destroy();
});
test('destroy removes the navigation listeners', (t) => {
const tab = installTab(t, SHARE_A);
const manager = new ShareLinkManager(fakeViewer());
manager.parseInitialHash();
let restores = 0;
manager.watchNavigation(() => {
restores += 1;
});
assert.deepEqual(tab.listeners, { hashchange: 1, popstate: 1 });
manager.watchNavigation(() => {
restores += 1;
});
assert.deepEqual(
tab.listeners,
{ hashchange: 1, popstate: 1 },
'rewatching replaces',
);
manager.destroy();
assert.deepEqual(tab.listeners, { hashchange: 0, popstate: 0 });
tab.navigate(SHARE_B);
assert.equal(restores, 0);
});
function deferred() {
let resolve;
const promise = new Promise((res) => {
resolve = res;
});
return { promise, resolve };
}
/** A ShareRestoration over a real ShareLinkManager with a stub restore sink. */
function restorationHarness(t, initialHash, navigationOverrides = {}) {
const tab = installTab(t, initialHash);
const timers = installManualTimers(t);
const restores = [];
let navigationGeneration = 0;
const manager = new ShareLinkManager(fakeViewer(), {
onRestore: (state) => {
const gate = deferred();
restores.push({ state, gate });
return gate.promise;
},
isNavigationCurrent: () => false,
});
const layerCalls = [];
const handoffs = [];
const owner = new ShareRestoration({
viewer: { canvas: new EventTarget() },
navigation: {
_beginDeferredNavigation: () => ++navigationGeneration,
_reassertNavigationHandoff: (generation, options) => {
handoffs.push({ generation, options });
return true;
},
_stampNavigation() {},
...navigationOverrides,
},
syncShareState() {},
syncModels3d() {},
showStatus() {},
feedback: {},
updateFeedback() {},
});
owner.attachLinks(manager);
owner._layerStateCoordinator = {
applyShareLayerState(options) {
layerCalls.push(options);
return Promise.resolve([]);
},
getDurableState: () => null,
cancelPendingShareTracking: () => false,
destroy() {},
};
owner.start();
t.after(() => owner.destroy());
return { tab, timers, manager, owner, restores, layerCalls, handoffs };
}
const flush = () => new Promise((resolve) => setImmediate(resolve));
test('a navigated share runs the start-up restore pipeline and supersedes the delayed one', async (t) => {
const { tab, timers, manager, owner, restores, layerCalls } =
restorationHarness(t, SHARE_A);
assert.equal(timers.size, 1, 'start-up restore waits for its delayed flight');
tab.navigate(SHARE_B);
assert.equal(timers.size, 0, 'the delayed start-up restore never runs');
assert.equal((await owner.initialRestorePromise).status, 'superseded');
assert.equal(restores.length, 1);
assert.equal(restores[0].state.style, 'normal');
assert.equal(restores[0].state.mapStack, 'photoreal');
assert.deepEqual(layerCalls[0].shareLayerState.enabledLayerIds, ['flights']);
restores[0].gate.resolve();
await flush();
assert.equal(manager._initialRestorePending, false);
});
test('a navigated share releases the old Follow passively, never as a saved preference', (t) => {
const { tab, handoffs } = restorationHarness(t, SHARE_A);
tab.navigate(SHARE_B);
assert.equal(handoffs.length, 1);
assert.deepEqual(handoffs[0].options, { trackingOrigin: 'programmatic' });
});
test('a navigated share that fails to start never freezes the address bar', async (t) => {
const settled = [];
let calls = 0;
const { tab, manager } = restorationHarness(t, SHARE_A, {
// Start-up reserves the camera; the navigated share then fails to.
_beginDeferredNavigation: () => {
calls += 1;
if (calls > 1) throw new Error('navigation unavailable');
return calls;
},
});
window.addEventListener('gev:share-navigation-restore-settled', (event) =>
settled.push(event.detail),
);
tab.navigate(SHARE_B);
assert.equal(manager._initialRestorePending, false);
assert.equal(settled[0]?.status, 'failed');
});
test('a second navigation during a restore supersedes it end to end', async (t) => {
const { tab, manager, restores, layerCalls } = restorationHarness(t, SHARE_A);
tab.navigate(SHARE_B);
tab.navigate(SHARE_A);
assert.equal(restores.length, 2);
assert.equal(layerCalls.length, 2);
assert.deepEqual(layerCalls[1].shareLayerState.enabledLayerIds, [
'earthquakes',
]);
// The newer restore finishes first; the older one settling late must not
// release or reopen anything.
restores[1].gate.resolve();
await flush();
assert.equal(manager._initialRestorePending, false);
manager._initialRestorePending = true; // observe any late release
restores[0].gate.resolve();
await flush();
assert.equal(manager._initialRestorePending, true);
});
test('the layer coordinator replaces in-flight share layers with the navigated share', async () => {
const enableGates = new Map();
const manager = new DataLayerManager({});
for (const id of REGISTERED_LAYER_IDS) {
manager.register({
id,
name: id,
icon: '',
source: 'test',
async init() {
return true;
},
async enable() {
const gate = enableGates.get(id);
if (gate) await gate.promise;
return true;
},
async update() {
return true;
},
async disable() {
return true;
},
});
}
manager.finalizeRegistrations(LAYER_STATE_REGISTRY);
const quakeGate = deferred();
enableGates.set('earthquakes', quakeGate);
const writes = [];
const storage = {
getItem: () => null,
setItem: (...args) => writes.push(args),
};
const sink = {
provider: null,
setLayerStateProvider(provider) {
this.provider = provider;
},
onLayerStateChange() {},
};
const coordinator = new LayerStateCoordinator(manager, sink, { storage });
const first = coordinator.start({
shareLayerState: decodeLayerStateParams(new URLSearchParams(SHARE_A)),
allowLocalState: false,
});
const second = coordinator.applyShareLayerState({
shareLayerState: decodeLayerStateParams(new URLSearchParams(SHARE_B)),
});
quakeGate.resolve();
await Promise.all([first, second]);
const enabled = (id) => manager.getLayerLifecycleState(id)?.enabled === true;
assert.equal(enabled('earthquakes'), false);
assert.equal(enabled('flights'), true);
assert.deepEqual(sink.provider().enabledLayerIds, ['flights']);
assert.equal(coordinator.source, 'share');
assert.deepEqual(
writes,
[],
'a passive share restore never writes local preferences',
);
// A share without a layer payload restores the defaults, as a reload does.
await coordinator.applyShareLayerState({ shareLayerState: null });
assert.equal(coordinator.source, 'legacy-share');
assert.deepEqual(
sink.provider().enabledLayerIds,
createDefaultLayerState().enabledLayerIds,
);
assert.equal(
enabled('flights'),
createDefaultLayerState().enabledLayerIds.includes('flights'),
);
coordinator.destroy();
});
+2
View File
@@ -19,6 +19,7 @@ import { createWeatherSource } from '../layers/weather/source.js';
import { createCycloneSource } from '../layers/cyclones/source.js';
import { createWindSource } from '../layers/wind/source.js';
import { createFirmsSource } from '../layers/firms/source.js';
import { createGhostMapsSource } from '../layers/ghostMaps/source.js';
import { createMapillarySource } from '../layers/streetLevel/providers/mapillary/source.js';
import { createReferenceSources } from '../sources/reference.js';
export { createReferenceSources as createStandaloneReferenceSources } from '../sources/reference.js';
@@ -50,6 +51,7 @@ export function createStandaloneLayerSources(overrides = {}) {
launches: createLaunchSource(),
alpr: createAlprTileSource(),
firms: createFirmsSource(),
's2-ghostmaps': createGhostMapsSource(),
wind: createWindSource(),
weather: createWeatherSource(),
cyclones: createCycloneSource(),
+1
View File
@@ -19,6 +19,7 @@ export function createStandaloneTools(options) {
...options,
voice: {
supportedProviders: ['openai', 'gemini'],
panel: true,
toolCatalog: loadToolCatalog,
...options?.voice,
},
+1 -1
View File
@@ -1,4 +1,4 @@
import { msg } from '../i18n/index.js';
import { msg } from '../i18n/marker.js';
/**
* Anime / Studio Ghibli Style — Cel-shading + Saturation boost
+1 -1
View File
@@ -1,4 +1,4 @@
import { msg } from '../i18n/index.js';
import { msg } from '../i18n/marker.js';
/**
* Noir Style — Desaturation + High Contrast + Vignette
+1 -1
View File
@@ -1,4 +1,4 @@
import { msg } from '../i18n/index.js';
import { msg } from '../i18n/marker.js';
/**
* CRT Terminal — Dynamic CRT Monitor Aesthetic
+1 -1
View File
@@ -1,4 +1,4 @@
import { msg } from '../i18n/index.js';
import { msg } from '../i18n/marker.js';
/**
* Snow Style — White overlay + Desaturation + Falling particles
+1 -1
View File
@@ -1,4 +1,4 @@
import { msg } from '../i18n/index.js';
import { msg } from '../i18n/marker.js';
/**
* Night Vision / NVG — PVS-14 Image Intensifier Emulation
+1 -1
View File
@@ -1,4 +1,4 @@
import { msg } from '../i18n/index.js';
import { msg } from '../i18n/marker.js';
/**
* FLIR — Forward Looking Infrared
+103
View File
@@ -0,0 +1,103 @@
/**
* Shared fixtures for the share-view codec tests: a seeded generator and
* real long-form links.
*/
import {
REGISTERED_LAYER_IDS,
createDefaultLayerState,
optionSpecs,
} from '../data/layerState.js';
/** A long-form link copied from a real session. */
export const HAL_EXAMPLE_HASH =
'v=2&lat=30.2672&lon=-97.7431&alt=600&heading=15&pitch=-30&roll=360&style=normal&bloom=0&sharpen=1&bi=0&bv=2&si=49&hud=tactical&hv=1&dm=DENSE&dd=75&da=elastic&kf=7&ko=1&cr=0&sc=1&scf=11&map=photoreal&l=a.f.s&lo=f.e.1_k.o.n&ui=c.c.1_c.p.0_l.c.1_l.p.0_d.c.1_v.c.0_i.c.0_t.c.1_r.c.1_s.c.1_g.c.1_p.c.1_m.c.0';
/** Deterministic generator (mulberry32) for property-style tests. */
export function rng(seed) {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const OPTION_CANDIDATES = [
true,
false,
'light',
'strong',
'gfs',
'ifs',
'none',
'speed',
'temperature',
'pressure',
'km/h',
'm/s',
'mph',
'proximity',
'all',
'core',
'dense',
'off',
'on',
'viewshed',
'tomtom',
'osm',
'hybrid',
'clouds',
'clouds-regional',
'pano',
'flat',
'filtered',
'full',
0,
1,
2,
37,
50,
100,
365,
-12345678,
12345678,
8505110,
'S30:2026-09-18',
'L30:2024-02-29',
'VIIRS:2025-01-02',
'genre:jazz & blues',
'news',
'public-safety',
0.35,
0.8,
'abc-1*def-0',
'osm-1',
25544,
'a1b2c3',
'~abc123',
];
/** A random layer state using only values each option accepts. */
export function randomLayerState(random) {
const state = createDefaultLayerState();
state.enabledLayerIds = REGISTERED_LAYER_IDS.filter(() => random() < 0.3);
for (const ownerId of Object.keys(state.options)) {
for (const spec of optionSpecs(ownerId)) {
if (random() < 0.5) continue;
const accepted = OPTION_CANDIDATES.filter(
(value) => spec.normalize(value) !== null,
);
if (accepted.length)
state.options[ownerId][spec.key] =
accepted[Math.floor(random() * accepted.length)];
}
}
return state;
}
/** One element of `values`, chosen by `random`. */
export function pick(random, values) {
return values[Math.floor(random() * values.length)];
}
+2
View File
@@ -11,6 +11,7 @@ test('data providers have both hooks; credential editing stays development-only'
const plugins = localProviderPlugins();
assert.ok(plugins.some(({ name }) => name === 'fire-perimeters'));
assert.ok(plugins.some(({ name }) => name === 'gnss-integrity'));
assert.ok(plugins.some(({ name }) => name === 'ghostmaps'));
for (const plugin of plugins) {
if (plugin.name === 'gev-key-setup') {
assert.equal(plugin.configurePreviewServer, undefined);
@@ -105,6 +106,7 @@ test('real dev and built-preview servers serve provider JSON and terminate unkno
['/api/firms/status', 200],
['/api/fire-perimeters/inciweb/publication/invalid', 400],
['/api/gnss-integrity?lat=invalid', 400],
['/api/ghostmaps/unknown', 404],
['/api/terrain/heights?points=invalid', 400],
['/api/overpass', 405],
['/api/overpass/status', 200],
-1
View File
@@ -70,7 +70,6 @@ export class AircraftDisplay {
// a screen reader without this. It matters more now that the button ships
// ACTIVE from markup (default-on, 2026-08-22): the very first thing assistive
// tech reported was an unpressed-looking control over an armed layer.
// Mirrors #scope-toggle, which has always carried aria-pressed.
this._models3dBtn?.setAttribute(
'aria-pressed',
String(this._models3dEnabled),
+44 -5
View File
@@ -35,6 +35,10 @@ import * as Cesium from 'cesium';
import { aircraftTrackingTarget } from '../cockpitTracking.js';
import { ShellFeedback } from './shellFeedback.js';
import {
applyShareFollowOffset,
readShareFollowSubject,
} from './shareFollow.js';
import { runCctvLayerEnableTransition } from '../cctvFocusPolicy.js';
@@ -74,7 +78,13 @@ export class StyleManager extends ShellFacade {
*/
constructor(
viewer,
{ mapStackController = null, placeSearch, services, requestServices } = {},
{
mapStackController = null,
placeSearch,
services,
requestServices,
initialShareCode = null,
} = {},
) {
super();
const {
@@ -165,6 +175,8 @@ export class StyleManager extends ShellFacade {
this._showGlobalStatusNotice(message, options),
feedback: this._feedback,
updateFeedback: () => this._updateGlobalLoadingFeedback(),
applyFollowOffset: (entity, offset) =>
applyShareFollowOffset(viewer, entity, offset),
});
this._visualSettings = new VisualSettings({
@@ -336,6 +348,7 @@ export class StyleManager extends ShellFacade {
_poiRow: this._poiRow,
_locationBarDivider: this._locationBarDivider,
_locationSearch: this._locationSearch,
_locationSearchError: this._locationSearchError,
_searchToggle: this._searchToggle,
_resetGlobeBtn: this._resetGlobeBtn,
_cockpitResetGlobeBtn: this._cockpitResetGlobeBtn,
@@ -430,7 +443,12 @@ export class StyleManager extends ShellFacade {
isNavigationCurrent: (generation) =>
generation === this._navigationGeneration,
cancelOwnedNavigation: () => this.viewer.camera.cancelFlight(),
// A host may open a share code without a `/share/<code>` address.
initialShareCode,
});
this.shareLinkManager.setFollowSubjectProvider(({ nowMs }) =>
readShareFollowSubject({ viewer: this.viewer, nowMs }),
);
this.shareLinkManager.setPanelStateProvider(() =>
this._buildSharePanelState(),
);
@@ -1652,14 +1670,35 @@ export class StyleManager extends ShellFacade {
// ── Share Button ─────────────────────────────
/**
* Wires the share button click to copy the current share link to the clipboard.
* Wires the share button. By default it copies the current share link to the
* clipboard; a host may pass `services.shareLink(url)` to share it another way
* (a system share sheet, for example). It resolves to 'shared', 'copied',
* 'cancelled' (no toast) or false (failed).
* @returns {void}
*/
_initShareButton() {
this._lifetime.listen(this._shareBtn, 'click', async () => {
const success = await this.shareLinkManager.copyLink();
if (!this._disposed)
this._showToast(success ? t('Link copied!') : t('Copy failed'));
const share = this.services.shareLink;
let result;
if (typeof share === 'function') {
// A host's hook may return a plain value or throw; both are answers.
result = await Promise.resolve()
.then(() => {
const href = this.shareLinkManager.buildLink();
return href ? share(href) : false;
})
.catch(() => false);
} else {
result = (await this.shareLinkManager.copyLink()) ? 'copied' : false;
}
if (this._disposed || result === 'cancelled') return;
this._showToast(
result === 'shared'
? t('Link shared')
: result === 'copied'
? t('Link copied!')
: t('Copy failed'),
);
});
}
+3
View File
@@ -86,6 +86,9 @@ export async function _restoreContextSession({
throw error;
}
}
// Clear All may have run while Contacts settled. It owns the selection
// now, so the snapshot must not come back behind it.
if (restoreState.cancelled) return;
await this._dataManager.restoreEnabledLayerIds(
restoreState.enabledLayerIds,
{
+30
View File
@@ -1,3 +1,4 @@
import { getScopeMaskFeather, isScopeMaskEnabled, setScopeMaskEnabled, setScopeMaskFeather } from '../scopeMask.js';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
@@ -271,6 +272,35 @@ test('Display and voice HUD routes preserve explicit Cyber entry and exit author
assert.equal(voice.owner.activeStyle, 'snow');
});
test('scope choice and expanded feather survive every HUD layout and both restore paths', async (t) => {
const { owner } = styleOwner(t, 'tactical');
const previous = { enabled: isScopeMaskEnabled(), feather: getScopeMaskFeather() };
t.after(() => { setScopeMaskEnabled(previous.enabled); setScopeMaskFeather(previous.feather); });
Object.assign(owner.services, { setScopeMaskEnabled, setScopeMaskFeather });
owner._scopeBtn = { checked: true };
owner._scopeFeatherSlider = { value: '11', disabled: false };
owner._scopeFeatherValue = {};
await owner.restoreShareState({ scopeEnabled: false, scopeFeatherPct: 200 });
assert.equal(owner._scopeBtn.checked, false);
assert.equal(owner._scopeFeatherSlider.disabled, true);
assert.equal(owner._scopeFeatherSlider.value, '200');
for (const layout of ['minimal', 'operator', 'cyber', 'tactical']) {
owner._setHudVariant(layout, { applyVisualDefaults: true });
assert.equal(isScopeMaskEnabled(), false, layout);
assert.equal(getScopeMaskFeather(), 2, layout);
assert.equal(owner._scopeBtn.checked, false, layout);
}
await owner.applyVisualState({ scope: { enabled: true, featherPct: 175 } });
assert.equal(isScopeMaskEnabled(), true);
assert.equal(getScopeMaskFeather(), 1.75);
assert.equal(owner._scopeBtn.checked, true);
assert.equal(owner._scopeFeatherSlider.disabled, false);
assert.equal(owner._scopeFeatherValue.textContent, '175%');
await owner.applyVisualState({ scope: { featherPct: 500 } });
assert.equal(getScopeMaskFeather(), 2);
assert.equal(owner._scopeFeatherSlider.value, '200');
});
test('scene and share HUD transitions do not restore a local pre-Cyber preset', async (t) => {
const { owner, hud } = styleOwner(t, 'tactical');
owner.setStyle('noir');
+12 -8
View File
@@ -1,6 +1,7 @@
import { clampScopeFeatherPct } from '../scopeMaskPolicy.js';
import { createFrameRateMonitor } from './frameRateMonitor.js';
import { bindApplicationShortcuts } from './visualInput.js';
import { bindDisplayControls } from './displayControls.js';
import { bindDisplayControls, syncScopeControls } from './displayControls.js';
import { canonicalizeDensity } from '../data/detectionPolicy.js';
/** Own keyboard/display event subscriptions; settings remain with their state owners. */
@@ -92,6 +93,11 @@ export class DisplayBindings {
},
});
syncScopeControls(
this._scopeBtn,
this._scopeFeatherSlider,
isScopeMaskEnabled(),
);
this._displayControls?.destroy();
this._displayControls = bindDisplayControls({
elements: {
@@ -100,7 +106,7 @@ export class DisplayBindings {
bloomSlider: this._bloomSlider,
sharpenButton: this._sharpenBtn,
sharpenSlider: this._sharpenSlider,
scopeButton: this._scopeBtn,
scopeToggle: this._scopeBtn,
scopeFeatherSlider: this._scopeFeatherSlider,
hudLayout: this._hudLayoutSelect,
hudButton: this._hudBtn,
@@ -134,17 +140,15 @@ export class DisplayBindings {
this.shareLinkManager?.claimRestoreLane?.('visual');
this._setSharpenEnabled(!this.sharpenEnabled);
},
toggleScope: () => {
setScopeEnabled: (enabled) => {
this.shareLinkManager?.claimRestoreLane?.('visual');
const next = !isScopeMaskEnabled();
setScopeMaskEnabled(next);
this._scopeBtn.classList.toggle('active', next);
this._scopeBtn.setAttribute('aria-pressed', String(next));
setScopeMaskEnabled(enabled);
syncScopeControls(this._scopeBtn, this._scopeFeatherSlider, enabled);
this._syncShareState();
},
setScopeFeather: (value) => {
this.shareLinkManager?.claimRestoreLane?.('visual');
const pct = Math.max(0, Math.min(100, value || 0));
const pct = clampScopeFeatherPct(value);
if (this._scopeFeatherValue)
this._scopeFeatherValue.textContent = `${pct}%`;
setScopeMaskFeather(pct / 100);
+7 -1
View File
@@ -18,7 +18,6 @@ export function bindDisplayControls({ elements, actions }) {
for (const [name, action] of [
['bloomButton', 'toggleBloom'],
['sharpenButton', 'toggleSharpen'],
['scopeButton', 'toggleScope'],
['cleanViewButton', 'toggleCleanView'],
['cleanViewExitButton', 'exitCleanView'],
['celestialButton', 'toggleCelestial'],
@@ -34,6 +33,7 @@ export function bindDisplayControls({ elements, actions }) {
['scopeFeatherSlider', 'setScopeFeather'],
])
listen(elements[name], 'input', action, integer);
listen(elements.scopeToggle, 'change', 'setScopeEnabled', (el) => el.checked);
listen(elements.densitySlider, 'input', 'setDensity', (el) => el.value);
listen(elements.hudLayout, 'change', 'setHudLayout', (el) => el.value);
for (const [name, action] of [
@@ -72,3 +72,9 @@ export function bindDisplayControls({ elements, actions }) {
},
};
}
/** Keep the native Scope checkbox and feather availability in step with the mask. */
export function syncScopeControls(toggle, featherSlider, enabled) {
if (toggle) toggle.checked = enabled;
if (featherSlider) featherSlider.disabled = !enabled;
}
+26
View File
@@ -158,3 +158,29 @@ test('optional controls are absent safely and subscriptions stay instance-owned'
assert.deepEqual(calls, ['b']);
two.destroy();
});
test('the native Scope checkbox reads checked state on change and disposes its listener', () => {
const scopeToggle = element();
scopeToggle.checked = true;
const scopeFeatherSlider = element('200');
const calls = [];
const controls = bindDisplayControls({
elements: { scopeToggle, scopeFeatherSlider },
actions: {
setScopeEnabled: (value) => calls.push(['enabled', value]),
setScopeFeather: (value) => calls.push(['feather', value]),
},
});
scopeToggle.checked = false;
scopeToggle.dispatchEvent(new Event('change'));
scopeToggle.checked = true;
scopeToggle.dispatchEvent(new Event('change'));
scopeFeatherSlider.dispatchEvent(new Event('input'));
controls.destroy();
scopeToggle.dispatchEvent(new Event('change'));
assert.deepEqual(calls, [
['enabled', false],
['enabled', true],
['feather', 200],
]);
});
+1 -1
View File
@@ -6,7 +6,7 @@ export function createFrameRateMonitor({ viewer, documentRef = document }) {
if (!host || !frameEvent) return { destroy() {} };
const readout = documentRef.createElement('div');
readout.className = 'frame-rate-readout';
readout.className = 'frame-rate-readout scene-text-holdout';
readout.hidden = true;
// i18n-ignore: FPS is a unit code, the same in every language
readout.textContent = 'FPS —';
+1
View File
@@ -34,6 +34,7 @@ export const LAYER_NAME_KEYS = Object.freeze({
'recent-imagery': msg('Recent Imagery'),
'fire-perimeters': msg('Fire Perimeters'),
'gnss-integrity': msg('GNSS Integrity'),
's2-ghostmaps': msg('S2 GhostMaps'),
wind: msg('Wind'),
'weather-radar': msg('Rain radar'),
'weather-satellite': msg('Satellite clouds'),
+1
View File
@@ -73,6 +73,7 @@ const PANEL_GROUPS = [
'local-firms',
'fire-perimeters',
'gnss-integrity',
's2-ghostmaps',
],
},
{
+6
View File
@@ -22,6 +22,7 @@ export class LocationControls {
onCity,
onPoi,
onSearch,
onSearchEdit,
onReset,
doc = document,
requestFrame = (callback) => requestAnimationFrame(callback),
@@ -74,6 +75,11 @@ export class LocationControls {
this.bind(elements.search, 'keydown', (event) => {
if (event.key === 'Enter') void onSearch(elements.search.value);
});
// Typing corrects a failed query; closing the field (which empties it) drops its error.
this.bind(elements.search, 'input', () => onSearchEdit?.());
this.bind(elements.search, 'blur', () => {
if (!elements.search.value) onSearchEdit?.();
});
for (const button of elements.resetButtons)
this.bind(button, 'click', onReset);
}
+11
View File
@@ -86,6 +86,7 @@ function fixture() {
onCity: (id) => calls.push(['city', id]),
onPoi: (id, index) => calls.push(['poi', id, index]),
onSearch: (query) => calls.push(['search', query]),
onSearchEdit: () => calls.push(['edit']),
onReset: () => calls.push(['reset']),
doc,
requestFrame: (fn) => {
@@ -147,3 +148,13 @@ test('location and POI keys route once while form controls retain typing', () =>
f.elements.resetButtons[1].fire('click');
assert.deepEqual(f.calls, [['poi', 'a', 1], ['reset']]);
});
test('typing clears a failed search error and closing the emptied field drops it too', () => {
const f = fixture();
f.elements.search.value = 'Pariss';
f.elements.search.fire('blur');
assert.deepEqual(f.calls, [], 'leaving a kept query keeps its error');
f.elements.search.fire('input');
f.elements.search.value = '';
f.elements.search.fire('blur');
assert.deepEqual(f.calls, [['edit'], ['edit']]);
});
+29 -5
View File
@@ -80,19 +80,27 @@ export class LocationNavigation {
_handleLocationSearchState(state, change) {
if (this._disposed || !change) return;
if (change.type === 'started')
if (change.type === 'started') {
this._activeLocationSearchGeneration = change.generation;
else if (change.type === 'found') {
this._setLocationSearchError(null);
} else if (change.type === 'found') {
this._searchedLocationLabel = state.destination.label || state.query;
this._setActiveLocation(null);
this._currentPoi = null;
this._collapsePOIRow();
this._updateLocationMiniStatus();
} else if (change.type === 'missing')
this._showToast(t('Location not found'));
else if (change.type === 'failed') this._showToast(t('Search failed'));
this._setLocationSearchError(
`${t('Location not found')}. ${t('Check the spelling and press Enter to search again.')}`,
);
else if (change.type === 'failed')
this._setLocationSearchError(
`${t('Search failed')}. ${t('Press Enter to try again.')}`,
);
else if (change.type === 'settled')
this._settleLocationSearchUi(change.generation);
this._settleLocationSearchUi(change.generation, {
keepQuery: state.status === 'missing' || state.status === 'failed',
});
else if (
change.type === 'reset' &&
this._activeLocationSearchGeneration !== null
@@ -101,6 +109,21 @@ export class LocationNavigation {
}
}
/** Show, or with null clear, the announced error under the search field. */
_setLocationSearchError(message) {
const input = this._locationSearch;
const error = this._locationSearchError;
if (error) error.textContent = message || '';
if (!input) return;
if (message) {
input.setAttribute('aria-invalid', 'true');
if (error?.id) input.setAttribute('aria-describedby', error.id);
} else {
input.removeAttribute('aria-invalid');
input.removeAttribute('aria-describedby');
}
}
_initLocationBar() {
const { CITY_POIS, searchAndFlyTo, LocationSearch } = this.services;
this._locationControls?.destroy();
@@ -144,6 +167,7 @@ export class LocationNavigation {
onCity: (id) => this._onCityPillClick(id),
onPoi: (id, index) => this._onPoiClick(id, index),
onSearch: (query) => this._locationLookup.run(query),
onSearchEdit: () => this._setLocationSearchError(null),
onReset: () => this.resetToGlobeView(),
});
}
+110
View File
@@ -0,0 +1,110 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { NavigationController } from './navigationController.js';
import { LocationNavigation } from './locationNavigation.js';
function input() {
const classes = new Set(['expanded', 'searching']);
const attributes = new Map();
return {
id: 'location-search',
value: 'Pariss',
focused: false,
selected: false,
classList: {
remove: (...names) => names.forEach((name) => classes.delete(name)),
contains: (name) => classes.has(name),
},
focus() {
this.focused = true;
},
blur() {
this.focused = false;
},
select() {
this.selected = true;
},
setAttribute: (name, value) => attributes.set(name, value),
removeAttribute: (name) => attributes.delete(name),
getAttribute: (name) => attributes.get(name) ?? null,
};
}
function settle(searchInput, options) {
const owner = { _activeLocationSearchGeneration: 4, searchInput };
NavigationController.prototype._settleLocationSearchUi.call(
owner,
4,
options,
);
return owner;
}
test('a search that found nothing keeps its query, the open field and focus', () => {
const field = input();
const owner = settle(field, { keepQuery: true });
assert.equal(owner._activeLocationSearchGeneration, null);
assert.equal(field.value, 'Pariss');
assert.equal(field.classList.contains('expanded'), true);
assert.equal(field.classList.contains('searching'), false);
assert.equal(field.focused, true);
assert.equal(field.selected, true);
});
test('a search that arrived still clears and closes the field', () => {
const field = input();
field.focused = true;
settle(field);
assert.equal(field.value, '');
assert.equal(field.classList.contains('expanded'), false);
assert.equal(field.focused, false);
});
function navigation() {
const field = input();
const error = { id: 'location-search-error', textContent: '' };
const settled = [];
const toasts = [];
const owner = {
_disposed: false,
_locationSearch: field,
_locationSearchError: error,
_activeLocationSearchGeneration: null,
_settleLocationSearchUi: (generation, options) =>
settled.push([generation, options]),
_showToast: (message) => toasts.push(message),
_setLocationSearchError:
LocationNavigation.prototype._setLocationSearchError,
};
const handle = (status, type) =>
LocationNavigation.prototype._handleLocationSearchState.call(
owner,
{ status, query: field.value },
{ type, generation: 7 },
);
return { owner, field, error, settled, toasts, handle };
}
for (const [status, text] of [
['missing', 'Location not found'],
['failed', 'Search failed'],
]) {
test(`a ${status} search announces a lasting error on the field instead of a toast`, () => {
const n = navigation();
n.handle('searching', 'started');
n.handle(status, status);
n.handle(status, 'settled');
assert.match(n.error.textContent, new RegExp(`^${text}\\. `));
assert.equal(n.field.getAttribute('aria-invalid'), 'true');
assert.equal(
n.field.getAttribute('aria-describedby'),
'location-search-error',
);
assert.deepEqual(n.settled, [[7, { keepQuery: true }]]);
assert.deepEqual(n.toasts, []);
n.handle('searching', 'started');
assert.equal(n.error.textContent, '');
assert.equal(n.field.getAttribute('aria-invalid'), null);
assert.equal(n.field.getAttribute('aria-describedby'), null);
});
}
+11 -4
View File
@@ -119,10 +119,17 @@ export class NavigationController {
return this._navigationGeneration;
}
_settleLocationSearchUi(generation) {
/** A search that found nothing keeps its query and focus so it can be corrected. */
_settleLocationSearchUi(generation, { keepQuery = false } = {}) {
if (this._activeLocationSearchGeneration !== generation) return;
this._activeLocationSearchGeneration = null;
this.searchInput?.classList.remove('searching', 'expanded');
this.searchInput?.classList.remove('searching');
if (keepQuery) {
this.searchInput?.focus();
this.searchInput?.select?.();
return;
}
this.searchInput?.classList.remove('expanded');
if (this.searchInput) this.searchInput.value = '';
this.searchInput?.blur();
}
@@ -241,7 +248,7 @@ export class NavigationController {
});
}
_reassertNavigationHandoff(generation) {
_reassertNavigationHandoff(generation, { trackingOrigin = 'tool' } = {}) {
return reassertNavigationHandoff({
generation,
currentGeneration: this._navigationGeneration,
@@ -253,7 +260,7 @@ export class NavigationController {
// was refused by the cockpit leaves the old readout standing.
release: () => {
this.clearLocation();
return this._releaseFollowCamera();
return this._releaseFollowCamera({ trackingOrigin });
},
});
}
+160
View File
@@ -0,0 +1,160 @@
// The share button copies by default; a host's `services.shareLink` shares the
// link its own way and reports how it went.
import test from 'node:test';
import assert from 'node:assert/strict';
import { StyleManager } from './applicationShell.js';
import { ShareLinkManager } from '../sharelink.js';
import { parseSharePath } from '../share/shareLinkPath.js';
import { decodeShareView } from '../share/viewCodec.js';
/** The decoded view of a `/share/<code>` link. */
const sharedState = (href) =>
decodeShareView(parseSharePath(new URL(href).pathname).code).state;
const SHARE_LINK = /^https:\/\/gev\.example\/share\/[A-Za-z0-9_-]+$/;
function makeManager(href, { positioned = true } = {}) {
globalThis.window = { location: { href, hash: new URL(href).hash } };
globalThis.history = { replaceState() {} };
const viewer = {
camera: {
changed: { addEventListener() {} },
positionCartographic: positioned
? { latitude: 0, longitude: 0, height: 1000 }
: undefined,
heading: 0,
pitch: -Math.PI / 2,
roll: 0,
},
};
return new ShareLinkManager(viewer);
}
function installClipboard(writeText) {
Object.defineProperty(globalThis, 'navigator', {
configurable: true,
value: { clipboard: { writeText } },
});
}
/** Wire the real share button handler onto a minimal shell; return a click. */
function shell({ manager, services = {} }) {
const toasts = [];
let click;
const owner = {
services,
shareLinkManager: manager,
_shareBtn: {},
_disposed: false,
_lifetime: { listen: (_el, _type, handler) => (click = handler) },
_showToast: (message) => toasts.push(message),
};
StyleManager.prototype._initShareButton.call(owner);
return { toasts, click: () => click() };
}
test('buildLink is a /share link stamped with its creation time, without ?lang', () => {
const manager = makeManager('https://gev.example/?lang=ru#v=2&lat=10&lon=20');
const href = manager.buildLink({ nowMs: 5_000_000 });
assert.match(href, SHARE_LINK);
const link = new URL(href);
assert.equal(link.searchParams.get('lang'), null);
assert.equal(link.hash, '');
assert.equal(sharedState(href).sharedAtMs, 5_000_000);
// The camera is the live one, not the address bar's.
assert.equal(sharedState(href).lat, 0);
assert.equal(
makeManager('https://gev.example/', { positioned: false }).buildLink(),
null,
);
});
test('copyLink copies exactly what buildLink builds', async () => {
let copied = null;
installClipboard(async (url) => {
copied = url;
});
const manager = makeManager('https://gev.example/#v=2');
assert.equal(await manager.copyLink({ nowMs: 7_000_000 }), true);
assert.equal(copied, manager.buildLink({ nowMs: 7_000_000 }));
});
test('without a host hook the button copies the link', async () => {
let copied = null;
installClipboard(async (url) => {
copied = url;
});
const { toasts, click } = shell({
manager: makeManager('https://gev.example/#v=2'),
});
await click();
assert.match(copied, SHARE_LINK);
assert.deepEqual(toasts, ['Link copied!']);
installClipboard(async () => {
throw new Error('denied');
});
await click();
assert.deepEqual(toasts, ['Link copied!', 'Copy failed']);
});
test('a host hook receives the link and its result picks the toast', async () => {
installClipboard(async () => assert.fail('the host shares instead'));
const shared = [];
const results = ['shared', 'copied', 'cancelled', false];
const { toasts, click } = shell({
manager: makeManager('https://gev.example/#v=2'),
services: {
shareLink: async (url) => {
shared.push(url);
return results.shift();
},
},
});
for (let i = 0; i < 4; i++) await click();
assert.equal(shared.length, 4);
assert.match(shared[0], SHARE_LINK);
// 'cancelled' (the user closed the sheet) shows nothing.
assert.deepEqual(toasts, ['Link shared', 'Link copied!', 'Copy failed']);
});
test('a failing host hook, or no camera position, reports a failure', async () => {
const failing = shell({
manager: makeManager('https://gev.example/#v=2'),
services: { shareLink: async () => Promise.reject(new Error('no sheet')) },
});
await failing.click();
assert.deepEqual(failing.toasts, ['Copy failed']);
let called = false;
const unpositioned = shell({
manager: makeManager('https://gev.example/', { positioned: false }),
services: {
shareLink: async () => {
called = true;
return 'shared';
},
},
});
await unpositioned.click();
assert.equal(called, false);
assert.deepEqual(unpositioned.toasts, ['Copy failed']);
});
test('a host hook that answers without a promise, or throws, still gets a toast', async () => {
for (const [shareLink, toast] of [
[() => 'shared', 'Link shared'],
[
() => {
throw new Error('no sheet');
},
'Copy failed',
],
]) {
const { toasts, click } = shell({
manager: makeManager('https://gev.example/#v=2'),
services: { shareLink },
});
await click();
assert.deepEqual(toasts, [toast]);
}
});
+190
View File
@@ -0,0 +1,190 @@
import * as Cesium from 'cesium';
import { trackedDisplayPositionForCamera } from '../data/trackedCamera.js';
import {
SHARE_VIEW_ENTITY_KINDS,
followOffsetFromEnu,
followOffsetToEnu,
} from '../share/follow.js';
/**
* The bridge between the camera following an entity and a share link's thing
* view: what the camera follows now, in the share format's terms, and the
* stored follow offset applied to an entity the camera has started to follow.
*/
const KIND_BY_LAYER = new Map(
SHARE_VIEW_ENTITY_KINDS.map((entry) => [entry.layerId, entry.kind]),
);
const MAX_FRAMES = 120;
/** `position`'s local east/north/up offset from `origin`, in metres. */
function enuOffset(origin, position) {
const frame = Cesium.Transforms.eastNorthUpToFixedFrame(origin);
const local = Cesium.Matrix4.multiplyByPoint(
Cesium.Matrix4.inverseTransformation(frame, new Cesium.Matrix4()),
position,
new Cesium.Cartesian3(),
);
return { east: local.x, north: local.y, up: local.z };
}
/**
* The entity the camera follows as `{ kind, id, offset, lastKnown }`, with
* the camera's offset from it and its displayed position as of `nowMs`; null
* when the camera follows nothing a share link can name. A selected vessel is
* not followed (the camera stays where the user puts it), so a link made then
* keeps its own camera.
*/
export function readShareFollowSubject({ viewer, nowMs = Date.now() }) {
const cameraPosition = viewer?.camera?.positionWC;
if (!cameraPosition) return null;
const tracked = viewer.trackedEntity;
const trackedId = String(tracked?.gevTrackedId || '');
const separator = trackedId.indexOf(':');
if (separator <= 0) return null;
const kind = KIND_BY_LAYER.get(trackedId.slice(0, separator));
const id = trackedId.slice(separator + 1);
const target = kind
? trackedDisplayPositionForCamera(
tracked,
viewer.clock.currentTime,
new Cesium.Cartesian3(),
)
: null;
if (!kind || !id || !target) return null;
const offset = followOffsetFromEnu(enuOffset(target, cameraPosition));
const fix = Cesium.Cartographic.fromCartesian(target);
if (!offset || !fix) return null;
return {
kind,
id,
offset,
lastKnown: {
lat: Cesium.Math.toDegrees(fix.latitude),
lon: Cesium.Math.toDegrees(fix.longitude),
alt: fix.height,
observedAtMs: nowMs,
},
};
}
/** Largest turn, in radians, the camera makes around the entity per frame. */
const MAX_TURN_PER_FRAME = Math.PI / 3;
/**
* Put the camera at a shared follow offset from `entity` once following it
* has framed the camera. Cesium keeps the offset afterwards, as it keeps one
* the user orbits to. The camera swings round the entity at most 60° a frame:
* the follow's own guard treats a jump to the far side as the camera crossing
* through the entity and puts it back. Returns a disposer.
*/
export function applyShareFollowOffset(viewer, entity, offset) {
if (!viewer?.scene?.preUpdate || !entity || !(offset?.range > 0))
return () => {};
const local = followOffsetToEnu(offset);
let frames = 0;
let remove = null;
const stop = () => {
remove?.();
remove = null;
};
remove = viewer.scene.preUpdate.addEventListener(() => {
if (viewer.isDestroyed?.() || viewer.trackedEntity !== entity) {
stop();
return;
}
frames += 1;
const camera = viewer.camera;
// Let the follow frame the camera first (its first frame), then move it.
const framed =
frames > 1 &&
!Cesium.Matrix4.equals(camera.transform, Cesium.Matrix4.IDENTITY);
const target = framed
? trackedDisplayPositionForCamera(
entity,
viewer.clock.currentTime,
new Cesium.Cartesian3(),
)
: null;
if (!target) {
if (frames >= MAX_FRAMES) stop();
return;
}
// The offset is east/north/up at the entity; the follow's own frame may
// differ, so place the camera in world space and re-express it there.
const world = Cesium.Matrix4.multiplyByPoint(
Cesium.Transforms.eastNorthUpToFixedFrame(target),
new Cesium.Cartesian3(local.east, local.north, local.up),
new Cesium.Cartesian3(),
);
const goal = Cesium.Matrix4.multiplyByPoint(
Cesium.Matrix4.inverseTransformation(
camera.transform,
new Cesium.Matrix4(),
),
world,
new Cesium.Cartesian3(),
);
const from = Cesium.Cartesian3.clone(
camera.position,
new Cesium.Cartesian3(),
);
const angle =
Cesium.Cartesian3.magnitude(from) > 0
? Cesium.Cartesian3.angleBetween(from, goal)
: 0;
const done = !(angle > MAX_TURN_PER_FRAME);
const next = done ? goal : turnToward(from, goal, MAX_TURN_PER_FRAME);
camera.lookAtTransform(camera.transform, next);
viewer.scene.requestRender?.();
if (done) stop();
});
return stop;
}
/**
* `from` turned `step` radians toward `to`, with its length moved the same
* fraction of the way: one frame of the swing round the entity.
*/
function turnToward(from, to, step) {
const angle = Cesium.Cartesian3.angleBetween(from, to);
const unitFrom = Cesium.Cartesian3.normalize(from, new Cesium.Cartesian3());
// The direction perpendicular to `from` in the plane toward `to` (or any
// perpendicular when they are opposite).
let toward = Cesium.Cartesian3.subtract(
Cesium.Cartesian3.normalize(to, new Cesium.Cartesian3()),
Cesium.Cartesian3.multiplyByScalar(
unitFrom,
Math.cos(angle),
new Cesium.Cartesian3(),
),
new Cesium.Cartesian3(),
);
if (Cesium.Cartesian3.magnitude(toward) < 1e-9)
toward = Cesium.Cartesian3.cross(
unitFrom,
Math.abs(unitFrom.z) < 0.9
? Cesium.Cartesian3.UNIT_Z
: Cesium.Cartesian3.UNIT_X,
new Cesium.Cartesian3(),
);
Cesium.Cartesian3.normalize(toward, toward);
const length =
Cesium.Cartesian3.magnitude(from) +
(Cesium.Cartesian3.magnitude(to) - Cesium.Cartesian3.magnitude(from)) *
(step / angle);
const direction = Cesium.Cartesian3.add(
Cesium.Cartesian3.multiplyByScalar(
unitFrom,
Math.cos(step),
new Cesium.Cartesian3(),
),
Cesium.Cartesian3.multiplyByScalar(
toward,
Math.sin(step),
new Cesium.Cartesian3(),
),
new Cesium.Cartesian3(),
);
return Cesium.Cartesian3.multiplyByScalar(direction, length, direction);
}
+554
View File
@@ -0,0 +1,554 @@
// Short share links: `/share/<code>` opens through the start-up restore
// pipeline, the address then follows the live view, and the share button
// makes `/share/<code>` links (following an entity when the camera does).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { ShareLinkManager } from '../sharelink.js';
import { ShareRestoration, sharedFixNotice } from './shareRestoration.js';
import {
applyShareFollowOffset,
readShareFollowSubject,
} from './shareFollow.js';
import { createDefaultLayerState } from '../data/layerState.js';
import { parseSharePath, shareLinkUrl } from '../share/shareLinkPath.js';
import {
decodeShareView,
encodeShareView,
followCameraFor,
followOffsetToEnu,
} from '../share/viewCodec.js';
const NOW_MS = Date.now();
const FIX = { lat: 37.6213, lon: -122.379, alt: 3200 };
const OFFSET = { range: 5800, heading: 0, pitch: -35 };
const PLACE_CODE = encodeShareView({
lat: 48.8584,
lon: 2.2945,
alt: 1200,
heading: 120,
pitch: -25,
style: 'noir',
panelState: { specs: [{ id: 'data-panel', collapsed: false }] },
sharedAtMs: NOW_MS - 60_000,
});
function aircraftCode() {
const layerState = createDefaultLayerState();
layerState.enabledLayerIds = ['flights'];
return encodeShareView({
view: {
kind: 'thing',
entity: { kind: 'aircraft', id: 'a1b2c3' },
offset: OFFSET,
lastKnown: { ...FIX, observedAtMs: NOW_MS - 120_000 },
},
layerState,
sharedAtMs: NOW_MS - 120_000,
});
}
/**
* A page at `href`. `history.replaceState` rewrites the address in place and
* is counted; `pushState` (a new history entry) is counted too.
*/
function installPage(t, href) {
const prior = { window: globalThis.window, history: globalThis.history };
const target = new EventTarget();
const location = new URL(href);
target.location = location;
globalThis.window = target;
const writes = { replaced: 0, pushed: 0 };
globalThis.history = {
replaceState(_state, _title, next) {
writes.replaced += 1;
location.href = new URL(next, location.href).href;
},
pushState() {
writes.pushed += 1;
},
};
t.after(() => Object.assign(globalThis, prior));
return { location, writes, target };
}
function installManualTimers(t) {
const prior = {
setTimeout: globalThis.setTimeout,
clearTimeout: globalThis.clearTimeout,
};
const timers = new Map();
let timerId = 0;
globalThis.setTimeout = (fn) => {
timers.set(++timerId, fn);
return timerId;
};
globalThis.clearTimeout = (id) => timers.delete(id);
t.after(() => Object.assign(globalThis, prior));
return {
timers,
runAll() {
for (const [id, fn] of [...timers]) {
timers.delete(id);
fn();
}
},
};
}
/** A viewer whose camera flights land at once. */
function fakeViewer() {
const flights = [];
return {
flights,
canvas: new EventTarget(),
camera: {
changed: { addEventListener: () => () => {} },
positionCartographic: { latitude: 0, longitude: 0, height: 1000 },
heading: 0,
pitch: -Math.PI / 2,
roll: 0,
flyTo(options) {
flights.push(options);
options.complete?.();
},
setView() {},
},
};
}
const flush = () => new Promise((resolve) => setImmediate(resolve));
/** A ShareRestoration over a real ShareLinkManager, as the shell wires them. */
function openPage(t, href, { managerOptions = {}, tracking = null } = {}) {
const page = installPage(t, href);
const clock = installManualTimers(t);
const viewer = fakeViewer();
const restores = [];
const manager = new ShareLinkManager(viewer, {
onRestore: async (state) => restores.push(state),
isNavigationCurrent: () => true,
...managerOptions,
});
const notices = [];
let generation = 0;
const owner = new ShareRestoration({
viewer,
navigation: {
_beginDeferredNavigation: () => ++generation,
_reassertNavigationHandoff: () => true,
_stampNavigation() {},
},
syncShareState() {},
syncModels3d() {},
showStatus() {},
feedback: {},
updateFeedback() {},
applyFollowOffset: (entity, offset) =>
applyShareFollowOffset(viewer, entity, offset),
});
// Notices are checked by message; their start-up-cover timing is not.
owner._presentAfterStartupCover = (message) => notices.push(message);
owner.attachLinks(manager);
owner._layerStateCoordinator = {
applyShareLayerState: () => Promise.resolve([]),
getDurableState: () => null,
cancelPendingShareTracking: () => false,
restoreShareTrackingSelection: async () => tracking?.(owner) ?? null,
destroy() {},
};
owner.start();
t.after(() => owner.destroy());
return { ...page, ...clock, viewer, manager, owner, restores, notices };
}
/** Run the delayed start-up restore and let it settle. */
async function restore(page) {
page.runAll();
const result = await page.owner.initialRestorePromise;
await flush();
return result;
}
test('a /share/<code> address opens its place view through the start-up restore', async (t) => {
const page = openPage(t, `http://localhost/share/${PLACE_CODE}`);
assert.equal(page.timers.size, 1, 'the restore waits for its delayed flight');
assert.equal(page.manager._initialRestorePending, true);
const result = await restore(page);
assert.equal(result.status, 'settled');
assert.equal(result.share.camera, 'applied');
assert.equal(page.restores[0].style, 'noir');
assert.deepEqual(page.restores[0].panelState.specs, [
{ id: 'data-panel', collapsed: false, pinned: null },
]);
const destination = Cesium.Cartographic.fromCartesian(
page.viewer.flights[0].destination,
);
assert.ok(
Math.abs(Cesium.Math.toDegrees(destination.latitude) - 48.8584) < 1e-6,
);
assert.equal(page.manager._initialRestorePending, false);
assert.deepEqual(page.notices, []);
});
test('after a restore the address becomes the app root and the long hash, with no new history entry', async (t) => {
for (const [href, root] of [
[`http://localhost/share/${PLACE_CODE}`, 'http://localhost/'],
[
`http://localhost:4173/gev/share/${PLACE_CODE}`,
'http://localhost:4173/gev/',
],
]) {
const page = openPage(t, href);
await restore(page);
page.manager._updateHash();
assert.equal(`${page.location.origin}${page.location.pathname}`, root);
// The live view's long form, as written for any other page.
const params = new URLSearchParams(page.location.hash.slice(1));
assert.equal(params.get('v'), '2');
assert.ok(params.has('lat') && params.has('style'));
assert.equal(page.writes.pushed, 0);
assert.equal(page.writes.replaced, 1);
page.manager.destroy();
}
});
test('a host can pass the share code instead of the address', async (t) => {
const page = openPage(t, 'http://localhost/', {
managerOptions: { initialShareCode: PLACE_CODE },
});
await restore(page);
assert.equal(page.restores[0].style, 'noir');
});
test('a code that fails its check restores nothing and says so', async (t) => {
// One character changed: the check byte no longer matches.
const last = PLACE_CODE.at(-2) === 'A' ? 'B' : 'A';
const typo = `${PLACE_CODE.slice(0, -2)}${last}${PLACE_CODE.at(-1)}`;
const page = openPage(t, `http://localhost/share/${typo}`);
assert.equal(page.manager.rejectedShareCode, 'checksum');
assert.notEqual(page.owner._hasShareState, true);
assert.equal(page.timers.size, 0, 'nothing to restore');
assert.equal(
(await page.owner.initialRestorePromise).status,
'not-requested',
);
assert.deepEqual(page.notices, ["This share link can't be opened"]);
// The default start's first address write leaves the bad link behind.
page.manager._updateHash();
assert.equal(page.location.pathname, '/');
});
test('a thing view turns its layer on, follows by id and frames the shared offset', async (t) => {
let entity = null;
const page = openPage(t, `http://localhost/share/${aircraftCode()}`, {
tracking: (owner) => {
// What the coordinator does when the aircraft is in the feed.
owner.viewer.trackedEntity = entity;
owner._handleShareTrackingRestoreStatus({
layerId: 'flights',
targetId: 'a1b2c3',
classification: 'followed',
});
return { classification: 'followed' };
},
});
const target = Cesium.Cartesian3.fromDegrees(FIX.lon, FIX.lat, FIX.alt);
const listeners = [];
const looks = [];
Object.assign(page.viewer, {
clock: { currentTime: Cesium.JulianDate.now() },
scene: {
preUpdate: {
addEventListener(fn) {
listeners.push(fn);
return () => listeners.splice(listeners.indexOf(fn), 1);
},
},
},
isDestroyed: () => false,
});
Object.assign(page.viewer.camera, {
transform: Cesium.Transforms.eastNorthUpToFixedFrame(target),
// The app's own follow framing: south of the aircraft and above it.
position: new Cesium.Cartesian3(0, -4800, 3300),
lookAtTransform(transform, offset) {
looks.push(offset);
this.position = Cesium.Cartesian3.clone(offset);
},
});
entity = {
gevTrackedId: 'flights:a1b2c3',
gevDisplayPosition: () => target,
};
const parsed = page.manager._parseShareCode(aircraftCode());
assert.deepEqual(parsed.layerState.enabledLayerIds, ['flights']);
assert.equal(
parsed.layerState.options.flights.selectedFlightsTrackingId,
'a1b2c3',
);
await restore(page);
// The opening flight goes where following the fix puts the camera.
const camera = followCameraFor(FIX, OFFSET);
const destination = Cesium.Cartographic.fromCartesian(
page.viewer.flights[0].destination,
);
assert.ok(
Math.abs(Cesium.Math.toDegrees(destination.latitude) - camera.lat) < 1e-3,
);
assert.ok(
Math.abs(Cesium.Math.toDegrees(destination.longitude) - camera.lon) < 1e-3,
);
// Once followed, the camera takes the shared offset after the follow's
// own first frame.
assert.equal(listeners.length, 1);
listeners[0]();
assert.equal(looks.length, 0);
listeners[0]?.();
assert.equal(looks.length, 1);
const expected = followOffsetToEnu(OFFSET);
assert.ok(Math.abs(looks[0].x - expected.east) < 1);
assert.ok(Math.abs(looks[0].y - expected.north) < 1);
assert.ok(Math.abs(looks[0].z - expected.up) < 1);
assert.equal(listeners.length, 0, 'the offset is applied once');
assert.deepEqual(page.notices, []);
});
test('a thing that is not reporting leaves the view at its last-known fix, with a notice', async (t) => {
const page = openPage(t, `http://localhost/share/${aircraftCode()}`, {
tracking: (owner) => {
// The coordinator's verdict once the follow window has passed.
const result = {
layerId: 'flights',
targetId: 'a1b2c3',
label: 'flight',
classification: 'expired',
};
owner._handleShareTrackingRestoreStatus(result);
return result;
},
});
await restore(page);
await flush();
assert.equal(page.notices.length, 1);
assert.match(
page.notices[0],
/^A1B2C3 isn't reporting now — showing where it was at .+/,
);
// A long link names no fix and keeps the existing notice.
page.owner._initialShareState = { shareView: null };
page.owner._handleShareTrackingRestoreStatus({
layerId: 'flights',
targetId: 'a1b2c3',
label: 'flight',
classification: 'expired',
});
assert.equal(page.notices[1], 'Shared flight follow expired');
});
test('the not-reporting notice names the id and the time of the fix', () => {
const at = new Date(2026, 9, 26, 14, 5).getTime();
assert.match(
sharedFixNotice({ kind: 'aircraft', id: 'a1b2c3' }, at, at + 60_000),
/^A1B2C3 isn't reporting now — showing where it was at .*05/,
);
assert.equal(
sharedFixNotice({ kind: 'vessel', id: '366999712' }, null),
"366999712 isn't reporting now — showing where it was last seen",
);
});
test('a shared vessel is selected once the vessel feed has it', async (t) => {
t.mock.timers.enable({ apis: ['Date'], now: NOW_MS });
const page = openPage(t, 'http://localhost/');
const selected = [];
let present = false;
page.owner._dataManager = {
layers: new Map([
[
'ais-live-vessels',
{
module: {
hasContact: (id) => present && id === '366999712',
selectById: (id) => selected.push(id) > 0,
},
},
],
]),
};
const view = {
kind: 'thing',
entity: { kind: 'vessel', id: '366999712' },
lastKnown: { lat: 1, lon: 2, alt: 0, observedAtMs: NOW_MS - 30_000 },
};
const found = page.owner._restoreSharedVessel(view, () => true);
await flush();
present = true;
page.runAll();
assert.equal(await found, true);
assert.deepEqual(selected, ['366999712']);
present = false;
const missing = page.owner._restoreSharedVessel(view, () => true);
for (let i = 0; i < 61; i += 1) {
await flush();
t.mock.timers.tick(2_000);
page.runAll();
}
assert.equal(await missing, false);
assert.match(page.notices.at(-1), /^366999712 isn't reporting now/);
});
test('the share button links to /share/<code>, following the followed entity', (t) => {
installPage(t, 'https://gev.example/?lang=ru#v=2&lat=1&lon=2');
const manager = new ShareLinkManager(fakeViewer());
// A fresh session's look, as the app reports it at start-up.
manager.onToggleChange(false, true, {
hudVisible: true,
detectionMode: 'DENSE',
detectionDensity: 75,
});
// No entity: a place view at the live camera.
const place = manager.buildLink({ nowMs: NOW_MS });
assert.match(place, /^https:\/\/gev\.example\/share\/[A-Za-z0-9_-]+$/);
assert.equal(
decodeShareView(parseSharePath(new URL(place).pathname).code).state.view
.kind,
'place',
);
manager.setFollowSubjectProvider(() => ({
kind: 'aircraft',
id: 'a1b2c3',
offset: OFFSET,
lastKnown: { ...FIX, observedAtMs: NOW_MS },
}));
const href = manager.buildLink({ nowMs: NOW_MS });
const code = parseSharePath(new URL(href).pathname).code;
const { state } = decodeShareView(code, { nowMs: NOW_MS });
assert.deepEqual(state.view, {
kind: 'thing',
entity: { kind: 'aircraft', id: 'a1b2c3' },
offset: OFFSET,
lastKnown: { ...FIX, observedAtMs: Math.floor(NOW_MS / 1000) * 1000 },
});
// The followed aircraft's layer is implied; the camera is derived.
assert.ok(code.length <= 45, `${code.length} characters`);
assert.equal(href, shareLinkUrl(code, 'https://gev.example/'));
manager.destroy();
});
test('the followed entity is read in the share format, and its offset round-trips', () => {
const target = Cesium.Cartesian3.fromDegrees(FIX.lon, FIX.lat, FIX.alt);
const offset = { range: 2400, heading: 30, pitch: -20 };
const local = followOffsetToEnu(offset);
const cameraPosition = Cesium.Matrix4.multiplyByPoint(
Cesium.Transforms.eastNorthUpToFixedFrame(target),
new Cesium.Cartesian3(local.east, local.north, local.up),
new Cesium.Cartesian3(),
);
const viewer = {
camera: { positionWC: cameraPosition },
clock: { currentTime: Cesium.JulianDate.now() },
trackedEntity: {
gevTrackedId: 'military:ae1234',
gevDisplayPosition: () => target,
},
};
const subject = readShareFollowSubject({ viewer, nowMs: NOW_MS });
assert.equal(subject.kind, 'military_aircraft');
assert.equal(subject.id, 'ae1234');
assert.deepEqual(subject.offset, offset);
assert.ok(Math.abs(subject.lastKnown.lat - FIX.lat) < 1e-9);
assert.ok(Math.abs(subject.lastKnown.alt - FIX.alt) < 1e-3);
// A followed entity the share format has no kind for is not a thing view.
viewer.trackedEntity.gevTrackedId = 'local-adsb:abc';
assert.equal(readShareFollowSubject({ viewer }), null);
// Nor is a camera that follows nothing (a selected vessel is not followed).
viewer.trackedEntity = undefined;
assert.equal(readShareFollowSubject({ viewer }), null);
});
test('a shared offset on the far side swings round, never jumping through the entity', () => {
const target = Cesium.Cartesian3.fromDegrees(FIX.lon, FIX.lat, FIX.alt);
const listeners = [];
const looks = [];
const entity = { gevDisplayPosition: () => target };
const viewer = {
clock: { currentTime: Cesium.JulianDate.now() },
scene: {
preUpdate: {
addEventListener(fn) {
listeners.push(fn);
return () => listeners.splice(listeners.indexOf(fn), 1);
},
},
},
trackedEntity: entity,
camera: {
transform: Cesium.Transforms.eastNorthUpToFixedFrame(target),
position: new Cesium.Cartesian3(0, -4800, 3300),
lookAtTransform(transform, offset) {
looks.push(Cesium.Cartesian3.clone(offset));
this.position = Cesium.Cartesian3.clone(offset);
},
},
};
// Looking south at the nose: the opposite side of the app's framing.
const offset = { range: 5800, heading: 180, pitch: -35 };
applyShareFollowOffset(viewer, entity, offset);
let previous = viewer.camera.position;
for (let frame = 0; frame < 10 && listeners.length; frame += 1) {
listeners[0]();
const now = viewer.camera.position;
// The follow's guard treats a turn of 90° or more as a crossing.
assert.ok(Cesium.Cartesian3.angleBetween(previous, now) < Math.PI / 2);
previous = now;
}
assert.equal(listeners.length, 0, 'it arrives');
assert.ok(looks.length >= 2, `${looks.length} steps`);
const expected = followOffsetToEnu(offset);
const last = looks.at(-1);
assert.ok(Math.abs(last.x - expected.east) < 1);
assert.ok(Math.abs(last.y - expected.north) < 1);
assert.ok(Math.abs(last.z - expected.up) < 1);
});
test('applying a follow offset gives up when the follow ends', () => {
const listeners = [];
const viewer = {
scene: {
preUpdate: {
addEventListener(fn) {
listeners.push(fn);
return () => listeners.splice(listeners.indexOf(fn), 1);
},
},
},
trackedEntity: null,
};
applyShareFollowOffset(viewer, {}, OFFSET);
listeners[0]();
assert.equal(listeners.length, 0);
});
test('long links and hash navigation are unaffected by a share code start', async (t) => {
// A long link still opens from the hash.
const long = openPage(t, 'http://localhost/#v=2&lat=10&lon=20&style=nvg');
await restore(long);
assert.equal(long.restores[0].style, 'surveillance');
assert.equal(long.restores[0].shareView, undefined);
long.manager._updateHash();
assert.equal(long.location.pathname, '/');
// After a /share start, navigating to a long link restores it (#1416).
const page = openPage(t, `http://localhost/share/${PLACE_CODE}`);
await restore(page);
page.location.hash = 'v=2&lat=1.0000&lon=2.0000&style=normal';
page.target.dispatchEvent(new Event('hashchange'));
await flush();
assert.equal(page.restores.length, 2);
assert.equal(page.restores[1].style, 'normal');
});
+292 -47
View File
@@ -1,9 +1,18 @@
import { LayerStateCoordinator } from '../data/layerStateCoordinator.js';
import { stampInitialShareGesture } from '../navigationPolicy.js';
import { canPresentDeferredStatusNotice } from '../loadingFeedback.js';
import { t, msg } from '../i18n/index.js';
import { t, msg, formatDate } from '../i18n/index.js';
import { SHARE_VIEW_ENTITY_KINDS } from '../share/follow.js';
import { UiLifetime } from './uiLifetime.js';
/** How long a shared vessel may take to appear in the vessel feed. */
export const SHARED_VESSEL_WAIT_MS = 120_000;
const SHARED_VESSEL_POLL_MS = 2_000;
const DAY_MS = 86_400_000;
const ENTITY_BY_KIND = new Map(
SHARE_VIEW_ENTITY_KINDS.map((entry) => [entry.kind, entry]),
);
// One ready-made sentence per restore policy (SHARE_TRACKING_RESTORE_POLICIES
// label) and outcome: a label dropped into a template cannot take the case or
// article the sentence needs in every language. The generic template stays
@@ -51,6 +60,36 @@ export function shareTrackingRestoreNotice(result) {
: t('Shared {subject} is unavailable', { subject });
}
/**
* The notice for a shared entity that is not reporting: the view shows where
* it was when the link was made.
* @param {{kind: string, id: string}} entity
* @param {number|null} observedAtMs
* @param {number} [nowMs]
* @returns {string}
*/
export function sharedFixNotice(entity, observedAtMs, nowMs = Date.now()) {
// ICAO addresses read as they are written on the aircraft: upper case.
const id =
ENTITY_BY_KIND.get(entity.kind)?.idForm === 'hex24'
? String(entity.id).toUpperCase()
: String(entity.id);
if (!Number.isFinite(observedAtMs))
return t("{id} isn't reporting now — showing where it was last seen", {
id,
});
const time = formatDate(
observedAtMs,
nowMs - observedAtMs < DAY_MS
? { hour: '2-digit', minute: '2-digit' }
: { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' },
);
return t("{id} isn't reporting now — showing where it was at {time}", {
id,
time,
});
}
/** Own initial share restoration, durable layer state and restoration notices. */
export class ShareRestoration {
constructor({
@@ -61,6 +100,7 @@ export class ShareRestoration {
showStatus,
feedback,
updateFeedback,
applyFollowOffset = null,
}) {
Object.assign(this, {
viewer,
@@ -70,6 +110,9 @@ export class ShareRestoration {
showStatus,
feedback,
updateFeedback,
// (entity, offset) => disposer: frame a followed entity from a shared
// follow offset (shareFollow.js).
applyFollowOffset,
});
this._lifetime = new UiLifetime();
this._disposed = false;
@@ -80,6 +123,14 @@ export class ShareRestoration {
this._initialShareRestoreTimeout = null;
this._layerStateCoordinator = null;
this._layerStateRestorePromise = null;
// Bumped by every share opened after start-up; an older restore that
// settles later must neither apply follow-up work nor release URL writes.
this._shareRestoreGeneration = 0;
this._navigatedShareRestoreActive = false;
this._stopFollowOffset = null;
// Bumped to abandon the wait for a shared vessel.
this._sharedVesselGeneration = 0;
this._sharedVesselPending = false;
}
attachLinks(shareLinkManager) {
this.shareLinkManager = shareLinkManager;
@@ -108,54 +159,35 @@ export class ShareRestoration {
const applyCamera =
Number.isInteger(generation) &&
this.navigation._reassertNavigationHandoff(generation);
void (async () => {
try {
const share = await this.shareLinkManager.applyState(savedState, {
applyCamera,
navigationToken: generation,
});
const layers = await (this._layerStateRestorePromise ||
Promise.resolve([]));
const tracking =
share.camera === 'applied'
? await this._layerStateCoordinator?.restoreShareTrackingSelection?.()
: {
status: 'superseded',
cleared:
this._layerStateCoordinator?.cancelPendingShareTracking?.(
'shared-camera-superseded',
{ clearSelection: true },
) === true,
};
this.shareLinkManager.completeInitialRestore();
this._settleInitialShareRestore({
status: 'settled',
share,
layers,
tracking,
});
} catch (error) {
this.shareLinkManager.completeInitialRestore();
this._settleInitialShareRestore({
status: 'failed',
error: String(error?.message || error),
share: null,
layers: [],
});
}
})();
void this._runShareRestore(savedState, {
applyCamera,
navigationToken: generation,
layersPromise: this._layerStateRestorePromise,
settle: (result) => this._settleInitialShareRestore(result),
});
}, 1500);
} else {
this.syncShareState();
if (this.shareLinkManager?.rejectedShareCode)
this._presentAfterStartupCover(
t("This share link can't be opened"),
++this._shareTrackingNoticeGeneration,
);
}
// A recipient can orbit before or during the delayed share flight. That
// gesture keeps ordinary layer state but revokes the passive base camera
// and selected-subject Follow so delayed work cannot seize navigation.
this._initialShareGestureHandler = () => {
// The recipient taking the camera also ends the wait for a vessel.
if (this._sharedVesselPending) {
this._sharedVesselGeneration += 1;
this._sharedVesselPending = false;
}
if (
this._disposed ||
!this._hasShareState ||
!this._resolveInitialShareRestore
(!this._resolveInitialShareRestore &&
!this._navigatedShareRestoreActive)
)
return;
stampInitialShareGesture((options) =>
@@ -176,6 +208,126 @@ export class ShareRestoration {
passive: true,
},
);
// A different share opened in this tab later (pasted address, link,
// Back/Forward) runs the same restore as start-up.
this.shareLinkManager?.watchNavigation?.((state) =>
this._restoreNavigatedShare(state),
);
}
/**
* Apply one incoming share: camera, visual, map and panels, then layers and
* any shared Follow. Only the newest share may finish: a superseded run
* stops before follow-up work and leaves URL writes suppressed for the
* newer one.
*/
async _runShareRestore(
state,
{ applyCamera, navigationToken, layersPromise, settle },
) {
const generation = this._shareRestoreGeneration;
const current = () =>
!this._disposed && generation === this._shareRestoreGeneration;
try {
const share = await this.shareLinkManager.applyState(state, {
applyCamera,
navigationToken,
});
const layers = await (layersPromise || Promise.resolve([]));
if (!current()) return;
const tracking =
share.camera === 'applied'
? await this._layerStateCoordinator?.restoreShareTrackingSelection?.()
: {
status: 'superseded',
cleared:
this._layerStateCoordinator?.cancelPendingShareTracking?.(
'shared-camera-superseded',
{ clearSelection: true },
) === true,
};
if (!current()) return;
this.shareLinkManager.completeInitialRestore(state.restoreToken);
// Selecting a vessel leaves the camera alone, so it does not depend on
// the shared flight having landed.
const vessel = this._sharedVessel(state);
if (vessel) void this._restoreSharedVessel(vessel, current);
settle({ status: 'settled', share, layers, tracking });
} catch (error) {
if (!current()) return;
this.shareLinkManager.completeInitialRestore(state.restoreToken);
settle({
status: 'failed',
error: String(error?.message || error),
share: null,
layers: [],
});
}
}
/** Restore a share opened in this tab after start-up, superseding any other. */
_restoreNavigatedShare(state) {
if (this._disposed || !state) return;
this._shareRestoreGeneration += 1;
const settle = (result) => {
this._navigatedShareRestoreActive = false;
window.dispatchEvent(
new CustomEvent('gev:share-navigation-restore-settled', {
detail: result,
}),
);
};
try {
return this._beginNavigatedShareRestore(state, settle);
} catch (error) {
// Never leave the address bar frozen on a restore that did not start.
this.shareLinkManager.completeInitialRestore(state.restoreToken);
settle({
status: 'failed',
error: String(error?.message || error),
share: null,
layers: [],
});
}
}
_beginNavigatedShareRestore(state, settle) {
// A start-up restore still waiting for its delayed flight never runs.
clearTimeout(this._initialShareRestoreTimeout);
this._initialShareRestoreTimeout = null;
this._settleInitialShareRestore({
status: 'superseded',
share: null,
layers: [],
});
this._initialShareState = state;
this._initialShareSelectionSuperseded = false;
this._hasShareState = true;
this._navigatedShareRestoreActive = true;
// A new deferred navigation generation retires any older share flight.
const navigationToken = this.navigation._beginDeferredNavigation(
'shared view',
{ cancelPendingSelection: false },
);
// Releasing the previous Follow is part of the passive share restore and
// must not be saved as the recipient's own layer preference.
const applyCamera =
Number.isInteger(navigationToken) &&
this.navigation._reassertNavigationHandoff(navigationToken, {
trackingOrigin: 'programmatic',
});
const layersPromise = this._layerStateCoordinator
? this._layerStateCoordinator.applyShareLayerState({
shareLayerState: state.layerState || null,
shareCreatedAtMs: state.sharedAtMs ?? null,
})
: null;
return this._runShareRestore(state, {
applyCamera,
navigationToken,
layersPromise,
settle,
});
}
connect(dataManager) {
this._dataManager = dataManager;
@@ -210,7 +362,79 @@ export class ShareRestoration {
});
}
}
/** The shared vessel a share's thing view selects, if it names one. */
_sharedVessel(state) {
const view = state?.shareView;
return view?.kind === 'thing' && view.entity.kind === 'vessel'
? view
: null;
}
/**
* Select a shared vessel once the vessel feed has it. Vessels are selected,
* not followed: the camera is already at the shared view. If the vessel does
* not appear in time, the view stays where it was when the link was made.
*/
async _restoreSharedVessel(view, current) {
const generation = ++this._sharedVesselGeneration;
const live = () => current() && generation === this._sharedVesselGeneration;
const deadline = Date.now() + SHARED_VESSEL_WAIT_MS;
const { id } = view.entity;
const { layerId } = ENTITY_BY_KIND.get(view.entity.kind);
this._sharedVesselPending = true;
try {
while (live()) {
const vessels = this._dataManager?.layers?.get?.(layerId)?.module;
if (vessels?.hasContact?.(id) && vessels.selectById(id)) return true;
if (Date.now() >= deadline) break;
await new Promise((resolve) =>
this._lifetime.timeout(resolve, SHARED_VESSEL_POLL_MS),
);
}
} finally {
if (generation === this._sharedVesselGeneration)
this._sharedVesselPending = false;
}
if (!live()) return false;
this._presentAfterStartupCover(
view.lastKnown
? sharedFixNotice(view.entity, view.lastKnown.observedAtMs)
: t('Shared vessel is unavailable'),
++this._shareTrackingNoticeGeneration,
);
return false;
}
/** The current share's thing view when `result` is about its entity. */
_sharedThingFor(result) {
const view = this._initialShareState?.shareView;
if (view?.kind !== 'thing') return null;
const entry = ENTITY_BY_KIND.get(view.entity.kind);
return entry?.tracking === result?.layerId &&
String(view.entity.id) === String(result?.targetId)
? view
: null;
}
/** Frame a shared entity the camera now follows from the shared offset. */
_applySharedFollowOffset(result) {
const view = this._sharedThingFor(result);
const entity = this.viewer?.trackedEntity;
// Only a link made while following carries a real offset (with the fix);
// a long link's follow keeps the app's own framing.
if (
!view?.lastKnown ||
String(entity?.gevTrackedId || '') !==
`${result.layerId}:${result.targetId}`
)
return;
this._stopFollowOffset?.();
this._stopFollowOffset = this.applyFollowOffset?.(entity, view.offset);
}
cancelSelection() {
// An explicit navigation also ends the wait for a shared vessel.
this._sharedVesselGeneration += 1;
if (
this._hasShareState &&
this._resolveInitialShareRestore &&
@@ -256,17 +480,39 @@ export class ShareRestoration {
this.updateFeedback();
}
}
if (
result.classification === 'followed' ||
result.classification === 'cancelled'
)
if (result.classification === 'followed') {
this._applySharedFollowOffset(result);
return;
}
if (result.classification === 'cancelled') return;
// A stale terminal result must never replace a newer target's acquisition.
if (this._shareTrackingAcquiringKey) return;
const noticeGeneration = ownsAcquiringNotice
? this._shareTrackingNoticeGeneration
: ++this._shareTrackingNoticeGeneration;
const message = shareTrackingRestoreNotice(result);
// A share link that knows where the entity was says so; the view is
// already there.
const fix = this._sharedThingFor(result)?.lastKnown;
const message = fix
? sharedFixNotice(
this._initialShareState.shareView.entity,
fix.observedAtMs,
)
: shareTrackingRestoreNotice(result);
if (this._resolveInitialShareRestore) {
void this.initialRestorePromise.then(() =>
this._presentAfterStartupCover(message, noticeGeneration),
);
return;
}
this._presentAfterStartupCover(message, noticeGeneration);
}
/**
* Show a status notice once the start-up cover is gone, unless a newer
* notice (generation) has taken its place.
*/
_presentAfterStartupCover(message, noticeGeneration) {
const showAfterStartupCover = () => {
this._lifetime.frame(() => {
if (
@@ -308,10 +554,6 @@ export class ShareRestoration {
fallbackTimer = this._lifetime.timeout(showOnce, 1000);
});
};
if (this._resolveInitialShareRestore) {
void this.initialRestorePromise.then(showAfterStartupCover);
return;
}
showAfterStartupCover();
}
_settleInitialShareRestore(result) {
@@ -328,6 +570,9 @@ export class ShareRestoration {
this._disposed = true;
this._shareTrackingNoticeGeneration += 1;
this._shareTrackingAcquiringKey = null;
this._sharedVesselGeneration += 1;
this._stopFollowOffset?.();
this._stopFollowOffset = null;
this._layerStateCoordinator?.destroy();
this._layerStateCoordinator = null;
this._layerStateRestorePromise = null;
+1
View File
@@ -180,6 +180,7 @@ export function readShellElements(document = globalThis.document) {
_cctvSyncProgress: document.getElementById('cctv-sync-progress'),
_toast: document.getElementById('toast'),
_locationSearch: document.getElementById('location-search'),
_locationSearchError: document.getElementById('location-search-error'),
_searchToggle: document.getElementById('search-toggle'),
_locationPills: document.getElementById('location-pills'),
_poiRow: document.getElementById('poi-row'),
+1 -1
View File
@@ -550,7 +550,7 @@ export class ShellFacade {
}
/** Settle only the search generation that still owns the shared input UI. */
_settleLocationSearchUi(generation) {
_settleLocationSearchUi(generation, options) {
return this._navigation._settleLocationSearchUi(...arguments);
}
+6
View File
@@ -478,6 +478,12 @@ body:not(.ui-clean-view):not(.recording-mode):has(
bottom: calc(2vh + 5.4rem);
}
:root[data-scope-mask='off']
#intel-hud[data-variant='minimal']
.hud-bottom-left {
bottom: calc(2vh + 6.5rem);
}
#intel-hud[data-variant='minimal'] .hud-bottom-bar {
bottom: calc(2vh + 9rem);
}
+21
View File
@@ -501,6 +501,27 @@ html:not([lang='en']) .panel-collapse-btn {
color: var(--accent);
}
.pp-scope-toggle input {
width: 14px;
height: 14px;
margin: 0 0 0 auto;
accent-color: var(--accent);
cursor: pointer;
}
.pp-scope-toggle:has(input:checked) .pp-label {
color: var(--accent);
}
.pp-scope-toggle:has(input:focus-visible) {
outline: 1px solid var(--accent);
outline-offset: 2px;
}
#scope-slider-row:has(input:disabled) {
opacity: 0.45;
}
/* ── Bloom Slider (inline with toggle) ───────── */
.pp-toggle-group {
display: flex;
+17
View File
@@ -298,3 +298,20 @@
#location-search.searching {
opacity: 0.5;
}
.location-search-error {
/* Its own line under the search row, which clips overflow in the dock. */
margin: 6px 0 0;
padding: 6px 10px;
border: 1px solid rgba(255, 99, 99, 0.45);
border-radius: 6px;
background: rgba(30, 8, 10, 0.92);
color: #ffb3b3;
font-family: var(--font-sans);
font-size: 12px;
line-height: 1.35;
}
.location-search-error:empty {
display: none;
}
+131
View File
@@ -371,3 +371,134 @@
#intel-hud[data-variant='minimal'] .hud-summary-wrap {
max-width: 280px;
}
/* Scope OFF adds text-sized backing; Scope ON retains the original HUD. */
.hud-readout {
display: contents;
}
:root[data-scope-mask='off']
#intel-hud:is(
[data-variant='minimal'],
[data-variant='operator'],
[data-variant='tactical']
)
.hud-readout,
:root[data-scope-mask='off']:not([data-ui-theme='cyber']) .scene-text-holdout {
background: rgba(0, 0, 0, 0.7);
box-shadow: none;
border-radius: 2px;
padding-block: 0;
padding-inline: 6px;
margin-block: 0;
margin-inline: -6px;
width: fit-content;
max-width: 100%;
box-sizing: content-box;
white-space: nowrap;
}
:root[data-scope-mask='off']
#intel-hud:is(
[data-variant='minimal'],
[data-variant='operator'],
[data-variant='tactical']
)
.hud-readout {
display: block;
width: max-content;
min-width: 0;
box-sizing: content-box;
white-space: nowrap;
}
:root[data-scope-mask='off']
#intel-hud:is(
[data-variant='minimal'],
[data-variant='operator'],
[data-variant='tactical']
)
:is(.hud-top-right, .hud-bottom-right)
.hud-readout,
:root[data-scope-mask='off']:not([data-ui-theme='cyber'])
#style-indicator
.scene-text-holdout {
margin-inline-start: auto;
}
/* Preserve glyph opacity without dimming the vertical backplates. */
:root[data-scope-mask='off']
#intel-hud:is([data-variant='operator'], [data-variant='tactical'])
.hud-edge {
opacity: 1;
}
:root[data-scope-mask='off']
#intel-hud:is([data-variant='operator'], [data-variant='tactical'])
.hud-edge
.hud-readout
> * {
opacity: 0.5;
}
:root[data-scope-mask='off'] #intel-hud[data-variant='operator'] .hud-corner,
:root[data-scope-mask='off']
#intel-hud:is([data-variant='minimal'], [data-variant='operator'])
:is(.hud-top-bar, .hud-bottom-bar) {
background: transparent;
}
:root[data-scope-mask='off']
#intel-hud:is([data-variant='minimal'], [data-variant='operator'])
:is(.hud-top-bar, .hud-bottom-bar) {
width: fit-content;
max-width: calc(100vw - 120px);
gap: 24px;
}
:root[data-scope-mask='off']
#intel-hud:is([data-variant='minimal'], [data-variant='operator'])
:is(.hud-top-bar, .hud-bottom-bar)
.hud-readout {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
:root[data-scope-mask='off']:not([data-ui-theme='cyber'])
#title-bar
h1::before {
content: '';
position: absolute;
inset: -0.12em -6px;
z-index: -1;
border-radius: 2px;
background: rgba(0, 0, 0, 0.7);
}
:root[data-scope-mask='off']:not([data-ui-theme='cyber'])
:is(#title-bar .subtitle, #style-indicator .indicator-label) {
color: var(--text-secondary);
}
:root[data-scope-mask='off']:not([data-ui-theme='cyber']) #title-bar .subtitle {
margin-top: 8px;
}
:root[data-scope-mask='off']:not([data-ui-theme='cyber'])
#style-indicator
.indicator-value {
margin-top: 4px;
}
:root[data-scope-mask='off']
#intel-hud[data-variant='minimal']
.hud-readout:has(> :is(.hud-classification, .hud-system)) {
display: none;
}
:root[data-scope-mask='off']:not([data-ui-theme='cyber'])
#title-bar
.frame-rate-readout {
margin-top: 6px;
}
+2 -1
View File
@@ -363,7 +363,8 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn {
#param-slider-panel.collapsed #param-sliders,
#location-bar.collapsed .poi-row-container,
#location-bar.collapsed .location-bar-divider,
#location-bar.collapsed .location-city-row {
#location-bar.collapsed .location-city-row,
#location-bar.collapsed .location-search-error {
display: none !important;
}
+246
View File
@@ -387,3 +387,249 @@ body.recording-mode .gev-pointer-reticle {
animation: none;
}
}
/* Opt-in shared panel: header stays fixed while only the body scrolls. */
#gev-voice-control .gev-voice-panel {
position: absolute;
bottom: calc(100% + 0.7rem);
left: 50%;
z-index: 10;
display: flex;
flex-direction: column;
width: clamp(18rem, 26vw, 22rem);
max-width: calc(100vw - 1.5rem);
max-height: min(
28rem,
max(7.5rem, calc(100dvh - 12rem - var(--gev-provider-lift, 0px)))
);
box-sizing: border-box;
padding: 0.6rem 0.7rem 0.65rem;
color: rgba(223, 245, 250, 0.92);
background: rgba(7, 22, 31, 0.96);
border: 1px solid rgba(54, 220, 255, 0.34);
border-radius: 0.6rem;
box-shadow:
0 0.85rem 2rem rgba(0, 0, 0, 0.58),
0 0 1.2rem rgba(0, 212, 255, 0.08);
backdrop-filter: blur(24px) saturate(1.3);
-webkit-backdrop-filter: blur(24px) saturate(1.3);
color-scheme: dark;
font: 0.75rem/1.45 var(--font-sans);
text-align: left;
transform: translateX(-50%);
}
:root[data-ui-theme='cyber'] #gev-voice-control .gev-voice-panel {
background-color: var(--cyber-panel-surface);
background-image: var(--cyber-panel-surface-detail);
border-color: var(--glass-border);
box-shadow: var(--cyber-panel-shadow);
}
#gev-voice-control .gev-voice-panel[hidden],
#gev-voice-control .gev-voice-panel [hidden] {
display: none;
}
#gev-voice-control .gev-voice-panel-header {
display: flex;
flex: 0 0 auto;
align-items: center;
justify-content: space-between;
gap: 0.35rem;
padding: 0 0 0.5rem;
border-bottom: 1px solid var(--glass-border);
}
.gev-voice-panel-title {
color: var(--voice-panel-accent, var(--accent));
font: 600 0.65rem/1 var(--font-mono);
letter-spacing: 0.14em;
}
.gev-voice-panel-tabs {
margin-left: auto;
}
#gev-voice-control .gev-voice-panel-header .gev-voice-panel-tab {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
padding: 0;
}
#gev-voice-control .gev-voice-panel-tab-icon {
font-size: 18px;
}
.gev-voice-panel-header [role='tablist'] {
display: flex;
flex-wrap: wrap;
gap: 0.2rem;
min-width: 0;
}
#gev-voice-control
.gev-voice-panel
:is(.gev-voice-panel-header button, select, input[type='number']) {
min-height: 2rem;
box-sizing: border-box;
color: var(--text-primary);
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--glass-border);
border-radius: 0.3rem;
font: inherit;
}
#gev-voice-control .gev-voice-panel .gev-voice-panel-header button {
padding: 0 0.6rem;
color: var(--text-secondary);
font-family: var(--font-mono);
font-size: 0.68rem;
letter-spacing: 0.04em;
line-height: 1;
cursor: pointer;
}
#gev-voice-control #gev-voice-settings-close {
width: 2rem;
padding: 0;
font-size: 1rem;
}
#gev-voice-control .gev-voice-panel-header button:hover {
color: var(--text-primary);
border-color: var(--voice-panel-accent, var(--accent));
}
#gev-voice-control .gev-voice-panel [role='tab'][aria-selected='true'] {
color: var(--voice-panel-accent, var(--accent));
border-color: currentColor;
box-shadow: 0 -2px 0 currentColor inset;
}
#gev-voice-control .gev-voice-panel [role='tab']:focus,
#gev-voice-control .gev-voice-panel :focus-visible {
outline: 2px solid var(--voice-panel-accent, var(--accent));
outline-offset: 2px;
}
.gev-voice-panel-body {
min-height: 0;
overflow: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
padding: 0.55rem 0.75rem 3px 3px;
line-height: 1.45;
overflow-wrap: anywhere;
}
.gev-voice-panel-body .gev-voice-card-who {
overflow-wrap: break-word;
}
.gev-voice-panel-body p {
margin: 0;
}
.gev-voice-preference {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0;
color: var(--text-primary);
}
.gev-voice-preference-toggle {
flex-direction: row-reverse;
justify-content: space-between;
min-height: 2.75rem;
font-weight: 600;
cursor: pointer;
}
.gev-voice-preference-toggle input {
flex: 0 0 auto;
width: 18px;
height: 18px;
margin: 0;
accent-color: var(--voice-panel-accent, var(--accent));
}
.gev-voice-setting-title {
margin-bottom: 0.4rem;
font-weight: 600;
}
.gev-voice-timeout-group {
margin-top: 0.65rem;
padding-top: 0.65rem;
border-top: 1px solid var(--glass-border);
}
.gev-voice-timeout-controls {
display: flex;
align-items: center;
gap: 0.4rem;
}
#gev-voice-timeout-select {
flex: 1 1 auto;
min-width: 0;
padding: 0 0.45rem;
}
#gev-voice-timeout-input {
flex: 0 0 4.5rem;
width: 4.5rem;
padding: 0 0.45rem;
font-variant-numeric: tabular-nums;
}
.gev-voice-timeout-unit {
order: 1;
color: var(--text-dim);
font-size: 0.63rem;
}
.gev-voice-timeout-controls:has(#gev-voice-timeout-input[hidden])
.gev-voice-timeout-unit {
display: none;
}
.gev-voice-settings-note {
color: rgba(190, 221, 230, 0.72);
font-size: 0.63rem;
line-height: 1.45;
}
.gev-voice-settings-note:not(:empty) {
margin-top: 0.25rem;
}
#gev-voice-control .gev-voice-panel .gev-voice-card {
position: static;
width: 100%;
max-height: none;
overflow: visible;
padding: 0;
border: 0;
background: transparent;
box-shadow: none;
transform: none;
animation: none;
}
#command-dock > #gev-voice-control:has(.gev-voice-panel:not([hidden])) {
z-index: 101;
}
#gev-voice-control:has(.gev-voice-panel:not([hidden])) .gev-voice-help-tray {
display: none;
}
#command-dock #gev-voice-control[data-provider-choice='true'] .gev-voice-panel {
bottom: min(
calc(100% + 4.8rem + var(--gev-provider-lift)),
calc(100dvh - 7.75rem)
);
}
#command-dock.dock-has-pinned-tray
#gev-voice-control:not([data-provider-choice='true'])
.gev-voice-panel {
bottom: min(
calc(100% + 1.7rem + var(--dock-pinned-stack-height)),
calc(100dvh - 18rem)
);
}
@media (pointer: coarse) {
#gev-voice-control
.gev-voice-panel
:is(
.gev-voice-panel-header button,
select,
input[type='number'],
.gev-voice-card-dismiss
) {
min-height: 44px;
}
#gev-voice-control
.gev-voice-panel
:is(.gev-voice-panel-header button, .gev-voice-card-dismiss) {
min-width: 44px;
}
}
/* A connection error remains reachable even while the manual panel is open. */
#gev-voice-control:has(.gev-voice-panel) .gev-voice-error-tray {
z-index: 12;
}
+333 -1
View File
@@ -62,6 +62,15 @@
color: #ffc46b;
}
.gev-voice-cost-warning {
margin-left: auto;
flex: 0 0 auto;
}
.gev-voice-cost-warning[data-level='unknown'] {
color: #ffc46b;
}
.gev-voice-cost-value[data-level='cap'] {
color: #ff6b6b;
font-weight: 600;
@@ -111,7 +120,7 @@ html:not([lang='en']) #command-dock .gev-voice-cost {
padding-left: 0.1rem;
}
html:not([lang='en']) #command-dock .gev-voice-tier-btn {
html:not([lang='en']) #command-dock .gev-voice-heading .gev-voice-tier-btn {
min-width: 0;
padding: 0 0.2rem;
}
@@ -232,3 +241,326 @@ html:not([lang='en'])
pointer-events: auto;
}
}
/* The opt-in provider and gear share a discoverable row at the existing anchor. */
#gev-voice-control:has(> .gev-voice-settings-button) {
--gev-voice-settings-gap: 0.75rem;
--gev-voice-tray-clearance: 0px;
}
#gev-voice-control:has(> .gev-voice-settings-button)
.gev-voice-provider-field:not([hidden]) {
bottom: calc(
100% + var(--gev-provider-lift, 0px) + var(--gev-voice-settings-gap)
);
display: flex;
align-items: center;
gap: 6px;
padding: 6px 46px 6px 6px;
border-color: rgba(123, 189, 211, 0.28);
background: rgba(9, 18, 27, 0.88);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.46);
color-scheme: dark;
opacity: 1;
pointer-events: auto;
}
#gev-voice-control:has(> .gev-voice-settings-button)
.gev-voice-provider-field
> span {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
#gev-voice-control:has(> .gev-voice-settings-button)
.gev-voice-provider-field
select {
min-height: 34px;
font-size: 0.7rem;
}
#gev-voice-control .gev-voice-settings-button {
position: absolute;
top: 1rem;
right: 0.25rem;
z-index: 11;
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
min-width: 34px;
min-height: 34px;
padding: 0;
color: rgba(190, 221, 230, 0.72);
background: rgba(9, 18, 27, 0.92);
border: 1px solid var(--glass-border);
border-radius: 0.3rem;
font-size: 18px;
line-height: 1;
cursor: pointer;
}
#gev-voice-control[data-provider-choice='true']:has(
.gev-voice-provider-field:not([hidden])
)
.gev-voice-settings-button {
top: auto;
right: 7px;
bottom: calc(
100% + var(--gev-provider-lift, 0px) + var(--gev-voice-settings-gap) + 7px
);
}
#gev-voice-control
.gev-voice-settings-button:is(:hover, [aria-expanded='true']) {
color: var(--voice-panel-accent, var(--accent));
border-color: currentColor;
background: rgba(0, 212, 255, 0.12);
}
#gev-voice-control .gev-voice-settings-button:focus-visible {
outline: 2px solid var(--voice-panel-accent, var(--accent));
outline-offset: 2px;
}
:root[data-ui-theme='cyber']
#gev-voice-control:has(> .gev-voice-settings-button) {
--voice-panel-accent: var(--cyber-red-bright);
}
:root[data-ui-theme='cyber']
#gev-voice-control:has(> .gev-voice-settings-button)
.gev-voice-provider-field {
background: var(--cyber-panel-surface);
border-color: var(--glass-border);
}
@media (pointer: coarse) {
#gev-voice-control .gev-voice-settings-button {
width: 44px;
height: 44px;
min-width: 44px;
min-height: 44px;
}
#gev-voice-control:has(> .gev-voice-settings-button)
.gev-voice-provider-field:not([hidden]) {
padding-right: 56px;
}
#gev-voice-control:has(> .gev-voice-settings-button)
.gev-voice-provider-field
select {
min-height: 44px;
}
}
/* Keep the row and popup on the same lift, with a small gap above pinned
trays. On very short screens the shared lift still yields to the viewport. */
#command-dock.dock-has-pinned-tray
#gev-voice-control[data-provider-choice='true']:has(
> .gev-voice-settings-button
) {
--gev-voice-settings-gap: 0.375rem;
--gev-provider-lift: min(
calc(var(--dock-pinned-stack-height) + var(--gev-voice-tray-clearance)),
max(0px, calc(100dvh - 20rem))
);
}
/* Match the attribution-aware tray offsets without moving the trays. The
popup can scroll before the shared viewport budget caps its lift. */
@media (max-width: 900px) {
#gev-voice-control:has(> .gev-voice-settings-button) {
--gev-voice-tray-clearance: calc(3rem - 1.2rem);
}
}
@media (max-width: 720px) {
#gev-voice-control:has(> .gev-voice-settings-button) {
--gev-voice-tray-clearance: calc(2vh + 2.5rem - 1.2rem);
}
}
/* Short touch landscapes have room beside the dock, not above its pinned
stack. Keep the same chooser and gear together in that empty lower gutter;
the open popup remains a bounded, scrollable overlay above the microphone. */
@media (pointer: coarse) and (orientation: landscape) and (min-width: 480px) and (max-height: 500px) {
#command-dock
> #gev-voice-control[data-provider-choice='true']:has(
> .gev-voice-settings-button
),
#command-dock.dock-has-pinned-tray
> #gev-voice-control[data-provider-choice='true']:has(
> .gev-voice-settings-button
) {
--gev-provider-lift: 0px;
--gev-landscape-row-width: clamp(
108px,
calc((100vw - var(--dock-unified-width)) / 2 - 0.875rem),
calc(100% - 2px)
);
}
#command-dock
> #gev-voice-control[data-provider-choice='true']:has(
> .gev-voice-settings-button
)
> .gev-voice-provider-field:not([hidden]) {
left: calc(50% - 50vw + 0.5rem);
right: auto;
bottom: 0.375rem;
width: var(--gev-landscape-row-width);
box-sizing: border-box;
}
#command-dock
> #gev-voice-control[data-provider-choice='true']:has(
> .gev-voice-settings-button
)
> .gev-voice-settings-button {
left: calc(50% - 50vw + 0.5rem + var(--gev-landscape-row-width) - 51px);
right: auto;
top: auto;
bottom: calc(0.375rem + 7px);
}
}
/* The revealed row clears pinned wings without making the Voice column's
noninteractive gaps cover their controls. Keep hit surfaces stable so hover
promotion cannot oscillate when the pointer crosses root padding. */
#command-dock
> #gev-voice-control[data-provider-choice='true']:has(
> .gev-voice-settings-button
) {
pointer-events: none;
}
#command-dock
> #gev-voice-control[data-provider-choice='true']:has(
> .gev-voice-settings-button
)
> :is(
#gev-voice-button,
.gev-voice-heading,
.gev-voice-readout,
.gev-voice-provider-field,
.gev-voice-settings-button,
.gev-voice-panel,
.gev-voice-error-tray
) {
pointer-events: auto;
}
@media (hover: hover) and (pointer: fine) {
#command-dock.dock-has-pinned-tray
> #gev-voice-control[data-provider-choice='true']:has(
> .gev-voice-settings-button
):is(
:hover,
[data-row-grace],
:has(
:focus-visible,
#gev-voice-provider:focus,
.gev-voice-panel[data-view='settings']:not([hidden])
)
) {
z-index: 101;
}
}
@media (hover: none), (pointer: coarse) {
#command-dock.dock-has-pinned-tray
> #gev-voice-control[data-provider-choice='true']:has(
> .gev-voice-settings-button
) {
z-index: 101;
}
}
/* Pointer grace crosses pinned trays without covering their controls. Keyboard
focus, the provider select and an open panel hold the shared row visible. */
@media (hover: hover) and (pointer: fine) {
#command-dock
#gev-voice-control[data-provider-choice='true']:has(
> .gev-voice-settings-button
)
:is(.gev-voice-provider-field, .gev-voice-settings-button) {
transition:
opacity 160ms ease,
transform 180ms ease;
}
#command-dock
#gev-voice-control[data-provider-choice='true']:has(
> .gev-voice-settings-button
):not(:hover):not([data-row-grace]):not(
:has(
:focus-visible,
#gev-voice-provider:focus,
.gev-voice-panel[data-view='settings']:not([hidden])
)
)
:is(.gev-voice-provider-field, .gev-voice-settings-button) {
opacity: 0;
pointer-events: none;
transform: translateY(8px);
}
#command-dock:not(.dock-has-pinned-tray)
#gev-voice-control[data-provider-choice='true']:has(
> .gev-voice-settings-button
)
.gev-voice-provider-field::after {
content: '';
position: absolute;
top: 100%;
right: 0;
left: 0;
height: calc(var(--gev-voice-settings-gap) + 8px);
}
}
/* Existing tier and estimate controls are readable Settings content in panel mode. */
.gev-voice-cost-settings {
display: grid;
gap: 0.55rem;
margin-top: 0.75rem;
padding-top: 0.7rem;
border-top: 1px solid var(--glass-border);
}
.gev-voice-cost-settings:has(#gev-voice-tier[hidden]):has(
#gev-voice-cost-value[hidden]
),
.gev-voice-cost-setting-row:has(> [hidden]) {
display: none;
}
.gev-voice-cost-setting-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
min-height: 2rem;
color: var(--text-primary);
font-size: 0.68rem;
}
.gev-voice-cost-setting-row > span {
min-width: 0;
}
#gev-voice-control .gev-voice-cost-settings .gev-voice-tier-btn {
flex: 0 0 auto;
min-height: 2rem;
min-width: 3.25rem;
padding: 0.3rem 0.55rem;
font-size: 0.67rem;
}
.gev-voice-cost-settings .gev-voice-cost-value {
flex: 0 0 auto;
font-size: 0.75rem;
}
.gev-voice-cost-settings .gev-voice-cost-value[data-level='ok'] {
color: var(--voice-panel-accent, var(--accent));
}
#gev-voice-control .gev-voice-settings-icon {
font-size: 18px;
line-height: 1;
}
@media (pointer: coarse) {
#gev-voice-control .gev-voice-cost-settings .gev-voice-tier-btn {
min-height: 44px;
min-width: 44px;
}
}
@media (prefers-reduced-motion: reduce) {
#command-dock
#gev-voice-control:has(> .gev-voice-settings-button)
:is(.gev-voice-provider-field, .gev-voice-settings-button) {
transition: none;
}
}
+1
View File
@@ -91,6 +91,7 @@
<input type="text" id="location-search" placeholder="Search any location..." autocomplete="off" spellcheck="false" aria-label="Search location by name or coordinates" data-i18n-aria-label data-i18n-placeholder />
</div>
</div>
<p id="location-search-error" class="location-search-error" role="alert"></p>
</div>
</div>
</div>
+5 -9
View File
@@ -106,19 +106,15 @@
</div>
</div>
<div class="pp-toggle-group">
<button class="pp-toggle-btn active" id="scope-toggle" title="Scope — the circular viewport mask" aria-pressed="true" data-i18n-title>
<label class="pp-toggle-btn pp-scope-toggle" title="Scope — the circular viewport mask" data-i18n-title>
<span class="pp-icon material-symbols-outlined" aria-hidden="true">adjust</span>
<span class="pp-label" data-i18n>Scope</span>
</button>
<input type="checkbox" id="scope-toggle" checked />
</label>
<div class="pp-slider-row visible" id="scope-slider-row">
<span class="pp-slider-mini-label" data-i18n>Feather</span>
<!-- Feather starts at an 11% soft scope-mask edge (owner final lock
2026-08-24, superseding the 08-22 hard-crop and 08-23 8% rulings);
mirrors SCOPE_FEATHER_RATIO_DEFAULT in src/scopeMask.js. Feather
softens the black mask edge; label fading is the separate Fade
slider. -->
<input type="range" class="gev-quantitative-slider pp-slider" id="scope-feather-slider" min="0" max="100" value="11" aria-label="Scope edge feather" title="Scope edge feather as a percentage of the keyhole radius" data-i18n-aria-label data-i18n-title />
<span class="pp-slider-value" id="scope-feather-value">11%</span>
<input type="range" class="gev-quantitative-slider pp-slider" id="scope-feather-slider" min="0" max="200" value="100" aria-label="Scope edge feather" title="Scope edge feather as a percentage of the keyhole radius" data-i18n-aria-label data-i18n-title />
<span class="pp-slider-value" id="scope-feather-value">100%</span>
</div>
</div>
<div class="pp-toggle-group">
+3 -3
View File
@@ -11,13 +11,13 @@
<div id="title-bar">
<div class="title-glow"></div>
<h1><span class="title-logo brand-logo" data-logo-gaze data-logo-src="/logo.svg" aria-hidden="true"><img src="/logo.svg" alt="" /></span> <span translate="no">GOD'S EYE <span class="title-accent">VIEW</span></span></h1>
<p class="subtitle" data-i18n>NO PLACE LEFT BEHIND</p>
<p class="subtitle scene-text-holdout" data-i18n>NO PLACE LEFT BEHIND</p>
</div>
<!-- Style indicator -->
<div id="style-indicator">
<span class="indicator-label" data-i18n>ACTIVE STYLE</span>
<span class="indicator-value" id="active-style-name" data-i18n>NORMAL</span>
<span class="indicator-label scene-text-holdout" data-i18n>ACTIVE STYLE</span>
<span class="indicator-value scene-text-holdout" id="active-style-name" data-i18n>NORMAL</span>
</div>
<!-- Top-center camera and share actions -->
+1 -1
View File
@@ -5,7 +5,7 @@ import { snowShader } from '../styles/snow.js';
import { nightVisionShader } from '../styles/surveillance.js';
import { thermalShader } from '../styles/thermal.js';
import { BLOOM_INTENSITY_DEFAULT } from '../bloom.js';
import { msg } from '../i18n/index.js';
import { msg } from '../i18n/marker.js';
/** Duration (ms) for shader intensity crossfade between style presets. */
export const TRANSITION_DURATION_MS = 500;
+23 -8
View File
@@ -1,3 +1,5 @@
import { clampScopeFeatherPct } from '../scopeMaskPolicy.js';
import { syncScopeControls } from './displayControls.js';
import { STYLE_STATUS_LABELS } from './visualPresets.js';
import { syncSegmentedControl } from './segmentedControl.js';
import { t } from '../i18n/index.js';
@@ -73,13 +75,24 @@ export class VisualSettings {
panelState,
styleParams,
} = state || {};
// Ignore the retired 'ai-edit' style from older share links.
if (style && style !== 'normal' && style !== 'ai-edit') {
// Ignore the retired 'ai-edit' style from older share links. Normal is
// the start-up style, but a share opened later in the tab must still leave
// a filtered style for it.
if (
style &&
style !== 'ai-edit' &&
(style !== 'normal' || this.activeStyle !== 'normal')
) {
const alreadyActive = style === this.activeStyle;
this.setStyle(style, {
applyPreset: true,
revealParameters: false,
restore: true,
});
// setStyle leaves an already-active style untouched; the incoming share
// still starts from that preset before its own parameters apply.
if (alreadyActive && style !== 'normal')
this._applyStylePresetDefaults(style);
}
if (styleParams && style && this.stages[style] && STYLES[style]?.uniforms) {
for (const [uniformName, uniformValue] of Object.entries(styleParams)) {
@@ -136,11 +149,10 @@ export class VisualSettings {
}
if (typeof scopeEnabled === 'boolean') {
setScopeMaskEnabled(scopeEnabled);
this._scopeBtn?.classList.toggle('active', scopeEnabled);
this._scopeBtn?.setAttribute('aria-pressed', String(scopeEnabled));
syncScopeControls(this._scopeBtn, this._scopeFeatherSlider, scopeEnabled);
}
if (typeof scopeFeatherPct === 'number' && this._scopeFeatherSlider) {
const pct = Math.max(0, Math.min(100, Math.round(scopeFeatherPct)));
const pct = clampScopeFeatherPct(scopeFeatherPct);
this._scopeFeatherSlider.value = String(pct);
if (this._scopeFeatherValue)
this._scopeFeatherValue.textContent = `${pct}%`;
@@ -1302,11 +1314,14 @@ export class VisualSettings {
const scopeState = state.scope || {};
if (typeof scopeState.enabled === 'boolean') {
setScopeMaskEnabled(scopeState.enabled);
this._scopeBtn?.classList.toggle('active', scopeState.enabled);
this._scopeBtn?.setAttribute('aria-pressed', String(scopeState.enabled));
syncScopeControls(
this._scopeBtn,
this._scopeFeatherSlider,
scopeState.enabled,
);
}
if (typeof scopeState.featherPct === 'number' && this._scopeFeatherSlider) {
const pct = Math.max(0, Math.min(100, Math.round(scopeState.featherPct)));
const pct = clampScopeFeatherPct(scopeState.featherPct);
this._scopeFeatherSlider.value = String(pct);
if (this._scopeFeatherValue)
this._scopeFeatherValue.textContent = `${pct}%`;
+13
View File
@@ -178,6 +178,9 @@ const schemas = [
'global-context-panel',
'weather-panel',
'recent-imagery-panel',
'voice-settings',
'voice-preview',
'provider-settings',
],
},
open: {
@@ -187,6 +190,15 @@ const schemas = [
required: ['panelId', 'open'],
},
},
{
name: 'set_voice_inactivity_timeout',
parameters: {
type: 'object',
additionalProperties: false,
properties: { minutes: { type: 'integer', minimum: 0, maximum: 60 } },
required: ['minutes'],
},
},
{
name: 'set_context_mode',
parameters: {
@@ -223,6 +235,7 @@ const schemas = [
aircraftClass: {
type: 'string',
},
voicePanel: { type: 'string', enum: ['preview', 'settings'] },
},
required: ['action'],
},
+31 -5
View File
@@ -32,7 +32,8 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea
.digest('hex');
assert.equal(
digest,
// Re-derived for the voice layer manifest (generated layer enums and
// Re-derived for Provider Settings and explicit Visual Presets/Scenes routing,
// plus the deliberate direct Panel tool/target additions and voice layer manifest (generated layer enums and
// aliases), point-and-ask (pointer sentinels, referent args) and the
// consolidated tool wording (each policy stated once); model-facing tools
// omit top-level anyOf (OpenAI rejects it). Contacts list routing preserves
@@ -41,8 +42,10 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea
// Air quality (ECCC AQHI) extends the manifest.
// Globe rotation adds one motion; visual keyhole fit and explicit
// reset guidance/defaults update only move_camera and mode descriptions.
// Restore base descriptions; append only globe-rotation defaults and fit guidance.
'967b5a043b812e2f8c1e190fc571cf12b960c9d4486df736902221aab4d99cf6',
// S2 GhostMaps extends the generated layer enums.
// Panel adds named voice targets and timeout; shared Panel/Cockpit guidance
// is consolidated without changing either feature's arguments.
'9ee82c4214499280c208c93751b19d7b724c81b7727a2d0c6dab0184749fbe15',
);
});
@@ -100,12 +103,18 @@ test('metadata cannot add tools, fields, types or enum values', () => {
test('all legacy action arguments are byte-identical after removing the deliberate additions', () => {
const legacy = structuredClone(GEV_ACTION_SCHEMAS).filter(
(tool) => !['next_satellite_pass', 'set_cyber_sonar'].includes(tool.name),
(tool) =>
![
'next_satellite_pass',
'set_cyber_sonar',
'set_voice_inactivity_timeout',
].includes(tool.name),
);
// Layer enums are generated from the voice layer manifest and pinned by
// layerManifest.test.mjs; the two shipped right-rail panels are additive.
const property = (name) =>
legacy.find((tool) => tool.name === name).parameters.properties;
delete property('control_cockpit').voicePanel;
delete property('set_layer_visibility').layerId.enum;
delete property('show_data_layers_menu').layerId.enum;
delete property('get_entity_context').layerId.enum;
@@ -123,7 +132,14 @@ test('all legacy action arguments are byte-identical after removing the delibera
motion.enum = motion.enum.filter((value) => value !== 'rotate_globe');
const panels = property('set_panel_open').panelId;
panels.enum = panels.enum.filter(
(id) => !['weather-panel', 'recent-imagery-panel'].includes(id),
(id) =>
![
'weather-panel',
'recent-imagery-panel',
'voice-settings',
'voice-preview',
'provider-settings',
].includes(id),
);
for (const tool of legacy) {
// Point-and-ask adds `referent` arguments and 'pointer' enum values.
@@ -228,3 +244,13 @@ test('globe reset guidance requires a separate explicit command after refusal',
assert.match(paragraph, /bare yes\/ok/);
assert.match(paragraph, /Do not ask a question/);
});
test('direct voice timeout schema accepts None and exact integers, rejects malformed duration', () => {
const tool = GEV_REALTIME_TOOLS.find(
(tool) => tool.name === 'set_voice_inactivity_timeout',
);
for (const minutes of [0, 1, 5, 60])
assert.equal(validateArgs(tool.parameters, { minutes }).length, 0);
for (const minutes of [-1, 61, 1.5, '5'])
assert.ok(validateArgs(tool.parameters, { minutes }).length > 0);
});
+39 -6
View File
@@ -2,17 +2,50 @@ import { createVoiceCommands as bindVoiceCommands } from './sessionCommands.js';
import { createRealtimeSession } from './realtimeSession.js';
import { createGeminiSession } from './geminiSession.js';
import { createProviderCommands } from './providerCommands.js';
import { createVoicePanelHost } from './panel/host.js';
/** Default composition; callers may supply another session adapter factory. */
export function createVoiceCommands(options = {}) {
const panelHost =
options.panel === true
? createVoicePanelHost({
storage: options.panelStorage,
clock: options.panelClock,
readCockpitState: options.styleManager?.getCockpitState
? () => options.styleManager.getCockpitState()
: undefined,
})
: null;
const bind = (settings) =>
bindVoiceCommands({
...settings,
...(panelHost
? {
panelHost,
nativePanelProvider: settings.createController
? null
: settings.createSession === createRealtimeSession
? 'openai'
: settings.createSession === createGeminiSession
? 'gemini'
: null,
}
: {}),
});
let commands;
// Generic hosts retain the existing default; standalone opts in explicitly.
if (!options.createSession && options.supportedProviders !== undefined)
return createProviderCommands(options, {
bind: bindVoiceCommands,
commands = createProviderCommands(options, {
bind,
factories: { openai: createRealtimeSession, gemini: createGeminiSession },
});
return bindVoiceCommands({
createSession: createRealtimeSession,
...options,
});
else commands = bind({ createSession: createRealtimeSession, ...options });
if (panelHost) {
const lifetime = commands.signal ?? commands.session?.signal;
lifetime?.addEventListener('abort', () => panelHost.destroy(), {
once: true,
});
if (lifetime?.aborted) panelHost.destroy();
}
return commands;
}
+74 -4
View File
@@ -27,7 +27,7 @@ export function voiceStatusLabel(status) {
}
/** Build the voice control independently of its connection backend. */
export function createVoiceControl({ reset = false } = {}) {
export function createVoiceControl({ reset = false, panel = false } = {}) {
let root = document.getElementById('gev-voice-control');
if (root && reset) {
root.remove();
@@ -43,13 +43,18 @@ export function createVoiceControl({ reset = false } = {}) {
<span>${t('Voice provider')}</span>
<select id="gev-voice-provider" aria-label="${t('Voice provider')}" title="${t('Changing provider reconnects voice if it is on.')}"></select>
</label>
${panel ? `<button id="gev-voice-settings-button" class="gev-voice-settings-button" type="button" aria-label="${t('Voice Settings')}" aria-controls="gev-voice-panel" aria-expanded="false"><span class="material-symbols-outlined gev-voice-settings-icon" aria-hidden="true">settings</span></button>` : ''}
<div class="gev-voice-heading">
<div class="gev-voice-kicker">${t('AI AGENT')}</div>
<div id="gev-voice-status">${voiceStatusLabel('idle')}</div>
<div class="gev-voice-cost">
${
panel
? `<span id="gev-voice-cost-warning" class="gev-voice-cost-value gev-voice-cost-warning" hidden role="status" aria-live="polite"></span>`
: `<div class="gev-voice-cost">
<button id="gev-voice-tier" class="gev-voice-tier-btn" type="button" aria-pressed="false" title="${t('Voice model tier — applies next session')}">${t('STD')}</button>
<span id="gev-voice-cost-value" class="gev-voice-cost-value" data-level="ok" title="${t('Estimated session cost')}">~$0.00</span>
</div>
</div>`
}
</div>
<button id="gev-voice-button" type="button" aria-label="${t('Voice control — activate to toggle voice; hold Space to speak')}" aria-describedby="gev-voice-help">
<span class="gev-mic-orbit"><img src="/mic.svg" alt="" /></span>
@@ -65,13 +70,29 @@ export function createVoiceControl({ reset = false } = {}) {
<span class="gev-voice-help-kicker">${t('VOICE CONTROL')}</span>
<span class="gev-voice-help-detail">${t('Hold Space to speak · tap Space to activate focused controls')}</span>
</div>
${
panel
? `<section id="gev-voice-panel" class="gev-voice-panel" hidden aria-label="${t('Voice Settings')}">
<div class="gev-voice-panel-header">
<span class="gev-voice-panel-title">${t('VOICE')}</span>
<div class="gev-voice-panel-tabs" role="tablist" aria-label="${t('Voice Settings')}">
<button id="gev-voice-preview-tab" class="gev-voice-panel-tab" type="button" role="tab" aria-label="${t('Preview')}" title="${t('Preview')}" aria-selected="true" aria-controls="gev-voice-preview-panel"><span class="material-symbols-outlined gev-voice-panel-tab-icon" aria-hidden="true">visibility</span></button>
<button id="gev-voice-settings-tab" class="gev-voice-panel-tab" type="button" role="tab" aria-label="${t('Settings')}" title="${t('Settings')}" aria-selected="false" tabindex="-1" aria-controls="gev-voice-settings-panel"><span class="material-symbols-outlined gev-voice-panel-tab-icon" aria-hidden="true">settings</span></button>
</div>
<button id="gev-voice-settings-close" type="button" aria-label="${t('Close voice panel')}">×</button>
</div>
<div class="gev-voice-panel-body">
<section id="gev-voice-preview-panel" role="tabpanel" aria-labelledby="gev-voice-preview-tab">
<p id="gev-voice-preview-empty">${t('Voice replies appear here.')}</p>`
: ''
}
<section id="gev-voice-card" class="gev-voice-card" hidden aria-label="${t('Voice assistant')}">
<div class="gev-voice-card-header">
<span class="gev-voice-card-kicker">${t('VOICE')}</span>
<span id="gev-voice-card-phase" class="gev-voice-card-phase"></span>
<button id="gev-voice-card-dismiss" class="gev-voice-card-dismiss" type="button" aria-label="${t('Dismiss voice card')}">×</button>
</div>
<p id="gev-voice-card-user" class="gev-voice-card-caption" data-role="user" hidden><span class="gev-voice-card-who">${t('YOU')}</span><span id="gev-voice-card-user-text" class="gev-voice-card-text"></span></p>
<p id="gev-voice-card-user" class="gev-voice-card-caption" data-role="user" hidden><span id="gev-voice-card-user-who" class="gev-voice-card-who">${t('YOU')}</span><span id="gev-voice-card-user-text" class="gev-voice-card-text"></span></p>
<p id="gev-voice-card-pointer" class="gev-voice-card-pointer" hidden><span id="gev-voice-card-pointer-kind" class="gev-voice-card-who">${t('THIS')}</span><span class="gev-voice-card-sr">: </span><span id="gev-voice-card-pointer-text" class="gev-voice-card-pointer-label"></span></p>
<p id="gev-voice-card-assistant" class="gev-voice-card-caption" data-role="assistant" hidden><span class="gev-voice-card-who">${ASSISTANT_NAME}</span><span id="gev-voice-card-assistant-text" class="gev-voice-card-text"></span></p>
<ol id="gev-voice-card-plan" class="gev-voice-card-plan" aria-label="${t('Plan')}" hidden></ol>
@@ -87,6 +108,40 @@ export function createVoiceControl({ reset = false } = {}) {
</div>
<div id="gev-voice-card-live" class="gev-voice-card-sr" aria-live="polite" aria-atomic="true"></div>
</section>
${
panel
? `</section>
<section id="gev-voice-settings-panel" role="tabpanel" aria-labelledby="gev-voice-settings-tab" hidden>
<label class="gev-voice-preference gev-voice-preference-toggle"><input id="gev-voice-preview-enabled" type="checkbox" checked aria-describedby="gev-voice-preview-note" /><span>${t('Show Voice Preview')}</span></label>
<p id="gev-voice-preview-note" class="gev-voice-settings-note"></p>
<div class="gev-voice-timeout-group">
<label class="gev-voice-preference gev-voice-setting-title" for="gev-voice-timeout-select">${t('Voice inactivity timeout')}</label>
<div class="gev-voice-timeout-controls">
<select id="gev-voice-timeout-select" aria-describedby="gev-voice-timeout-note gev-voice-timeout-status">
<option value="3">3 ${t('Minutes')}</option><option value="5" selected>5 ${t('Minutes')}</option><option value="10">10 ${t('Minutes')}</option><option value="15">15 ${t('Minutes')}</option>
<option value="custom">${t('Custom')}</option><option value="0">${t('None')}</option>
</select>
<label class="gev-voice-timeout-unit" for="gev-voice-timeout-input">${t('Minutes')}</label>
<input id="gev-voice-timeout-input" type="number" min="1" max="60" step="1" value="5" aria-describedby="gev-voice-timeout-note" />
</div>
<p id="gev-voice-timeout-note" class="gev-voice-settings-note">${t('These preferences stay in this browser.')}</p>
<p id="gev-voice-timeout-status" class="gev-voice-settings-note" role="status" aria-live="polite"></p>
</div>
<div class="gev-voice-cost-settings">
<div class="gev-voice-cost-setting-row">
<span>${t('Voice model tier — applies next session')}</span>
<button id="gev-voice-tier" class="gev-voice-tier-btn" type="button" aria-pressed="false" title="${t('Voice model tier — applies next session')}">${t('STD')}</button>
</div>
<div class="gev-voice-cost-setting-row">
<span>${t('Estimated session cost')}</span>
<span id="gev-voice-cost-value" class="gev-voice-cost-value" data-level="ok" title="${t('Estimated session cost')}">~$0.00</span>
</div>
</div>
</section>
</div>
</section>`
: ''
}
<div class="gev-voice-error-tray" role="alert" aria-live="assertive">
<div class="gev-voice-error-header">
<span>${t('VOICE SYSTEM ERROR')}</span>
@@ -114,6 +169,20 @@ export function createVoiceControl({ reset = false } = {}) {
}
return {
root,
voicePanel: root.querySelector('#gev-voice-panel'),
previewPanel: root.querySelector('#gev-voice-preview-panel'),
voiceSettingsPanel: root.querySelector('#gev-voice-settings-panel'),
previewTab: root.querySelector('#gev-voice-preview-tab'),
settingsTab: root.querySelector('#gev-voice-settings-tab'),
voiceSettingsButton: root.querySelector('#gev-voice-settings-button'),
voiceSettingsClose: root.querySelector('#gev-voice-settings-close'),
previewEmpty: root.querySelector('#gev-voice-preview-empty'),
voicePreviewInput: root.querySelector('#gev-voice-preview-enabled'),
voicePreviewNote: root.querySelector('#gev-voice-preview-note'),
voiceTimeoutSelect: root.querySelector('#gev-voice-timeout-select'),
voiceTimeoutInput: root.querySelector('#gev-voice-timeout-input'),
voiceTimeoutNote: root.querySelector('#gev-voice-timeout-note'),
voiceTimeoutStatus: root.querySelector('#gev-voice-timeout-status'),
providerField: root.querySelector('#gev-voice-provider-field'),
providerSelect: root.querySelector('#gev-voice-provider'),
button: root.querySelector('#gev-voice-button'),
@@ -124,6 +193,7 @@ export function createVoiceControl({ reset = false } = {}) {
errorDetail: root.querySelector('#gev-voice-error-detail'),
tierButton: root.querySelector('#gev-voice-tier'),
costValue: root.querySelector('#gev-voice-cost-value'),
costWarning: root.querySelector('#gev-voice-cost-warning'),
card: root.querySelector('#gev-voice-card'),
};
}
+188
View File
@@ -74,8 +74,17 @@ const PANEL_ALIASES = new Map([
['styles', 'control-panel'],
['filters', 'control-panel'],
['visual styles', 'control-panel'],
['visual preset', 'control-panel'],
['visual presets', 'control-panel'],
['provider settings', 'provider-settings'],
['cctv', 'cctv-panel'],
['cameras', 'cctv-panel'],
['voice settings', 'voice-settings'],
['voice-settings', 'voice-settings'],
['voice preview', 'voice-preview'],
['voice-preview', 'voice-preview'],
['voice control', 'voice-preview'],
['voice controls', 'voice-preview'],
['radio', 'radio-panel'],
['internet radio', 'radio-panel'],
['radio stations', 'radio-panel'],
@@ -110,6 +119,7 @@ const PANEL_IDS = new Set([
'pp-toggles',
'weather-panel',
'recent-imagery-panel',
'provider-settings',
]);
const CONTEXT_MODE_ALIASES = new Map([
['off', 'off'],
@@ -243,6 +253,7 @@ export function createGevActionRunner({
searchNavigation = searchAndFlyTo,
speechBuilders = null,
deixis = null,
readVoicePanelOperation = null,
}) {
// Voice enable times and analyst follow-up memory belong to this runner.
// Speech builders turn results into say/display envelopes; callers extend
@@ -268,6 +279,81 @@ export function createGevActionRunner({
});
// Nested calls (select_nearest_aircraft composing layer + flight) use the
// raw runner; only the outer call gets a voice envelope.
const voiceUnavailable = (action) => ({
ok: false,
action,
applied: false,
unavailable: true,
error: 'Voice panel controls are unavailable in this host.',
say: 'Voice panel controls are unavailable in this host.',
});
async function showVoicePanel(
view,
open,
options,
operation = readVoicePanelOperation?.(),
) {
const current = () =>
!options.signal?.aborted &&
(!options.isCurrent || options.isCurrent()) &&
operation?.isCurrent();
if (!operation) return voiceUnavailable('set_panel_open');
if (!current())
return {
ok: false,
action: 'set_panel_open',
applied: false,
cancelled: true,
};
let exit = null;
if (open) {
if (typeof styleManager?.getCockpitState !== 'function')
return voiceUnavailable('set_panel_open');
if (styleManager.getCockpitState()?.active) {
if (typeof styleManager.controlCockpit !== 'function')
return voiceUnavailable('set_panel_open');
exit = await styleManager.controlCockpit('exit');
if (styleManager.getCockpitState()?.active)
return {
ok: false,
action: 'set_panel_open',
applied: false,
exit,
error: exit?.error || 'Cockpit did not exit.',
};
}
}
if (!current())
return {
ok: false,
action: 'set_panel_open',
applied: false,
cancelled: true,
exit,
cockpitExited: Boolean(exit && !styleManager.getCockpitState()?.active),
say:
exit && !styleManager.getCockpitState()?.active
? 'Cockpit exited; the voice panel was left as you last set it.'
: 'The voice panel was left as you last set it.',
};
const result = operation.show(view, open);
return {
...result,
action: 'set_panel_open',
panelId: view === 'settings' ? 'voice-settings' : 'voice-preview',
exit,
state: styleManager?.getCockpitState?.() ?? null,
say: !result.ok
? 'The voice panel was not changed.'
: result.settingsOpen
? 'Voice Settings is open.'
: result.panelOpen
? result.previewEnabled
? 'Voice Preview is open.'
: 'Voice control is open; Preview remains off.'
: 'The voice panel is closed.',
};
}
const runGevAction = async function runGevAction(
name,
rawArgs = {},
@@ -639,6 +725,25 @@ export function createGevActionRunner({
return { ok: true, action: 'set_visual_style', style };
}
if (name === 'set_voice_inactivity_timeout') {
const operation = readVoicePanelOperation?.();
if (!operation) return voiceUnavailable(name);
if (!current() || !operation.isCurrent())
return { ok: false, action: name, applied: false, cancelled: true };
const result = operation.setMinutes(args.minutes);
const duration =
result.minutes === 0 ? 'disabled' : `${result.minutes} minutes`;
return {
...result,
action: name,
say: !result.applied
? 'Voice inactivity was not changed.'
: result.persisted
? `Voice inactivity is ${duration}, saved in this browser.`
: `Voice inactivity is ${duration} for now, but could not be saved in this browser.`,
};
}
if (name === 'set_panel_open') {
const panelId = normalizePanelId(args.panelId || args.panel);
if (!panelId)
@@ -646,6 +751,70 @@ export function createGevActionRunner({
`Unknown panel: ${args.panelId || args.panel || 'missing'}`,
);
const open = args.open !== false;
if (panelId === 'voice-settings' || panelId === 'voice-preview')
return showVoicePanel(
panelId === 'voice-settings' ? 'settings' : 'preview',
open,
runOptions,
);
if (panelId === 'provider-settings') {
if (!current())
return {
ok: false,
action: name,
panelId,
applied: false,
cancelled: true,
};
const doc = globalThis.document;
const panel = doc?.getElementById?.('key-setup');
const button = open
? doc?.getElementById?.('key-setup-chip')
: panel?.querySelector?.('[data-key-setup-close]');
const concealed =
open &&
[
'ui-clean-view',
'recording-mode',
'cockpit-mode',
'scene-playback-mode',
].some((mode) => doc?.body?.classList?.contains(mode));
if (
concealed ||
panel?.dataset?.ready !== 'true' ||
typeof button?.click !== 'function'
) {
const error = concealed
? 'Provider Settings is hidden in the current view. Leave that view before opening it.'
: 'Provider Settings is unavailable in this runtime.';
return {
ok: false,
action: name,
panelId,
applied: false,
unavailable: true,
error,
say: error,
};
}
button.click();
// Opening unhides immediately; closing removes visible before its fade ends.
const applied = open
? !panel.hidden
: !panel.classList.contains('visible');
const say = applied
? `Provider Settings is ${open ? 'open' : 'closed'}.`
: 'Provider Settings did not change.';
return {
ok: applied,
action: name,
panelId,
applied,
open: applied ? open : !panel.hidden,
say,
...(!applied ? { error: say } : {}),
};
}
setPanelOpen(styleManager, panelId, open);
return { ok: true, action: 'set_panel_open', panelId, open };
}
@@ -734,6 +903,25 @@ export function createGevActionRunner({
error: `Unknown cockpit action: ${args.action || args.command || 'missing'}`,
};
}
if (args.voicePanel !== undefined) {
if (
action !== 'exit' ||
!['preview', 'settings'].includes(args.voicePanel)
)
return {
ok: false,
action: 'control_cockpit',
applied: false,
error: 'Voice panel reveal requires exit and a valid view.',
};
const result = await showVoicePanel(args.voicePanel, true, runOptions);
return {
...result,
action: 'control_cockpit',
cockpitAction: 'exit',
voicePanel: args.voicePanel,
};
}
const inferred = normalizeCockpitNavigationHints(rawAction);
const targetLayer = normalizeCockpitTargetLayer(
args.targetLayer || inferred.targetLayer || args.layer || args.layerId,
+7 -2
View File
@@ -50,12 +50,17 @@ export function initGevVoiceCommands(options) {
const referents = createReferentRegistry();
// The controller exists only after the runner, so its hooks bind late.
const deixis = { pointer, referents, cameraKey };
const commands = createVoiceCommands({
let commands;
commands = createVoiceCommands({
...options,
pointer,
referents,
runner: withToolCatalog(
createGevActionRunner({ ...options, deixis }),
createGevActionRunner({
...options,
deixis,
readVoicePanelOperation: () => commands?.captureVoicePanelOperation?.(),
}),
options.toolCatalog,
),
});
Loaded 100 of 145 files, more files were not shown because too many files have changed in this diff. Show more