mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Merge branch 'main' into fix/d-military-positionless-rows
This commit is contained in:
44 files changed
+1296
-247
No files matched your search
@@ -29,6 +29,7 @@ import { createApplicationFirePerimeters } from './layers/perimeters.js';
|
||||
import { createApplicationGnssIntegrity } from './layers/gnss.js';
|
||||
import { createApplicationGhostMaps } from './layers/ghostMaps.js';
|
||||
import { createApplicationCables } from './layers/submarineCables.js';
|
||||
import { createOsmOverlayLayers } from '../data/osmOverlays.js';
|
||||
import { createApplicationStreetLevel } from './layers/streetLevel.js';
|
||||
import { createInfrastructureLayers } from '../data/infrastructure.js';
|
||||
import { localGeoJsonServices } from './localGeojsonServices.js';
|
||||
@@ -169,6 +170,7 @@ export function createApplicationCatalog({
|
||||
clock: weatherClock,
|
||||
}),
|
||||
createCyclonesLayer({ feed: sources.cyclones }),
|
||||
...createOsmOverlayLayers(),
|
||||
...createInfrastructureLayers(localGeoJsonServices),
|
||||
createApplicationCables({ source: sources.cables }),
|
||||
createApplicationFirms({
|
||||
|
||||
@@ -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, 33);
|
||||
assert.equal(first.layers.length, 35);
|
||||
assert.ok(first.get('local-adsb'), 'Local ADS-B is registered');
|
||||
assert.deepEqual(
|
||||
first.metadata.find(({ id }) => id === 'local-adsb'),
|
||||
@@ -155,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, 33);
|
||||
assert.equal(catalog.layers.length, 35);
|
||||
assert.deepEqual(catalog.get('street-level').providerIds, []);
|
||||
assert.equal(
|
||||
catalogControlServices(catalog).streetLevelLayer,
|
||||
|
||||
@@ -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, 33);
|
||||
assert.equal(actual.layers.length, 35);
|
||||
const manager = new LayerLifecycle(
|
||||
{},
|
||||
{ getSourceAvailability: actual.getSourceAvailability },
|
||||
|
||||
@@ -150,6 +150,20 @@ export const DATA_CREDITS = [
|
||||
key: 'usgs',
|
||||
html: 'Earthquakes: Data courtesy of the U.S. Geological Survey',
|
||||
},
|
||||
{
|
||||
key: 'openseamap',
|
||||
html:
|
||||
'Sea marks overlay: ' +
|
||||
'<a href="https://www.openseamap.org" target="_blank" rel="noopener">© OpenSeaMap contributors</a> (CC BY-SA 2.0) · ' +
|
||||
'Data © OpenStreetMap contributors (ODbL)',
|
||||
},
|
||||
{
|
||||
key: 'opensnowmap',
|
||||
html:
|
||||
'Ski pistes overlay: ' +
|
||||
'<a href="https://www.opensnowmap.org" target="_blank" rel="noopener">© www.opensnowmap.org</a> (CC BY-SA) · ' +
|
||||
'Data © OpenStreetMap contributors (ODbL)',
|
||||
},
|
||||
{
|
||||
key: 'nasa-gibs',
|
||||
html:
|
||||
|
||||
@@ -666,6 +666,8 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
|
||||
disposition: 'enabled+options',
|
||||
optionOwner: 'satellites',
|
||||
}),
|
||||
Object.freeze({ id: 'sea-marks', token: '6', disposition: 'enabled-only' }),
|
||||
Object.freeze({ id: 'ski-pistes', token: '7', disposition: 'enabled-only' }),
|
||||
Object.freeze({
|
||||
id: 'street-level',
|
||||
token: '0',
|
||||
|
||||
@@ -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, 32);
|
||||
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 32);
|
||||
assert.equal(REGISTERED_LAYER_IDS.length, 34);
|
||||
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 34);
|
||||
assert.ok(REGISTERED_LAYER_IDS.includes('transit'));
|
||||
assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort());
|
||||
assert.deepEqual(LEGACY_LAYER_STATE_TOKENS, {
|
||||
@@ -242,20 +242,18 @@ 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(), '6');
|
||||
assert.equal(nextLayerStateToken(), '8');
|
||||
assert.equal(
|
||||
nextLayerStateToken({
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
alpha: '6',
|
||||
bravo: '7',
|
||||
alpha: '8',
|
||||
bravo: '9',
|
||||
}),
|
||||
'8',
|
||||
'00',
|
||||
);
|
||||
const digitsExhausted = {
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
...Object.fromEntries(
|
||||
[...'6789'].map((digit) => [`prior-${digit}`, digit]),
|
||||
),
|
||||
...Object.fromEntries([...'89'].map((digit) => [`prior-${digit}`, digit])),
|
||||
};
|
||||
assert.equal(nextLayerStateToken(digitsExhausted), '00');
|
||||
assert.equal(
|
||||
@@ -307,8 +305,8 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
||||
assert.equal(
|
||||
validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
future: '6',
|
||||
next: '7',
|
||||
future: '8',
|
||||
next: '9',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
@@ -318,7 +316,7 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
future: '00',
|
||||
}),
|
||||
/next free token 6/,
|
||||
/next free token 8/,
|
||||
);
|
||||
const beforeLastDigit = { ...digitsExhausted };
|
||||
delete beforeLastDigit['prior-9'];
|
||||
@@ -341,10 +339,10 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
||||
assert.throws(
|
||||
() =>
|
||||
validateLayerStateAllocations(
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '6' },
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '6', competing: '6' },
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '8' },
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '8', competing: '8' },
|
||||
),
|
||||
/next free token 7/,
|
||||
/next free token 9/,
|
||||
);
|
||||
assert.throws(
|
||||
() => validateLayerStateAllocations({ future: '00' }, { future: '01' }),
|
||||
|
||||
@@ -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, 32);
|
||||
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 34);
|
||||
assert.deepEqual(
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
|
||||
{
|
||||
@@ -137,6 +137,8 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
|
||||
'gnss-integrity': '3',
|
||||
'air-quality': '4',
|
||||
's2-ghostmaps': '5',
|
||||
'sea-marks': '6',
|
||||
'ski-pistes': '7',
|
||||
},
|
||||
);
|
||||
assert.throws(
|
||||
@@ -162,9 +164,9 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
|
||||
/Duplicate layer-state token reservation/,
|
||||
);
|
||||
assert.equal(
|
||||
parseLayerStateTokenReservations([...reservationRows, ['future', '6']])
|
||||
parseLayerStateTokenReservations([...reservationRows, ['future', '8']])
|
||||
.future,
|
||||
'6',
|
||||
'8',
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
@@ -182,14 +184,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', '6']] },
|
||||
{ rows: [...reservationRows, ['retired-layer', '8']] },
|
||||
(cwd) => {
|
||||
const published = readPublishedLayerStateReservations('HEAD', cwd);
|
||||
assert.equal(nextLayerStateToken(published), '7');
|
||||
assert.equal(nextLayerStateToken(published), '9');
|
||||
assert.equal(
|
||||
validateLayerStateAllocations(published, {
|
||||
...published,
|
||||
newcomer: '7',
|
||||
newcomer: '9',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
@@ -197,15 +199,15 @@ test('published retired digits and competing PRs advance the merge-time allocati
|
||||
() =>
|
||||
validateLayerStateAllocations(published, {
|
||||
...published,
|
||||
competing: '6',
|
||||
competing: '8',
|
||||
}),
|
||||
/next free token 7/,
|
||||
/next free token 9/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
validateLayerStateAllocations(published, {
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
newcomer: '6',
|
||||
newcomer: '8',
|
||||
}),
|
||||
/changed or removed: retired-layer/,
|
||||
);
|
||||
@@ -213,8 +215,8 @@ test('published retired digits and competing PRs advance the merge-time allocati
|
||||
);
|
||||
});
|
||||
|
||||
test('PR B manually replaces provisional 6 with 7 after PR A publishes 6', () => {
|
||||
withPublishedBase({ rows: [...reservationRows, ['pr-a', '6']] }, (cwd) => {
|
||||
test('PR B manually replaces provisional 8 with 9 after PR A publishes 8', () => {
|
||||
withPublishedBase({ rows: [...reservationRows, ['pr-a', '8']] }, (cwd) => {
|
||||
mkdirSync(path.join(cwd, 'scripts'));
|
||||
const fixtureChecker = path.join(
|
||||
cwd,
|
||||
@@ -235,8 +237,8 @@ test('PR B manually replaces provisional 6 with 7 after PR A publishes 6', () =>
|
||||
|
||||
writeCodecFixture(
|
||||
cwd,
|
||||
[...reservationRows, ['pr-b', '6']],
|
||||
[{ id: 'pr-b', token: '6' }],
|
||||
[...reservationRows, ['pr-b', '8']],
|
||||
[{ id: 'pr-b', token: '8' }],
|
||||
);
|
||||
const beforeRebase = runCheck();
|
||||
assert.equal(beforeRebase.status, 1);
|
||||
@@ -244,10 +246,10 @@ test('PR B manually replaces provisional 6 with 7 after PR A publishes 6', () =>
|
||||
|
||||
writeCodecFixture(
|
||||
cwd,
|
||||
[...reservationRows, ['pr-a', '6'], ['pr-b', '6']],
|
||||
[...reservationRows, ['pr-a', '8'], ['pr-b', '8']],
|
||||
[
|
||||
{ id: 'pr-a', token: '6' },
|
||||
{ id: 'pr-b', token: '6' },
|
||||
{ id: 'pr-a', token: '8' },
|
||||
{ id: 'pr-b', token: '8' },
|
||||
],
|
||||
);
|
||||
const staleRebase = runCheck();
|
||||
@@ -255,16 +257,16 @@ test('PR B manually replaces provisional 6 with 7 after PR A publishes 6', () =>
|
||||
assert.match(staleRebase.stderr, /Duplicate layer-state token reservation/);
|
||||
|
||||
const published = readPublishedLayerStateReservations('HEAD', cwd);
|
||||
assert.equal(nextLayerStateToken(published), '7');
|
||||
const validBaselineRows = [...reservationRows, ['pr-a', '6']];
|
||||
writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '6' }]);
|
||||
assert.equal(nextLayerStateToken(published), '9');
|
||||
const validBaselineRows = [...reservationRows, ['pr-a', '8']];
|
||||
writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '8' }]);
|
||||
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: 7/);
|
||||
assert.match(helper.stdout, /pr-b: 9/);
|
||||
assert.deepEqual(
|
||||
JSON.parse(readFileSync(path.join(cwd, LEDGER_PATH), 'utf8')),
|
||||
validBaselineRows,
|
||||
@@ -272,20 +274,20 @@ test('PR B manually replaces provisional 6 with 7 after PR A publishes 6', () =>
|
||||
);
|
||||
writeCodecFixture(
|
||||
cwd,
|
||||
[...reservationRows, ['pr-a', '6'], ['pr-b', '7']],
|
||||
[...reservationRows, ['pr-a', '8'], ['pr-b', '9']],
|
||||
[
|
||||
{ id: 'pr-a', token: '6' },
|
||||
{ id: 'pr-b', token: '7' },
|
||||
{ id: 'pr-a', token: '8' },
|
||||
{ id: 'pr-b', token: '9' },
|
||||
],
|
||||
);
|
||||
const corrected = runCheck();
|
||||
assert.equal(corrected.status, 0, corrected.stderr);
|
||||
assert.match(corrected.stdout, /33 published, 1 new/);
|
||||
assert.match(corrected.stdout, /35 published, 1 new/);
|
||||
});
|
||||
});
|
||||
|
||||
test('allocation batches cross the last digit and base-36 pair boundaries in order', () => {
|
||||
const digitRows = [...'6789'].map((digit) => [`retired-${digit}`, digit]);
|
||||
const digitRows = [...'89'].map((digit) => [`retired-${digit}`, digit]);
|
||||
const allDigits = parseLayerStateTokenReservations([
|
||||
...reservationRows,
|
||||
...digitRows,
|
||||
@@ -337,7 +339,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 = [...'6789'].map((digit) => [
|
||||
const priorDigits = [...'89'].map((digit) => [
|
||||
`qa-prior-digit-${digit}`,
|
||||
digit,
|
||||
]);
|
||||
@@ -374,7 +376,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, 75);
|
||||
assert.equal(params.get('l').length, 79);
|
||||
const restored = codec.decodeLayerStateParams(params);
|
||||
assert.deepEqual(restored?.enabledLayerIds, expectedLayerIds);
|
||||
assert.deepEqual(restored?.options, state.options);
|
||||
@@ -392,7 +394,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, 33);
|
||||
assert.equal(Object.keys(published).length, 35);
|
||||
assert.equal(published['retired-layer'], '00');
|
||||
assert.throws(
|
||||
() =>
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
["rocket-launches", "x"],
|
||||
["s2-ghostmaps", "5"],
|
||||
["satellites", "s"],
|
||||
["sea-marks", "6"],
|
||||
["ski-pistes", "7"],
|
||||
["street-level", "0"],
|
||||
["telegeography-submarine-cables", "u"],
|
||||
["traffic", "t"],
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { NO_IMAGERY_HOST, resolveImageryHost } from '../maps/imageryHost.js';
|
||||
|
||||
/**
|
||||
* @file Open-data raster overlays: OpenSeaMap sea marks and OpenSnowMap ski
|
||||
* pistes (#10). Each overlay is its own layer with its own row under
|
||||
* Infrastructure; both are built from the descriptors below.
|
||||
*
|
||||
* OpenRailwayMap and OpenInfraMap, also named in #10, are deliberately not
|
||||
* included:
|
||||
* - OpenRailwayMap's public tile server (tiles.openrailwaymap.org) only
|
||||
* serves requests whose Referer is openrailwaymap.org; every other Referer,
|
||||
* including none, gets 403. A tab on this app sends its own origin, so the
|
||||
* tiles would fail, and embedding them would run against their tile-usage
|
||||
* policy either way.
|
||||
* - OpenInfraMap serves MapLibre vector tiles, not raster XYZ tiles. That
|
||||
* needs a vector-tile rendering pipeline, not an imagery-layer drop-in.
|
||||
*
|
||||
* The overlays drape on whatever surface the active map stack offers (see
|
||||
* `maps/imageryHost.js`): the globe on the OSM and Bing stacks, the Google
|
||||
* Photorealistic tileset's own `imageryLayers` on Google 3D. Only a stack with
|
||||
* neither leaves them hidden, and the row says so.
|
||||
* @module data/osmOverlays
|
||||
*/
|
||||
|
||||
/** @type {ReadonlyArray<{id:string,name:string,icon:string,source:string,urlTemplate:string,credit:string,maximumLevel:number}>} */
|
||||
export const OSM_OVERLAYS = Object.freeze([
|
||||
Object.freeze({
|
||||
id: 'sea-marks',
|
||||
name: 'Sea Marks',
|
||||
icon: '⚓',
|
||||
source: 'OpenSeaMap',
|
||||
urlTemplate: 'https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png',
|
||||
// Chart tiles CC BY-SA 2.0; the underlying data is OpenStreetMap (ODbL).
|
||||
credit:
|
||||
'Sea marks © OpenSeaMap contributors (CC BY-SA 2.0) · Data © OpenStreetMap contributors (ODbL)',
|
||||
maximumLevel: 18,
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'ski-pistes',
|
||||
name: 'Ski Pistes',
|
||||
icon: '⛷',
|
||||
source: 'OpenSnowMap',
|
||||
urlTemplate: 'https://www.opensnowmap.org/pistes/{z}/{x}/{y}.png',
|
||||
// OpenSnowMap asks for "(c)www.opensnowmap.org" wherever its tiles appear.
|
||||
credit:
|
||||
'Ski pistes © www.opensnowmap.org (CC BY-SA) · Data © OpenStreetMap contributors (ODbL)',
|
||||
maximumLevel: 18,
|
||||
}),
|
||||
]);
|
||||
|
||||
/** Why an enabled overlay is showing nothing, or null. */
|
||||
export function osmOverlayHiddenReason(enabled, hostKind) {
|
||||
return enabled && hostKind === 'none' ? NO_IMAGERY_HOST : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the layer for one overlay descriptor. Each application catalog gets
|
||||
* its own instances; the Cesium factories are injectable so tests run without
|
||||
* WebGL.
|
||||
*/
|
||||
export function createOsmOverlayLayer(
|
||||
overlay,
|
||||
{
|
||||
host = resolveImageryHost,
|
||||
createProvider = (descriptor) =>
|
||||
new Cesium.UrlTemplateImageryProvider({
|
||||
url: descriptor.urlTemplate,
|
||||
credit: descriptor.credit,
|
||||
maximumLevel: descriptor.maximumLevel,
|
||||
}),
|
||||
createImageryLayer = (provider) => new Cesium.ImageryLayer(provider),
|
||||
} = {},
|
||||
) {
|
||||
if (!OSM_OVERLAYS.includes(overlay))
|
||||
throw new TypeError(`Unknown open map overlay: ${overlay?.id}`);
|
||||
let viewer = null;
|
||||
let mapStack = null;
|
||||
let unsubscribeMap = null;
|
||||
let enabled = false;
|
||||
let hostInfo = { collection: null, kind: 'none' };
|
||||
/** The imagery layer this instance owns while enabled, and where it sits. */
|
||||
let owned = null;
|
||||
|
||||
const usable = (collection) => collection && !collection.isDestroyed?.();
|
||||
|
||||
function detach(destroy) {
|
||||
if (!owned) return;
|
||||
const { collection, layer } = owned;
|
||||
if (usable(collection) && collection.contains(layer))
|
||||
collection.remove(layer, destroy);
|
||||
else if (destroy && !layer.isDestroyed?.()) layer.destroy?.();
|
||||
owned.collection = null;
|
||||
if (destroy) owned = null;
|
||||
}
|
||||
|
||||
function resolveHost() {
|
||||
return (
|
||||
host({ viewer, tileset: mapStack?.getImageryHostTileset?.() }) || {
|
||||
collection: null,
|
||||
kind: 'none',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/** Create, move or drop the owned imagery layer to match the state. */
|
||||
function sync() {
|
||||
if (!viewer) return;
|
||||
hostInfo = enabled ? resolveHost() : { collection: null, kind: 'none' };
|
||||
if (!enabled) {
|
||||
detach(true);
|
||||
} else {
|
||||
owned ||= {
|
||||
layer: createImageryLayer(createProvider(overlay)),
|
||||
collection: null,
|
||||
};
|
||||
if (owned.collection !== hostInfo.collection) {
|
||||
detach(false);
|
||||
if (usable(hostInfo.collection)) {
|
||||
// Appended, so it draws above the map stack's base imagery.
|
||||
hostInfo.collection.add(owned.layer);
|
||||
owned.collection = hostInfo.collection;
|
||||
}
|
||||
}
|
||||
}
|
||||
viewer.scene?.requestRender?.();
|
||||
}
|
||||
|
||||
return {
|
||||
id: overlay.id,
|
||||
name: overlay.name,
|
||||
icon: overlay.icon,
|
||||
source: overlay.source,
|
||||
updateInterval: 0,
|
||||
init(nextViewer) {
|
||||
viewer = nextViewer;
|
||||
},
|
||||
/** Follow map-stack switches so the drape moves with the surface. */
|
||||
attachMapStackController(controller) {
|
||||
unsubscribeMap?.();
|
||||
unsubscribeMap = null;
|
||||
mapStack = controller || null;
|
||||
const off = controller?.subscribe?.(() => {
|
||||
if (enabled) sync();
|
||||
});
|
||||
unsubscribeMap = typeof off === 'function' ? off : null;
|
||||
},
|
||||
enable() {
|
||||
enabled = true;
|
||||
sync();
|
||||
},
|
||||
disable() {
|
||||
enabled = false;
|
||||
sync();
|
||||
},
|
||||
update() {
|
||||
return true;
|
||||
},
|
||||
getStats() {
|
||||
// Without a controller subscription the stats poll doubles as the
|
||||
// map-stack watch.
|
||||
if (enabled && !unsubscribeMap) {
|
||||
const next = resolveHost();
|
||||
if (next.collection !== hostInfo.collection) sync();
|
||||
}
|
||||
return {
|
||||
count: enabled ? 1 : 0,
|
||||
error: osmOverlayHiddenReason(enabled, hostInfo.kind),
|
||||
};
|
||||
},
|
||||
/** Owned layer count and host kind, for tests and live gates. */
|
||||
diagnostics() {
|
||||
return { host: hostInfo.kind, ownedCount: owned ? 1 : 0 };
|
||||
},
|
||||
destroy() {
|
||||
enabled = false;
|
||||
detach(true);
|
||||
unsubscribeMap?.();
|
||||
unsubscribeMap = null;
|
||||
mapStack = null;
|
||||
viewer = null;
|
||||
hostInfo = { collection: null, kind: 'none' };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** One layer per overlay, in panel order. */
|
||||
export function createOsmOverlayLayers(options) {
|
||||
return OSM_OVERLAYS.map((overlay) => createOsmOverlayLayer(overlay, options));
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
OSM_OVERLAYS,
|
||||
createOsmOverlayLayer,
|
||||
createOsmOverlayLayers,
|
||||
osmOverlayHiddenReason,
|
||||
} from './osmOverlays.js';
|
||||
import { NO_IMAGERY_HOST } from '../maps/imageryHost.js';
|
||||
|
||||
function fakeCollection() {
|
||||
const layers = [];
|
||||
const removed = [];
|
||||
return {
|
||||
layers,
|
||||
removed,
|
||||
add(layer) {
|
||||
layers.push(layer);
|
||||
},
|
||||
contains(layer) {
|
||||
return layers.includes(layer);
|
||||
},
|
||||
remove(layer, destroy) {
|
||||
const index = layers.indexOf(layer);
|
||||
if (index < 0) return false;
|
||||
layers.splice(index, 1);
|
||||
removed.push({ layer, destroy });
|
||||
if (destroy) layer.destroyed = true;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function fakeMapStack() {
|
||||
const subscribers = new Set();
|
||||
return {
|
||||
tileset: null,
|
||||
getImageryHostTileset() {
|
||||
return this.tileset;
|
||||
},
|
||||
subscribe(fn) {
|
||||
subscribers.add(fn);
|
||||
return () => subscribers.delete(fn);
|
||||
},
|
||||
emit() {
|
||||
for (const fn of subscribers) fn();
|
||||
},
|
||||
subscribers,
|
||||
};
|
||||
}
|
||||
|
||||
function setup(overlay = OSM_OVERLAYS[0]) {
|
||||
const globe = fakeCollection();
|
||||
const viewer = {
|
||||
imageryLayers: globe,
|
||||
scene: { globe: { show: true }, requestRender() {} },
|
||||
};
|
||||
const created = [];
|
||||
const layer = createOsmOverlayLayer(overlay, {
|
||||
createProvider: (descriptor) => ({ descriptor }),
|
||||
createImageryLayer: (provider) => {
|
||||
const imagery = { provider, destroyed: false };
|
||||
created.push(imagery);
|
||||
return imagery;
|
||||
},
|
||||
});
|
||||
const mapStack = fakeMapStack();
|
||||
layer.init(viewer);
|
||||
layer.attachMapStackController(mapStack);
|
||||
return { layer, viewer, globe, created, mapStack };
|
||||
}
|
||||
|
||||
test('OSM_OVERLAYS: the two shipped layers, each with a tile template and credit', () => {
|
||||
assert.deepEqual(
|
||||
OSM_OVERLAYS.map((o) => o.id),
|
||||
['sea-marks', 'ski-pistes'],
|
||||
);
|
||||
for (const overlay of OSM_OVERLAYS) {
|
||||
assert.match(overlay.urlTemplate, /^https:\/\/.*\{z\}.*\{x\}.*\{y\}/);
|
||||
assert.match(overlay.credit, /OpenStreetMap contributors \(ODbL\)/);
|
||||
}
|
||||
assert.match(OSM_OVERLAYS[0].credit, /CC BY-SA 2\.0/);
|
||||
assert.match(OSM_OVERLAYS[1].credit, /www\.opensnowmap\.org/);
|
||||
});
|
||||
|
||||
test('createOsmOverlayLayers builds one independent layer per overlay', () => {
|
||||
const layers = createOsmOverlayLayers();
|
||||
assert.deepEqual(
|
||||
layers.map((layer) => [layer.id, layer.name]),
|
||||
[
|
||||
['sea-marks', 'Sea Marks'],
|
||||
['ski-pistes', 'Ski Pistes'],
|
||||
],
|
||||
);
|
||||
assert.notEqual(createOsmOverlayLayers()[0], layers[0]);
|
||||
assert.throws(() => createOsmOverlayLayer({ id: 'other' }), /Unknown/);
|
||||
});
|
||||
|
||||
test('osmOverlayHiddenReason: only when enabled with no imagery host', () => {
|
||||
assert.equal(osmOverlayHiddenReason(false, 'none'), null);
|
||||
assert.equal(osmOverlayHiddenReason(true, 'globe'), null);
|
||||
assert.equal(osmOverlayHiddenReason(true, 'tileset'), null);
|
||||
assert.equal(osmOverlayHiddenReason(true, 'none'), NO_IMAGERY_HOST);
|
||||
});
|
||||
|
||||
test('a layer owns no imagery until enabled and destroys it on disable', () => {
|
||||
const { layer, globe, created } = setup(OSM_OVERLAYS[1]);
|
||||
assert.equal(created.length, 0);
|
||||
|
||||
layer.enable();
|
||||
assert.equal(globe.layers.length, 1);
|
||||
assert.equal(globe.layers[0].provider.descriptor.id, 'ski-pistes');
|
||||
assert.deepEqual(layer.getStats(), { count: 1, error: null });
|
||||
|
||||
layer.disable();
|
||||
assert.equal(globe.layers.length, 0);
|
||||
assert.equal(created[0].destroyed, true);
|
||||
assert.deepEqual(layer.diagnostics(), { host: 'none', ownedCount: 0 });
|
||||
assert.deepEqual(layer.getStats(), { count: 0, error: null });
|
||||
|
||||
layer.enable();
|
||||
assert.equal(created.length, 2, 're-enabling creates a fresh imagery layer');
|
||||
});
|
||||
|
||||
test('the overlay follows a map-stack switch onto the Google 3D tileset and back', () => {
|
||||
const { layer, viewer, globe, mapStack } = setup();
|
||||
layer.enable();
|
||||
const [imagery] = globe.layers;
|
||||
|
||||
const tileset = { imageryLayers: fakeCollection() };
|
||||
viewer.scene.globe.show = false;
|
||||
mapStack.tileset = tileset;
|
||||
mapStack.emit();
|
||||
assert.equal(globe.layers.length, 0);
|
||||
assert.deepEqual(tileset.imageryLayers.layers, [imagery]);
|
||||
assert.equal(globe.removed[0].destroy, false);
|
||||
assert.equal(layer.diagnostics().host, 'tileset');
|
||||
assert.equal(layer.getStats().error, null);
|
||||
|
||||
viewer.scene.globe.show = true;
|
||||
mapStack.tileset = null;
|
||||
mapStack.emit();
|
||||
assert.deepEqual(globe.layers, [imagery]);
|
||||
assert.equal(tileset.imageryLayers.layers.length, 0);
|
||||
});
|
||||
|
||||
test('with no surface to drape on the row explains itself, and recovers when one appears', () => {
|
||||
const { layer, viewer, globe, mapStack } = setup();
|
||||
viewer.scene.globe.show = false;
|
||||
layer.enable();
|
||||
assert.equal(globe.layers.length, 0);
|
||||
assert.equal(layer.getStats().error, NO_IMAGERY_HOST);
|
||||
|
||||
viewer.scene.globe.show = true;
|
||||
mapStack.emit();
|
||||
assert.equal(globe.layers.length, 1);
|
||||
assert.equal(layer.getStats().error, null);
|
||||
});
|
||||
|
||||
test('destroy removes owned imagery and unsubscribes from the map stack', () => {
|
||||
const { layer, globe, created, mapStack } = setup();
|
||||
layer.enable();
|
||||
layer.destroy();
|
||||
assert.equal(globe.layers.length, 0);
|
||||
assert.equal(created[0].destroyed, true);
|
||||
assert.equal(mapStack.subscribers.size, 0);
|
||||
});
|
||||
@@ -1030,11 +1030,12 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
|
||||
// preserves the mission mapping and globe-rotation arguments.
|
||||
// S2 GhostMaps extends the generated layer enums; both feature contracts remain.
|
||||
// The layer alias hint leaves out aliases the layer id already spells.
|
||||
// The Utilities panel adds one set_panel_open target.
|
||||
assert.equal(block.length, 30919, 'serialized tool schema length drifted');
|
||||
// The Utilities panel adds one set_panel_open target; Sea Marks and Ski
|
||||
// Pistes extend the generated layer enums and alias hint.
|
||||
assert.equal(block.length, 31026, 'serialized tool schema length drifted');
|
||||
assert.equal(
|
||||
crypto.createHash('sha256').update(block).digest('hex'),
|
||||
'dbf9a45649179e626b2e3d938c43903f573c50ffa279b3245b18244dbe9a9284',
|
||||
'5f5275cd6e320afef4fd224b235f3e0f191ec06599b257ffe1f60b703a255199',
|
||||
'the first-run missions must ride EXISTING tools: no schema edit, no cache bust',
|
||||
);
|
||||
const instructions = fs.readFileSync(
|
||||
|
||||
@@ -3222,5 +3222,7 @@
|
||||
"Audio": "الصوت",
|
||||
"UTILITIES": "الأدوات",
|
||||
"Exit cockpit to fly to a drawing": "اخرج من القمرة للانتقال إلى رسم",
|
||||
"Directions, drawing and your own files": "الاتجاهات والرسم وملفاتك الخاصة"
|
||||
"Directions, drawing and your own files": "الاتجاهات والرسم وملفاتك الخاصة",
|
||||
"Sea Marks": "علامات بحرية",
|
||||
"Ski Pistes": "مسارات التزلج"
|
||||
}
|
||||
@@ -2942,5 +2942,7 @@
|
||||
"Audio": "Audio",
|
||||
"UTILITIES": "WERKZEUGE",
|
||||
"Exit cockpit to fly to a drawing": "Cockpit verlassen, um zu einer Zeichnung zu fliegen",
|
||||
"Directions, drawing and your own files": "Route, Zeichnen und eigene Dateien"
|
||||
"Directions, drawing and your own files": "Route, Zeichnen und eigene Dateien",
|
||||
"Sea Marks": "Seezeichen",
|
||||
"Ski Pistes": "Skipisten"
|
||||
}
|
||||
@@ -3012,5 +3012,7 @@
|
||||
"Right panels": "Paneles derechos",
|
||||
"UTILITIES": "UTILIDADES",
|
||||
"Exit cockpit to fly to a drawing": "Sal de la cabina para volar a un dibujo",
|
||||
"Directions, drawing and your own files": "Indicaciones, dibujo y tus propios archivos"
|
||||
"Directions, drawing and your own files": "Indicaciones, dibujo y tus propios archivos",
|
||||
"Sea Marks": "Señales marítimas",
|
||||
"Ski Pistes": "Pistas de esquí"
|
||||
}
|
||||
@@ -3012,5 +3012,7 @@
|
||||
"Audio": "Audio",
|
||||
"UTILITIES": "OUTILS",
|
||||
"Exit cockpit to fly to a drawing": "Quittez le cockpit pour rejoindre un dessin",
|
||||
"Directions, drawing and your own files": "Itinéraire, dessin et vos propres fichiers"
|
||||
"Directions, drawing and your own files": "Itinéraire, dessin et vos propres fichiers",
|
||||
"Sea Marks": "Balisage maritime",
|
||||
"Ski Pistes": "Pistes de ski"
|
||||
}
|
||||
@@ -3012,5 +3012,7 @@
|
||||
"Audio": "Audio",
|
||||
"UTILITIES": "STRUMENTI",
|
||||
"Exit cockpit to fly to a drawing": "Esci dal cockpit per volare a un disegno",
|
||||
"Directions, drawing and your own files": "Indicazioni, disegno e i tuoi file"
|
||||
"Directions, drawing and your own files": "Indicazioni, disegno e i tuoi file",
|
||||
"Sea Marks": "Segnalamenti marittimi",
|
||||
"Ski Pistes": "Piste da sci"
|
||||
}
|
||||
@@ -2872,5 +2872,7 @@
|
||||
"Audio": "オーディオ",
|
||||
"UTILITIES": "ユーティリティ",
|
||||
"Exit cockpit to fly to a drawing": "描画の位置へ移動するにはコックピットを終了してください",
|
||||
"Directions, drawing and your own files": "ルート案内、描画、自分のファイル"
|
||||
"Directions, drawing and your own files": "ルート案内、描画、自分のファイル",
|
||||
"Sea Marks": "航路標識",
|
||||
"Ski Pistes": "スキーゲレンデ"
|
||||
}
|
||||
@@ -3012,5 +3012,7 @@
|
||||
"Audio": "Áudio",
|
||||
"UTILITIES": "UTILITÁRIOS",
|
||||
"Exit cockpit to fly to a drawing": "Saia da cabine para voar até um desenho",
|
||||
"Directions, drawing and your own files": "Rotas, desenho e seus próprios arquivos"
|
||||
"Directions, drawing and your own files": "Rotas, desenho e seus próprios arquivos",
|
||||
"Sea Marks": "Sinais marítimos",
|
||||
"Ski Pistes": "Pistas de esqui"
|
||||
}
|
||||
@@ -3082,5 +3082,7 @@
|
||||
"Right panels": "Правые панели",
|
||||
"UTILITIES": "ИНСТРУМЕНТЫ",
|
||||
"Exit cockpit to fly to a drawing": "Выйдите из кабины, чтобы перелететь к рисунку",
|
||||
"Directions, drawing and your own files": "Маршруты, рисование и ваши файлы"
|
||||
"Directions, drawing and your own files": "Маршруты, рисование и ваши файлы",
|
||||
"Sea Marks": "Морские знаки",
|
||||
"Ski Pistes": "Горнолыжные трассы"
|
||||
}
|
||||
@@ -3082,5 +3082,7 @@
|
||||
"Audio": "Аудіо",
|
||||
"UTILITIES": "ІНСТРУМЕНТИ",
|
||||
"Exit cockpit to fly to a drawing": "Вийдіть із кабіни, щоб перелетіти до малюнка",
|
||||
"Directions, drawing and your own files": "Маршрути, малювання та ваші файли"
|
||||
"Directions, drawing and your own files": "Маршрути, малювання та ваші файли",
|
||||
"Sea Marks": "Морські знаки",
|
||||
"Ski Pistes": "Гірськолижні траси"
|
||||
}
|
||||
@@ -2872,5 +2872,7 @@
|
||||
"Right panels": "右侧面板",
|
||||
"UTILITIES": "工具",
|
||||
"Exit cockpit to fly to a drawing": "请先退出座舱,再飞往绘制位置",
|
||||
"Directions, drawing and your own files": "路线导航、绘制和你自己的文件"
|
||||
"Directions, drawing and your own files": "路线导航、绘制和你自己的文件",
|
||||
"Sea Marks": "航标",
|
||||
"Ski Pistes": "滑雪道"
|
||||
}
|
||||
@@ -2872,5 +2872,7 @@
|
||||
"Right panels": "右側面板",
|
||||
"UTILITIES": "工具",
|
||||
"Exit cockpit to fly to a drawing": "請先退出座艙,再飛往繪製位置",
|
||||
"Directions, drawing and your own files": "路線導航、繪製和你自己的文件"
|
||||
"Directions, drawing and your own files": "路線導航、繪製和你自己的文件",
|
||||
"Sea Marks": "航標",
|
||||
"Ski Pistes": "滑雪道"
|
||||
}
|
||||
@@ -3,13 +3,16 @@
|
||||
*
|
||||
* The credential panel has its own stricter gate (`admitKeySetupRequest` in
|
||||
* keySetupCore.mjs): loopback socket + local Host + exact Origin + JSON
|
||||
* Content-Type. The four endpoints here — `/api/realtime/token` (mints an
|
||||
* OpenAI Realtime token = spends money), `/api/openai/hud-summary` (OpenAI
|
||||
* spend), `/api/google/nearby-places` (Google spend), and
|
||||
* `/api/realtime/debug-log` (appends to a local JSONL) — were previously
|
||||
* ungated, so a hostile web page open in the same browser could reach them
|
||||
* cross-site: a `fetch` carries a foreign `Origin`, and an `<img>`/navigation
|
||||
* carries NO `Origin` at all but does carry `Sec-Fetch-Site: cross-site`.
|
||||
* Content-Type. The endpoints behind this gate — `/api/realtime/token` (mints
|
||||
* an OpenAI Realtime token = spends money), `/api/openai/hud-summary` (OpenAI
|
||||
* spend), `/api/google/nearby-places` (Google spend),
|
||||
* `/api/realtime/debug-log` (appends to a local JSONL) and the quota-bearing
|
||||
* routes SECURITY.md lists with them — were previously ungated, so a hostile
|
||||
* web page open in the same browser could reach them cross-site: a `fetch`
|
||||
* carries a foreign `Origin`, and an `<img>`/navigation carries NO `Origin`
|
||||
* at all but does carry `Sec-Fetch-Site: cross-site`. The keyless map routes
|
||||
* (`/api/terrain/heights`, `/api/overpass`) reuse this core through
|
||||
* `admitSameSiteKeyless` with the forwarding-header refusal switched off.
|
||||
*
|
||||
* This module is the pure core that refuses those shapes while keeping the
|
||||
* documented `HOST=0.0.0.0` LAN opt-in and non-browser loopback tools (the two
|
||||
|
||||
@@ -1,11 +1,27 @@
|
||||
/** A reveal must beat the one-second idle rotation refresh, regardless of FPS. */
|
||||
/**
|
||||
* Wall-clock budget for EACH observed frame, regardless of FPS. Both the settle
|
||||
* frame (measured from the forced moveEnd) and the reveal frame (measured from
|
||||
* the settle frame that hid the contact) must land inside it, so neither can be
|
||||
* repaired by the one-second idle rotation refresh before it is read. The
|
||||
* refresh clock starts at the fleet tick (preRender) of the frame the budget is
|
||||
* measured from, so the budget must leave room for that frame's own render
|
||||
* time: half the refresh interval tolerates frames up to ~500 ms, and a ~300 ms
|
||||
* software-rendered frame (headless SwiftShader) fits with margin.
|
||||
*/
|
||||
export const ARRIVAL_OBSERVATION_MS = 500;
|
||||
|
||||
/** A measured rotation error at or above this fails the arrival check. */
|
||||
export const ARRIVAL_MAX_ERROR_DEG = 10;
|
||||
|
||||
/** Attempts allowed when every earlier one timed out without a wrong angle. */
|
||||
export const ARRIVAL_MAX_ATTEMPTS = 2;
|
||||
|
||||
/**
|
||||
* Observe the first completed moveEnd frame and the first subsequent reveal.
|
||||
* The deadline starts when moveEnd is forced, before its rotation baseline, so
|
||||
* waiting for either frame cannot let the one-second idle refresh mask a bug.
|
||||
* `read` snapshots visibility and rotation error against the current position.
|
||||
* The settle deadline starts when moveEnd is forced, before its rotation
|
||||
* baseline; the reveal deadline restarts at the settle frame, which is where
|
||||
* the contact is hidden. `read` snapshots visibility and rotation error
|
||||
* against the current position.
|
||||
*/
|
||||
export function observeArrivalRotation({
|
||||
scene,
|
||||
@@ -18,6 +34,7 @@ export function observeArrivalRotation({
|
||||
}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const startedAt = now();
|
||||
let phaseStartedAt = startedAt;
|
||||
let moveEnd = null;
|
||||
let moveEndMs = null;
|
||||
let revealFrames = 0;
|
||||
@@ -34,24 +51,42 @@ export function observeArrivalRotation({
|
||||
cleanup();
|
||||
resolve(result);
|
||||
};
|
||||
const expired = () => ({
|
||||
// A timeout keeps every angle it measured: the settle angle before a
|
||||
// reveal timeout, and the angle on a late frame itself (a late frame is
|
||||
// read first; the idle refresh can only repair an angle, so a wrong one
|
||||
// seen late was wrong on time too). `arrivalAngleViolated` then makes the
|
||||
// timeout final instead of retryable.
|
||||
const expired = (late = null) => ({
|
||||
error: `arrival ${moveEnd ? 'reveal' : 'moveEnd'} observation exceeded ${ARRIVAL_OBSERVATION_MS} ms`,
|
||||
elapsedMs: now() - startedAt,
|
||||
moveEndMs,
|
||||
...(moveEnd
|
||||
? { dMoveEnd: moveEnd.rotationError }
|
||||
: late
|
||||
? { dMoveEnd: late.rotationError }
|
||||
: {}),
|
||||
...(moveEnd && late?.shown ? { dReveal: late.rotationError } : {}),
|
||||
flipFrame: -1,
|
||||
});
|
||||
const armDeadline = () => {
|
||||
clearTimer(timer);
|
||||
timer = setTimer(() => finish(expired()), ARRIVAL_OBSERVATION_MS);
|
||||
};
|
||||
remove = scene.postRender.addEventListener(() => {
|
||||
if (done) return;
|
||||
try {
|
||||
const elapsedMs = now() - startedAt;
|
||||
if (elapsedMs >= ARRIVAL_OBSERVATION_MS) {
|
||||
finish(expired());
|
||||
const at = now();
|
||||
const elapsedMs = at - startedAt;
|
||||
const sample = read();
|
||||
if (at - phaseStartedAt >= ARRIVAL_OBSERVATION_MS) {
|
||||
finish(expired(sample));
|
||||
return;
|
||||
}
|
||||
const sample = read();
|
||||
if (moveEnd === null) {
|
||||
moveEnd = sample;
|
||||
moveEndMs = elapsedMs;
|
||||
phaseStartedAt = at;
|
||||
armDeadline();
|
||||
startReveal();
|
||||
} else {
|
||||
revealFrames++;
|
||||
@@ -76,7 +111,7 @@ export function observeArrivalRotation({
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
timer = setTimer(() => finish(expired()), ARRIVAL_OBSERVATION_MS);
|
||||
armDeadline();
|
||||
try {
|
||||
startMoveEnd();
|
||||
scene.requestRender();
|
||||
@@ -87,14 +122,51 @@ export function observeArrivalRotation({
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an observation measured a rotation error over the threshold on any
|
||||
* frame it captured, including the settle frame of a reveal that then timed
|
||||
* out. Such an observation is a demonstrated failure, never inconclusive.
|
||||
*/
|
||||
export function arrivalAngleViolated(observation) {
|
||||
const limit = (ARRIVAL_MAX_ERROR_DEG * Math.PI) / 180;
|
||||
return [observation?.dMoveEnd, observation?.dReveal].some(
|
||||
(error) => Number.isFinite(error) && error >= limit,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Observe arrival rotation, retrying only an inconclusive timeout. A wall-clock
|
||||
* timeout means the frame landed where the idle refresh could already have
|
||||
* repaired it, so it earns another attempt; an observation that measured a
|
||||
* wrong angle (or succeeded) is final, so a later clean attempt can never
|
||||
* replace it. `prepare` runs before every attempt; the result carries
|
||||
* `attempts`.
|
||||
*/
|
||||
export async function observeArrivalRotationWithRetry({
|
||||
prepare = async () => {},
|
||||
observe = observeArrivalRotation,
|
||||
maxAttempts = ARRIVAL_MAX_ATTEMPTS,
|
||||
...options
|
||||
}) {
|
||||
let observed;
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
await prepare();
|
||||
observed = { ...(await observe(options)), attempts: attempt };
|
||||
if (!observed.error || arrivalAngleViolated(observed)) break;
|
||||
}
|
||||
return observed;
|
||||
}
|
||||
|
||||
/** Test the captured frames without retrying a bad first observation. */
|
||||
export function arrivalRotationPassed(observation) {
|
||||
return (
|
||||
!!observation &&
|
||||
!observation.error &&
|
||||
observation.shown === true &&
|
||||
observation.elapsedMs < ARRIVAL_OBSERVATION_MS &&
|
||||
(observation.dMoveEnd * 180) / Math.PI < 10 &&
|
||||
(observation.dReveal * 180) / Math.PI < 10
|
||||
observation.moveEndMs < ARRIVAL_OBSERVATION_MS &&
|
||||
observation.revealMs < ARRIVAL_OBSERVATION_MS &&
|
||||
Number.isFinite(observation.dMoveEnd) &&
|
||||
Number.isFinite(observation.dReveal) &&
|
||||
!arrivalAngleViolated(observation)
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,23 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import * as arrivalModule from './trackArrivalObservation.mjs';
|
||||
import {
|
||||
ARRIVAL_OBSERVATION_MS,
|
||||
arrivalAngleViolated,
|
||||
arrivalRotationPassed,
|
||||
observeArrivalRotation,
|
||||
observeArrivalRotationWithRetry,
|
||||
} from './trackArrivalObservation.mjs';
|
||||
|
||||
function fixture() {
|
||||
function fixture(start = observeArrivalRotation) {
|
||||
let time = 0;
|
||||
let state = { shown: true, rotationError: 0 };
|
||||
const listeners = new Set();
|
||||
const timers = new Map();
|
||||
let renders = 0;
|
||||
let moveEnds = 0;
|
||||
const observation = observeArrivalRotation({
|
||||
const observation = start({
|
||||
scene: {
|
||||
postRender: {
|
||||
addEventListener(listener) {
|
||||
@@ -110,7 +114,8 @@ test('no reveal fails at a deadline before the one-second idle catch-up', async
|
||||
assert.equal(ARRIVAL_OBSERVATION_MS, 500);
|
||||
f.frame(8, { shown: true, rotationError: 0 });
|
||||
f.frame(480);
|
||||
f.expire(500);
|
||||
// The reveal budget is measured from the 8 ms settle frame.
|
||||
f.expire(508);
|
||||
const result = await f.observation;
|
||||
assert.match(result.error, /reveal.*500 ms/);
|
||||
assert.equal(arrivalRotationPassed(result), false);
|
||||
@@ -123,20 +128,46 @@ test('no reveal fails at a deadline before the one-second idle catch-up', async
|
||||
test('a late visible frame fails even if the timeout callback is delayed', async () => {
|
||||
const f = fixture();
|
||||
f.frame(8, { shown: true, rotationError: 0 });
|
||||
f.frame(501, { shown: true, rotationError: 0 });
|
||||
f.frame(509, { shown: true, rotationError: 0 });
|
||||
const result = await f.observation;
|
||||
assert.match(result.error, /reveal.*500 ms/);
|
||||
assert.equal(result.elapsedMs, 501);
|
||||
assert.equal(result.elapsedMs, 509);
|
||||
assert.equal(arrivalRotationPassed(result), false);
|
||||
});
|
||||
|
||||
test('the deadline includes moveEnd setup rather than restarting when hidden', async () => {
|
||||
test('a slow software renderer passes when each frame stays inside its own budget', async () => {
|
||||
// Headless SwiftShader frames run ~300 ms: settle lands at 300 ms and the
|
||||
// reveal at 600 ms from moveEnd. Both stay inside the 1 s idle refresh.
|
||||
const f = fixture();
|
||||
f.frame(400, { shown: true, rotationError: 0 });
|
||||
f.frame(300, { shown: true, rotationError: 0 });
|
||||
f.expire(500); // the settle frame re-armed the deadline to 800 ms
|
||||
f.frame(600, { shown: true, rotationError: 0 });
|
||||
const result = await f.observation;
|
||||
assert.equal(result.moveEndMs, 400);
|
||||
assert.equal(result.moveEndMs, 300);
|
||||
assert.equal(result.revealMs, 300);
|
||||
assert.equal(result.elapsedMs, 600);
|
||||
assert.equal(result.flipFrame, 1);
|
||||
assert.equal(arrivalRotationPassed(result), true);
|
||||
assert.equal(f.resources.timers, 0);
|
||||
});
|
||||
|
||||
test('a settle frame later than its budget fails as a moveEnd timeout', async () => {
|
||||
const f = fixture();
|
||||
f.frame(500, { shown: true, rotationError: 0 });
|
||||
const result = await f.observation;
|
||||
assert.match(result.error, /moveEnd.*500 ms/);
|
||||
assert.equal(result.moveEndMs, null);
|
||||
assert.equal(arrivalRotationPassed(result), false);
|
||||
});
|
||||
|
||||
test('a reveal later than its own budget fails even after a fast settle frame', async () => {
|
||||
const f = fixture();
|
||||
f.frame(400, { shown: true, rotationError: 0 });
|
||||
f.frame(901, { shown: true, rotationError: 0 });
|
||||
const result = await f.observation;
|
||||
assert.match(result.error, /reveal.*500 ms/);
|
||||
assert.equal(result.moveEndMs, 400);
|
||||
assert.equal(result.elapsedMs, 901);
|
||||
assert.equal(arrivalRotationPassed(result), false);
|
||||
});
|
||||
|
||||
@@ -149,3 +180,195 @@ test('missing moveEnd frames fail without waiting for a render callback', async
|
||||
assert.equal(f.resources.listeners, 0);
|
||||
assert.equal(f.resources.timers, 0);
|
||||
});
|
||||
|
||||
// Let a retry's next attempt register its postRender listener.
|
||||
const settle = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
function retryFixture(extra = {}) {
|
||||
const counts = { prepares: 0 };
|
||||
const f = fixture((options) =>
|
||||
observeArrivalRotationWithRetry({
|
||||
...options,
|
||||
...extra,
|
||||
async prepare() {
|
||||
counts.prepares++;
|
||||
},
|
||||
}),
|
||||
);
|
||||
f.counts = counts;
|
||||
return f;
|
||||
}
|
||||
|
||||
test('a wrong settle angle followed by a reveal timeout is final, not retried into a pass', async () => {
|
||||
const f = retryFixture();
|
||||
await settle();
|
||||
// 0.5 rad ≈ 29°: a real threshold violation on the settle frame.
|
||||
f.frame(8, { shown: true, rotationError: 0.5 });
|
||||
f.expire(508); // the reveal never comes inside its budget
|
||||
await settle();
|
||||
// An otherwise clean second attempt, which must never be started.
|
||||
f.frame(516, { shown: true, rotationError: 0 });
|
||||
f.frame(524, { shown: true, rotationError: 0 });
|
||||
const result = await f.observation;
|
||||
assert.match(result.error, /reveal.*500 ms/);
|
||||
assert.equal(result.dMoveEnd, 0.5, 'the measured settle angle is kept');
|
||||
assert.equal(arrivalAngleViolated(result), true);
|
||||
assert.equal(result.attempts, 1);
|
||||
assert.equal(f.counts.prepares, 1);
|
||||
assert.equal(f.resources.moveEnds, 1);
|
||||
assert.equal(arrivalRotationPassed(result), false);
|
||||
assert.equal(f.resources.listeners, 0);
|
||||
assert.equal(f.resources.timers, 0);
|
||||
});
|
||||
|
||||
test('an inconclusive reveal timeout after a correct settle earns one more attempt', async () => {
|
||||
const f = retryFixture();
|
||||
await settle();
|
||||
f.frame(8, { shown: true, rotationError: 0 });
|
||||
f.expire(508);
|
||||
await settle();
|
||||
f.frame(516, { shown: true, rotationError: 0 });
|
||||
f.frame(596, { shown: true, rotationError: 0 });
|
||||
const result = await f.observation;
|
||||
assert.equal(result.attempts, 2);
|
||||
assert.equal(f.counts.prepares, 2);
|
||||
assert.equal(f.resources.moveEnds, 2);
|
||||
assert.equal(arrivalRotationPassed(result), true);
|
||||
});
|
||||
|
||||
test('timeouts stop after the attempt limit and still fail', async () => {
|
||||
const f = retryFixture();
|
||||
await settle();
|
||||
f.expire(500);
|
||||
await settle();
|
||||
f.expire(1000);
|
||||
const result = await f.observation;
|
||||
assert.match(result.error, /moveEnd.*500 ms/);
|
||||
assert.equal(result.attempts, 2);
|
||||
assert.equal(arrivalRotationPassed(result), false);
|
||||
assert.equal(f.resources.listeners, 0);
|
||||
assert.equal(f.resources.timers, 0);
|
||||
});
|
||||
|
||||
test('a missing or non-finite angle never passes', () => {
|
||||
const clean = {
|
||||
shown: true,
|
||||
moveEndMs: 8,
|
||||
revealMs: 80,
|
||||
dMoveEnd: 0,
|
||||
dReveal: 0,
|
||||
};
|
||||
assert.equal(arrivalRotationPassed(clean), true);
|
||||
for (const field of ['dMoveEnd', 'dReveal']) {
|
||||
assert.equal(arrivalRotationPassed({ ...clean, [field]: NaN }), false);
|
||||
assert.equal(
|
||||
arrivalRotationPassed({ ...clean, [field]: undefined }),
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('a wrong angle on a late reveal frame is final, not retried into a pass', async () => {
|
||||
const f = retryFixture();
|
||||
await settle();
|
||||
f.frame(100, { shown: true, rotationError: 0 });
|
||||
// Shown at 600 ms (500 ms after settle) still wearing the tampered nose.
|
||||
f.frame(600, { shown: true, rotationError: 1.5 });
|
||||
await settle();
|
||||
f.frame(608, { shown: true, rotationError: 0 });
|
||||
f.frame(688, { shown: true, rotationError: 0 });
|
||||
const result = await f.observation;
|
||||
assert.match(result.error, /reveal.*500 ms/);
|
||||
assert.equal(result.dReveal, 1.5);
|
||||
assert.equal(result.attempts, 1);
|
||||
assert.equal(f.resources.moveEnds, 1);
|
||||
assert.equal(arrivalRotationPassed(result), false);
|
||||
});
|
||||
|
||||
test('a wrong angle on a late settle frame is final, not retried into a pass', async () => {
|
||||
const f = retryFixture();
|
||||
await settle();
|
||||
f.frame(926, { shown: true, rotationError: 1.5 });
|
||||
await settle();
|
||||
f.frame(934, { shown: true, rotationError: 0 });
|
||||
f.frame(1014, { shown: true, rotationError: 0 });
|
||||
const result = await f.observation;
|
||||
assert.match(result.error, /moveEnd.*500 ms/);
|
||||
assert.equal(result.dMoveEnd, 1.5);
|
||||
assert.equal(result.attempts, 1);
|
||||
assert.equal(arrivalRotationPassed(result), false);
|
||||
});
|
||||
|
||||
test('a late hidden reveal frame records no angle and stays retryable', async () => {
|
||||
const f = retryFixture();
|
||||
await settle();
|
||||
f.frame(8, { shown: true, rotationError: 0 });
|
||||
// Still hidden, still tampered: not a measured reveal angle.
|
||||
f.frame(600, { shown: false });
|
||||
const first = f.observation;
|
||||
await settle();
|
||||
f.frame(608, { shown: true, rotationError: 0 });
|
||||
f.frame(688, { shown: true, rotationError: 0 });
|
||||
const result = await first;
|
||||
assert.equal(result.attempts, 2);
|
||||
assert.equal(result.dReveal, 0);
|
||||
assert.equal(arrivalRotationPassed(result), true);
|
||||
});
|
||||
|
||||
test('a late frame with correct angles stays an inconclusive, retryable timeout', async () => {
|
||||
const f = retryFixture();
|
||||
await settle();
|
||||
f.frame(926, { shown: true, rotationError: 0 });
|
||||
await settle();
|
||||
f.frame(934, { shown: true, rotationError: 0 });
|
||||
f.frame(1014, { shown: true, rotationError: 0 });
|
||||
const result = await f.observation;
|
||||
assert.equal(result.attempts, 2);
|
||||
assert.equal(arrivalRotationPassed(result), true);
|
||||
});
|
||||
|
||||
test('a wrong first reveal on time is final: no retry', async () => {
|
||||
const f = retryFixture();
|
||||
await settle();
|
||||
f.frame(8, { shown: true, rotationError: 0 });
|
||||
f.frame(88, { shown: true, rotationError: 1.5 });
|
||||
const result = await f.observation;
|
||||
assert.equal(result.error, undefined);
|
||||
assert.equal(result.attempts, 1);
|
||||
assert.equal(f.resources.moveEnds, 1);
|
||||
assert.equal(arrivalRotationPassed(result), false);
|
||||
});
|
||||
|
||||
test('maxAttempts bounds inconclusive retries', async () => {
|
||||
const f = retryFixture({ maxAttempts: 3 });
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
await settle();
|
||||
f.expire(attempt * 500);
|
||||
}
|
||||
const result = await f.observation;
|
||||
assert.equal(result.attempts, 3);
|
||||
assert.equal(f.counts.prepares, 3);
|
||||
assert.equal(arrivalRotationPassed(result), false);
|
||||
});
|
||||
|
||||
test('the 10° limit is inclusive', () => {
|
||||
const limit = Math.PI / 18;
|
||||
assert.equal(arrivalAngleViolated({ dMoveEnd: limit }), true);
|
||||
assert.equal(arrivalAngleViolated({ dReveal: limit * 0.999 }), false);
|
||||
});
|
||||
|
||||
test('every name the track harness imports from this module is exported', () => {
|
||||
const harness = readFileSync(
|
||||
new URL('../../scripts/track-regression.mjs', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const names = [
|
||||
...harness.matchAll(
|
||||
/(?:import|const)\s*\{([^}]+)\}\s*(?:from\s*|=\s*await import\()[^;]*trackArrivalObservation\.mjs/g,
|
||||
),
|
||||
].flatMap((match) => match[1].split(',').map((name) => name.trim()));
|
||||
assert.ok(names.includes('arrivalRotationPassed'));
|
||||
assert.ok(names.includes('observeArrivalRotationWithRetry'));
|
||||
for (const name of names)
|
||||
assert.equal(typeof arrivalModule[name], 'function', name);
|
||||
});
|
||||
@@ -43,6 +43,8 @@ export const LAYER_NAME_KEYS = Object.freeze({
|
||||
'air-quality': msg('Air quality'),
|
||||
directions: msg('Directions'),
|
||||
'alpr-cameras': msg('ALPR Cameras'),
|
||||
'sea-marks': msg('Sea Marks'),
|
||||
'ski-pistes': msg('Ski Pistes'),
|
||||
});
|
||||
|
||||
/** The layer panel's own English names, where they differ from the module's. */
|
||||
|
||||
@@ -64,6 +64,8 @@ const PANEL_GROUPS = [
|
||||
'local-datacenters',
|
||||
'telegeography-submarine-cables',
|
||||
'local-dams',
|
||||
'sea-marks',
|
||||
'ski-pistes',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -49,7 +49,7 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea
|
||||
// Snow leaves the set_visual_style enum.
|
||||
// The Operator HUD layout leaves set_hud's layout enum.
|
||||
// The layer alias hint leaves out aliases the layer id already spells.
|
||||
'dc2bac1be93a503dc88d637beea2f1bc457e1f9167422bd91548b44756a8aca5',
|
||||
'7f113854cdfe3114b9edd69f49995502276c718ce8878320161a68bafb63ebe2',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -71,6 +71,18 @@ const layers = [
|
||||
context: false,
|
||||
noQuery: 'published third-party intel placemarks; not analyst records',
|
||||
},
|
||||
{
|
||||
id: 'sea-marks',
|
||||
aliases: ['seamarks', 'nautical charts', 'buoys', 'openseamap'],
|
||||
context: false,
|
||||
noQuery: 'raster tile overlay; no countable records',
|
||||
},
|
||||
{
|
||||
id: 'ski-pistes',
|
||||
aliases: ['ski slopes', 'ski runs', 'snow map', 'opensnowmap'],
|
||||
context: false,
|
||||
noQuery: 'raster tile overlay; no countable records',
|
||||
},
|
||||
{
|
||||
id: 'flights',
|
||||
track: true,
|
||||
|
||||
Reference in new issue
Block a user