Merge remote-tracking branch 'upstream/main' into feature/catalonia-cctv-cameras

This commit is contained in:
ulihhhh committed 2026-10-12 12:29:16 +02:00
commit fef7878e54
288 files changed
+45726 -1950

No files matched your search

+879 -144
View File
File diff suppressed because it is too large. Load diff
+374 -7
View File
@@ -174,11 +174,95 @@ test('analyst: nearest sorting attaches distanceKm ascending', async () => {
test('analyst: follow-up re-filters the remembered set without re-snapshot', async () => {
const eng = makeEngine();
await eng.query({ layers: ['flights'], scope: { kind: 'region', name: 'Texland' } });
const r = await eng.query({ followUp: true, filters: [{ field: 'onGround', op: 'eq', value: true }] });
const first = await eng.query({
layers: ['flights'],
scope: { kind: 'region', name: 'Texland' },
});
const r = await eng.query({
followUp: true,
filters: [{ field: 'onGround', op: 'eq', value: true }],
});
assert.equal(r.count, 1);
assert.equal(r.items[0].id, 'GND1');
assert.equal(r.coverage.followUp, true);
assert.equal(r.scopeLabel, first.scopeLabel);
assert.equal(r.display.scope, first.display.scope);
assert.equal(r.coverage.scope, first.coverage.scope);
});
test('analyst: follow-ups preserve partial and unanswered coverage', async () => {
const eng = createAnalystEngine({
getRecords: (key) => (key === 'flights' ? FLIGHTS : []),
getLayerSnapshot: (key) => ({
enabled: key === 'flights',
feedState: key === 'flights' ? 'nominal' : 'off',
}),
getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }),
});
const first = await eng.query({
layers: ['flights', 'military'],
scope: { kind: 'anywhere' },
});
assert.equal(first.partial, true);
assert.deepEqual(first.unanswered, ['military']);
const followUp = await eng.query({ followUp: true, limit: 1 });
assert.equal(followUp.partial, true);
assert.deepEqual(followUp.unanswered, ['military']);
assert.deepEqual(
followUp.coverage.layersQueried.map(({ layerKey, status }) => [
layerKey,
status,
]),
first.coverage.layersQueried.map(({ layerKey, status }) => [
layerKey,
status,
]),
);
});
test('analyst: cancelled pre-commit work cannot replace follow-up memory', async () => {
const eng = makeEngine();
await eng.query({
layers: ['flights'],
scope: { kind: 'anywhere' },
filters: [{ field: 'id', op: 'eq', value: 'SWA1' }],
});
let current = true;
const stale = await eng.query(
{ layers: ['local-firms'], scope: { kind: 'anywhere' } },
{
isCurrent: () => current,
beforeCommit: async () => {
current = false;
},
},
);
assert.equal(stale.code, 'CANCELLED');
const followUp = await eng.query({ followUp: true });
assert.deepEqual(followUp.items.map((item) => item.id), ['SWA1']);
});
test('analyst: failed pre-commit work cannot replace follow-up memory', async () => {
const eng = makeEngine();
await eng.query({
layers: ['flights'],
scope: { kind: 'anywhere' },
filters: [{ field: 'id', op: 'eq', value: 'SWA1' }],
});
await assert.rejects(
eng.query(
{ layers: ['local-firms'], scope: { kind: 'anywhere' } },
{
beforeCommit: async () => {
throw new Error('projection failed');
},
},
),
/projection failed/,
);
const followUp = await eng.query({ followUp: true });
assert.deepEqual(followUp.items.map((item) => item.id), ['SWA1']);
});
test('analyst: unresolved region is an honest failure, not empty success', async () => {
@@ -200,6 +284,39 @@ test('analyst: a region lookup timeout is reported as region-timeout', async ()
assert.equal(r.coverage.scope, 'region:Texas:timeout');
});
test('analyst: region lookup receives and obeys the owning action signal', async () => {
const controller = new AbortController();
let receivedSignal = null;
const eng = createAnalystEngine({
getRecords: () => FLIGHTS,
resolveRegionRing: async (_name, signal) => {
receivedSignal = signal;
await new Promise((resolve) =>
signal.addEventListener('abort', resolve, { once: true }),
);
return null;
},
getViewContext: () => ({
lat: 30.27,
lon: -97.74,
viewRadiusKm: 150,
}),
});
const pending = eng.query(
{ layers: ['flights'], scope: { kind: 'region', name: 'Texas' } },
{
signal: controller.signal,
isCurrent: () => !controller.signal.aborted,
},
);
await Promise.resolve();
controller.abort();
const result = await pending;
assert.equal(receivedSignal, controller.signal);
assert.equal(result.code, 'CANCELLED');
assert.equal(result.cancelled, true);
});
test('analyst: route fields queryable from cached enrichment only', async () => {
const r = await makeEngine().query({
layers: ['flights'], scope: { kind: 'anywhere' },
@@ -262,20 +379,132 @@ test('helpers: haversine sanity + scope radius', () => {
assert.deepEqual(scoped.map((f) => f.id).sort(), ['GND1', 'SWA1']);
});
test('bounded loaded cohorts disclose truncation before filtering and retain it on follow-up', async () => {
test('capped cohorts report returned/total/truncated and keep it on follow-up', async () => {
const engine = createAnalystEngine({
getRecords: () => [{ id: 'sample dam', lat: 0, lon: 0, name: 'sample' }],
getRecordCoverage: () => ({ basis: 'bounded-loaded-records', recordsExamined: 1, loadedCount: 3000, sourceTruncated: true }),
getRecordCoverage: () => ({ total: 3000, truncated: true }),
getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }),
});
const result = await engine.query({ layers: ['local-dams'], scope: { kind: 'anywhere' }, sortBy: 'distance' });
assert.equal(result.count, 1);
assert.equal(result.coverage.layersQueried[0].sourceTruncated, true);
assert.match(result.coverage.note, /omitted records may change the nearest item or count/);
const layer = result.coverage.layersQueried[0];
assert.deepEqual({ returned: layer.returned, total: layer.total, truncated: layer.truncated }, { returned: 1, total: 3000, truncated: true });
assert.deepEqual(result.coverage.records, { returned: 1, total: 3000, truncated: true });
assert.match(result.display.caveat, /counted 1 of 3,000 loaded records/);
const followUp = await engine.query({ followUp: true });
assert.equal(followUp.coverage.layersQueried[0].loadedCount, 3000);
assert.equal(followUp.coverage.layersQueried[0].total, 3000);
});
test('an uncapped cohort is complete and carries no caveat', async () => {
const result = await makeEngine().query({ layers: ['flights'], scope: { kind: 'anywhere' } });
assert.deepEqual(result.coverage.records, { returned: 4, total: 4, truncated: false });
assert.equal(result.display.caveat, undefined);
assert.equal(result.display.scope, 'anywhere in the loaded data');
});
test('unknown fields, operators, scopes and wrong-typed values are refused with the allowed values', async () => {
const engine = makeEngine();
const field = await engine.query({ layers: ['flights'], filters: [{ field: 'time', op: 'gte', value: 5 }] });
assert.equal(field.ok, false);
assert.equal(field.code, 'UNKNOWN_FIELD');
assert.ok(field.allowed.includes('altitudeM'));
const op = await engine.query({ layers: ['flights'], filters: [{ field: 'altitudeM', op: 'wat', value: 5 }] });
assert.equal(op.code, 'BAD_OPERATOR');
assert.deepEqual(op.allowed, ['gt', 'gte', 'lt', 'lte', 'eq', 'neq']);
const flag = await engine.query({ layers: ['flights'], filters: [{ field: 'military', op: 'eq', value: 'false' }] });
assert.equal(flag.code, 'BAD_VALUE', 'the string "false" must not match the TRUE record');
const num = await engine.query({ layers: ['flights'], filters: [{ field: 'altitudeM', op: 'gt', value: 'high' }] });
assert.equal(num.code, 'BAD_VALUE');
const scope = await engine.query({ layers: ['flights'], scope: { kind: 'polygon' } });
assert.equal(scope.code, 'UNKNOWN_SCOPE');
assert.deepEqual(scope.allowed, ['view', 'region', 'radius', 'anywhere']);
const sort = await engine.query({ layers: ['flights'], sortBy: 'loudness' });
assert.equal(sort.code, 'UNKNOWN_FIELD');
assert.equal(engine.hasMemory(), false, 'refusals never become follow-up memory');
});
test('a typed boolean filter still matches exactly', async () => {
const r = await makeEngine().query({ layers: ['flights'], scope: { kind: 'anywhere' }, filters: [{ field: 'military', op: 'eq', value: false }] });
assert.deepEqual(r.items.map((i) => i.id).sort(), ['GND1', 'N123', 'SWA1']);
});
test('distance honours sortDir: desc is farthest first', async () => {
const far = await makeEngine().query({ layers: ['flights'], scope: { kind: 'anywhere' }, sortBy: 'distance', sortDir: 'desc', limit: 1 });
assert.equal(far.items[0].id, 'N123');
const near = await makeEngine().query({ layers: ['flights'], scope: { kind: 'anywhere' }, sortBy: 'distance', limit: 1 });
assert.equal(near.items[0].id, 'SWA1');
});
test('a follow-up without a previous answer is refused, not a fresh flights query', async () => {
const r = await makeEngine().query({ followUp: true, filters: [] });
assert.equal(r.ok, false);
assert.equal(r.code, 'NO_RESULT_CONTEXT');
});
test('follow-ups never rewrite the remembered rows', async () => {
const engine = makeEngine();
await engine.query({ layers: ['flights'], scope: { kind: 'anywhere' } });
await engine.query({ followUp: true, sortBy: 'distance' });
const again = await engine.query({ followUp: true });
assert.equal(again.count, 4, 'a follow-up keeps the remembered set, not the view');
assert.ok(again.items.every((item) => item.distanceKm === undefined));
});
test('a superseded slow region query cannot overwrite the newer follow-up memory', async () => {
let releaseSlow;
const engine = createAnalystEngine({
getRecords: (key) => ({ flights: FLIGHTS, 'ais-live-vessels': SHIPS }[key] || []),
resolveRegionRing: () => new Promise((resolve) => { releaseSlow = () => resolve(TEXLAND); }),
getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }),
});
let generation = 0;
const slowGeneration = ++generation;
const slow = engine.query({ layers: ['flights'], scope: { kind: 'region', name: 'Texland' } }, { isCurrent: () => generation === slowGeneration });
const newGeneration = ++generation;
const fresh = await engine.query({ layers: ['ais-live-vessels'], scope: { kind: 'anywhere' } }, { isCurrent: () => generation === newGeneration });
assert.equal(fresh.ok, true);
releaseSlow();
const stale = await slow;
assert.equal(stale.code, 'CANCELLED');
const followUp = await engine.query({ followUp: true });
assert.deepEqual(followUp.items.map((i) => i.layerKey), ['ais-live-vessels', 'ais-live-vessels']);
});
test('off, loading and unavailable layers are refused distinctly; a real zero stays ok', async () => {
const engineFor = (snapshot, rows = [], warming = false) => createAnalystEngine({
getRecords: () => rows,
getLayerSnapshot: () => snapshot,
isWarming: () => warming,
getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }),
});
assert.equal((await engineFor({ enabled: false, feedState: 'off' }).query({ layers: ['earthquakes'] })).code, 'LAYER_OFF');
assert.equal((await engineFor({ enabled: true, feedState: 'loading' }).query({ layers: ['earthquakes'] })).code, 'NOT_READY');
assert.equal((await engineFor({ enabled: true, feedState: 'nominal' }, [], true).query({ layers: ['earthquakes'] })).code, 'NOT_READY');
assert.equal((await engineFor({ enabled: true, feedState: 'unavailable' }).query({ layers: ['earthquakes'] })).code, 'FEED_UNAVAILABLE');
const zero = await engineFor({ enabled: true, feedState: 'nominal' }, [{ id: 'q', lat: 50, lon: 50, magnitude: 2 }]).query({ layers: ['earthquakes'], filters: [{ field: 'magnitude', op: 'gte', value: 5 }] });
assert.equal(zero.ok, true);
assert.equal(zero.count, 0);
assert.equal(zero.coverage.layersQueried[0].status, 'ok');
});
test('time fields filter by ISO and derive ageHours; the feed window is exposed', async () => {
const now = Date.parse('2026-09-23T12:00:00Z');
const engine = createAnalystEngine({
now: () => now,
getRecords: () => [
{ id: 'recent', lat: 0, lon: 0, magnitude: 4, timeMs: now - 2 * 3_600_000 },
{ id: 'older', lat: 0, lon: 0, magnitude: 5, timeMs: now - 20 * 3_600_000 },
],
getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }),
});
const iso = await engine.query({ layers: ['earthquakes'], scope: { kind: 'anywhere' }, filters: [{ field: 'timeMs', op: 'gte', value: '2026-09-23T06:00:00Z' }] });
assert.deepEqual(iso.items.map((i) => i.id), ['recent']);
const age = await engine.query({ layers: ['earthquakes'], scope: { kind: 'anywhere' }, filters: [{ field: 'ageHours', op: 'lte', value: 6 }] });
assert.deepEqual(age.items.map((i) => i.id), ['recent']);
assert.equal(age.items[0].ageHours, 2);
assert.equal(age.display.window, 'last 24 h');
assert.equal(age.coverage.layersQueried[0].window, 'last 24 h');
});
test('follow-up provenance stays attached to old rows after a feed recovers', async () => {
let state = 'stale';
@@ -293,3 +522,141 @@ test('follow-up provenance stays attached to old rows after a feed recovers', as
assert.equal(engine.hasMemory(), false);
assert.equal((await engine.query()).coverage.feedProvenance.overall, 'nominal');
});
test('a large loaded set sorts and summarizes without overflowing the stack', async () => {
const rows = Array.from({ length: 200_000 }, (_, i) => ({ id: `FIRE-${i}`, lat: 0, lon: 0, frp: i }));
const engine = createAnalystEngine({
getRecords: () => rows,
getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }),
});
const result = await engine.query({ layers: ['local-firms'], scope: { kind: 'anywhere' }, sortBy: 'frp', limit: 1 });
assert.equal(result.count, 200_000);
assert.equal(result.items[0].frp, 199_999);
assert.equal(result.summary.frpMin, 0);
});
test('an enabled but empty layer says why on screen', async () => {
const engine = createAnalystEngine({
getRecords: () => [],
getLayerSnapshot: () => ({ enabled: true, feedState: 'nominal' }),
getEmptyNote: () => 'Fly below 400 km to a covered region',
getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }),
});
const result = await engine.query({ layers: ['transit'] });
assert.equal(result.ok, true);
assert.equal(result.count, 0);
assert.equal(result.coverage.layersQueried[0].status, 'empty');
assert.match(result.display.caveat, /transit: Fly below 400 km/);
});
test('a radius around a named place without a centre is refused, not measured around the camera', async () => {
const r = await makeEngine().query({ layers: ['ais-live-vessels'], scope: { kind: 'radius', name: 'Singapore', km: 200 } });
assert.equal(r.ok, false);
assert.equal(r.code, 'BAD_SCOPE');
const ok = await makeEngine().query({ layers: ['ais-live-vessels'], scope: { kind: 'radius', name: 'Houston', km: 200, center: { lat: 29.6, lon: -95.0 } } });
assert.equal(ok.ok, true);
});
test('a field copied with its hinted unit still resolves', async () => {
const r = await makeEngine().query({ layers: ['local-firms'], scope: { kind: 'anywhere' }, sortBy: 'frp(MW)', filters: [{ field: 'frp (MW)', op: 'gt', value: 100 }] });
assert.equal(r.ok, true);
assert.deepEqual(r.items.map((i) => i.id), ['FIRE-3', 'FIRE-1']);
});
test('a written unit converts to the field unit or is refused', async () => {
const engine = makeEngine();
const feet = await engine.query({ layers: ['flights'], scope: { kind: 'anywhere' }, filters: [{ field: 'altitudeM(ft)', op: 'gt', value: 40000 }] });
assert.equal(feet.ok, true);
assert.deepEqual(feet.items.map((i) => i.id), ['RCH01'], '40,000 ft is 12,192 m, not 40,000 m');
const same = await engine.query({ layers: ['flights'], scope: { kind: 'anywhere' }, filters: [{ field: 'altitudeM (m)', op: 'gt', value: 12192 }] });
assert.deepEqual(same.items.map((i) => i.id), ['RCH01']);
const knots = await engine.query({ layers: ['flights'], scope: { kind: 'anywhere' }, filters: [{ field: 'speedMps(kts)', op: 'gt', value: 470 }] });
assert.deepEqual(knots.items.map((i) => i.id), ['RCH01'], '470 kn is ~242 m/s');
const wrong = await engine.query({ layers: ['flights'], filters: [{ field: 'altitudeM(kts)', op: 'gt', value: 1 }] });
assert.equal(wrong.code, 'BAD_UNIT');
const unitless = await engine.query({ layers: ['flights'], filters: [{ field: 'callsign(ft)', op: 'eq', value: 'X' }] });
assert.equal(unitless.code, 'BAD_UNIT');
const hinted = await engine.query({ layers: ['local-firms'], scope: { kind: 'anywhere' }, sortBy: 'frp(MW)' });
assert.equal(hinted.ok, true);
});
test('unambiguous numeric strings are numbers; blanks, booleans and words are not', async () => {
const engine = makeEngine();
const text = await engine.query({ layers: ['flights'], scope: { kind: 'anywhere' }, filters: [{ field: 'altitudeM', op: 'gt', value: '12192' }] });
assert.equal(text.ok, true);
assert.deepEqual(text.items.map((i) => i.id), ['RCH01']);
for (const value of ['', ' ', true, 'high', '12k'])
assert.equal((await engine.query({ layers: ['flights'], filters: [{ field: 'altitudeM', op: 'gt', value }] })).code, 'BAD_VALUE', JSON.stringify(value));
});
test('any supplied centre must be a real coordinate', async () => {
const engine = makeEngine();
for (const center of [{ lat: 0 }, { lat: 999, lon: 0 }, { lat: 10, lon: 200 }, { lat: '10', lon: 20 }])
assert.equal((await engine.query({ layers: ['flights'], scope: { kind: 'radius', km: 100, center } })).code, 'BAD_SCOPE', JSON.stringify(center));
assert.equal((await engine.query({ layers: ['flights'], scope: { kind: 'radius', km: 100, center: { lat: 30.2, lon: -97.7 } } })).ok, true);
});
test('mixed off/loading/unavailable layers refuse; a partly answerable query says so', async () => {
const snapshots = { earthquakes: { enabled: true, feedState: 'unavailable' }, 'local-firms': { enabled: true, feedState: 'loading' }, flights: { enabled: false, feedState: 'off' } };
const engine = createAnalystEngine({
getRecords: (key) => (key === 'ais-live-vessels' ? SHIPS : []),
getLayerSnapshot: (key) => snapshots[key] || { enabled: true, feedState: 'nominal' },
getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }),
});
const none = await engine.query({ layers: ['earthquakes', 'local-firms', 'flights'] });
assert.equal(none.ok, false);
assert.equal(none.code, 'NOT_READY');
assert.deepEqual(none.layerStatus.map((l) => l.status), ['error', 'loading', 'off']);
assert.equal(engine.hasMemory(), false, 'a refusal never becomes follow-up memory');
const down = await engine.query({ layers: ['earthquakes', 'flights'] });
assert.equal(down.code, 'FEED_UNAVAILABLE');
const partial = await engine.query({ layers: ['ais-live-vessels', 'earthquakes'], scope: { kind: 'anywhere' } });
assert.equal(partial.ok, true);
assert.equal(partial.partial, true);
assert.deepEqual(partial.unanswered, ['earthquakes']);
assert.match(partial.display.caveat, /earthquakes error/);
});
test('returned items are copies: mutating them cannot change the remembered answer', async () => {
const engine = makeEngine();
const first = await engine.query({ layers: ['flights'], scope: { kind: 'anywhere' }, sortBy: 'altitudeM' });
for (const item of first.items) {
item.altitudeM = -1;
item.id = 'MUTATED';
}
const again = await engine.query({ followUp: true, sortBy: 'altitudeM' });
assert.deepEqual(again.items.map((i) => i.id), ['RCH01', 'SWA1', 'N123', 'GND1']);
assert.equal(FLIGHTS[1].altitudeM, 13500, 'the provider rows are untouched');
});
test('ranking 250k rows stays within a budget and yields to the page', async () => {
const rows = Array.from({ length: 250_000 }, (_, i) => ({ id: `F${i}`, lat: (i % 170) - 85, lon: (i % 350) - 175, frp: (i * 7919) % 100_003 }));
const engine = createAnalystEngine({ getRecords: () => rows, getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }) });
let ticks = 0;
const timer = setInterval(() => ticks++, 0);
const started = performance.now();
const result = await engine.query({ layers: ['local-firms'], scope: { kind: 'anywhere' }, sortBy: 'frp', limit: 1 });
const elapsed = performance.now() - started;
clearInterval(timer);
assert.equal(result.count, 250_000);
assert.equal(result.items[0].frp, 100_002);
assert.ok(elapsed < 400, `took ${Math.round(elapsed)} ms`);
assert.ok(ticks > 0, 'the scan yielded between slices');
let current = true;
const pending = engine.query({ layers: ['local-firms'], scope: { kind: 'anywhere' }, sortBy: 'distance' }, { isCurrent: () => current });
current = false;
assert.equal((await pending).code, 'CANCELLED', 'a superseded scan stops at the next slice');
});
test('analyst: a follow-up naming other layers than the last answer is refused', async () => {
const engine = createAnalystEngine({
getRecords: (key) => ({ flights: FLIGHTS, 'local-firms': FIRES }[key] || []),
resolveRegionRing: async () => null,
getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }),
});
await engine.query({ layers: ['flights'], scope: { kind: 'anywhere' } });
const r = await engine.query({ layers: ['local-firms'], followUp: true });
assert.equal(r.ok, false);
assert.equal(r.code, 'FOLLOW_UP_MISMATCH');
assert.equal(engine.hasMemory(), true, 'the earlier answer is still the last answer');
});
+5 -2
View File
@@ -1,3 +1,4 @@
import { createDirectionsSource } from '../layers/directions/source.js';
/**
* @module directions
* @description Service bundle for the Directions layer.
@@ -37,8 +38,10 @@ export const directionsServices = Object.freeze({
});
/** Construct one Directions layer over the application scene owners. */
export function createApplicationDirectionsLayer() {
return createDirectionsLayer({ services: directionsServices });
export function createApplicationDirectionsLayer({
source = createDirectionsSource(),
} = {}) {
return createDirectionsLayer({ services: directionsServices, source });
}
export {
+1
View File
@@ -25,6 +25,7 @@ test('firms analyst record: full record maps every contract field', () => {
frp: 1520.4,
confidence: 0.9,
satellite: 'N21',
sensor: 'VIIRS',
acqTime: 1_753_600_000_000,
});
});
+1069 -236
View File
File diff suppressed because it is too large. Load diff
+63
View File
@@ -0,0 +1,63 @@
const MAX_SWITCHES_LENGTH = 256;
const ENTRY = /^([a-z][a-z0-9-]*)-([01])$/;
/** Canonical, bounded boolean switches keyed by stable slugs. */
export function normalizeIdSwitches(value, reservedIds = []) {
if (typeof value !== 'string' || value.length > MAX_SWITCHES_LENGTH)
return null;
if (!value) return '';
const entries = new Map();
for (const field of value.split('*')) {
const match = ENTRY.exec(field);
if (!match || reservedIds.includes(match[1]) || entries.has(match[1]))
return null;
entries.set(match[1], match[2] === '1');
}
return [...entries]
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
.map(([id, on]) => `${id}-${on ? '1' : '0'}`)
.join('*');
}
/** Encode by stable IDs rather than registration order or generated tokens. */
export function encodeIdSwitches(switches) {
const value = [...switches]
.map(([id, on]) => `${id}-${on === true ? '1' : '0'}`)
.join('*');
if (value.length > MAX_SWITCHES_LENGTH)
throw new RangeError(
`Switch IDs exceed the ${MAX_SWITCHES_LENGTH}-character share-link limit`,
);
const normalized = normalizeIdSwitches(value);
if (normalized === null) throw new TypeError('Invalid boolean switch IDs');
return normalized;
}
/** A Map keeps switch IDs separate from object prototype properties. */
export function decodeIdSwitches(value) {
const normalized = normalizeIdSwitches(value);
return new Map(
normalized
? normalized.split('*').map((entry) => {
const [, id, on] = ENTRY.exec(entry);
return [id, on === '1'];
})
: [],
);
}
/** Persist only explicitly requested switch IDs, not unrelated live changes. */
export function patchRequestedIdSwitches(previous, applied, requested) {
const current = decodeIdSwitches(applied);
const next = new Map(
[...decodeIdSwitches(previous)].filter(([id]) => current.has(id)),
);
let changed = false;
for (const [id, on] of current) {
if (Object.hasOwn(requested, id) && typeof requested[id] === 'boolean') {
next.set(id, on);
changed = true;
}
}
return changed ? encodeIdSwitches(next) : undefined;
}
+28
View File
@@ -1,3 +1,4 @@
import { normalizeIdSwitches, patchRequestedIdSwitches } from './idSwitches.js';
import reservationRows from './layerStateTokenReservations.json' with { type: 'json' };
const VALID_DISPOSITIONS = new Set([
@@ -410,6 +411,27 @@ const OPTION_GROUPS = Object.freeze({
),
booleanOption('viirs', 'v', false),
]),
'street-level': Object.freeze([
// Preserve the published Mapillary token; custom switches use stable IDs.
booleanOption('mapillary', 'm', true),
Object.freeze({
key: 'providerSwitches',
patchRequested: patchRequestedIdSwitches,
token: 'r',
defaultValue: '',
normalize: (value) => normalizeIdSwitches(value, ['mapillary']),
encode: normalizeIdSwitches,
decode: (value) => normalizeIdSwitches(value, ['mapillary']),
}),
enumOption('pano', 'p', 'all', ['all', 'pano', 'flat'], {
all: 'a',
pano: 'p',
flat: 'f',
}),
// Relative days, so a link means the same next year. The max matches
// MAX_SINCE_DAYS in streetLevel/policy.js (pinned by layerState.test).
boundedIntegerOption('sinceDays', 's', 0, { min: 0, max: 36500 }),
]),
radio: Object.freeze([
Object.freeze({
key: 'filter',
@@ -631,6 +653,12 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
disposition: 'enabled+options',
optionOwner: 'satellites',
}),
Object.freeze({
id: 'street-level',
token: '0',
disposition: 'enabled+options',
optionOwner: 'street-level',
}),
Object.freeze({
id: 'telegeography-submarine-cables',
token: 'u',
+172 -27
View File
@@ -1,3 +1,4 @@
import { encodeParams, decodeParams } from '../layers/streetLevel/params.js';
import { readShellSource } from '../testSupport/readShellSource.mjs';
import { expandApplicationHtml } from '../../build/application-html.js';
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
@@ -26,6 +27,8 @@ import {
import { LayerStateCoordinator } from './layerStateCoordinator.js';
import radioLayer from './radio.js';
import { stampInitialShareGesture } from '../navigationPolicy.js';
import { MAX_SINCE_DAYS } from '../layers/streetLevel/policy.js';
import { SINCE_STOPS } from '../ui/streetLevelPresentation.js';
function deferred() {
let resolve;
@@ -199,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, 28);
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 28);
assert.equal(REGISTERED_LAYER_IDS.length, 29);
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 29);
assert.ok(REGISTERED_LAYER_IDS.includes('transit'));
assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort());
assert.deepEqual(LEGACY_LAYER_STATE_TOKENS, {
@@ -239,15 +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(), '0');
assert.equal(nextLayerStateToken(), '3');
assert.equal(
nextLayerStateToken({ ...LAYER_STATE_TOKEN_RESERVATIONS, alpha: '0', bravo: '3' }),
'4',
nextLayerStateToken({
...LAYER_STATE_TOKEN_RESERVATIONS,
alpha: '3',
bravo: '4',
}),
'5',
);
const digitsExhausted = {
...LAYER_STATE_TOKEN_RESERVATIONS,
...Object.fromEntries(
[...'03456789'].map((digit) => [`prior-${digit}`, digit]),
[...'3456789'].map((digit) => [`prior-${digit}`, digit]),
),
};
assert.equal(nextLayerStateToken(digitsExhausted), '00');
@@ -274,7 +281,9 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
[
...'0123456789',
...[...LAYER_STATE_TOKEN_ALPHABET].flatMap((first) =>
[...LAYER_STATE_TOKEN_ALPHABET].map((second) => `${first}${second}`),
[...LAYER_STATE_TOKEN_ALPHABET].map(
(second) => `${first}${second}`,
),
),
].map((token, index) => [`occupied-${index}`, token]),
),
@@ -296,42 +305,46 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
true,
);
assert.equal(
validateLayerStateAllocations(
LAYER_STATE_TOKEN_RESERVATIONS,
{ ...LAYER_STATE_TOKEN_RESERVATIONS, future: '0', next: '3' },
),
validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
...LAYER_STATE_TOKEN_RESERVATIONS,
future: '3',
next: '4',
}),
true,
);
assert.throws(
() => validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
...LAYER_STATE_TOKEN_RESERVATIONS,
future: '00',
}),
/next free token 0/,
() =>
validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
...LAYER_STATE_TOKEN_RESERVATIONS,
future: '00',
}),
/next free token 3/,
);
const beforeLastDigit = { ...digitsExhausted };
delete beforeLastDigit['prior-9'];
assert.equal(
validateLayerStateAllocations(
beforeLastDigit,
{ ...beforeLastDigit, futurePair: '00', futureDigit: '9' },
),
validateLayerStateAllocations(beforeLastDigit, {
...beforeLastDigit,
futurePair: '00',
futureDigit: '9',
}),
true,
);
assert.equal(
validateLayerStateAllocations(
digitsExhausted,
{ ...digitsExhausted, pairB: '01', pairA: '00' },
),
validateLayerStateAllocations(digitsExhausted, {
...digitsExhausted,
pairB: '01',
pairA: '00',
}),
true,
);
assert.throws(
() =>
validateLayerStateAllocations(
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '0' },
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '0', competing: '0' },
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '3' },
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '3', competing: '3' },
),
/next free token 3/,
/next free token 4/,
);
assert.throws(
() => validateLayerStateAllocations({ future: '00' }, { future: '01' }),
@@ -2642,3 +2655,135 @@ test('fire perimeters uses digit 2 without colliding with wind or recent imagery
decoded,
);
});
/** Street Level options decoded from a v2 link with these `lo` assignments. */
function streetLevelOptions(lo) {
const params = new URLSearchParams([
['v', '2'],
['l', '0'],
]);
if (lo) params.set('lo', lo);
return decodeLayerStateParams(params).options['street-level'];
}
test('Street Level: every option round-trips through a share link and stored state', () => {
const state = createDefaultLayerState();
state.enabledLayerIds = ['street-level'];
state.options['street-level'] = {
mapillary: false,
providerSwitches: '',
pano: 'flat',
sinceDays: MAX_SINCE_DAYS,
};
const decoded = decodeLayerStateParams(new URLSearchParams(encode(state)));
assert.deepEqual(decoded.options['street-level'], {
mapillary: false,
providerSwitches: '',
pano: 'flat',
sinceDays: MAX_SINCE_DAYS,
});
assert.deepEqual(
parseStoredLayerState(serializeStoredLayerState(state)).options[
'street-level'
],
state.options['street-level'],
);
});
test('Street Level: the link codec accepts exactly the windows the filter keeps', () => {
// A window past MAX_SINCE_DAYS reads as "any date".
assert.equal(
streetLevelOptions(`0.s.${MAX_SINCE_DAYS}`).sinceDays,
MAX_SINCE_DAYS,
);
assert.equal(streetLevelOptions(`0.s.${MAX_SINCE_DAYS + 1}`).sinceDays, 0);
// Every SINCE slider stop survives, the LAST 10 YEARS one included.
for (const { days } of SINCE_STOPS)
assert.equal(streetLevelOptions(`0.s.${days}`).sinceDays, days);
assert.equal(streetLevelOptions('0.s.3652').sinceDays, 3652);
});
test('Street Level: a link without the provider switch keeps Mapillary on', () => {
assert.deepEqual(streetLevelOptions(''), {
mapillary: true,
providerSwitches: '',
pano: 'all',
sinceDays: 0,
});
assert.equal(streetLevelOptions('0.p.f').mapillary, true);
assert.equal(streetLevelOptions('0.m.0').mapillary, false);
assert.equal(
createDefaultLayerState().options['street-level'].mapillary,
true,
);
});
test('explicit provider switches reach durable state without adopting unrelated live switches', () => {
const manager = new DataLayerManager({});
const providers = new Map([
['mapillary', true],
['alpha', true],
['beta', true],
]);
const filter = { pano: 'all', sinceDays: 0 };
for (const id of REGISTERED_LAYER_IDS) {
manager.register(
id === 'street-level'
? {
...fakeLayer(id),
getParams: () => encodeParams({ providers, filter }),
setParams(params) {
const decoded = decodeParams(params, {
providerIds: providers.keys(),
filter,
});
for (const [id, on] of decoded.providers) providers.set(id, on);
return true;
},
}
: fakeLayer(id),
);
}
manager.finalizeRegistrations(LAYER_STATE_REGISTRY);
const storage = memoryStorage();
const coordinator = new LayerStateCoordinator(manager, shareSink(), {
storage,
});
try {
providers.set('beta', false); // Passive state does not belong to the next action.
assert.equal(
manager.setLayerParams(
'street-level',
{ alpha: false },
{ origin: 'user' },
),
true,
);
let saved = parseStoredLayerState(storage.getItem(LAYER_STATE_STORAGE_KEY));
assert.equal(saved.options['street-level'].providerSwitches, 'alpha-0');
const shared = decodeLayerStateParams(new URLSearchParams(encode(saved)));
const restored = decodeParams(shared.options['street-level'], {
providerIds: ['beta', 'alpha'],
filter,
});
assert.deepEqual([...restored.providers], [['alpha', false]]);
manager.setLayerParams('street-level', { beta: true }, { origin: 'user' });
saved = parseStoredLayerState(storage.getItem(LAYER_STATE_STORAGE_KEY));
assert.equal(
saved.options['street-level'].providerSwitches,
'alpha-0*beta-1',
);
manager.setLayerParams(
'street-level',
{ alpha: true },
{ origin: 'local-restore' },
);
saved = parseStoredLayerState(storage.getItem(LAYER_STATE_STORAGE_KEY));
assert.equal(
saved.options['street-level'].providerSwitches,
'alpha-0*beta-1',
);
} finally {
coordinator.destroy();
}
});
+16 -2
View File
@@ -255,8 +255,22 @@ export class LayerStateCoordinator {
const explicitlyRequested = Object.hasOwn(requestedParams, spec.key);
const implicitTrackingSync =
!explicitlyRequested && spec.key === trackingOptionKey;
if (!explicitlyRequested && !implicitTrackingSync) continue;
const value = spec.normalize(change.params[spec.key]);
// Packed options can persist only the subkeys owned by this request.
const requestedValue =
explicitlyRequested || implicitTrackingSync
? change.params[spec.key]
: spec.patchRequested?.(
nextOwnerOptions[spec.key],
change.params[spec.key],
requestedParams,
);
if (
!explicitlyRequested &&
!implicitTrackingSync &&
requestedValue === undefined
)
continue;
const value = spec.normalize(requestedValue);
if (value === null) {
if (implicitTrackingSync) {
nextOwnerOptions[spec.key] = null;
+35 -29
View File
@@ -98,6 +98,12 @@ function withPublishedBase(
}
function writeCodecFixture(cwd, rows, entries) {
const providerCodec = path.join(cwd, 'src/data/idSwitches.js');
mkdirSync(path.dirname(providerCodec), { recursive: true });
writeFileSync(
providerCodec,
readFileSync(new URL('./idSwitches.js', import.meta.url)),
);
const source = readFileSync(CODEC_SOURCE_PATH, 'utf8');
const registryEnd = ']);\n\nexport const REGISTERED_LAYER_IDS';
assert.equal(source.split(registryEnd).length, 2);
@@ -122,10 +128,10 @@ 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, 28);
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 29);
assert.deepEqual(
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
{ ...LEGACY_LAYER_STATE_TOKENS },
{ ...LEGACY_LAYER_STATE_TOKENS, 'street-level': '0' },
);
assert.throws(
() => parseLayerStateTokenReservations(reservationRows.slice(1)),
@@ -145,9 +151,9 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
/Duplicate layer-state token reservation/,
);
assert.equal(
parseLayerStateTokenReservations([...reservationRows, ['future', '0']])
parseLayerStateTokenReservations([...reservationRows, ['future', '3']])
.future,
'0',
'3',
);
assert.throws(
() =>
@@ -165,14 +171,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', '0']] },
{ rows: [...reservationRows, ['retired-layer', '3']] },
(cwd) => {
const published = readPublishedLayerStateReservations('HEAD', cwd);
assert.equal(nextLayerStateToken(published), '3');
assert.equal(nextLayerStateToken(published), '4');
assert.equal(
validateLayerStateAllocations(published, {
...published,
newcomer: '3',
newcomer: '4',
}),
true,
);
@@ -180,15 +186,15 @@ test('published retired digits and competing PRs advance the merge-time allocati
() =>
validateLayerStateAllocations(published, {
...published,
competing: '0',
competing: '3',
}),
/next free token 3/,
/next free token 4/,
);
assert.throws(
() =>
validateLayerStateAllocations(published, {
...LAYER_STATE_TOKEN_RESERVATIONS,
newcomer: '0',
newcomer: '3',
}),
/changed or removed: retired-layer/,
);
@@ -196,8 +202,8 @@ test('published retired digits and competing PRs advance the merge-time allocati
);
});
test('PR B manually replaces provisional 0 with 3 after PR A publishes 0', () => {
withPublishedBase({ rows: [...reservationRows, ['pr-a', '0']] }, (cwd) => {
test('PR B manually replaces provisional 3 with 4 after PR A publishes 3', () => {
withPublishedBase({ rows: [...reservationRows, ['pr-a', '3']] }, (cwd) => {
mkdirSync(path.join(cwd, 'scripts'));
const fixtureChecker = path.join(
cwd,
@@ -218,8 +224,8 @@ test('PR B manually replaces provisional 0 with 3 after PR A publishes 0', () =>
writeCodecFixture(
cwd,
[...reservationRows, ['pr-b', '0']],
[{ id: 'pr-b', token: '0' }],
[...reservationRows, ['pr-b', '3']],
[{ id: 'pr-b', token: '3' }],
);
const beforeRebase = runCheck();
assert.equal(beforeRebase.status, 1);
@@ -227,10 +233,10 @@ test('PR B manually replaces provisional 0 with 3 after PR A publishes 0', () =>
writeCodecFixture(
cwd,
[...reservationRows, ['pr-a', '0'], ['pr-b', '0']],
[...reservationRows, ['pr-a', '3'], ['pr-b', '3']],
[
{ id: 'pr-a', token: '0' },
{ id: 'pr-b', token: '0' },
{ id: 'pr-a', token: '3' },
{ id: 'pr-b', token: '3' },
],
);
const staleRebase = runCheck();
@@ -238,16 +244,16 @@ test('PR B manually replaces provisional 0 with 3 after PR A publishes 0', () =>
assert.match(staleRebase.stderr, /Duplicate layer-state token reservation/);
const published = readPublishedLayerStateReservations('HEAD', cwd);
assert.equal(nextLayerStateToken(published), '3');
const validBaselineRows = [...reservationRows, ['pr-a', '0']];
writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '0' }]);
assert.equal(nextLayerStateToken(published), '4');
const validBaselineRows = [...reservationRows, ['pr-a', '3']];
writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '3' }]);
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: 3/);
assert.match(helper.stdout, /pr-b: 4/);
assert.deepEqual(
JSON.parse(readFileSync(path.join(cwd, LEDGER_PATH), 'utf8')),
validBaselineRows,
@@ -255,20 +261,20 @@ test('PR B manually replaces provisional 0 with 3 after PR A publishes 0', () =>
);
writeCodecFixture(
cwd,
[...reservationRows, ['pr-a', '0'], ['pr-b', '3']],
[...reservationRows, ['pr-a', '3'], ['pr-b', '4']],
[
{ id: 'pr-a', token: '0' },
{ id: 'pr-b', token: '3' },
{ id: 'pr-a', token: '3' },
{ id: 'pr-b', token: '4' },
],
);
const corrected = runCheck();
assert.equal(corrected.status, 0, corrected.stderr);
assert.match(corrected.stdout, /29 published, 1 new/);
assert.match(corrected.stdout, /30 published, 1 new/);
});
});
test('allocation batches cross the last digit and base-36 pair boundaries in order', () => {
const digitRows = [...'03456789'].map((digit) => [`retired-${digit}`, digit]);
const digitRows = [...'3456789'].map((digit) => [`retired-${digit}`, digit]);
const allDigits = parseLayerStateTokenReservations([
...reservationRows,
...digitRows,
@@ -320,7 +326,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 = [...'03456789'].map((digit) => [
const priorDigits = [...'3456789'].map((digit) => [
`qa-prior-digit-${digit}`,
digit,
]);
@@ -357,7 +363,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, 67);
assert.equal(params.get('l').length, 69);
const restored = codec.decodeLayerStateParams(params);
assert.deepEqual(restored?.enabledLayerIds, expectedLayerIds);
assert.deepEqual(restored?.options, state.options);
@@ -375,7 +381,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, 29);
assert.equal(Object.keys(published).length, 30);
assert.equal(published['retired-layer'], '00');
assert.throws(
() =>
@@ -19,6 +19,7 @@
["recent-imagery", "1"],
["rocket-launches", "x"],
["satellites", "s"],
["street-level", "0"],
["telegeography-submarine-cables", "u"],
["traffic", "t"],
["transit", "j"],
+23 -1
View File
@@ -74,7 +74,11 @@ function refreshFailureFromStats(stats, label) {
* for real-time data overlays on the CesiumJS globe.
*/
export class LayerLifecycle {
constructor(viewer, { allowQaRegistration = false } = {}) {
constructor(
viewer,
{ allowQaRegistration = false, getSourceAvailability } = {},
) {
this._catalogSourceAvailability = getSourceAvailability;
this.viewer = viewer;
this._activityListeners = new Set();
this.layers = new Map(); // id → { module, enabled, initialized, intervalId, lifecycleState, lifecycleUncertain }
@@ -228,6 +232,7 @@ export class LayerLifecycle {
_normalizedStats(entry) {
const moduleStats = this._moduleStats(entry);
const availability = this._sourceAvailability(entry.module.id);
const lifecycleLoading =
entry.lifecycleState === 'enabling' ||
entry.lifecycleState === 'disabling';
@@ -235,6 +240,13 @@ export class LayerLifecycle {
count: 0,
lastUpdate: null,
...moduleStats,
...(availability?.available === false
? {
status: 'unavailable',
sourceUnavailable: true,
error: availability.reason,
}
: {}),
loading: lifecycleLoading || moduleStats.loading === true,
refreshing: entry.refreshing || moduleStats.refreshing === true,
managerRefreshError: entry.managerRefreshError,
@@ -2282,7 +2294,17 @@ export class LayerLifecycle {
}
}
_sourceAvailability(layerId) {
return (
this._catalogSourceAvailability?.(layerId) ??
this.layers.get(layerId)?.module.getSourceAvailability?.()
);
}
async _visibilityBlockReason(change) {
const availability = this._sourceAvailability(change.layerId);
if (change.enabled && availability?.available === false)
return availability.reason || 'Data source unavailable';
for (const callback of this._visibilityGuards) {
try {
const result = await callback(change);
+65
View File
@@ -41,6 +41,7 @@ import {
formatAwarenessLabel,
getAwarenessNavigationTargets,
} from './militaryAwarenessEngine.js';
import { formatAwarenessCount } from '../layers/awareness/panel.js';
import { NAVIGATION_AUTHORITY_EVENT } from '../navigationPolicy.js';
const militaryAwarenessSource = readLayerSource(
@@ -55,6 +56,18 @@ const AWARENESS_DEPENDENCIES = [
'military-installations',
];
test('Contacts labels a capped cohort as a lower bound', () => {
assert.equal(
formatAwarenessCount({ count: 20_000, complete: false, truncated: true }),
'At least 20,000',
);
assert.equal(
formatAwarenessCount({ count: 19_999, complete: true, truncated: false }),
'19,999',
);
assert.equal(formatAwarenessCount({ count: null }), '?');
});
function installAwarenessRuntime({
isEnabled = () => true,
isEffectivelyEnabled = isEnabled,
@@ -2554,6 +2567,7 @@ test('the contacts window reports exactly the counts the panel renders', () => {
assert.equal(window.flights, 42);
assert.equal(window.military, 13);
assert.equal(window.aircraft, 55);
assert.equal(window.complete, true);
assert.equal(window.centeredOn, 'ASA635');
assert.equal(window.radiusKm, Math.round(AWARENESS_RADIUS_M / 1000));
// A feed that cannot answer says so rather than reporting a confident zero.
@@ -2561,6 +2575,57 @@ test('the contacts window reports exactly the counts the panel renders', () => {
assert.equal(window.vessels, 'unknown');
});
test('the voice query snapshot is a detached copy of the rendered aircraft cohort', async () => {
const subjectPosition = Cesium.Cartesian3.fromDegrees(-97.74, 30.27, 1_000);
const nearbyPosition = Cesium.Cartesian3.fromDegrees(-97.7, 30.3, 1_500);
const restoreCollections = stubAwarenessCollections({
flights: [
{
icao24: 'subject',
id: 'SUBJECT',
position: subjectPosition,
distance: 0,
},
{
icao24: 'nearby',
id: 'NEARBY',
position: nearbyPosition,
distance: 5_000,
},
],
});
const runtime = installAwarenessRuntime();
try {
runtime.dispatch(
'gev:awareness-subject-selected',
awarenessSubject('flights', 'subject', subjectPosition),
);
await nextTurn();
const first = militaryAwarenessLayer.getAircraftQuerySnapshot();
assert.equal(first.cohorts.flights.count, 1);
assert.equal(first.cohorts.flights.complete, true);
assert.equal(first.cohorts.flights.provenance.id, 'flights');
assert.equal(first.cohorts.flights.provenance.enabled, true);
assert.equal(first.cohorts.flights.items[0].icao24, 'nearby');
first.cohorts.flights.items[0].icao24 = 'mutated';
first.cohorts.flights.items[0].position.x = 99;
first.cohorts.flights.provenance.source = 'mutated';
first.cohorts.flights.provenance.stats.coverage = { radiusM: 1 };
first.subject.position.x = 77;
const second = militaryAwarenessLayer.getAircraftQuerySnapshot();
assert.equal(second.cohorts.flights.items[0].icao24, 'nearby');
assert.equal(second.cohorts.flights.items[0].position.x, nearbyPosition.x);
assert.notEqual(second.cohorts.flights.provenance.source, 'mutated');
assert.notDeepEqual(second.cohorts.flights.provenance.stats.coverage, {
radiusM: 1,
});
assert.equal(second.subject.position.x, subjectPosition.x);
} finally {
runtime.restore();
restoreCollections();
}
});
test('there is no contacts window without a subject', () => {
assert.equal(contactsWindowFromSnapshot(null), null);
assert.equal(contactsWindowFromSnapshot({ cohorts: [] }), null);
+17
View File
@@ -65,6 +65,23 @@ export function unregisterPickOwner(layerId) {
_owners.delete(layerId);
}
/**
* The registered layer that owns a picked id, or null.
* @param {string} pickedId - Canonical pick id from {@link resolvePickId}.
* @returns {string|null} Owning layer id.
*/
export function findPickOwner(pickedId) {
if (!pickedId) return null;
for (const [ownerId, predicate] of _owners) {
try {
if (predicate(pickedId)) return ownerId;
} catch {
// a broken predicate must never break picking
}
}
return null;
}
/**
* True when some OTHER registered layer owns the picked id.
* @param {string} layerId - The asking layer's id (excluded from the scan).
+128
View File
@@ -0,0 +1,128 @@
import { getContextStore, isContextRecordActive } from './contextStore.js';
import { findPickOwner, resolvePickId } from './pickRegistry.js';
/**
* Resolve a `scene.pick()` result to the identity a person would name: which
* layer, which record, and its display label.
*
* Two lanes, in order:
* 1. Context-store carriers. Entity-backed layers (infrastructure GeoJSON,
* selected vessels, fires, installations) stamp `__gevContextId` on the
* picked object, and the store record already carries the label and
* position voice uses everywhere else.
* 2. Pick owners. Primitive-backed layers (aircraft, satellites, vessels)
* register an ownership predicate in `pickRegistry`; the owning layer's
* `findByQuery` turns the pick id into a live descriptor.
*
* Trails claim their pick ids only to make clicks inert, so they never
* resolve. Disabled layers never resolve: what the user cannot see is not
* what they are pointing at.
*/
const KIND_BY_LAYER = Object.freeze({
flights: 'aircraft',
military: 'aircraft',
'local-adsb': 'aircraft',
'ais-live-vessels': 'vessel',
satellites: 'satellite',
'local-firms': 'fire',
});
const IGNORED_OWNERS = new Set(['trails']);
function text(value) {
return String(value ?? '').trim();
}
function finite(value) {
if (value === null || value === undefined || value === '') return null;
const number = Number(value);
return Number.isFinite(number) ? number : null;
}
/** Kind of thing a layer holds, for deixis ("this aircraft", "this place"). */
export function pickedKindForLayer(layerId) {
return KIND_BY_LAYER[layerId] || 'feature';
}
function contextCarrierRecord(picked, dataManager) {
const store = getContextStore();
for (const carrier of [
picked?.id,
picked?.primitive?.id,
picked?.primitive,
]) {
const contextId =
carrier && typeof carrier === 'object' ? carrier.__gevContextId : null;
if (!contextId) continue;
const record = store.entities.get(contextId);
if (record && isContextRecordActive(record, dataManager)) return record;
}
return null;
}
function fromRecord(record) {
const layerId = record.layerId || null;
const id =
layerId === 'ais-live-vessels' && record.properties?.mmsi
? String(record.properties.mmsi)
: String(record.id);
return {
layerId,
id,
label:
text(record.label) ||
text(record.properties?.name) ||
text(record.layerName) ||
id,
kind: pickedKindForLayer(layerId),
lat: finite(record.latitude),
lon: finite(record.longitude),
};
}
function descriptorLabel(found, fallback) {
return (
text(found?.callsign) ||
text(found?.registration) ||
text(found?.name) ||
text(found?.icao24) ||
text(found?.mmsi) ||
text(found?.noradId) ||
fallback
);
}
/**
* @param {object|null} picked Result of `scene.pick()`.
* @param {object} [options]
* @param {object} [options.dataManager] Layer lifecycle (enabled state, modules).
* @returns {{layerId: string, id: string, label: string, kind: string, lat: number|null, lon: number|null, altitudeM?: number|null}|null}
*/
export function resolvePickedContext(picked, { dataManager = null } = {}) {
if (!picked) return null;
const record = contextCarrierRecord(picked, dataManager);
if (record) return fromRecord(record);
const pickId = resolvePickId(picked);
if (!pickId) return null;
const layerId = findPickOwner(pickId);
if (!layerId || IGNORED_OWNERS.has(layerId)) return null;
if (dataManager?.isEnabled && !dataManager.isEnabled(layerId)) return null;
const layer = dataManager?.layers?.get?.(layerId);
let found = null;
try {
found = layer?.module?.findByQuery?.(pickId) || null;
} catch {
found = null;
}
return {
layerId,
id: pickId,
label: descriptorLabel(found, text(layer?.name) || pickId),
kind: pickedKindForLayer(layerId),
lat: finite(found?.latitude),
lon: finite(found?.longitude),
altitudeM: finite(found?.altitudeM),
};
}
+75
View File
@@ -0,0 +1,75 @@
import assert from 'node:assert/strict';
import test from 'node:test';
globalThis.window ||= globalThis;
globalThis.CustomEvent ||= class CustomEvent {
constructor(type, init) {
this.type = type;
this.detail = init?.detail;
}
};
globalThis.dispatchEvent ||= () => true;
const { registerEntityContext } = await import('./contextStore.js');
const { registerPickOwner, unregisterPickOwner, findPickOwner } = await import('./pickRegistry.js');
const { resolvePickedContext, pickedKindForLayer } = await import('./pickedContext.js');
function manager({ enabled = new Set(['flights', 'local-datacenters']), modules = {} } = {}) {
return {
isEnabled: (id) => enabled.has(id),
layers: new Map(Object.entries(modules).map(([id, module]) => [id, { name: id, module }])),
};
}
test('an aircraft billboard resolves through its pick owner to a live label', () => {
registerPickOwner('flights', (id) => id === 'a1b2c3');
const dataManager = manager({
modules: {
flights: {
findByQuery: (q) => (q === 'a1b2c3' ? { icao24: 'a1b2c3', callsign: 'UPS793', latitude: 30.2, longitude: -97.7, altitudeM: 9000 } : null),
},
},
});
const picked = { id: 'a1b2c3', primitive: {} };
assert.equal(findPickOwner('a1b2c3'), 'flights');
assert.deepEqual(resolvePickedContext(picked, { dataManager }), {
layerId: 'flights',
id: 'a1b2c3',
label: 'UPS793',
kind: 'aircraft',
lat: 30.2,
lon: -97.7,
altitudeM: 9000,
});
unregisterPickOwner('flights');
});
test('an entity-backed feature resolves through the context store', () => {
const entity = { show: true };
registerEntityContext(entity, {
id: 'local-datacenters:7',
layerId: 'local-datacenters',
label: 'Data Center 7',
latitude: 30.1,
longitude: -97.6,
});
const out = resolvePickedContext({ id: entity }, { dataManager: manager() });
assert.deepEqual(out, {
layerId: 'local-datacenters',
id: 'local-datacenters:7',
label: 'Data Center 7',
kind: 'feature',
lat: 30.1,
lon: -97.6,
});
});
test('disabled layers, trails and empty picks never resolve', () => {
registerPickOwner('military', (id) => id === 'm1');
assert.equal(resolvePickedContext({ id: 'm1' }, { dataManager: manager() }), null, 'military is disabled');
unregisterPickOwner('military');
assert.equal(resolvePickedContext({ id: 'gev-trail:1' }, { dataManager: manager() }), null);
assert.equal(resolvePickedContext(null), null);
assert.equal(resolvePickedContext({ id: 'unknown' }, { dataManager: manager() }), null);
assert.equal(pickedKindForLayer('ais-live-vessels'), 'vessel');
});
+2 -1
View File
@@ -7,6 +7,7 @@ import { createBhoteKoshiLocatorLayer } from './bhoteKoshiLocator.js';
import { createWorldAnnotationRenderer } from '../annotations/worldAnnotationRenderer.js';
import * as annotations from '../annotations/worldAnnotationRenderer.js';
import { createDirectionsLayer } from '../layers/directions/index.js';
import { createDirectionsSource } from '../layers/directions/source.js';
import { createLifecycle as createCctvLifecycle } from '../layers/cctv/lifecycle.js';
import * as input from './inputOwnership.js';
import { createOverpassAlprSource } from '../layers/alpr/source.js';
@@ -64,7 +65,7 @@ test('voice route geometry introduces the shared OSM credit', (t) => {
test('Directions introduces the shared OSM credit when the requested route draws', async (t) => {
const v = viewer(t);
t.mock.method(globalThis, 'fetch', async () => Response.json({ ok: true, geometry: [[0, 0], [0.01, 0.01]], steps: [] }));
const layer = createDirectionsLayer({ services: {
const layer = createDirectionsLayer({ source: createDirectionsSource(), services: {
credits: { showOsmCredit, hideOsmCredit }, annotations, input, scenePick: {}, camera: {},
overlays: { setOverlayEntries: noop, setOverlaySourceVisible: noop, clearOverlaySource: noop },
render: { governorRequestRender: noop, holdContinuousRender: noop, releaseContinuousRender: noop },