mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
Merge branch 'main' into upstream/traffic-layer-stale
This commit is contained in:
145 files changed
+17257
-457
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Vendored
+8
-1
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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']),
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
|
||||
@@ -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\(\{/);
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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.": "Автоматическая остановка недоступна для этого голосового подключения."
|
||||
}
|
||||
@@ -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 产品。新上传的内容会在数秒内被检测到,并自动替换旧版本。点击标记查看报告。"
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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?.(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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><Null></td></tr>
|
||||
<tr><td>Description</td><td>Locals gather here & protest — 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><Null></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('<b> AB &bogus;'),
|
||||
'<b> AB &bogus;',
|
||||
);
|
||||
assert.equal(decodeHtmlEntities('��'), '');
|
||||
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');
|
||||
});
|
||||
@@ -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();
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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(),
|
||||
|
||||
@@ -19,6 +19,7 @@ export function createStandaloneTools(options) {
|
||||
...options,
|
||||
voice: {
|
||||
supportedProviders: ['openai', 'gemini'],
|
||||
panel: true,
|
||||
toolCatalog: loadToolCatalog,
|
||||
...options?.voice,
|
||||
},
|
||||
|
||||
+1
-1
@@ -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
@@ -1,4 +1,4 @@
|
||||
import { msg } from '../i18n/index.js';
|
||||
import { msg } from '../i18n/marker.js';
|
||||
|
||||
/**
|
||||
* Noir Style — Desaturation + High Contrast + Vignette
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { msg } from '../i18n/index.js';
|
||||
import { msg } from '../i18n/marker.js';
|
||||
|
||||
/**
|
||||
* CRT Terminal — Dynamic CRT Monitor Aesthetic
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { msg } from '../i18n/index.js';
|
||||
import { msg } from '../i18n/marker.js';
|
||||
|
||||
/**
|
||||
* Snow Style — White overlay + Desaturation + Falling particles
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
import { msg } from '../i18n/index.js';
|
||||
import { msg } from '../i18n/marker.js';
|
||||
|
||||
/**
|
||||
* FLIR — Forward Looking Infrared
|
||||
|
||||
@@ -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)];
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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'),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
{
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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],
|
||||
]);
|
||||
});
|
||||
@@ -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 —';
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -73,6 +73,7 @@ const PANEL_GROUPS = [
|
||||
'local-firms',
|
||||
'fire-perimeters',
|
||||
'gnss-integrity',
|
||||
's2-ghostmaps',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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']]);
|
||||
});
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}%`;
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
|
||||
@@ -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
@@ -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
@@ -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'),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in new issue
Block a user