mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
Retain upstream changes to the panel controller, formatting scope, changelog and docs. Preserve the #804 ownership fix and its rail restoration regression (original report: @raysung-2026).
This commit is contained in:
commit
19372875e2
632 files changed
+93502
-5546
No files matched your search
@@ -0,0 +1,236 @@
|
||||
// Area annotations draw bundled state/province/county outlines with no
|
||||
// network lookup, and multi-part outlines reach the renderer intact.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { FEATURE_SOURCE_METHODS } from '../sources/featureSource.js';
|
||||
import { createAnnotationResolver } from './resolver.js';
|
||||
import { createAnnotationEngine } from './annotationEngine.js';
|
||||
import { _resetRenderGovernorForTest } from '../renderGovernor.js';
|
||||
|
||||
function viewerAt(lat, lon, height = 20_000) {
|
||||
return {
|
||||
camera: {
|
||||
positionCartographic: {
|
||||
latitude: (lat * Math.PI) / 180,
|
||||
longitude: (lon * Math.PI) / 180,
|
||||
height,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A resolver whose boundary lookups and geocoder record every call. */
|
||||
function harness(place = null) {
|
||||
const lookups = [];
|
||||
const featureSource = {};
|
||||
for (const method of FEATURE_SOURCE_METHODS)
|
||||
featureSource[method] = async () => {
|
||||
lookups.push(method);
|
||||
return null;
|
||||
};
|
||||
const geocodes = [];
|
||||
const placeSearch = {
|
||||
async geocode(query) {
|
||||
geocodes.push(query);
|
||||
return { place };
|
||||
},
|
||||
async textSearch() {
|
||||
geocodes.push('text-search');
|
||||
return [];
|
||||
},
|
||||
};
|
||||
const { resolveAnnotationTarget } = createAnnotationResolver({
|
||||
featureSource,
|
||||
});
|
||||
const resolve = (target, viewer, extra = {}) =>
|
||||
resolveAnnotationTarget({
|
||||
placeSearch,
|
||||
viewer,
|
||||
target,
|
||||
footprint: true,
|
||||
deferFootprint: true,
|
||||
...extra,
|
||||
});
|
||||
return { resolve, lookups, geocodes };
|
||||
}
|
||||
|
||||
const AUSTIN = viewerAt(30.2672, -97.7431, 3000);
|
||||
|
||||
test('"Texas", "Travis County, Texas" and "Bavaria" outline offline', async () => {
|
||||
const { resolve, lookups, geocodes } = harness();
|
||||
for (const [target, label] of [
|
||||
['Texas', 'Texas'],
|
||||
['Travis County, Texas', 'Travis County'],
|
||||
// Far from the camera, but named outright: no proximity gate applies.
|
||||
['Bavaria', 'Bavaria'],
|
||||
]) {
|
||||
const resolved = await resolve(target, AUSTIN);
|
||||
assert.ok(resolved, target);
|
||||
assert.equal(resolved.source, 'bundled', target);
|
||||
assert.equal(resolved.label, label);
|
||||
assert.equal(resolved.footprintKind, 'area');
|
||||
assert.equal(resolved.synthesized, false);
|
||||
assert.equal(resolved.resolveOutline, undefined, 'drawn at once');
|
||||
assert.ok(resolved.ring.length >= 9, `${target} ring`);
|
||||
assert.deepEqual(resolved.ring[0], resolved.ring.at(-1), 'ring closed');
|
||||
assert.ok(resolved.polygons.length >= 1, `${target} parts`);
|
||||
assert.equal(resolved.polygons[0][0].length + 1, resolved.ring.length);
|
||||
assert.ok(resolved.viewport?.low && resolved.viewport?.high);
|
||||
}
|
||||
assert.deepEqual(geocodes, [], 'no geocoding');
|
||||
assert.deepEqual(lookups, [], 'no boundary lookups');
|
||||
});
|
||||
|
||||
test('duplicate county names go to the one near the camera', async () => {
|
||||
const { resolve } = harness();
|
||||
const nearPortland = await resolve(
|
||||
'Washington County',
|
||||
viewerAt(45.52, -122.68),
|
||||
);
|
||||
assert.ok(nearPortland.lat > 45 && nearPortland.lon < -122, 'Oregon');
|
||||
const nearAustin = await resolve('Washington County', AUSTIN);
|
||||
assert.ok(nearAustin.lat > 29.9 && nearAustin.lat < 30.5, 'Texas');
|
||||
assert.ok(nearAustin.lon > -96.8 && nearAustin.lon < -96, 'Texas');
|
||||
});
|
||||
|
||||
test('multi-part outlines carry every part and hole', async () => {
|
||||
const { resolve } = harness();
|
||||
const hawaii = await resolve('Hawaii', AUSTIN);
|
||||
assert.ok(hawaii.polygons.length >= 7, 'islands');
|
||||
const brandenburg = await resolve('Brandenburg', AUSTIN);
|
||||
assert.ok(
|
||||
brandenburg.polygons.some((part) => part.length === 2),
|
||||
'Berlin hole',
|
||||
);
|
||||
});
|
||||
|
||||
test('Georgia resolves from camera geography without a geocoder', async () => {
|
||||
const { resolve, lookups, geocodes } = harness();
|
||||
for (const [query, viewer, latitude] of [
|
||||
['Georgia', viewerAt(33.75, -84.39, 400_000), 32],
|
||||
['the country of Georgia', AUSTIN, 42],
|
||||
['Georgia', viewerAt(41.7, 44.8), 42],
|
||||
]) {
|
||||
const resolved = await resolve(query, viewer);
|
||||
assert.equal(resolved.source, 'bundled');
|
||||
assert.ok(Math.abs(resolved.lat - latitude) < 2);
|
||||
assert.ok(resolved.ring.length > 20);
|
||||
}
|
||||
assert.deepEqual(lookups, []);
|
||||
assert.deepEqual(geocodes, []);
|
||||
});
|
||||
|
||||
test('country asks and geocoded country scopes use the bundled outline', async () => {
|
||||
const direct = harness();
|
||||
for (const name of ['Switzerland', 'Iran', 'France', 'Japan']) {
|
||||
const resolved = await direct.resolve(name, AUSTIN);
|
||||
assert.equal(resolved.source, 'bundled');
|
||||
assert.ok(resolved.polygons.length);
|
||||
if (name === 'Japan') assert.ok(resolved.polygons.length > 3);
|
||||
}
|
||||
assert.deepEqual(direct.lookups, []);
|
||||
assert.deepEqual(direct.geocodes, []);
|
||||
const geocoded = harness({
|
||||
lat: 46.95,
|
||||
lng: 7.45,
|
||||
label: 'Switzerland',
|
||||
name: 'Switzerland',
|
||||
types: ['country', 'political'],
|
||||
});
|
||||
const resolved = await geocoded.resolve(
|
||||
'Swiss country outline',
|
||||
viewerAt(46.95, 7.45),
|
||||
);
|
||||
assert.equal(resolved.source, 'geocode');
|
||||
const outline = await resolved.resolveOutline();
|
||||
assert.ok(outline.polygons.length);
|
||||
assert.deepEqual(geocoded.lookups, []);
|
||||
});
|
||||
|
||||
test('non-admin asks and entity facts keep the existing path', async () => {
|
||||
const { resolve, geocodes } = harness();
|
||||
await resolve('Texas Capitol', AUSTIN);
|
||||
await resolve('Texas', AUSTIN, { entityKind: 'building' });
|
||||
await resolve('Zilker Park', AUSTIN);
|
||||
assert.equal(
|
||||
geocodes.filter((q) => q !== 'text-search').length,
|
||||
3,
|
||||
'each ask geocoded',
|
||||
);
|
||||
// Points and explicit coordinates never use the pack.
|
||||
const point = await resolve('Texas', AUSTIN, { footprint: false });
|
||||
assert.notEqual(point?.source, 'bundled');
|
||||
});
|
||||
|
||||
test('the engine keeps every part on the mark, drawn at once or upgraded', async (t) => {
|
||||
globalThis.requestAnimationFrame ??= () => 1;
|
||||
globalThis.cancelAnimationFrame ??= () => {};
|
||||
_resetRenderGovernorForTest();
|
||||
t.after(() => _resetRenderGovernorForTest());
|
||||
const polygons = [
|
||||
[
|
||||
[
|
||||
[0, 0],
|
||||
[2, 0],
|
||||
[2, 2],
|
||||
[0, 2],
|
||||
],
|
||||
[
|
||||
[0.5, 0.5],
|
||||
[0.5, 1],
|
||||
[1, 1],
|
||||
[1, 0.5],
|
||||
],
|
||||
],
|
||||
[
|
||||
[
|
||||
[3, 3],
|
||||
[4, 3],
|
||||
[4, 4],
|
||||
],
|
||||
],
|
||||
];
|
||||
const ring = [...polygons[0][0], polygons[0][0][0]];
|
||||
const seen = [];
|
||||
const renderer = {
|
||||
add: (anno) => seen.push(['add', anno.polygons]),
|
||||
update: (anno) => seen.push(['update', anno.polygons]),
|
||||
remove() {},
|
||||
sync() {},
|
||||
destroy() {},
|
||||
};
|
||||
const engine = createAnnotationEngine({
|
||||
viewer: {},
|
||||
renderer,
|
||||
resolveTarget: async ({ target }) =>
|
||||
target === 'now'
|
||||
? { lon: 1, lat: 1, height: 0, ring, polygons, footprintKind: 'area' }
|
||||
: {
|
||||
lon: 1,
|
||||
lat: 1,
|
||||
height: 0,
|
||||
ring: null,
|
||||
resolveOutline: async () => ({
|
||||
ring,
|
||||
polygons,
|
||||
footprintKind: 'area',
|
||||
lat: 1,
|
||||
lon: 1,
|
||||
height: 0,
|
||||
}),
|
||||
},
|
||||
});
|
||||
await engine.annotate([{ type: 'area', target: 'now' }]);
|
||||
await engine.annotate([{ type: 'area', target: 'later', label: 'later' }]);
|
||||
for (let i = 0; i < 20; i += 1) await Promise.resolve();
|
||||
const marks = engine.list();
|
||||
assert.equal(marks[0].polygons, polygons);
|
||||
assert.ok(
|
||||
marks.some((m) => m.label === 'later' && m.polygons === polygons),
|
||||
'upgrade carries the parts',
|
||||
);
|
||||
assert.ok(
|
||||
seen.some(([kind, parts]) => kind === 'update' && parts === polygons),
|
||||
);
|
||||
engine.destroy();
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isUnavailableCapability } from '../sources/capability.js';
|
||||
import { defaultGeospatial } from '../search/defaults.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
@@ -81,6 +82,7 @@ export async function resolveOutlineWithRetry(
|
||||
} catch {
|
||||
fp = null; // hard failure — definitive, keep the honest point
|
||||
}
|
||||
if (isUnavailableCapability(fp)) return fp;
|
||||
if (isRateLimitedOutcome(fp)) {
|
||||
// A throttle gets one deliberately spaced retry. If that retry is throttled
|
||||
// too, stop this mark's outline task instead of replaying the batch storm.
|
||||
@@ -333,6 +335,7 @@ export function createAnnotationEngine({
|
||||
// refreshes it — usually instantly from the footprint cache.
|
||||
if (anno.pendingOutline && !anno.ring && dup.ring) {
|
||||
anno.ring = dup.ring;
|
||||
anno.polygons = dup.polygons || null;
|
||||
anno.footprintKind = dup.footprintKind || null;
|
||||
anno.buildingHeight = dup.buildingHeight || null;
|
||||
anno.synthesized = Boolean(dup.synthesized);
|
||||
@@ -673,8 +676,11 @@ export function createAnnotationEngine({
|
||||
if (myGen !== generation || controller.signal.aborted) return; // board superseded
|
||||
if (!annotations.has(anno.id)) return; // mark replaced/removed while resolving
|
||||
anno.pendingOutline = false;
|
||||
if (fp) {
|
||||
anno.outlineUnavailable = isUnavailableCapability(fp);
|
||||
if (anno.outlineUnavailable) renderer.update(anno);
|
||||
if (fp && !anno.outlineUnavailable) {
|
||||
anno.ring = fp.ring;
|
||||
anno.polygons = fp.polygons || null;
|
||||
anno.footprintKind = fp.footprintKind || null;
|
||||
anno.buildingHeight = fp.buildingHeight || null;
|
||||
anno.synthesized = Boolean(fp.synthesized);
|
||||
@@ -725,7 +731,14 @@ export function createAnnotationEngine({
|
||||
id: anno.id,
|
||||
label: anno.label || null,
|
||||
target: anno.targetKey || null,
|
||||
status: fp ? 'resolved' : 'failed',
|
||||
status: anno.outlineUnavailable
|
||||
? 'unavailable'
|
||||
: fp
|
||||
? 'resolved'
|
||||
: 'failed',
|
||||
...(anno.outlineUnavailable
|
||||
? { message: 'Detailed outline unavailable' }
|
||||
: {}),
|
||||
...(fp && anno.synthesized ? { approximate: true } : {}),
|
||||
});
|
||||
} finally {
|
||||
@@ -753,6 +766,9 @@ export function createAnnotationEngine({
|
||||
// layer narrate without waiting out a slow Overpass — and without calling the
|
||||
// missing outline a failure.
|
||||
...(anno.pendingOutline ? { outlinePending: true } : {}),
|
||||
...(anno.outlineUnavailable
|
||||
? { outlineUnavailable: true, message: 'Detailed outline unavailable' }
|
||||
: {}),
|
||||
// approximate=true means the area was SYNTHESIZED (a buffered blob around a label
|
||||
// point), not a real OSM boundary — so the voice layer can be honest.
|
||||
...(anno.synthesized ? { approximate: true } : {}),
|
||||
@@ -919,6 +935,9 @@ export function createAnnotationEngine({
|
||||
anchor: { lon: resolved.lon, lat: resolved.lat, height: resolved.height },
|
||||
to: null,
|
||||
ring: resolved.ring || null,
|
||||
// Every part with its holes ([outer, ...holes][]), when the outline has
|
||||
// more than the main ring (Hawaii's islands, Berlin inside Brandenburg).
|
||||
polygons: resolved.polygons || null,
|
||||
footprintKind: resolved.footprintKind || null, // 'building' | 'area'
|
||||
buildingHeight: resolved.buildingHeight || null, // meters, for extruded volume
|
||||
synthesized: Boolean(resolved.synthesized), // approximate buffered area → dashed render
|
||||
@@ -926,6 +945,7 @@ export function createAnnotationEngine({
|
||||
// the upgrade task fills ring/kind in place when it lands. Transient render state
|
||||
// (not persisted).
|
||||
pendingOutline: typeof resolved.resolveOutline === 'function',
|
||||
outlineUnavailable: Boolean(resolved.outlineUnavailable),
|
||||
// Which THING + SHAPE was asked for — the dedup identity while geometry is still
|
||||
// pending (see findDuplicate). targetKey is the normalized place name with trailing
|
||||
// locality qualifiers stripped ("California, United States" ≡ "California"; null for
|
||||
@@ -1047,7 +1067,11 @@ export function createAnnotationEngine({
|
||||
// Places viewport box when we have one, so a big compound isn't framed at
|
||||
// building scale while its outline is traced. Never re-fly when the ring lands.
|
||||
const range = anno.ring
|
||||
? ringRange(anno.ring)
|
||||
? ringRange(
|
||||
anno.polygons?.length
|
||||
? anno.polygons.flatMap((poly) => poly[0])
|
||||
: anno.ring,
|
||||
)
|
||||
: viewportRange(anno.viewport) || 600;
|
||||
viewer.camera.flyToBoundingSphere(
|
||||
new Cesium.BoundingSphere(
|
||||
@@ -1388,7 +1412,11 @@ function ringRange(ring) {
|
||||
let maxLat = -Infinity;
|
||||
let minLon = Infinity;
|
||||
let maxLon = -Infinity;
|
||||
for (const [lon, lat] of ring) {
|
||||
// Unwrap around the first vertex so parts cut at the antimeridian (the
|
||||
// Aleutians) measure as neighbours, not as a globe-wide span.
|
||||
const ref = ring[0]?.[0] ?? 0;
|
||||
for (const [rawLon, lat] of ring) {
|
||||
const lon = rawLon - 360 * Math.round((rawLon - ref) / 360);
|
||||
if (lat < minLat) minLat = lat;
|
||||
if (lat > maxLat) maxLat = lat;
|
||||
if (lon < minLon) minLon = lon;
|
||||
|
||||
@@ -254,7 +254,9 @@ test('retry: HTTP 429 Retry-After 5s gets one ladder-spaced retry; a second 429
|
||||
t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now: 10_000 });
|
||||
globalThis.window = { setTimeout: globalThis.setTimeout, clearTimeout: globalThis.clearTimeout };
|
||||
const requestTimes = [];
|
||||
globalThis.fetch = async () => {
|
||||
globalThis.fetch = async (url) => {
|
||||
// The one-time capability probe is not an outline request.
|
||||
if (String(url).endsWith('/status')) return httpFailure(404);
|
||||
requestTimes.push(Date.now());
|
||||
return httpFailure(429, '5');
|
||||
};
|
||||
|
||||
@@ -77,7 +77,11 @@ export function createHybridAnnotationRenderer(viewer) {
|
||||
add(anno);
|
||||
return;
|
||||
}
|
||||
if (anno.type !== 'area' || !anno.ring || anno.ring.length < 3) return;
|
||||
if (anno.type !== 'area' || !anno.ring || anno.ring.length < 3) {
|
||||
if (anno.outlineUnavailable && entry.screenProxy)
|
||||
screen.update(entry.screenProxy);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!entry.worldProxy) {
|
||||
entry.worldProxy = liveProxy(anno, { label: null });
|
||||
|
||||
@@ -10,20 +10,33 @@ function featureSource(overrides = {}) {
|
||||
return Object.assign(source, overrides);
|
||||
}
|
||||
|
||||
const texasGeocode = {
|
||||
// A state-typed place outside every bundled boundary, so the admin-boundary
|
||||
// feature lookup (not the bundled packs) answers.
|
||||
const offPackGeocode = {
|
||||
async geocode() {
|
||||
return {
|
||||
place: {
|
||||
lat: 31.0,
|
||||
lng: -99.9,
|
||||
label: 'Texas, USA',
|
||||
name: 'Texas',
|
||||
lat: 0.5,
|
||||
lng: -150.5,
|
||||
label: 'Atlantis',
|
||||
name: 'Atlantis',
|
||||
types: ['administrative_area_level_1', 'political'],
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
function geocoderFor(place) {
|
||||
const calls = [];
|
||||
return {
|
||||
calls,
|
||||
async geocode(query) {
|
||||
calls.push(query);
|
||||
return { place };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('region ring: Natural Earth names resolve without a geocoder', async () => {
|
||||
const { resolveRegionRingForQuery } = createAnnotationResolver({
|
||||
featureSource: featureSource(),
|
||||
@@ -50,13 +63,13 @@ test('region ring: a slow admin-boundary lookup returns region-timeout within th
|
||||
});
|
||||
const started = Date.now();
|
||||
const region = await resolveRegionRingForQuery(
|
||||
'Texas',
|
||||
'Atlantis',
|
||||
undefined,
|
||||
texasGeocode,
|
||||
offPackGeocode,
|
||||
{ budgetMs: 50 },
|
||||
);
|
||||
assert.deepEqual(region, {
|
||||
name: 'Texas',
|
||||
name: 'Atlantis',
|
||||
ring: null,
|
||||
error: 'region-timeout',
|
||||
});
|
||||
@@ -77,10 +90,64 @@ test('region ring: an unbounded budget waits for the lookup', async () => {
|
||||
}),
|
||||
});
|
||||
const region = await resolveRegionRingForQuery(
|
||||
'Texas',
|
||||
'Atlantis',
|
||||
undefined,
|
||||
texasGeocode,
|
||||
offPackGeocode,
|
||||
{ budgetMs: Infinity },
|
||||
);
|
||||
assert.equal(region, null);
|
||||
});
|
||||
|
||||
test('region ring: bundled states and counties resolve without a geocoder', async () => {
|
||||
const lookups = [];
|
||||
const { resolveRegionRingForQuery } = createAnnotationResolver({
|
||||
featureSource: featureSource({
|
||||
getAdministrativeAreas: async () => {
|
||||
lookups.push('admin');
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
});
|
||||
const geocoder = geocoderFor(null);
|
||||
for (const [query, name] of [
|
||||
['Texas', 'Texas'],
|
||||
['Bavaria', 'Bavaria'],
|
||||
['Travis County, Texas', 'Travis County'],
|
||||
]) {
|
||||
const region = await resolveRegionRingForQuery(query, undefined, geocoder);
|
||||
assert.equal(region?.name, name, query);
|
||||
assert.ok(region.ring.length >= 8, `${query} has a real ring`);
|
||||
}
|
||||
assert.deepEqual(geocoder.calls, [], 'no geocoding');
|
||||
assert.deepEqual(lookups, [], 'no boundary lookups');
|
||||
});
|
||||
|
||||
test('region ring: a geocoder-typed state resolves from the bundled pack', async () => {
|
||||
const lookups = [];
|
||||
const { resolveRegionRingForQuery } = createAnnotationResolver({
|
||||
featureSource: featureSource({
|
||||
getAdministrativeAreas: async () => {
|
||||
lookups.push('admin');
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
});
|
||||
// "Georgia" alone is also a country, so the words don't settle it; the
|
||||
// geocoder's state type and point inside the US state do.
|
||||
const geocoder = geocoderFor({
|
||||
lat: 32.16,
|
||||
lng: -82.9,
|
||||
label: 'Georgia, USA',
|
||||
name: 'Georgia',
|
||||
types: ['administrative_area_level_1', 'political'],
|
||||
});
|
||||
const region = await resolveRegionRingForQuery(
|
||||
'Georgia',
|
||||
undefined,
|
||||
geocoder,
|
||||
);
|
||||
assert.equal(region?.name, 'Georgia');
|
||||
assert.ok(region.ring.length >= 8);
|
||||
assert.deepEqual(geocoder.calls, ['Georgia']);
|
||||
assert.deepEqual(lookups, [], 'no boundary lookups');
|
||||
});
|
||||
+157
-13
@@ -1,3 +1,4 @@
|
||||
import { isUnavailableCapability } from '../sources/capability.js';
|
||||
import { requireFeatureSource } from '../sources/featureSource.js';
|
||||
import { createOverpassFeatureSource } from '../sources/overpassFeatures.js';
|
||||
import {
|
||||
@@ -18,9 +19,11 @@ import {
|
||||
lookupNaturalRegionOutline,
|
||||
findNaturalRegion,
|
||||
} from '../data/naturalEarthRegions.js';
|
||||
import { findAdminArea, findAdminAreaAt } from '../data/adminBoundaries.js';
|
||||
import {
|
||||
registerDynamicCredit,
|
||||
NATURAL_EARTH_CREDIT,
|
||||
US_CENSUS_CREDIT,
|
||||
} from '../data/dataCredits.js';
|
||||
import { unavailablePlaceSearch } from '../search/placeSearch.js';
|
||||
import { isPickedWorldPosition } from '../data/scenePick.js';
|
||||
@@ -28,6 +31,14 @@ import { isPickedWorldPosition } from '../data/scenePick.js';
|
||||
/** Ms the analyst region lookup waits on geocode + admin boundary. */
|
||||
export const REGION_FALLBACK_BUDGET_MS = 3_000;
|
||||
|
||||
/** Entity facts that rule out a state/county reading of the ask. */
|
||||
const NON_ADMIN_ENTITY_KINDS = new Set([
|
||||
'building',
|
||||
'compound',
|
||||
'street',
|
||||
'point_feature',
|
||||
]);
|
||||
|
||||
/** Own annotation lookup caches and ranking of supplied feature candidates. */
|
||||
export function createAnnotationResolver({
|
||||
boundarySource,
|
||||
@@ -176,6 +187,23 @@ export function createAnnotationResolver({
|
||||
// ("the Texas Capitol" → the state), so geocode types must never grant it.
|
||||
const bypassNearViewGuards = Boolean(adminScopeFromAsk(target, entityKind));
|
||||
|
||||
// Bundled administrative outlines resolve settled names without a lookup.
|
||||
// Georgia uses its qualifier or camera; city/state homonyms defer to geocoding.
|
||||
if (
|
||||
footprint &&
|
||||
(!Number.isFinite(lat) || !Number.isFinite(lon)) &&
|
||||
trace.query &&
|
||||
!NON_ADMIN_ENTITY_KINDS.has(entityKind)
|
||||
) {
|
||||
const center =
|
||||
pickWorldFromScreen(viewer, 0.5, 0.5) || viewportProximity(viewer);
|
||||
const admin = await findAdminArea(trace.query, { near: center }).catch(
|
||||
() => null,
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
if (admin) return bundledAdminTarget(viewer, admin, trace.query);
|
||||
}
|
||||
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
|
||||
const query = String(target || '').trim();
|
||||
if (query) {
|
||||
@@ -474,8 +502,32 @@ export function createAnnotationResolver({
|
||||
// GROUNDS-like ask must not short-circuit here: the model phrases "the Capitol
|
||||
// grounds" as around_the_thing, but the grounds ARE the thing — the real enclosing
|
||||
// polygon (below) beats a 400 m disc (field test 8's "spherical round one").
|
||||
const availability = await fetchEnclosingArea(
|
||||
lat,
|
||||
lon,
|
||||
signal,
|
||||
matchName,
|
||||
);
|
||||
if (
|
||||
isUnavailableCapability(availability) ||
|
||||
isRateLimitedOutcome(availability)
|
||||
)
|
||||
return availability;
|
||||
if (availability === undefined) return undefined;
|
||||
fp = synthesizeBufferedArea(lat, lon, AROUND_LANDMARK_RADIUS_M);
|
||||
} else if (isAdmin) {
|
||||
// A country, state or county the geocoder typed: the bundled unit that carries the
|
||||
// name AND contains the geocoded point (offline; the pack's ambiguity
|
||||
// marks don't apply — the geocoder already chose "Georgia" the state).
|
||||
if (scope === 'state' || scope === 'county' || scope === 'country') {
|
||||
const admin = await findAdminAreaAt(
|
||||
[target, matchName],
|
||||
lat,
|
||||
lon,
|
||||
scope,
|
||||
).catch(() => null);
|
||||
if (admin) return bundledAdminOutline(viewer, admin);
|
||||
}
|
||||
// Pure admin: only an admin boundary is correct — never fall back to a
|
||||
// building/landuse (a city is never a single building).
|
||||
fp = await fetchAdminArea(lat, lon, matchName, scope, signal);
|
||||
@@ -501,6 +553,7 @@ export function createAnnotationResolver({
|
||||
// A neighborhood whose canonical name carries a city suffix ("Presidio of San
|
||||
// Francisco") can match the CITY admin; treat an oversized admin as a DEFINITIVE
|
||||
// no-neighborhood-polygon so it still falls through to the named-landuse path.
|
||||
if (isUnavailableCapability(adminFp)) return adminFp;
|
||||
if (adminFp && exceedsScopeArea(adminFp, scope)) adminFp = null;
|
||||
if (adminFp) {
|
||||
fp = adminFp;
|
||||
@@ -515,6 +568,7 @@ export function createAnnotationResolver({
|
||||
signal,
|
||||
'strict',
|
||||
);
|
||||
if (isUnavailableCapability(footFp)) return footFp;
|
||||
if (footFp) {
|
||||
fp = footFp;
|
||||
} else if (footFp === null) {
|
||||
@@ -531,6 +585,7 @@ export function createAnnotationResolver({
|
||||
signal,
|
||||
'loose',
|
||||
);
|
||||
if (isUnavailableCapability(looseFp)) return looseFp;
|
||||
if (looseFp && looseFp.kind !== 'building') fp = looseFp;
|
||||
else if (looseFp === null)
|
||||
fp = synthesizeBufferedArea(lat, lon, NEIGHBORHOOD_RADIUS_M);
|
||||
@@ -580,6 +635,7 @@ export function createAnnotationResolver({
|
||||
// synthesized disc only when OSM DEFINITIVELY has none.
|
||||
if (groundsLike && (fp === null || fp?.kind === 'building')) {
|
||||
const area = await fetchEnclosingArea(lat, lon, signal, matchName);
|
||||
if (isUnavailableCapability(area)) return area;
|
||||
if (area) {
|
||||
fp = area; // real grounds polygon (synthesized:false) — beats both the dome and a disc
|
||||
// It's a grounds/compound feature (already capped at SCOPE_AREA_CAP_M2.compound inside
|
||||
@@ -619,7 +675,7 @@ export function createAnnotationResolver({
|
||||
) {
|
||||
fp = null;
|
||||
}
|
||||
if (isRateLimitedOutcome(fp)) return fp;
|
||||
if (isUnavailableCapability(fp) || isRateLimitedOutcome(fp)) return fp;
|
||||
if (fp === undefined) return undefined; // TRANSIENT — a backoff retry may still find it
|
||||
if (!fp || !Array.isArray(fp.ring) || fp.ring.length < 3) return null;
|
||||
const centroid = ringCentroid(fp.ring);
|
||||
@@ -646,10 +702,12 @@ export function createAnnotationResolver({
|
||||
let ring = null;
|
||||
let footprintKind = null; // 'building' | 'area'
|
||||
let buildingHeight = null; // meters, only for buildings
|
||||
let outlineUnavailable = false;
|
||||
let synthesized = false; // true = buffered/approximate area, render dashed/feathered
|
||||
if (footprint && !deferFootprint) {
|
||||
const fp = await resolveOutline();
|
||||
if (fp && !isRateLimitedOutcome(fp)) {
|
||||
outlineUnavailable = isUnavailableCapability(fp);
|
||||
if (fp && !outlineUnavailable && !isRateLimitedOutcome(fp)) {
|
||||
ring = fp.ring;
|
||||
footprintKind = fp.footprintKind;
|
||||
buildingHeight = fp.buildingHeight;
|
||||
@@ -680,6 +738,7 @@ export function createAnnotationResolver({
|
||||
label,
|
||||
source,
|
||||
synthesized,
|
||||
outlineUnavailable,
|
||||
viewport: placeViewport,
|
||||
...(footprint && deferFootprint ? { resolveOutline } : {}),
|
||||
};
|
||||
@@ -720,6 +779,65 @@ export function createAnnotationResolver({
|
||||
const GROUNDS_RADIUS_MIN_M = 150; // viewport-derived grounds disc is clamped to this band so a tiny
|
||||
const GROUNDS_RADIUS_MAX_M = 1200; // place can't shrink to a dot, nor a city-wide viewport balloon
|
||||
|
||||
/** Credit the pack a bundled boundary came from. */
|
||||
function creditAdminSource(viewer, admin) {
|
||||
registerDynamicCredit(
|
||||
viewer,
|
||||
admin.source === 'us-census' ? US_CENSUS_CREDIT : NATURAL_EARTH_CREDIT,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Outline patch (resolveOutline's contract) for a bundled administrative unit.
|
||||
* `ring` is the main part, closed; `polygons` carries every part with its
|
||||
* holes (Hawaii's islands, Berlin inside Brandenburg) for renderers that
|
||||
* draw them. Like the Natural Earth rung, it bypasses the scope caps and the
|
||||
* centroid drift bound: the unit IS the asked scope.
|
||||
*/
|
||||
function bundledAdminOutline(viewer, admin) {
|
||||
creditAdminSource(viewer, admin);
|
||||
const { lat, lon } = admin.label;
|
||||
return {
|
||||
ring: closeRing([...admin.ring]), // copy: closeRing mutates, the pack is shared
|
||||
polygons: admin.polygons,
|
||||
footprintKind: 'area',
|
||||
buildingHeight: null,
|
||||
synthesized: false,
|
||||
adminArea: admin.name,
|
||||
lat,
|
||||
lon,
|
||||
height: sampleGroundHeight(viewer, lon, lat),
|
||||
};
|
||||
}
|
||||
|
||||
/** A complete resolved target for a bundled administrative unit (no outline pending). */
|
||||
function bundledAdminTarget(viewer, admin, query) {
|
||||
const outline = bundledAdminOutline(viewer, admin);
|
||||
const [west, south, east, north] = admin.bbox;
|
||||
console.log(
|
||||
`[Resolver] "${query}": bundled ${admin.kind} "${admin.name}"` +
|
||||
`${admin.region ? `, ${admin.region}` : ''} (${admin.source}, ` +
|
||||
`${admin.polygons.length} part(s), ${admin.candidates} candidate(s)) → FINAL source=bundled`,
|
||||
);
|
||||
return {
|
||||
lon: outline.lon,
|
||||
lat: outline.lat,
|
||||
height: outline.height,
|
||||
ring: outline.ring,
|
||||
polygons: outline.polygons,
|
||||
footprintKind: 'area',
|
||||
buildingHeight: null,
|
||||
label: admin.name,
|
||||
source: 'bundled',
|
||||
synthesized: false,
|
||||
outlineUnavailable: false,
|
||||
viewport: {
|
||||
low: { latitude: south, longitude: west },
|
||||
high: { latitude: north, longitude: east },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Synthesize an approximate circular AREA by buffering a label point. Returns a ring
|
||||
* marked `synthesized:true` so the renderers draw it "approximate" (dashed/feathered),
|
||||
@@ -1004,7 +1122,8 @@ export function createAnnotationResolver({
|
||||
{ lat, lon },
|
||||
{ signal: signal },
|
||||
);
|
||||
if (isRateLimitedOutcome(candidates)) return candidates;
|
||||
if (isUnavailableCapability(candidates) || isRateLimitedOutcome(candidates))
|
||||
return candidates;
|
||||
if (!candidates) return undefined; // transient upstream failure (vs null = definitive miss)
|
||||
|
||||
const queryWords = normalizedWords(query);
|
||||
@@ -1067,7 +1186,8 @@ export function createAnnotationResolver({
|
||||
// without first trying the place= polygon (and a named landuse via the caller).
|
||||
if (scope === 'neighborhood' && (!best || bestCoverage < 0.8)) {
|
||||
const place = await fetchPlaceArea(lat, lon, query, signal);
|
||||
if (isRateLimitedOutcome(place)) return place;
|
||||
if (isUnavailableCapability(place) || isRateLimitedOutcome(place))
|
||||
return place;
|
||||
if (place) {
|
||||
cacheWrite(footprintCache, cacheKey, place);
|
||||
return place;
|
||||
@@ -1097,7 +1217,8 @@ export function createAnnotationResolver({
|
||||
const relEls = await featureSource.getAreaGeometry(cand.el.id, {
|
||||
signal: signal,
|
||||
});
|
||||
if (isRateLimitedOutcome(relEls)) return relEls;
|
||||
if (isUnavailableCapability(relEls) || isRateLimitedOutcome(relEls))
|
||||
return relEls;
|
||||
if (relEls === null) {
|
||||
transient = true;
|
||||
break;
|
||||
@@ -1126,7 +1247,8 @@ export function createAnnotationResolver({
|
||||
if (exceedsScopeArea(fp, scope)) {
|
||||
if (scope === 'neighborhood') {
|
||||
const place = await fetchPlaceArea(lat, lon, query, signal);
|
||||
if (isRateLimitedOutcome(place)) return place;
|
||||
if (isUnavailableCapability(place) || isRateLimitedOutcome(place))
|
||||
return place;
|
||||
if (place) {
|
||||
cacheWrite(footprintCache, cacheKey, place);
|
||||
return place;
|
||||
@@ -1149,7 +1271,8 @@ export function createAnnotationResolver({
|
||||
// caller relies on that to gate named-landuse fallback / synthesis.
|
||||
if (scope === 'neighborhood') {
|
||||
const place = await fetchPlaceArea(lat, lon, query, signal);
|
||||
if (isRateLimitedOutcome(place)) return place;
|
||||
if (isUnavailableCapability(place) || isRateLimitedOutcome(place))
|
||||
return place;
|
||||
if (place) {
|
||||
cacheWrite(footprintCache, cacheKey, place);
|
||||
return place;
|
||||
@@ -1171,7 +1294,7 @@ export function createAnnotationResolver({
|
||||
{ lat, lon },
|
||||
{ signal: signal },
|
||||
);
|
||||
if (isRateLimitedOutcome(els)) return els;
|
||||
if (isUnavailableCapability(els) || isRateLimitedOutcome(els)) return els;
|
||||
if (els === null) return undefined; // transient upstream failure (vs [] = no match)
|
||||
let bestRing = null;
|
||||
let bestScore = 0;
|
||||
@@ -1220,7 +1343,8 @@ export function createAnnotationResolver({
|
||||
{ lat, lon },
|
||||
{ signal: signal },
|
||||
);
|
||||
if (isRateLimitedOutcome(areaEls)) return areaEls;
|
||||
if (isUnavailableCapability(areaEls) || isRateLimitedOutcome(areaEls))
|
||||
return areaEls;
|
||||
if (areaEls) {
|
||||
let bestRing = null;
|
||||
let bestScore = 0;
|
||||
@@ -1253,7 +1377,8 @@ export function createAnnotationResolver({
|
||||
{ lat, lon },
|
||||
{ signal: signal },
|
||||
);
|
||||
if (isRateLimitedOutcome(wayEls)) return wayEls;
|
||||
if (isUnavailableCapability(wayEls) || isRateLimitedOutcome(wayEls))
|
||||
return wayEls;
|
||||
if (wayEls) {
|
||||
const segments = [];
|
||||
for (const el of wayEls) {
|
||||
@@ -1326,7 +1451,8 @@ export function createAnnotationResolver({
|
||||
{ lat, lon },
|
||||
{ signal: signal },
|
||||
);
|
||||
if (isRateLimitedOutcome(elements)) return elements;
|
||||
if (isUnavailableCapability(elements) || isRateLimitedOutcome(elements))
|
||||
return elements;
|
||||
if (elements === null || signal?.aborted) return undefined;
|
||||
const fp = selectFootprint(elements, lat, lon, query, mode);
|
||||
if (fp) {
|
||||
@@ -1374,7 +1500,8 @@ export function createAnnotationResolver({
|
||||
{ lat, lon },
|
||||
{ signal: signal },
|
||||
);
|
||||
if (isRateLimitedOutcome(elements)) return elements;
|
||||
if (isUnavailableCapability(elements) || isRateLimitedOutcome(elements))
|
||||
return elements;
|
||||
if (elements === null) return undefined; // transient upstream failure (vs [] = definitive no-match)
|
||||
|
||||
const queryWords = normalizedWords(query);
|
||||
@@ -1497,7 +1624,11 @@ export function createAnnotationResolver({
|
||||
pending = featureSource
|
||||
.getMonuments({ lat, lon }, { signal: undefined })
|
||||
.then((elements) => {
|
||||
if (elements === null || isRateLimitedOutcome(elements))
|
||||
if (
|
||||
elements === null ||
|
||||
isUnavailableCapability(elements) ||
|
||||
isRateLimitedOutcome(elements)
|
||||
)
|
||||
return null; // transient — NEVER cached (a poisoned bucket
|
||||
// would silently disable the snap for the whole session, field test 7 §1)
|
||||
const feats = [];
|
||||
@@ -1912,6 +2043,10 @@ export function createAnnotationResolver({
|
||||
const ring = [...ne.polygons].sort((a, b) => b.length - a.length)[0];
|
||||
if (ring?.length >= 3) return { name: ne.name, ring };
|
||||
}
|
||||
// Bundled states/provinces/counties by name (offline); the main part only,
|
||||
// like the Natural Earth rung above.
|
||||
const admin = await findAdminArea(q).catch(() => null);
|
||||
if (admin) return { name: admin.name, ring: [...admin.ring] };
|
||||
const lookup = resolveAdminRegionRing(q, signal, placeSearch);
|
||||
if (!Number.isFinite(budgetMs)) return lookup;
|
||||
let timer;
|
||||
@@ -1935,6 +2070,15 @@ export function createAnnotationResolver({
|
||||
if (!geo) return null;
|
||||
const scope = scopeFromTypes(geo.types);
|
||||
if (!['country', 'state', 'county', 'city'].includes(scope)) return null;
|
||||
if (scope === 'state' || scope === 'county' || scope === 'country') {
|
||||
const admin = await findAdminAreaAt(
|
||||
[q, geo.primaryName],
|
||||
geo.lat,
|
||||
geo.lon,
|
||||
scope,
|
||||
).catch(() => null);
|
||||
if (admin) return { name: q, ring: [...admin.ring] };
|
||||
}
|
||||
const fp = await fetchAdminArea(geo.lat, geo.lon, q, scope, signal).catch(
|
||||
() => null,
|
||||
);
|
||||
|
||||
@@ -161,7 +161,12 @@ export function createScreenAnnotationRenderer(
|
||||
filter: 'url(#gev-sketch)',
|
||||
});
|
||||
group.appendChild(parts.poly);
|
||||
parts.label = makeCallout(anno.label, c);
|
||||
parts.label = makeCallout(
|
||||
anno.outlineUnavailable
|
||||
? `${anno.label || ''} · Detailed outline unavailable`
|
||||
: anno.label,
|
||||
c,
|
||||
);
|
||||
if (parts.label) group.appendChild(parts.label.node);
|
||||
} else if (anno.type === 'arrow' && anno.to) {
|
||||
parts.path = svgEl('path', {
|
||||
@@ -174,7 +179,12 @@ export function createScreenAnnotationRenderer(
|
||||
});
|
||||
ensureArrowMarker(defs, anno.color || 'primary', c);
|
||||
group.appendChild(parts.path);
|
||||
parts.label = makeCallout(anno.label, c);
|
||||
parts.label = makeCallout(
|
||||
anno.outlineUnavailable
|
||||
? `${anno.label || ''} · Detailed outline unavailable`
|
||||
: anno.label,
|
||||
c,
|
||||
);
|
||||
if (parts.label) group.appendChild(parts.label.node);
|
||||
} else if (
|
||||
anno.type === 'route' &&
|
||||
@@ -203,7 +213,12 @@ export function createScreenAnnotationRenderer(
|
||||
group.appendChild(dot);
|
||||
return dot;
|
||||
});
|
||||
parts.label = makeCallout(anno.label, c);
|
||||
parts.label = makeCallout(
|
||||
anno.outlineUnavailable
|
||||
? `${anno.label || ''} · Detailed outline unavailable`
|
||||
: anno.label,
|
||||
c,
|
||||
);
|
||||
if (parts.label) group.appendChild(parts.label.node);
|
||||
} else {
|
||||
// pin / highlight / label — pulsing target rings + a marker dot + callout
|
||||
@@ -242,7 +257,12 @@ export function createScreenAnnotationRenderer(
|
||||
'stroke-opacity': '0.7',
|
||||
});
|
||||
group.appendChild(parts.leader);
|
||||
parts.label = makeCallout(anno.label, c);
|
||||
parts.label = makeCallout(
|
||||
anno.outlineUnavailable
|
||||
? `${anno.label || ''} · Detailed outline unavailable`
|
||||
: anno.label,
|
||||
c,
|
||||
);
|
||||
if (parts.label) group.appendChild(parts.label.node);
|
||||
}
|
||||
|
||||
@@ -301,6 +321,10 @@ export function createScreenAnnotationRenderer(
|
||||
return;
|
||||
}
|
||||
rec.anno = anno;
|
||||
if (anno.outlineUnavailable && rec.parts.label) {
|
||||
rec.parts.label.text.textContent = `${anno.label || ''} · Detailed outline unavailable`;
|
||||
sizeCallout(rec.parts.label);
|
||||
}
|
||||
|
||||
if (empty) {
|
||||
for (const child of Array.from(rec.group.children)) child.remove();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { showOsmCredit, hideOsmCredit } from '../data/dataCredits.js';
|
||||
|
||||
/**
|
||||
* World-space annotation renderer (Direction A).
|
||||
@@ -10,7 +11,7 @@ import * as Cesium from 'cesium';
|
||||
* IMPORTANT — drawing on Google Photorealistic 3D Tiles:
|
||||
* The Cesium globe is hidden, so there is no terrain to clamp to. Ground
|
||||
* geometry (areas, rings, connectors) is draped onto the photoreal tiles with
|
||||
* `classificationType: CESIUM_3D_TILE`; points and labels clamp to the tile
|
||||
* `classificationType: CESIUM_3D_TILE`; points clamp to the tile
|
||||
* surface with `heightReference: CLAMP_TO_GROUND` (which requires the tileset
|
||||
* to have `enableCollision = true`, set in initAnnotations). This keeps marks
|
||||
* sitting ON the world instead of buried at sea level.
|
||||
@@ -49,6 +50,7 @@ const CLAMP = Cesium.HeightReference.CLAMP_TO_GROUND;
|
||||
export function createWorldAnnotationRenderer(viewer) {
|
||||
const dataSource = new Cesium.CustomDataSource('gev-annotations');
|
||||
viewer.dataSources.add(dataSource);
|
||||
const osmAnnotations = new Set();
|
||||
|
||||
// Register the GevRouteFlow fabric once so Cesium's Material.fromType() can build the
|
||||
// material the route pipeline renders. The animated `time` uniform is read straight
|
||||
@@ -159,51 +161,74 @@ export function createWorldAnnotationRenderer(viewer) {
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (anno.label) entities.push(labelMarker(anno, base, { point: false }));
|
||||
} else if (anno.ring && anno.ring.length >= 3) {
|
||||
// Larger area (district / compound / park) → flat fill draped on the tiles
|
||||
// + a glowing outline. Draping is right here: you can't extrude a whole
|
||||
// neighbourhood, and the GIS overlay shows the boundary clearly.
|
||||
const fillPositions = Cesium.Cartesian3.fromDegreesArray(
|
||||
anno.ring.flat(),
|
||||
);
|
||||
entities.push(
|
||||
dataSource.entities.add({
|
||||
polygon: {
|
||||
hierarchy: new Cesium.PolygonHierarchy(fillPositions),
|
||||
// Synthesized (approximate) areas get a fainter fill so they don't read as solid.
|
||||
material: new Cesium.ColorMaterialProperty(
|
||||
liveColor(anno, base, {
|
||||
alpha: anno.synthesized ? 0.1 : 0.2,
|
||||
pulse: true,
|
||||
}),
|
||||
),
|
||||
classificationType: CLASSIFY,
|
||||
},
|
||||
// Larger area (district / compound / park / state) → flat fill draped on
|
||||
// the tiles + a glowing outline. Draping is right here: you can't extrude
|
||||
// a whole neighbourhood, and the GIS overlay shows the boundary clearly.
|
||||
// A multi-part outline (`anno.polygons`: Hawaii's islands, Berlin as a
|
||||
// hole in Brandenburg) draws every part with its holes. All parts share
|
||||
// ONE fill and ONE line material, so Cesium batches the whole mark into a
|
||||
// ground primitive per kind instead of one per island.
|
||||
const parts =
|
||||
Array.isArray(anno.polygons) && anno.polygons.length
|
||||
? anno.polygons
|
||||
: [[anno.ring]];
|
||||
const fillMaterial = new Cesium.ColorMaterialProperty(
|
||||
// Synthesized (approximate) areas get a fainter fill so they don't read as solid.
|
||||
liveColor(anno, base, {
|
||||
alpha: anno.synthesized ? 0.1 : 0.2,
|
||||
pulse: true,
|
||||
}),
|
||||
);
|
||||
entities.push(
|
||||
dataSource.entities.add({
|
||||
polyline: {
|
||||
positions: fillPositions,
|
||||
width: 6,
|
||||
// Synthesized → DASHED outline (signals "approximate, not an authoritative
|
||||
// boundary", research §8.4/§8.6); real footprints → solid glow.
|
||||
material: anno.synthesized
|
||||
? new Cesium.PolylineDashMaterialProperty({
|
||||
color: liveColor(anno, base, { alpha: 0.95 }),
|
||||
dashLength: 24,
|
||||
})
|
||||
: new Cesium.PolylineGlowMaterialProperty({
|
||||
glowPower: 0.35,
|
||||
color: liveColor(anno, base, { alpha: 1 }),
|
||||
}),
|
||||
clampToGround: true,
|
||||
classificationType: CLASSIFY,
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (anno.label) entities.push(labelMarker(anno, base, { point: false }));
|
||||
// Synthesized → DASHED outline (signals "approximate, not an authoritative
|
||||
// boundary", research §8.4/§8.6); real footprints → solid glow.
|
||||
const lineMaterial = anno.synthesized
|
||||
? new Cesium.PolylineDashMaterialProperty({
|
||||
color: liveColor(anno, base, { alpha: 0.95 }),
|
||||
dashLength: 24,
|
||||
})
|
||||
: new Cesium.PolylineGlowMaterialProperty({
|
||||
glowPower: 0.35,
|
||||
color: liveColor(anno, base, { alpha: 1 }),
|
||||
});
|
||||
for (const [outer, ...holes] of parts) {
|
||||
if (!Array.isArray(outer) || outer.length < 3) continue;
|
||||
const rings = [outer, ...holes.filter((h) => h?.length >= 3)];
|
||||
entities.push(
|
||||
dataSource.entities.add({
|
||||
polygon: {
|
||||
hierarchy: new Cesium.PolygonHierarchy(
|
||||
Cesium.Cartesian3.fromDegreesArray(outer.flat()),
|
||||
rings
|
||||
.slice(1)
|
||||
.map(
|
||||
(hole) =>
|
||||
new Cesium.PolygonHierarchy(
|
||||
Cesium.Cartesian3.fromDegreesArray(hole.flat()),
|
||||
),
|
||||
),
|
||||
),
|
||||
material: fillMaterial,
|
||||
classificationType: CLASSIFY,
|
||||
},
|
||||
}),
|
||||
);
|
||||
for (const ring of rings) {
|
||||
entities.push(
|
||||
dataSource.entities.add({
|
||||
polyline: {
|
||||
positions: Cesium.Cartesian3.fromDegreesArray(
|
||||
closedRing(ring).flat(),
|
||||
),
|
||||
width: 6,
|
||||
material: lineMaterial,
|
||||
clampToGround: true,
|
||||
classificationType: CLASSIFY,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
anno.type === 'route' &&
|
||||
Array.isArray(anno.path) &&
|
||||
@@ -228,7 +253,8 @@ export function createWorldAnnotationRenderer(viewer) {
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (anno.label) entities.push(labelMarker(anno, base, { point: false }));
|
||||
osmAnnotations.add(anno);
|
||||
showOsmCredit(viewer, anno);
|
||||
} else if (anno.type === 'arrow' && anno.to) {
|
||||
// Connector draped across the ground from origin to destination.
|
||||
const positions = [
|
||||
@@ -248,18 +274,6 @@ export function createWorldAnnotationRenderer(viewer) {
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (anno.label) {
|
||||
const mid = {
|
||||
lon: (anno.anchor.lon + anno.to.lon) / 2,
|
||||
lat: (anno.anchor.lat + anno.to.lat) / 2,
|
||||
};
|
||||
entities.push(
|
||||
dataSource.entities.add({
|
||||
position: Cesium.Cartesian3.fromDegrees(mid.lon, mid.lat),
|
||||
label: labelGraphic(anno, base),
|
||||
}),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// pin / highlight / label — a camera-proportional target ring + a marker.
|
||||
if (anno.type !== 'label') {
|
||||
@@ -284,54 +298,28 @@ export function createWorldAnnotationRenderer(viewer) {
|
||||
}),
|
||||
);
|
||||
}
|
||||
entities.push(labelMarker(anno, base, { point: true }));
|
||||
entities.push(pointMarker(anno, base));
|
||||
}
|
||||
}
|
||||
|
||||
// A clamped point + (optional) label that sits on the tile surface.
|
||||
function labelMarker(anno, base, { point }) {
|
||||
// Captions belong to the hybrid renderer's screen-space callouts.
|
||||
function pointMarker(anno, base) {
|
||||
return dataSource.entities.add({
|
||||
position: Cesium.Cartesian3.fromDegrees(anno.anchor.lon, anno.anchor.lat),
|
||||
point: point
|
||||
? {
|
||||
pixelSize: anno.type === 'label' ? 8 : 14,
|
||||
color: liveColor(anno, base, { alpha: 1 }),
|
||||
outlineColor: liveColor(anno, Cesium.Color.WHITE, { alpha: 0.95 }),
|
||||
outlineWidth: 3,
|
||||
heightReference: CLAMP,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
}
|
||||
: undefined,
|
||||
label: anno.label ? labelGraphic(anno, base) : undefined,
|
||||
point: {
|
||||
pixelSize: anno.type === 'label' ? 8 : 14,
|
||||
color: liveColor(anno, base, { alpha: 1 }),
|
||||
outlineColor: liveColor(anno, Cesium.Color.WHITE, { alpha: 0.95 }),
|
||||
outlineWidth: 3,
|
||||
heightReference: CLAMP,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function labelGraphic(anno, base) {
|
||||
return {
|
||||
text: anno.label,
|
||||
font: '600 14px "Inter", system-ui, sans-serif',
|
||||
fillColor: liveColor(anno, Cesium.Color.WHITE, { alpha: 1 }),
|
||||
outlineColor: liveColor(anno, Cesium.Color.BLACK, { alpha: 0.85 }),
|
||||
outlineWidth: 3,
|
||||
style: Cesium.LabelStyle.FILL_AND_OUTLINE,
|
||||
verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
|
||||
pixelOffset: new Cesium.Cartesian2(0, -16),
|
||||
showBackground: true,
|
||||
backgroundColor: new Cesium.CallbackProperty(
|
||||
() =>
|
||||
Cesium.Color.fromCssColorString('#0b1622').withAlpha(
|
||||
0.72 * (anno.alpha ?? 1),
|
||||
),
|
||||
false,
|
||||
),
|
||||
backgroundPadding: new Cesium.Cartesian2(8, 5),
|
||||
heightReference: CLAMP,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
scaleByDistance: new Cesium.NearFarScalar(500, 1.05, 14000, 0.55),
|
||||
};
|
||||
}
|
||||
|
||||
function remove(anno) {
|
||||
hideOsmCredit(viewer, anno);
|
||||
osmAnnotations.delete(anno);
|
||||
if (!anno?._entities) return;
|
||||
for (const entity of anno._entities) {
|
||||
try {
|
||||
@@ -348,6 +336,8 @@ export function createWorldAnnotationRenderer(viewer) {
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
for (const anno of osmAnnotations) hideOsmCredit(viewer, anno);
|
||||
osmAnnotations.clear();
|
||||
try {
|
||||
viewer.dataSources.remove(dataSource, true);
|
||||
} catch {
|
||||
@@ -452,6 +442,13 @@ FlowMaterialProperty.prototype.equals = function equals(other) {
|
||||
return this === other;
|
||||
};
|
||||
|
||||
/** The ring with its first vertex repeated at the end, so an outline closes. */
|
||||
function closedRing(ring) {
|
||||
const first = ring[0];
|
||||
const last = ring[ring.length - 1];
|
||||
return first[0] === last[0] && first[1] === last[1] ? ring : [...ring, first];
|
||||
}
|
||||
|
||||
/** Evenly down-sample a [[lon,lat],...] ring to at most n points (keeps shape). */
|
||||
function decimateRing(ring, n) {
|
||||
if (ring.length <= n) return ring;
|
||||
|
||||
@@ -4,6 +4,7 @@ const CONTROL_LAYER_IDS = Object.freeze({
|
||||
militaryFlightsLayer: 'military',
|
||||
satellitesLayer: 'satellites',
|
||||
cctvLayer: 'cctv',
|
||||
streetLevelLayer: 'street-level',
|
||||
radioLayer: 'radio',
|
||||
bikeshareLayer: 'bikeshare',
|
||||
transitLayer: 'transit',
|
||||
|
||||
@@ -65,13 +65,28 @@ test('data setup seals the caller catalog before controls can restore and drains
|
||||
},
|
||||
},
|
||||
},
|
||||
catalog: createLayerCatalog(layers, metadata(layers)),
|
||||
catalog: {
|
||||
...createLayerCatalog(layers, metadata(layers)),
|
||||
getSourceAvailability: (id) =>
|
||||
id === 'one'
|
||||
? {
|
||||
available: false,
|
||||
reason: 'One: data source not configured',
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
defer: (release) => releases.push(release),
|
||||
onData: (manager) => {
|
||||
observed = manager;
|
||||
},
|
||||
});
|
||||
assert.equal(observed, dataManager);
|
||||
assert.equal(await dataManager.setEnabled('one', true), false);
|
||||
assert.equal(dataManager.layers.get('one').initialized, false);
|
||||
assert.equal(
|
||||
dataManager.getAll().find(({ id }) => id === 'one').stats.error,
|
||||
'One: data source not configured',
|
||||
);
|
||||
assert.deepEqual(attached, [['one', 'two']]);
|
||||
assert.deepEqual([...dataManager.layers.keys()], ['one', 'two']);
|
||||
assert.throws(() => dataManager.register({ id: 'late' }), /finalized/);
|
||||
@@ -93,6 +108,7 @@ test('controls bind catalog instances rather than similarly named defaults', ()
|
||||
'military-awareness',
|
||||
'military-installations',
|
||||
'rocket-launches',
|
||||
'street-level',
|
||||
'local-adsb',
|
||||
];
|
||||
const layers = ids.map((id) => ({ id }));
|
||||
@@ -101,7 +117,7 @@ test('controls bind catalog instances rather than similarly named defaults', ()
|
||||
assert.equal(services.flightsLayer, layers[1]);
|
||||
assert.equal(services.transitLayer, layers[7]);
|
||||
assert.equal(services.aisLiveVesselsLayer, layers[8]);
|
||||
assert.equal(services.localAdsbLayer, layers[12]);
|
||||
assert.equal(services.localAdsbLayer, layers[13]);
|
||||
assert.equal(new Set(Object.values(services)).size, layers.length);
|
||||
assert.throws(
|
||||
() => catalogControlServices(createLayerCatalog([], [])),
|
||||
|
||||
+18
-37
@@ -1,3 +1,4 @@
|
||||
import { resolveCatalogSources } from './sourceComposition.js';
|
||||
import { createWeatherClock } from '../layers/weather/clock.js';
|
||||
import { createWeatherLayer } from '../layers/weather/index.js';
|
||||
import { createCyclonesLayer } from '../layers/cyclones/index.js';
|
||||
@@ -25,38 +26,12 @@ import { createApplicationFirms } from './layers/firms.js';
|
||||
import { createApplicationEarthquakes } from './layers/earthquakes.js';
|
||||
import { createApplicationFirePerimeters } from './layers/perimeters.js';
|
||||
import { createApplicationCables } from './layers/submarineCables.js';
|
||||
import { createApplicationStreetLevel } from './layers/streetLevel.js';
|
||||
import { createInfrastructureLayers } from '../data/infrastructure.js';
|
||||
import { localGeoJsonServices } from './localGeojsonServices.js';
|
||||
import { createBhoteKoshiEventLayer } from '../data/bhoteKoshiEvent.js';
|
||||
import { createBhoteKoshiLocatorLayer } from '../data/bhoteKoshiLocator.js';
|
||||
|
||||
const SOURCE_METHODS = Object.freeze({
|
||||
flights: ['getSnapshot'],
|
||||
military: ['getSnapshot'],
|
||||
vessels: ['getSnapshot'],
|
||||
cctv: ['getCatalog', 'getHealth', 'getFrameUrl', 'getMediaUrl'],
|
||||
radio: ['getDirectory', 'recordClick'],
|
||||
traffic: [
|
||||
'requestRoads',
|
||||
'getStatus',
|
||||
'fetchFlowForBounds',
|
||||
'getFlowSessionStats',
|
||||
'resetFlowTileCache',
|
||||
],
|
||||
bikeshare: ['getStations'],
|
||||
installations: ['getMappedSites', 'searchNearby'],
|
||||
satellites: ['readGroup'],
|
||||
launches: ['getLaunches', 'getActiveTle'],
|
||||
alpr: ['fetch'],
|
||||
firms: ['getSnapshot'],
|
||||
wind: ['getSnapshot'],
|
||||
weather: ['getSnapshot'],
|
||||
cyclones: ['getSnapshot'],
|
||||
earthquakes: ['getSnapshot'],
|
||||
'fire-perimeters': ['getSnapshot'],
|
||||
cables: ['fetch'],
|
||||
});
|
||||
|
||||
/**
|
||||
* Hardware-local layers are registered like any other but never enter share
|
||||
* links or stored layer state: another browser cannot have this receiver.
|
||||
@@ -83,6 +58,7 @@ export function createApplicationCatalog({
|
||||
vesselOptions,
|
||||
resolveAsset,
|
||||
nepalBoundaryResolver,
|
||||
streetLevelProviders,
|
||||
}) {
|
||||
if (!signal?.addEventListener)
|
||||
throw new TypeError('An application lifetime signal is required');
|
||||
@@ -90,12 +66,8 @@ export function createApplicationCatalog({
|
||||
if (!surface?.groundFloor || !surface?.terrain)
|
||||
throw new TypeError('Application surface services are required');
|
||||
|
||||
for (const [name, methods] of Object.entries(SOURCE_METHODS)) {
|
||||
if (
|
||||
methods.some((method) => typeof sources?.[name]?.[method] !== 'function')
|
||||
)
|
||||
throw new TypeError(`Invalid catalog source: ${name}`);
|
||||
}
|
||||
const sourceComposition = resolveCatalogSources(sources);
|
||||
sources = sourceComposition.sources;
|
||||
const militaryRegistry = createMilitaryRegistry();
|
||||
const weatherClock = createWeatherClock();
|
||||
const dispose = () => {
|
||||
@@ -105,7 +77,8 @@ export function createApplicationCatalog({
|
||||
};
|
||||
signal.addEventListener('abort', dispose, { once: true });
|
||||
try {
|
||||
militaryRegistry.configureSource(sources.military, { signal });
|
||||
if (sourceComposition.isConfigured('military'))
|
||||
militaryRegistry.configureSource(sources.military, { signal });
|
||||
const flights = createApplicationFlights({
|
||||
surface,
|
||||
source: sources.flights,
|
||||
@@ -148,15 +121,20 @@ export function createApplicationCatalog({
|
||||
source: sources['fire-perimeters'],
|
||||
}),
|
||||
createApplicationAlpr({ surface, source: sources.alpr }),
|
||||
createApplicationStreetLevel({
|
||||
surface,
|
||||
sources: { mapillary: sources.mapillary },
|
||||
providers: streetLevelProviders,
|
||||
}),
|
||||
satellites,
|
||||
createApplicationLaunches({ source: sources.launches, satellites }),
|
||||
createApplicationTraffic({ source: sources.traffic }),
|
||||
createApplicationTraffic({ source: sources.traffic, surface }),
|
||||
createApplicationCctv({ surface, source: sources.cctv }),
|
||||
createApplicationRadio({ surface, source: sources.radio }),
|
||||
createApplicationTransit({ surface, source: sources.transit }),
|
||||
createApplicationBikeshare({ source: sources.bikeshare }),
|
||||
createApplicationDirections(),
|
||||
createApplicationRecentImagery(),
|
||||
createApplicationDirections({ source: sources.directions }),
|
||||
createApplicationRecentImagery({ source: sources['recent-imagery'] }),
|
||||
vessels,
|
||||
installations,
|
||||
createApplicationAwareness({
|
||||
@@ -197,6 +175,9 @@ export function createApplicationCatalog({
|
||||
);
|
||||
return Object.freeze({
|
||||
...catalog,
|
||||
getSourceAvailability: sourceComposition.createAvailabilityLookup(
|
||||
catalog.layers,
|
||||
),
|
||||
militaryRegistry,
|
||||
surface,
|
||||
weatherClock,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createApplicationCatalog } from './constructCatalog.js';
|
||||
import { createStandaloneLayerSources } from '../standalone/layerSources.js';
|
||||
import { catalogControlServices } from './catalog.js';
|
||||
import { LayerLifecycle } from '../data/lifecycle.js';
|
||||
import { fakeStreetLevelProvider } from '../testSupport/streetLevelFakes.mjs';
|
||||
|
||||
function fixtureSources(ids, calls) {
|
||||
const sources = createStandaloneLayerSources();
|
||||
@@ -39,7 +40,7 @@ test('catalogs construct distinct layers and classification from their supplied
|
||||
signal: b.signal,
|
||||
surface: fixtureSurface(b.signal),
|
||||
});
|
||||
assert.equal(first.layers.length, 29);
|
||||
assert.equal(first.layers.length, 30);
|
||||
assert.ok(first.get('local-adsb'), 'Local ADS-B is registered');
|
||||
assert.deepEqual(
|
||||
first.metadata.find(({ id }) => id === 'local-adsb'),
|
||||
@@ -116,7 +117,7 @@ test('invalid or already cancelled construction fails before classification can
|
||||
assert.throws(
|
||||
() =>
|
||||
createApplicationCatalog({
|
||||
sources: {},
|
||||
sources: { flights: {} },
|
||||
signal: lifetime.signal,
|
||||
surface: fixtureSurface(lifetime.signal),
|
||||
}),
|
||||
@@ -140,3 +141,53 @@ function fixtureSurface(signal) {
|
||||
eventTarget: null,
|
||||
});
|
||||
}
|
||||
|
||||
test('Mapillary can be omitted or replaced without changing catalog membership', (t) => {
|
||||
const lifetime = new AbortController();
|
||||
t.after(() => lifetime.abort());
|
||||
const sources = fixtureSources([], []);
|
||||
delete sources.mapillary;
|
||||
const options = {
|
||||
sources,
|
||||
signal: lifetime.signal,
|
||||
surface: fixtureSurface(lifetime.signal),
|
||||
};
|
||||
const catalog = createApplicationCatalog(options);
|
||||
assert.equal(catalog.layers.length, 30);
|
||||
assert.deepEqual(catalog.get('street-level').providerIds, []);
|
||||
assert.equal(
|
||||
catalogControlServices(catalog).streetLevelLayer,
|
||||
catalog.get('street-level'),
|
||||
);
|
||||
assert.match(
|
||||
catalog.get('street-level').getUIState().coverage.hint,
|
||||
/No street-level imagery providers/,
|
||||
);
|
||||
const provider = fakeStreetLevelProvider({
|
||||
id: 'example',
|
||||
pickPrefix: 'example:',
|
||||
});
|
||||
const replacement = createApplicationCatalog({
|
||||
...options,
|
||||
streetLevelProviders: [provider],
|
||||
});
|
||||
assert.deepEqual(replacement.get('street-level').providerIds, ['example']);
|
||||
assert.deepEqual(replacement.metadata, catalog.metadata);
|
||||
assert.equal(provider.calls.activate, 0, 'construction must not acquire');
|
||||
assert.deepEqual(
|
||||
createApplicationCatalog({
|
||||
...options,
|
||||
sources: fixtureSources([], []),
|
||||
streetLevelProviders: [],
|
||||
}).get('street-level').providerIds,
|
||||
[],
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
createApplicationCatalog({
|
||||
...options,
|
||||
sources: { ...sources, mapillary: {} },
|
||||
}),
|
||||
/Mapillary source/,
|
||||
);
|
||||
});
|
||||
@@ -13,6 +13,7 @@ export function createApplicationData({
|
||||
// Initialize data layer manager
|
||||
const dataManager = new LayerLifecycle(viewer, {
|
||||
allowQaRegistration,
|
||||
getSourceAvailability: catalog?.getSourceAvailability,
|
||||
});
|
||||
defer(async () => {
|
||||
await dataManager.destroyAll();
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* Embed mode and views in the running app.
|
||||
*
|
||||
* With `?embed=1` the app shows only the globe: clean view, with the HUD,
|
||||
* welcome and setup prompts hidden. A page that frames it changes what it
|
||||
* shows by posting `{ type: 'gev:view', id, view }`; the app applies the view
|
||||
* through its own actions and answers `{ type: 'gev:view-applied', id, ok,
|
||||
* steps }`. It announces `{ type: 'gev:ready' }` once it can take views.
|
||||
*
|
||||
* Annotations in a share link are drawn once the link has been restored,
|
||||
* embedded or not.
|
||||
*/
|
||||
|
||||
import * as Cesium from 'cesium';
|
||||
import { announceNavigationAuthority } from '../navigationPolicy.js';
|
||||
import { annotationsFromParams, createView } from '../view/index.js';
|
||||
|
||||
export const EMBED_VIEW_MESSAGE = 'gev:view';
|
||||
export const EMBED_APPLIED_MESSAGE = 'gev:view-applied';
|
||||
export const EMBED_READY_MESSAGE = 'gev:ready';
|
||||
|
||||
const FOLLOW_LAYERS = {
|
||||
aircraft: 'flights',
|
||||
military_aircraft: 'military',
|
||||
satellite: 'satellites',
|
||||
};
|
||||
const FOLLOW_ATTEMPTS = 20;
|
||||
const FOLLOW_RETRY_MS = 1000;
|
||||
const FLIGHT_SECONDS = 2;
|
||||
|
||||
/**
|
||||
* Whether this page shows the app embedded: `?embed=1` when another page
|
||||
* frames it, or inline when a panel page loads the app into itself and sets
|
||||
* `globalThis.GEV_EMBED_INLINE` first.
|
||||
*/
|
||||
export function isEmbedded(location = globalThis.location) {
|
||||
return (
|
||||
isEmbeddedInline() ||
|
||||
new URLSearchParams(location?.search || '').get('embed') === '1'
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether a panel page loaded the app into itself. */
|
||||
export function isEmbeddedInline() {
|
||||
return globalThis.GEV_EMBED_INLINE === true;
|
||||
}
|
||||
|
||||
// A panel keeps drawing at about this rate when its host stops animation
|
||||
// frames; see keepPanelRendering.
|
||||
const PANEL_FRAME_MS = 33;
|
||||
const MISSED_FRAMES_MS = 250;
|
||||
|
||||
/** A render error's details, including the plain objects workers report. */
|
||||
function describeError(error) {
|
||||
if (error instanceof Error)
|
||||
return `${error.name}: ${error.message}\n${error.stack}`;
|
||||
try {
|
||||
return JSON.stringify(error, Object.getOwnPropertyNames(error ?? {}));
|
||||
} catch {
|
||||
return String(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the globe drawing in an inline panel. Some hosts report a panel on
|
||||
* screen as hidden, which stops the browser's animation frames and with them
|
||||
* Cesium's render loop; while frames stop arriving, draw from a timer.
|
||||
* Returns a function that stops it.
|
||||
*/
|
||||
export function keepPanelRendering(
|
||||
viewer,
|
||||
{ windowRef = globalThis.window, now = () => performance.now() } = {},
|
||||
) {
|
||||
let lastFrame = now();
|
||||
let frameRequest = null;
|
||||
const onFrame = () => {
|
||||
lastFrame = now();
|
||||
frameRequest = windowRef.requestAnimationFrame(onFrame);
|
||||
};
|
||||
frameRequest = windowRef.requestAnimationFrame(onFrame);
|
||||
// Cesium stops drawing after a render error, and so does the timer.
|
||||
// Report the error in full: workers report plain objects, which Cesium
|
||||
// prints as [object Object].
|
||||
let failed = false;
|
||||
const removeErrorListener = viewer.scene?.renderError?.addEventListener(
|
||||
(_scene, error) => {
|
||||
failed = true;
|
||||
console.error(
|
||||
"[God's Eye View panel] render error:",
|
||||
describeError(error),
|
||||
);
|
||||
},
|
||||
);
|
||||
const timer = windowRef.setInterval(() => {
|
||||
if (
|
||||
failed ||
|
||||
now() - lastFrame < MISSED_FRAMES_MS ||
|
||||
viewer.isDestroyed?.()
|
||||
)
|
||||
return;
|
||||
viewer.resize();
|
||||
viewer.render();
|
||||
}, PANEL_FRAME_MS);
|
||||
return () => {
|
||||
windowRef.clearInterval(timer);
|
||||
windowRef.cancelAnimationFrame(frameRequest);
|
||||
removeErrorListener?.();
|
||||
};
|
||||
}
|
||||
|
||||
const delay = (ms, signal) =>
|
||||
new Promise((resolve) => {
|
||||
const timer = setTimeout(resolve, ms);
|
||||
signal?.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
|
||||
/** Fly the camera to a view's camera and resolve when the flight ends. */
|
||||
function flyTo(viewer, camera) {
|
||||
announceNavigationAuthority('embed-view');
|
||||
return new Promise((resolve) => {
|
||||
viewer.camera.flyTo({
|
||||
destination: Cesium.Cartesian3.fromDegrees(
|
||||
camera.lon,
|
||||
camera.lat,
|
||||
camera.altitude_m,
|
||||
),
|
||||
orientation: {
|
||||
heading: Cesium.Math.toRadians(camera.heading_deg),
|
||||
pitch: Cesium.Math.toRadians(camera.pitch_deg),
|
||||
roll: 0,
|
||||
},
|
||||
duration: FLIGHT_SECONDS,
|
||||
complete: () => resolve(true),
|
||||
cancel: () => resolve(false),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Make the app show a view: style and map, exactly the view's layers,
|
||||
* annotations, the camera, and the followed entity. Each step runs through the
|
||||
* app's own actions (`run(name, args)`); a failed step is reported and the
|
||||
* rest still run. Resolves to the steps and whether each succeeded.
|
||||
*/
|
||||
export async function applyView(
|
||||
view,
|
||||
{ viewer, dataManager, run, signal, retryMs = FOLLOW_RETRY_MS },
|
||||
) {
|
||||
const steps = [];
|
||||
const act = async (name, args) => {
|
||||
let result = null;
|
||||
try {
|
||||
result = await run(name, args);
|
||||
} catch (error) {
|
||||
result = { ok: false, error: error?.message || String(error) };
|
||||
}
|
||||
steps.push({
|
||||
step: name,
|
||||
ok: result?.ok !== false,
|
||||
...(result?.error ? { error: result.error } : {}),
|
||||
});
|
||||
return result;
|
||||
};
|
||||
if (view.style) await act('set_visual_style', { style: view.style });
|
||||
if (view.map) await act('set_map_stack', { stack: view.map });
|
||||
const wanted = new Set(view.layers);
|
||||
for (const layer of dataManager.getAll()) {
|
||||
if (layer.enabled && !wanted.has(layer.id))
|
||||
await act('set_layer_visibility', { layerId: layer.id, enabled: false });
|
||||
}
|
||||
for (const layerId of view.layers) {
|
||||
if (!dataManager.isEnabled(layerId))
|
||||
await act('set_layer_visibility', { layerId, enabled: true });
|
||||
}
|
||||
if (!view.follow) await act('stop_tracking', {});
|
||||
// Annotations go first: drawing them may frame the marks, and the view's
|
||||
// own camera should have the last word.
|
||||
await act('clear_annotations', {});
|
||||
if (view.annotations.length)
|
||||
await act('annotate_map', { annotations: [...view.annotations] });
|
||||
if (!view.follow) {
|
||||
steps.push({ step: 'camera', ok: await flyTo(viewer, view.camera) });
|
||||
return steps;
|
||||
}
|
||||
// A followed entity owns the camera, and a camera flight would end the
|
||||
// follow, so fly to the view only while the entity is not there yet. It
|
||||
// appears once its layer has data; retry until then.
|
||||
const layerId = FOLLOW_LAYERS[view.follow.kind];
|
||||
const follow = async () => {
|
||||
try {
|
||||
return (
|
||||
(await run('track_entity', { query: view.follow.id, layerId }))?.ok ===
|
||||
true
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let followed = await follow();
|
||||
if (!followed) {
|
||||
steps.push({ step: 'camera', ok: await flyTo(viewer, view.camera) });
|
||||
for (let attempt = 1; attempt < FOLLOW_ATTEMPTS && !followed; attempt++) {
|
||||
if (signal?.aborted) break;
|
||||
await delay(retryMs, signal);
|
||||
followed = await follow();
|
||||
}
|
||||
}
|
||||
steps.push({ step: 'follow', ok: followed });
|
||||
if (followed && view.follow.cockpit)
|
||||
await act('control_cockpit', { action: 'enter', targetLayer: layerId });
|
||||
return steps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Install embed mode when the page asks for it, and draw any annotations the
|
||||
* opening link carries. Returns a function that removes the message handler.
|
||||
*/
|
||||
export function installViews({
|
||||
shell,
|
||||
viewer,
|
||||
dataManager,
|
||||
run,
|
||||
signal,
|
||||
location = globalThis.location,
|
||||
windowRef = globalThis.window,
|
||||
}) {
|
||||
const ready = Promise.resolve(shell.initialRestorePromise).catch(() => {});
|
||||
const linked = annotationsFromParams(
|
||||
new URLSearchParams(String(location?.hash || '').replace(/^#/, '')),
|
||||
);
|
||||
if (linked.length)
|
||||
void ready.then(() => {
|
||||
if (!signal?.aborted) void run('annotate_map', { annotations: linked });
|
||||
});
|
||||
if (!isEmbedded(location)) return () => {};
|
||||
|
||||
windowRef.document.body.classList.add('ui-embed');
|
||||
shell.setCleanView?.(true);
|
||||
const stopRendering = isEmbeddedInline()
|
||||
? keepPanelRendering(viewer, { windowRef })
|
||||
: () => {};
|
||||
// A framing page talks to the app across frames; an inline panel shares
|
||||
// the page with it and talks through the page's own window.
|
||||
const peer = isEmbeddedInline() ? windowRef : windowRef.parent;
|
||||
// Answers go back only to the origin that asked. An inline panel posts to
|
||||
// its own window, and a sandboxed framing page has no origin to name.
|
||||
const post = (message, origin = '*') => {
|
||||
if (peer && (peer !== windowRef || isEmbeddedInline()))
|
||||
peer.postMessage(message, origin);
|
||||
};
|
||||
// Views apply one at a time, in the order they arrive.
|
||||
let queue = ready;
|
||||
const onMessage = (event) => {
|
||||
if (event.source !== peer || event.data?.type !== EMBED_VIEW_MESSAGE)
|
||||
return;
|
||||
const { id = null } = event.data;
|
||||
const replyOrigin =
|
||||
isEmbeddedInline() || !event.origin || event.origin === 'null'
|
||||
? '*'
|
||||
: event.origin;
|
||||
let view;
|
||||
try {
|
||||
view = createView(event.data.view);
|
||||
} catch (error) {
|
||||
post(
|
||||
{
|
||||
type: EMBED_APPLIED_MESSAGE,
|
||||
id,
|
||||
ok: false,
|
||||
error: error.message,
|
||||
},
|
||||
replyOrigin,
|
||||
);
|
||||
return;
|
||||
}
|
||||
queue = queue.then(async () => {
|
||||
if (signal?.aborted) return;
|
||||
const steps = await applyView(view, { viewer, dataManager, run, signal });
|
||||
post(
|
||||
{
|
||||
type: EMBED_APPLIED_MESSAGE,
|
||||
id,
|
||||
ok: steps.every((step) => step.ok),
|
||||
steps,
|
||||
},
|
||||
replyOrigin,
|
||||
);
|
||||
});
|
||||
};
|
||||
windowRef.addEventListener('message', onMessage);
|
||||
void ready.then(() => {
|
||||
if (!signal?.aborted) post({ type: EMBED_READY_MESSAGE });
|
||||
});
|
||||
return () => {
|
||||
stopRendering();
|
||||
windowRef.removeEventListener('message', onMessage);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
applyView,
|
||||
installViews,
|
||||
isEmbedded,
|
||||
keepPanelRendering,
|
||||
} from './embed.js';
|
||||
import { createView } from '../view/index.js';
|
||||
|
||||
const fakeViewer = () => {
|
||||
const flights = [];
|
||||
return {
|
||||
flights,
|
||||
camera: {
|
||||
flyTo(options) {
|
||||
flights.push(options);
|
||||
options.complete();
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
const fakeLayers = (enabled) => ({
|
||||
getAll: () =>
|
||||
['flights', 'earthquakes', 'ais-live-vessels'].map((id) => ({
|
||||
id,
|
||||
enabled: enabled.has(id),
|
||||
})),
|
||||
isEnabled: (id) => enabled.has(id),
|
||||
});
|
||||
const recorder = (answers = {}) => {
|
||||
const calls = [];
|
||||
const run = async (name, args) => {
|
||||
calls.push([name, args]);
|
||||
const answer = answers[name];
|
||||
return typeof answer === 'function'
|
||||
? answer(calls)
|
||||
: (answer ?? { ok: true });
|
||||
};
|
||||
return { calls, run };
|
||||
};
|
||||
|
||||
test('embed mode is opt-in by query parameter', () => {
|
||||
assert.equal(isEmbedded({ search: '?embed=1' }), true);
|
||||
assert.equal(isEmbedded({ search: '?welcome=0' }), false);
|
||||
});
|
||||
|
||||
test('a view is applied through the app actions in order', async () => {
|
||||
const viewer = fakeViewer();
|
||||
const { calls, run } = recorder();
|
||||
const view = createView({
|
||||
camera: { lat: 25, lon: 121, altitude_m: 300000, pitch_deg: -40 },
|
||||
layers: ['ais-live-vessels'],
|
||||
style: 'thermal',
|
||||
map: 'osm',
|
||||
annotations: [{ type: 'pin', target: 'Taipei 101' }],
|
||||
});
|
||||
const steps = await applyView(view, {
|
||||
viewer,
|
||||
dataManager: fakeLayers(new Set(['earthquakes'])),
|
||||
run,
|
||||
});
|
||||
assert.deepEqual(
|
||||
calls.map(([name, args]) => [
|
||||
name,
|
||||
args.layerId ?? args.style ?? args.stack ?? null,
|
||||
args.enabled ?? null,
|
||||
]),
|
||||
[
|
||||
['set_visual_style', 'thermal', null],
|
||||
['set_map_stack', 'osm', null],
|
||||
['set_layer_visibility', 'earthquakes', false],
|
||||
['set_layer_visibility', 'ais-live-vessels', true],
|
||||
['stop_tracking', null, null],
|
||||
['clear_annotations', null, null],
|
||||
['annotate_map', null, null],
|
||||
],
|
||||
);
|
||||
assert.equal(viewer.flights.length, 1);
|
||||
assert.ok(steps.every((step) => step.ok));
|
||||
assert.ok(steps.some((step) => step.step === 'camera'));
|
||||
});
|
||||
|
||||
test('a followed entity is retried until its layer has it', async () => {
|
||||
let tries = 0;
|
||||
const { run } = recorder({
|
||||
track_entity: () => ({ ok: ++tries >= 3 }),
|
||||
});
|
||||
const view = createView({
|
||||
camera: { lat: 0, lon: 0 },
|
||||
follow: { kind: 'aircraft', id: 'abc123' },
|
||||
});
|
||||
const steps = await applyView(view, {
|
||||
viewer: fakeViewer(),
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run,
|
||||
retryMs: 1,
|
||||
});
|
||||
assert.equal(tries, 3);
|
||||
assert.deepEqual(steps.at(-1), { step: 'follow', ok: true });
|
||||
});
|
||||
|
||||
test('an embedded page takes views only from its parent and answers it', async () => {
|
||||
const posted = [];
|
||||
const targets = [];
|
||||
const parent = {
|
||||
postMessage: (message, target) => {
|
||||
posted.push(message);
|
||||
targets.push(target);
|
||||
},
|
||||
};
|
||||
const windowRef = new EventTarget();
|
||||
windowRef.parent = parent;
|
||||
windowRef.document = { body: { classList: new Set() } };
|
||||
windowRef.document.body.classList.add = Set.prototype.add;
|
||||
const shell = {
|
||||
initialRestorePromise: Promise.resolve(),
|
||||
clean: null,
|
||||
setCleanView(value) {
|
||||
this.clean = value;
|
||||
},
|
||||
};
|
||||
const { calls, run } = recorder();
|
||||
const remove = installViews({
|
||||
shell,
|
||||
viewer: fakeViewer(),
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run,
|
||||
location: { search: '?embed=1', hash: '' },
|
||||
windowRef,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(shell.clean, true);
|
||||
assert.deepEqual(posted, [{ type: 'gev:ready' }]);
|
||||
const send = (source, data, origin = 'https://host.example') => {
|
||||
const event = new Event('message');
|
||||
Object.assign(event, { source, data, origin });
|
||||
windowRef.dispatchEvent(event);
|
||||
};
|
||||
send({}, { type: 'gev:view', id: 1, view: { camera: { lat: 1, lon: 2 } } });
|
||||
send(parent, { type: 'gev:view', id: 2, view: { camera: {} } });
|
||||
send(parent, {
|
||||
type: 'gev:view',
|
||||
id: 3,
|
||||
view: { camera: { lat: 1, lon: 2 }, layers: ['flights'] },
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
assert.deepEqual(posted[1], {
|
||||
type: 'gev:view-applied',
|
||||
id: 2,
|
||||
ok: false,
|
||||
error: 'A view needs a camera lat and lon',
|
||||
});
|
||||
assert.equal(posted[2].id, 3);
|
||||
assert.equal(posted[2].ok, true);
|
||||
assert.ok(
|
||||
calls.some(
|
||||
([name, args]) =>
|
||||
name === 'set_layer_visibility' && args.layerId === 'flights',
|
||||
),
|
||||
);
|
||||
assert.equal(posted.filter((message) => message.id === 1).length, 0);
|
||||
// Ready names no data and goes to any parent; answers go only to the
|
||||
// origin that asked.
|
||||
assert.deepEqual(targets, [
|
||||
'*',
|
||||
'https://host.example',
|
||||
'https://host.example',
|
||||
]);
|
||||
remove();
|
||||
});
|
||||
|
||||
test('an app loaded inline into a panel page talks through its own window', async () => {
|
||||
const posted = [];
|
||||
const windowRef = new EventTarget();
|
||||
windowRef.parent = windowRef;
|
||||
windowRef.postMessage = (message) => posted.push(message);
|
||||
windowRef.document = { body: { classList: new Set() } };
|
||||
windowRef.document.body.classList.add = Set.prototype.add;
|
||||
Object.assign(windowRef, {
|
||||
requestAnimationFrame: () => 1,
|
||||
cancelAnimationFrame() {},
|
||||
setInterval: () => 1,
|
||||
clearInterval() {},
|
||||
});
|
||||
globalThis.GEV_EMBED_INLINE = true;
|
||||
try {
|
||||
assert.equal(isEmbedded({ search: '' }), true);
|
||||
const remove = installViews({
|
||||
shell: { initialRestorePromise: Promise.resolve() },
|
||||
viewer: fakeViewer(),
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run: recorder().run,
|
||||
location: { search: '', hash: '' },
|
||||
windowRef,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(posted, [{ type: 'gev:ready' }]);
|
||||
const event = new Event('message');
|
||||
Object.assign(event, {
|
||||
source: windowRef,
|
||||
data: { type: 'gev:view', id: 7, view: { camera: { lat: 1, lon: 2 } } },
|
||||
});
|
||||
windowRef.dispatchEvent(event);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
assert.equal(posted[1].id, 7);
|
||||
assert.equal(posted[1].ok, true);
|
||||
remove();
|
||||
} finally {
|
||||
delete globalThis.GEV_EMBED_INLINE;
|
||||
}
|
||||
});
|
||||
|
||||
test('annotations in a link are drawn after it restores, embedded or not', async () => {
|
||||
const { calls, run } = recorder();
|
||||
const an = encodeURIComponent(
|
||||
JSON.stringify([{ type: 'pin', target: 'Austin' }]),
|
||||
);
|
||||
installViews({
|
||||
shell: { initialRestorePromise: Promise.resolve() },
|
||||
viewer: fakeViewer(),
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run,
|
||||
location: { search: '', hash: `#v=2&lat=1&lon=2&an=${an}` },
|
||||
windowRef: new EventTarget(),
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(calls, [
|
||||
['annotate_map', { annotations: [{ type: 'pin', target: 'Austin' }] }],
|
||||
]);
|
||||
});
|
||||
|
||||
test('a panel keeps drawing from a timer while animation frames stop', () => {
|
||||
let clock = 0;
|
||||
let tick = null;
|
||||
let frame = null;
|
||||
const windowRef = {
|
||||
requestAnimationFrame: (fn) => ((frame = fn), 1),
|
||||
cancelAnimationFrame: () => (frame = null),
|
||||
setInterval: (fn) => ((tick = fn), 1),
|
||||
clearInterval: () => (tick = null),
|
||||
};
|
||||
const drawn = [];
|
||||
const viewer = {
|
||||
resize: () => drawn.push('resize'),
|
||||
render: () => drawn.push('render'),
|
||||
};
|
||||
const stop = keepPanelRendering(viewer, { windowRef, now: () => clock });
|
||||
// Frames arrive: the render loop draws, so the timer does nothing.
|
||||
clock = 100;
|
||||
frame();
|
||||
tick();
|
||||
assert.deepEqual(drawn, []);
|
||||
// Frames stop, as in a panel its host reports hidden: the timer draws.
|
||||
clock = 400;
|
||||
tick();
|
||||
assert.deepEqual(drawn, ['resize', 'render']);
|
||||
stop();
|
||||
assert.equal(tick, null);
|
||||
assert.equal(frame, null);
|
||||
});
|
||||
|
||||
test('a panel reports a render error in full and stops drawing', () => {
|
||||
let listener = null;
|
||||
let tick = null;
|
||||
const windowRef = {
|
||||
requestAnimationFrame: () => 1,
|
||||
cancelAnimationFrame() {},
|
||||
setInterval: (fn) => ((tick = fn), 1),
|
||||
clearInterval() {},
|
||||
};
|
||||
const drawn = [];
|
||||
const viewer = {
|
||||
resize() {},
|
||||
render: () => drawn.push('render'),
|
||||
scene: {
|
||||
renderError: {
|
||||
addEventListener: (fn) => ((listener = fn), () => (listener = null)),
|
||||
},
|
||||
},
|
||||
};
|
||||
const logged = [];
|
||||
const original = console.error;
|
||||
console.error = (...args) => logged.push(args.join(' '));
|
||||
try {
|
||||
let clock = 0;
|
||||
const stop = keepPanelRendering(viewer, { windowRef, now: () => clock });
|
||||
listener(viewer.scene, { name: 'RuntimeError', message: 'worker failed' });
|
||||
assert.match(logged[0], /render error: .*"message":"worker failed"/);
|
||||
clock = 1000;
|
||||
tick();
|
||||
assert.deepEqual(drawn, []);
|
||||
stop();
|
||||
assert.equal(listener, null);
|
||||
} finally {
|
||||
console.error = original;
|
||||
}
|
||||
});
|
||||
|
||||
test('a followed aircraft owns the camera, and cockpit view is entered after it', async () => {
|
||||
const viewer = fakeViewer();
|
||||
const { calls, run } = recorder();
|
||||
const steps = await applyView(
|
||||
createView({
|
||||
camera: { lat: 32.7, lon: -117.2 },
|
||||
follow: { kind: 'military_aircraft', id: 'AE1234', cockpit: true },
|
||||
}),
|
||||
{ viewer, dataManager: fakeLayers(new Set()), run, retryMs: 0 },
|
||||
);
|
||||
// Following worked at once, so no camera flight ended it.
|
||||
assert.equal(viewer.flights.length, 0);
|
||||
assert.deepEqual(calls.slice(-2), [
|
||||
['track_entity', { query: 'ae1234', layerId: 'military' }],
|
||||
['control_cockpit', { action: 'enter', targetLayer: 'military' }],
|
||||
]);
|
||||
assert.ok(steps.every((step) => step.ok));
|
||||
assert.throws(
|
||||
() =>
|
||||
createView({
|
||||
camera: { lat: 0, lon: 0 },
|
||||
follow: { kind: 'satellite', id: '25544', cockpit: true },
|
||||
}),
|
||||
/no cockpit view/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,662 @@
|
||||
/**
|
||||
* The God's Eye View panel's script, run inside the panel page a host shows.
|
||||
* It is sent as source text (see src/tools/globePanel.js), so it must not use
|
||||
* imports or anything outside the function.
|
||||
*
|
||||
* The page lives on the host's site, and hosts may refuse other addresses,
|
||||
* such as a server on the user's machine. So the panel never requests the
|
||||
* app's server itself: every request for the app's own paths goes to the
|
||||
* panel's MCP server as a call to `config.toolName`, which requests the path
|
||||
* from the app's server. The app's built page, scripts, styles, images and
|
||||
* data all arrive that way; map imagery from providers loads directly.
|
||||
*/
|
||||
export function panelRuntime(config) {
|
||||
const status = document.getElementById('status');
|
||||
const open = document.getElementById('open');
|
||||
const expand = document.getElementById('expand');
|
||||
const say = (text) => {
|
||||
if (status.isConnected) status.textContent = text;
|
||||
};
|
||||
let blocked = null;
|
||||
document.addEventListener('securitypolicyviolation', (event) => {
|
||||
blocked = `${event.effectiveDirective} blocked ${event.blockedURI || 'a resource'}`;
|
||||
});
|
||||
|
||||
// JSON-RPC with the host.
|
||||
let nextId = 1;
|
||||
const pending = new Map();
|
||||
const send = (message) =>
|
||||
window.parent.postMessage({ jsonrpc: '2.0', ...message }, '*');
|
||||
const request = (method, params) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const id = nextId++;
|
||||
pending.set(id, { resolve, reject });
|
||||
send({ id, method, params });
|
||||
});
|
||||
const notify = (method, params) => send({ method, params });
|
||||
|
||||
// Requests to the app's server, through the MCP server.
|
||||
const MAX_CALLS = 6;
|
||||
let calls = 0;
|
||||
const waiting = [];
|
||||
async function callTool(args) {
|
||||
if (calls >= MAX_CALLS) await new Promise((go) => waiting.push(go));
|
||||
calls += 1;
|
||||
try {
|
||||
const result = await request('tools/call', {
|
||||
name: config.toolName,
|
||||
// The server refuses the panel's requests without its key.
|
||||
arguments: { ...args, key: config.panelKey },
|
||||
});
|
||||
if (result?.isError || !result?.structuredContent)
|
||||
throw new Error(result?.content?.[0]?.text || 'The request failed');
|
||||
return result.structuredContent;
|
||||
} finally {
|
||||
calls -= 1;
|
||||
waiting.shift()?.();
|
||||
}
|
||||
}
|
||||
const toBase64 = (bytes) => {
|
||||
let binary = '';
|
||||
for (let start = 0; start < bytes.length; start += 0x8000)
|
||||
binary += String.fromCharCode(...bytes.subarray(start, start + 0x8000));
|
||||
return btoa(binary);
|
||||
};
|
||||
const fromBase64 = (text) =>
|
||||
Uint8Array.from(atob(text), (char) => char.charCodeAt(0));
|
||||
async function bodyBytes(body) {
|
||||
if (body == null) return null;
|
||||
if (typeof body === 'string') return new TextEncoder().encode(body);
|
||||
if (body instanceof ArrayBuffer) return new Uint8Array(body);
|
||||
if (ArrayBuffer.isView(body))
|
||||
return new Uint8Array(body.buffer, body.byteOffset, body.byteLength);
|
||||
return new Uint8Array(await new Response(body).arrayBuffer());
|
||||
}
|
||||
const NULL_BODY = new Set([101, 204, 205, 304]);
|
||||
const held = new Map();
|
||||
|
||||
/** Request a path on the app's server; resolves to a Response. */
|
||||
function appFetch(path, init = {}) {
|
||||
const method = (init.method || 'GET').toUpperCase();
|
||||
// Built files never change while the panel runs, so each loads once.
|
||||
const shared = method === 'GET' && path.startsWith(config.panelBase);
|
||||
if (shared && held.has(path)) return held.get(path).then(respond);
|
||||
const loading = (async () => {
|
||||
const headers = {};
|
||||
new Headers(init.headers || {}).forEach((value, name) => {
|
||||
headers[name] = value;
|
||||
});
|
||||
const bytes = await bodyBytes(init.body);
|
||||
let part = await callTool({
|
||||
path,
|
||||
method,
|
||||
headers,
|
||||
...(bytes ? { body: toBase64(bytes) } : {}),
|
||||
});
|
||||
const parts = [fromBase64(part.body)];
|
||||
while (part.nextOffset !== undefined) {
|
||||
if (init.signal?.aborted) break;
|
||||
part = await callTool({ id: part.id, offset: part.nextOffset });
|
||||
parts.push(fromBase64(part.body));
|
||||
}
|
||||
let body = new Blob(parts);
|
||||
if (part.encoding === 'gzip')
|
||||
body = await new Response(
|
||||
body.stream().pipeThrough(new DecompressionStream('gzip')),
|
||||
).blob();
|
||||
return {
|
||||
body: await body.arrayBuffer(),
|
||||
status: part.status,
|
||||
statusText: part.statusText,
|
||||
headers: part.headers,
|
||||
};
|
||||
})();
|
||||
if (shared) {
|
||||
held.set(path, loading);
|
||||
loading.catch(() => held.delete(path));
|
||||
}
|
||||
const result = loading.then(respond);
|
||||
if (!init.signal) return result;
|
||||
return Promise.race([
|
||||
result,
|
||||
new Promise((_, reject) => {
|
||||
const abort = () =>
|
||||
reject(new DOMException('The request was aborted', 'AbortError'));
|
||||
if (init.signal.aborted) abort();
|
||||
init.signal.addEventListener('abort', abort, { once: true });
|
||||
}),
|
||||
]);
|
||||
}
|
||||
function respond({ body, status, statusText, headers }) {
|
||||
return new Response(NULL_BODY.has(status) ? null : body.slice(0), {
|
||||
status,
|
||||
statusText,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
// The app's own paths: relative ones, absolute ones on this page's site
|
||||
// (matched by scheme and host, since a host may use its own scheme), and
|
||||
// ones under the app's address in the page, an https address for code
|
||||
// that needs one (`config.appBaseUrl`).
|
||||
const page = new URL(document.baseURI);
|
||||
const appOrigin = new URL(config.appBaseUrl).origin;
|
||||
function appPath(value) {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(String(value), document.baseURI);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const own =
|
||||
url.origin === appOrigin ||
|
||||
(url.protocol === page.protocol && url.host === page.host);
|
||||
return own ? url.pathname + url.search : null;
|
||||
}
|
||||
const fileUrls = new Map();
|
||||
/**
|
||||
* A data: URL holding an app file, for elements that load by URL. Hosts
|
||||
* allow data: images where some refuse blob: ones. Files of the panel
|
||||
* build never change and are kept; others, such as live camera frames
|
||||
* that change address on every refresh, are not.
|
||||
*/
|
||||
function appFileUrl(path) {
|
||||
if (fileUrls.has(path)) return fileUrls.get(path);
|
||||
const loading = appFetch(path)
|
||||
.then((response) => response.blob())
|
||||
.then(
|
||||
(blob) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result);
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsDataURL(blob);
|
||||
}),
|
||||
);
|
||||
if (path.startsWith(config.panelBase)) {
|
||||
fileUrls.set(path, loading);
|
||||
loading.catch(() => fileUrls.delete(path));
|
||||
}
|
||||
return loading;
|
||||
}
|
||||
|
||||
function installRequests() {
|
||||
const nativeFetch = window.fetch.bind(window);
|
||||
window.fetch = async (input, init = {}) => {
|
||||
const isRequest = input instanceof Request;
|
||||
const path = appPath(isRequest ? input.url : input);
|
||||
if (path === null) return nativeFetch(input, init);
|
||||
if (!isRequest) return appFetch(path, init);
|
||||
return appFetch(path, {
|
||||
method: init.method || input.method,
|
||||
headers: init.headers || input.headers,
|
||||
body:
|
||||
init.body ??
|
||||
(['GET', 'HEAD'].includes(input.method)
|
||||
? undefined
|
||||
: await input.arrayBuffer()),
|
||||
signal: init.signal || input.signal,
|
||||
});
|
||||
};
|
||||
|
||||
// XMLHttpRequest for app paths: answered from appFetch, with the
|
||||
// instance's state replaced, and the usual events dispatched.
|
||||
const xhr = XMLHttpRequest.prototype;
|
||||
const nativeOpen = xhr.open;
|
||||
const nativeSend = xhr.send;
|
||||
const nativeHeader = xhr.setRequestHeader;
|
||||
const nativeAbort = xhr.abort;
|
||||
xhr.open = function (method, url, ...rest) {
|
||||
const path = appPath(url);
|
||||
if (path === null) {
|
||||
this.__gev = null;
|
||||
return nativeOpen.call(this, method, url, ...rest);
|
||||
}
|
||||
this.__gev = { method, path, headers: {}, controller: null };
|
||||
setState(this, { readyState: 1 });
|
||||
this.dispatchEvent(new Event('readystatechange'));
|
||||
};
|
||||
xhr.setRequestHeader = function (name, value) {
|
||||
if (!this.__gev) return nativeHeader.call(this, name, value);
|
||||
this.__gev.headers[name] = value;
|
||||
};
|
||||
xhr.abort = function () {
|
||||
if (!this.__gev) return nativeAbort.call(this);
|
||||
this.__gev.controller?.abort();
|
||||
};
|
||||
xhr.send = function (body) {
|
||||
const state = this.__gev;
|
||||
if (!state) return nativeSend.call(this, body);
|
||||
state.controller = new AbortController();
|
||||
appFetch(state.path, {
|
||||
method: state.method,
|
||||
headers: state.headers,
|
||||
body,
|
||||
signal: state.controller.signal,
|
||||
})
|
||||
.then(async (response) => {
|
||||
const type = this.responseType;
|
||||
const value =
|
||||
type === 'arraybuffer'
|
||||
? await response.arrayBuffer()
|
||||
: type === 'blob'
|
||||
? await response.blob()
|
||||
: await response.text();
|
||||
const text = typeof value === 'string' ? value : null;
|
||||
let parsed = value;
|
||||
if (type === 'json') {
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
parsed = null;
|
||||
}
|
||||
} else if (type === 'document')
|
||||
parsed = new DOMParser().parseFromString(text, 'text/html');
|
||||
const headers = [...response.headers]
|
||||
.map(([name, val]) => `${name}: ${val}`)
|
||||
.join('\r\n');
|
||||
setState(this, {
|
||||
readyState: 4,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
response: parsed,
|
||||
responseText: type === '' || type === 'text' ? text : '',
|
||||
responseURL: new URL(state.path, document.baseURI).href,
|
||||
getAllResponseHeaders: () => headers,
|
||||
getResponseHeader: (name) => response.headers.get(name),
|
||||
});
|
||||
for (const name of ['readystatechange', 'load', 'loadend'])
|
||||
this.dispatchEvent(
|
||||
name === 'readystatechange'
|
||||
? new Event(name)
|
||||
: new ProgressEvent(name),
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
setState(this, { readyState: 4, status: 0 });
|
||||
const name = error?.name === 'AbortError' ? 'abort' : 'error';
|
||||
for (const event of ['readystatechange', name, 'loadend'])
|
||||
this.dispatchEvent(
|
||||
event === 'readystatechange'
|
||||
? new Event(event)
|
||||
: new ProgressEvent(event),
|
||||
);
|
||||
});
|
||||
};
|
||||
function setState(target, values) {
|
||||
for (const [name, value] of Object.entries(values))
|
||||
Object.defineProperty(target, name, {
|
||||
configurable: true,
|
||||
...(typeof value === 'function' ? { value } : { get: () => value }),
|
||||
});
|
||||
}
|
||||
|
||||
const nativeBeacon = navigator.sendBeacon?.bind(navigator);
|
||||
if (nativeBeacon)
|
||||
navigator.sendBeacon = (url, data) => {
|
||||
const path = appPath(url);
|
||||
if (path === null) return nativeBeacon(url, data);
|
||||
appFetch(path, { method: 'POST', body: data }).catch(() => {});
|
||||
return true;
|
||||
};
|
||||
|
||||
// Images name app files by URL; give them the file as a data: URL.
|
||||
const image = Object.getOwnPropertyDescriptor(
|
||||
HTMLImageElement.prototype,
|
||||
'src',
|
||||
);
|
||||
Object.defineProperty(HTMLImageElement.prototype, 'src', {
|
||||
...image,
|
||||
set(value) {
|
||||
const path = appPath(value);
|
||||
if (path === null) return image.set.call(this, value);
|
||||
appFileUrl(path).then(
|
||||
(url) => image.set.call(this, url),
|
||||
() => this.dispatchEvent(new Event('error')),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// Workers on the app's server start from a small worker here that is
|
||||
// handed the script once it has loaded; messages wait until then.
|
||||
const NativeWorker = window.Worker;
|
||||
const BOOT =
|
||||
'const early=[];let ready=false;' +
|
||||
"addEventListener('message',e=>{if(e.data&&e.data.__gevScript){" +
|
||||
'const go=()=>{ready=true;for(const d of early)dispatchEvent(new MessageEvent("message",{data:d}))};' +
|
||||
'if(e.data.module)import(e.data.__gevScript).then(go);else{importScripts(e.data.__gevScript);go()}' +
|
||||
'e.stopImmediatePropagation();return}' +
|
||||
'if(!ready){early.push(e.data);e.stopImmediatePropagation()}});';
|
||||
window.Worker = function Worker(url, options) {
|
||||
const path = appPath(url);
|
||||
if (path === null || String(url).startsWith('blob:'))
|
||||
return new NativeWorker(url, options);
|
||||
const worker = new NativeWorker(
|
||||
URL.createObjectURL(new Blob([BOOT], { type: 'text/javascript' })),
|
||||
options,
|
||||
);
|
||||
appFetch(path)
|
||||
.then((response) => response.blob())
|
||||
.then((blob) => URL.createObjectURL(blob))
|
||||
.then((script) =>
|
||||
worker.postMessage({
|
||||
__gevScript: script,
|
||||
module: options?.type === 'module',
|
||||
}),
|
||||
);
|
||||
return worker;
|
||||
};
|
||||
window.Worker.prototype = NativeWorker.prototype;
|
||||
|
||||
// Some hosts drop the GPU contents of a panel's 2D canvases while they
|
||||
// consider the panel off screen, and the overlays then cover the globe
|
||||
// in black. Keep 2D canvases in memory instead, which hosts do not drop.
|
||||
const getContext = HTMLCanvasElement.prototype.getContext;
|
||||
HTMLCanvasElement.prototype.getContext = function (type, options) {
|
||||
return type === '2d'
|
||||
? getContext.call(this, type, { ...options, willReadFrequently: true })
|
||||
: getContext.call(this, type, options);
|
||||
};
|
||||
|
||||
// Markup the app adds as HTML text would request its images from this
|
||||
// page's site at once; hold their addresses until the files are here.
|
||||
const htmlWithHeldImages = (html) =>
|
||||
String(html).replace(
|
||||
/(<(?:img|source)\b[^>]*?\s)src=(["'])([^"']*)\2/gi,
|
||||
(tag, before, quote, value) =>
|
||||
appPath(value) === null
|
||||
? tag
|
||||
: `${before}data-gev-src=${quote}${value}${quote}`,
|
||||
);
|
||||
const fillHeldImages = (root) => {
|
||||
for (const element of root?.querySelectorAll?.('[data-gev-src]') ?? []) {
|
||||
const path = appPath(element.getAttribute('data-gev-src'));
|
||||
element.removeAttribute('data-gev-src');
|
||||
appFileUrl(path).then((url) => element.setAttribute('src', url));
|
||||
}
|
||||
};
|
||||
const innerHtml = Object.getOwnPropertyDescriptor(
|
||||
Element.prototype,
|
||||
'innerHTML',
|
||||
);
|
||||
Object.defineProperty(Element.prototype, 'innerHTML', {
|
||||
...innerHtml,
|
||||
set(value) {
|
||||
innerHtml.set.call(this, htmlWithHeldImages(value));
|
||||
fillHeldImages(this);
|
||||
},
|
||||
});
|
||||
const insertHtml = Element.prototype.insertAdjacentHTML;
|
||||
Element.prototype.insertAdjacentHTML = function (position, html) {
|
||||
insertHtml.call(this, position, htmlWithHeldImages(html));
|
||||
fillHeldImages(this.parentNode ?? this);
|
||||
};
|
||||
|
||||
// Markup and styles the app adds later name app files too.
|
||||
new MutationObserver((records) => {
|
||||
for (const record of records)
|
||||
for (const node of record.addedNodes) {
|
||||
if (node.nodeType !== 1) continue;
|
||||
if (node.nodeName === 'STYLE') void inlineStyleUrls(node);
|
||||
for (const element of [node, ...node.querySelectorAll('img[src]')]) {
|
||||
if (element.nodeName !== 'IMG') continue;
|
||||
const path = appPath(element.getAttribute('src'));
|
||||
if (path !== null)
|
||||
appFileUrl(path).then((url) => element.setAttribute('src', url));
|
||||
}
|
||||
}
|
||||
}).observe(document.documentElement, { childList: true, subtree: true });
|
||||
}
|
||||
|
||||
/** Replace url() references to app files in CSS with data: URLs. */
|
||||
async function cssWithFiles(text, baseUrl) {
|
||||
const found = new Map();
|
||||
for (const match of text.matchAll(/url\(\s*(['"]?)([^'")]+)\1\s*\)/g)) {
|
||||
const reference = match[2];
|
||||
if (/^(data|blob):/.test(reference)) continue;
|
||||
const path = appPath(new URL(reference, baseUrl).href);
|
||||
if (path !== null && !found.has(reference))
|
||||
found.set(
|
||||
reference,
|
||||
appFileUrl(path).catch(() => null),
|
||||
);
|
||||
}
|
||||
let result = text;
|
||||
for (const [reference, loading] of found) {
|
||||
const url = await loading;
|
||||
if (url) result = result.split(reference).join(url);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
async function inlineStyleUrls(style) {
|
||||
if (!/url\(/.test(style.textContent)) return;
|
||||
style.textContent = await cssWithFiles(style.textContent, document.baseURI);
|
||||
}
|
||||
|
||||
async function addStylesheet(href) {
|
||||
const path = appPath(href);
|
||||
const style = document.createElement('style');
|
||||
document.head.appendChild(style);
|
||||
if (path === null) {
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = href;
|
||||
style.replaceWith(link);
|
||||
return;
|
||||
}
|
||||
const text = await (await appFetch(path)).text();
|
||||
style.textContent = await cssWithFiles(
|
||||
text,
|
||||
new URL(path, document.baseURI).href,
|
||||
);
|
||||
}
|
||||
|
||||
function runScript(text, type) {
|
||||
const script = document.createElement('script');
|
||||
if (type) script.type = type;
|
||||
script.textContent = text;
|
||||
document.body.appendChild(script);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load God's Eye View into this page from the app's panel build, in
|
||||
* inline embed mode, starting at the first view as its link would.
|
||||
*/
|
||||
async function startApp(url, view) {
|
||||
window.GEV_EMBED_INLINE = true;
|
||||
window.GEV_APP_BASE_URL = config.appBaseUrl;
|
||||
try {
|
||||
history.replaceState(
|
||||
null,
|
||||
'',
|
||||
location.href.split('#')[0] + new URL(url).hash,
|
||||
);
|
||||
// The app restores this view from its link, following included, so
|
||||
// it needs applying again only for what links cannot carry: cockpit.
|
||||
if (queued === view && !view.follow?.cockpit) queued = null;
|
||||
} catch {
|
||||
// The view still arrives once the app is ready.
|
||||
}
|
||||
installRequests();
|
||||
const response = await appFetch(config.panelBase);
|
||||
if (!response.ok)
|
||||
throw new Error(
|
||||
response.status === 404
|
||||
? 'the app has no panel build (npm run build:panel)'
|
||||
: `the app answered ${response.status}`,
|
||||
);
|
||||
const built = new DOMParser().parseFromString(
|
||||
await response.text(),
|
||||
'text/html',
|
||||
);
|
||||
window.CESIUM_BASE_URL = `${config.panelBase}cesium/`;
|
||||
const [prelude] = await Promise.all([
|
||||
appFetch(config.panelBase + config.workerPreludePath).then((r) =>
|
||||
r.text(),
|
||||
),
|
||||
...[...built.head.querySelectorAll('link[rel="stylesheet"]')].map(
|
||||
(link) => addStylesheet(link.getAttribute('href')),
|
||||
),
|
||||
]);
|
||||
// Cesium's script sets CESIUM_WORKERS, its workers as one script it
|
||||
// starts from memory. Workers request files themselves, which this page
|
||||
// cannot answer, so the prelude, holding those files, runs ahead of it.
|
||||
let workers;
|
||||
Object.defineProperty(window, 'CESIUM_WORKERS', {
|
||||
configurable: true,
|
||||
get: () => workers,
|
||||
set: (script) => {
|
||||
workers = prelude + script;
|
||||
},
|
||||
});
|
||||
// Images in the markup load their files once added; give them the
|
||||
// files instead of letting them request this page's site.
|
||||
const images = [];
|
||||
for (const image of built.body.querySelectorAll('img[src]')) {
|
||||
const path = appPath(image.getAttribute('src'));
|
||||
if (path === null) continue;
|
||||
image.removeAttribute('src');
|
||||
images.push([image, path]);
|
||||
}
|
||||
for (const node of [...built.body.childNodes]) {
|
||||
if (node.nodeName !== 'SCRIPT')
|
||||
document.body.insertBefore(document.adoptNode(node), status);
|
||||
}
|
||||
for (const [image, path] of images)
|
||||
appFileUrl(path).then((url) => image.setAttribute('src', url));
|
||||
const scripts = await Promise.all(
|
||||
[...built.querySelectorAll('script')].map(async (script) => ({
|
||||
type: script.type,
|
||||
text: script.getAttribute('src')
|
||||
? await (await appFetch(appPath(script.getAttribute('src')))).text()
|
||||
: script.textContent,
|
||||
})),
|
||||
);
|
||||
for (const { type, text } of scripts) runScript(text, type);
|
||||
// Browsers cap live 3D contexts across pages and take one back when
|
||||
// there are too many; say so rather than show no map.
|
||||
document.addEventListener(
|
||||
'webglcontextlost',
|
||||
() => {
|
||||
const notice = document.createElement('div');
|
||||
notice.id = 'status';
|
||||
notice.textContent =
|
||||
"This panel lost its 3D graphics, likely because other God's Eye " +
|
||||
"View panels in this conversation hold them. Use Open in God's " +
|
||||
'Eye View above, or show it in a new conversation.';
|
||||
document.body.appendChild(notice);
|
||||
},
|
||||
{ capture: true, once: true },
|
||||
);
|
||||
}
|
||||
|
||||
// Tool results: the first view starts the app, later ones move it.
|
||||
let started = false;
|
||||
let ready = false;
|
||||
let queued = null;
|
||||
let currentUrl = null;
|
||||
const postView = (view) =>
|
||||
window.postMessage({ type: 'gev:view', id: nextId++, view }, '*');
|
||||
function show(result) {
|
||||
const data = result?.structuredContent;
|
||||
const view = data?.view;
|
||||
const url = data?.url || view?.url;
|
||||
if (!view || !url) return;
|
||||
currentUrl = url;
|
||||
open.hidden = false;
|
||||
if (ready) return postView(view);
|
||||
queued = view;
|
||||
if (started) return;
|
||||
started = true;
|
||||
say("Loading God's Eye View…");
|
||||
startApp(url, view).catch((error) =>
|
||||
say(`God's Eye View could not load here: ${error?.message || error}.`),
|
||||
);
|
||||
setTimeout(() => {
|
||||
if (!ready)
|
||||
say(
|
||||
"God's Eye View did not load here" +
|
||||
(blocked ? ` (${blocked})` : '') +
|
||||
". Use Open in God's Eye View above.",
|
||||
);
|
||||
}, config.loadTimeoutMs);
|
||||
}
|
||||
|
||||
window.addEventListener('message', (event) => {
|
||||
const message = event.data;
|
||||
if (event.source === window) {
|
||||
if (message?.type === 'gev:ready') {
|
||||
ready = true;
|
||||
status.remove();
|
||||
if (queued) postView(queued);
|
||||
queued = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.source !== window.parent || message?.jsonrpc !== '2.0') return;
|
||||
if (
|
||||
message.id !== undefined &&
|
||||
pending.has(message.id) &&
|
||||
!message.method
|
||||
) {
|
||||
const { resolve, reject } = pending.get(message.id);
|
||||
pending.delete(message.id);
|
||||
if (message.error) reject(message.error);
|
||||
else resolve(message.result);
|
||||
return;
|
||||
}
|
||||
if (message.method === 'ui/notifications/tool-result') show(message.params);
|
||||
if (message.method === 'ui/notifications/host-context-changed')
|
||||
showDisplayMode(message.params);
|
||||
});
|
||||
|
||||
// Fullscreen, where the host offers it, gives the globe the room.
|
||||
let displayMode = 'inline';
|
||||
const showDisplayMode = (context) => {
|
||||
if (context?.displayMode) displayMode = context.displayMode;
|
||||
if (context?.availableDisplayModes)
|
||||
expand.hidden = !context.availableDisplayModes.includes('fullscreen');
|
||||
expand.textContent = displayMode === 'fullscreen' ? 'Collapse' : 'Expand';
|
||||
};
|
||||
expand.addEventListener('click', () => {
|
||||
request('ui/request-display-mode', {
|
||||
mode: displayMode === 'fullscreen' ? 'inline' : 'fullscreen',
|
||||
}).then(
|
||||
(result) => showDisplayMode({ displayMode: result?.mode }),
|
||||
() => {},
|
||||
);
|
||||
});
|
||||
|
||||
open.addEventListener('click', () => {
|
||||
if (!currentUrl) return;
|
||||
request('ui/open-link', { url: currentUrl }).catch(() =>
|
||||
window.open(currentUrl, '_blank', 'noopener'),
|
||||
);
|
||||
});
|
||||
|
||||
// The handshake the MCP Apps SDK's App.connect performs: hosts validate
|
||||
// these parameters, and keep a view hidden until it is initialized.
|
||||
request('ui/initialize', {
|
||||
appCapabilities: { availableDisplayModes: ['inline', 'fullscreen'] },
|
||||
appInfo: {
|
||||
name: 'gods-eye-view',
|
||||
title: "God's Eye View",
|
||||
version: '1.0.0',
|
||||
},
|
||||
protocolVersion: config.protocolVersion,
|
||||
}).then(
|
||||
(result) => {
|
||||
showDisplayMode(result?.hostContext);
|
||||
send({ method: 'ui/notifications/initialized' });
|
||||
notify('ui/notifications/size-changed', {
|
||||
width: document.body.clientWidth,
|
||||
height: config.panelHeight,
|
||||
});
|
||||
},
|
||||
(error) =>
|
||||
say(
|
||||
"This client did not accept the God's Eye View panel" +
|
||||
(error?.message ? `: ${error.message}` : '.'),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import * as credits from '../../data/dataCredits.js';
|
||||
import { createAlprCamerasLayer } from '../../layers/alpr/index.js';
|
||||
import * as render from '../../renderGovernor.js';
|
||||
import * as context from '../../data/contextStore.js';
|
||||
@@ -12,6 +13,7 @@ export function createApplicationAlpr({ surface, source }) {
|
||||
return createAlprCamerasLayer({
|
||||
source,
|
||||
services: {
|
||||
credits,
|
||||
render,
|
||||
context,
|
||||
picking,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createCctvLayer } from '../../layers/cctv/index.js';
|
||||
import * as credits from '../../data/dataCredits.js';
|
||||
import * as sprites from '../../data/spriteOrder.js';
|
||||
import * as activation from '../../cctvFocusRequest.js';
|
||||
import * as overlays from '../../overlays/worldOverlay.js';
|
||||
@@ -13,6 +14,7 @@ export function createApplicationCctv({ surface, source }) {
|
||||
return createCctvLayer({
|
||||
source,
|
||||
services: {
|
||||
credits,
|
||||
sprites,
|
||||
activation,
|
||||
overlays,
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import { createApplicationDirectionsLayer } from '../../data/directions.js';
|
||||
|
||||
/**
|
||||
* Construct one Directions layer using the application scene owners.
|
||||
*
|
||||
* It takes no data source: a route is fetched from `/api/route` when the
|
||||
* operator places both endpoints, so there is nothing to supply up front.
|
||||
* @returns {object} A fresh layer instance for this catalog.
|
||||
*/
|
||||
export function createApplicationDirections() {
|
||||
return createApplicationDirectionsLayer();
|
||||
/** Construct Directions with application scene owners and an explicit route source. */
|
||||
export function createApplicationDirections({ source }) {
|
||||
return createApplicationDirectionsLayer({ source });
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { overlayHost } from './overlayHost.js';
|
||||
import * as credits from '../../data/dataCredits.js';
|
||||
import { createInstallationsLayer } from '../../layers/installations/index.js';
|
||||
import * as render from '../../renderGovernor.js';
|
||||
import * as context from '../../data/contextStore.js';
|
||||
@@ -8,6 +10,27 @@ export function createApplicationInstallations({ surface, source }) {
|
||||
const { groundFloor: ground, anchors } = surface;
|
||||
return createInstallationsLayer({
|
||||
source,
|
||||
services: { render, context, ground, anchors, picking },
|
||||
overlayHost,
|
||||
services: {
|
||||
credits,
|
||||
render,
|
||||
context,
|
||||
ground,
|
||||
anchors,
|
||||
picking,
|
||||
maps: {
|
||||
subscribeMapStack(callback) {
|
||||
globalThis.window?.addEventListener?.(
|
||||
'gev:map-stack-changed',
|
||||
callback,
|
||||
);
|
||||
return () =>
|
||||
globalThis.window?.removeEventListener?.(
|
||||
'gev:map-stack-changed',
|
||||
callback,
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import {
|
||||
clearOverlaySource,
|
||||
getWorldOverlayDiagnostics,
|
||||
setOverlayEntries,
|
||||
setOverlaySourceVisible,
|
||||
} from '../../overlays/worldOverlay.js';
|
||||
export const overlayHost = Object.freeze({
|
||||
clearSource: clearOverlaySource,
|
||||
getDiagnostics: getWorldOverlayDiagnostics,
|
||||
setEntries: setOverlayEntries,
|
||||
setVisible: setOverlaySourceVisible,
|
||||
});
|
||||
@@ -3,17 +3,17 @@ import { createRecentImageryRenderer } from '../../layers/recentImagery/renderin
|
||||
import { createThumbnailLoader } from '../../layers/recentImagery/thumbnails.js';
|
||||
import { governorRequestRender } from '../../renderGovernor.js';
|
||||
|
||||
/**
|
||||
* Construct the Recent Imagery layer with the real GIBS renderer and the
|
||||
* browser thumbnail loader. It takes no data source: the catalog is NASA
|
||||
* CMR and the tiles are GIBS, both browser-direct and keyless.
|
||||
* @returns {object} A fresh layer instance for this catalog.
|
||||
*/
|
||||
export function createApplicationRecentImagery() {
|
||||
/** Construct imagery presentation over one explicitly supplied acquisition source. */
|
||||
export function createApplicationRecentImagery({ source }) {
|
||||
return createRecentImageryLayer({
|
||||
catalog: { searchHls: (...args) => source.searchHls(...args) },
|
||||
renderer: createRecentImageryRenderer({
|
||||
requestRender: governorRequestRender,
|
||||
tileTemplate: (...args) => source.getTileTemplate(...args),
|
||||
credit: source.credit || source.label || 'Imagery',
|
||||
}),
|
||||
thumbnails: createThumbnailLoader({
|
||||
requestThumbnail: (...args) => source.getThumbnail(...args),
|
||||
}),
|
||||
thumbnails: createThumbnailLoader(),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { createStreetLevelLayer } from '../../layers/streetLevel/index.js';
|
||||
import { createMapillaryProvider } from '../../layers/streetLevel/providers/mapillary/index.js';
|
||||
import * as sprites from '../../data/spriteOrder.js';
|
||||
import * as picking from '../../data/pickRegistry.js';
|
||||
import * as input from '../../data/inputOwnership.js';
|
||||
import * as render from '../../renderGovernor.js';
|
||||
|
||||
/**
|
||||
* Inject providers, or use Mapillary only when its source is supplied.
|
||||
* An explicit provider list replaces the default; unused sources.mapillary is
|
||||
* neither constructed nor validated, including when the list is empty.
|
||||
*/
|
||||
export function createApplicationStreetLevel({
|
||||
surface,
|
||||
sources = {},
|
||||
providers = sources.mapillary == null
|
||||
? []
|
||||
: [createMapillaryProvider({ source: sources.mapillary })],
|
||||
}) {
|
||||
return createStreetLevelLayer({
|
||||
providers,
|
||||
services: {
|
||||
sprites,
|
||||
picking,
|
||||
input,
|
||||
render,
|
||||
ground: surface?.groundFloor ?? null,
|
||||
meshFloor: surface?.meshFloor ?? null,
|
||||
terrain: surface?.terrain ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createApplicationStreetLevel } from './streetLevel.js';
|
||||
import { fakeStreetLevelProvider } from '../../testSupport/streetLevelFakes.mjs';
|
||||
|
||||
test('explicit providers replace the default source and an empty list removes it', () => {
|
||||
const sources = { mapillary: {} };
|
||||
const provider = fakeStreetLevelProvider({
|
||||
id: 'example',
|
||||
pickPrefix: 'example:',
|
||||
});
|
||||
const layer = createApplicationStreetLevel({
|
||||
sources,
|
||||
providers: [provider],
|
||||
});
|
||||
assert.deepEqual(layer.providerIds, ['example']);
|
||||
assert.deepEqual(
|
||||
createApplicationStreetLevel({ sources, providers: [] }).providerIds,
|
||||
[],
|
||||
);
|
||||
assert.throws(
|
||||
() => createApplicationStreetLevel({ sources }),
|
||||
/Mapillary source/,
|
||||
);
|
||||
assert.throws(
|
||||
() => createApplicationStreetLevel({ providers: null }),
|
||||
/array/,
|
||||
);
|
||||
});
|
||||
@@ -1,11 +1,12 @@
|
||||
import { createTrafficLayer } from '../../layers/traffic/index.js';
|
||||
import * as credits from '../../data/dataCredits.js';
|
||||
import * as ground from '../../data/groundFloor.js';
|
||||
import * as render from '../../renderGovernor.js';
|
||||
|
||||
/** Construct one layer using the application scene owners and a supplied source. */
|
||||
export function createApplicationTraffic({ source }) {
|
||||
export function createApplicationTraffic({ source, surface }) {
|
||||
return createTrafficLayer({
|
||||
source,
|
||||
services: { credits, render },
|
||||
services: { credits, render, ground: surface?.groundFloor || ground },
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { showOsmCredit, hideOsmCredit } from '../data/dataCredits.js';
|
||||
import { governorRequestRender } from '../renderGovernor.js';
|
||||
import {
|
||||
clearSelectedEntityContextForLayer,
|
||||
@@ -22,4 +23,6 @@ export const localGeoJsonServices = Object.freeze({
|
||||
removeEntityContextsForLayer,
|
||||
selectEntityContext,
|
||||
governorRequestRender,
|
||||
showOsmCredit,
|
||||
hideOsmCredit,
|
||||
});
|
||||
@@ -39,6 +39,7 @@ export function createApplicationOperations({ requests, signal, eventTarget }) {
|
||||
searchAndFlyTo(viewer, query, {
|
||||
...options,
|
||||
features,
|
||||
ground: surface.groundFloor,
|
||||
signal:
|
||||
signal && options.signal
|
||||
? AbortSignal.any([signal, options.signal])
|
||||
|
||||
+3
-1
@@ -19,6 +19,7 @@ import { describeError } from './errors.js';
|
||||
export async function createApplicationScene({
|
||||
requestServices,
|
||||
googleApiKey,
|
||||
googleTokens = null,
|
||||
cesiumToken,
|
||||
credits,
|
||||
MapController = MapStackController,
|
||||
@@ -59,11 +60,12 @@ export async function createApplicationScene({
|
||||
registerDataCredits(viewer, credits);
|
||||
configureCreditKeyboardAccess(document);
|
||||
loaderStatus.textContent =
|
||||
googleApiKey || cesiumToken
|
||||
googleApiKey || googleTokens || cesiumToken
|
||||
? 'Loading Google 3D Tiles...'
|
||||
: 'Loading the keyless globe...';
|
||||
const photoreal = await loadPhotorealisticTileset(Cesium, {
|
||||
googleApiKey,
|
||||
googleTokens,
|
||||
cesiumToken,
|
||||
});
|
||||
const tileset = photoreal.tileset;
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
const contract = (layers, methods) =>
|
||||
Object.freeze({
|
||||
layers: Object.freeze(layers),
|
||||
methods: Object.freeze(methods),
|
||||
});
|
||||
|
||||
/** Acquisition contracts and their consumers; records remain family-specific. */
|
||||
export const CATALOG_SOURCE_CONTRACTS = Object.freeze({
|
||||
directions: contract(['directions'], ['getRoute']),
|
||||
'recent-imagery': contract(
|
||||
['recent-imagery'],
|
||||
['searchHls', 'getThumbnail', 'getTileTemplate'],
|
||||
),
|
||||
flights: contract(['flights'], ['getSnapshot']),
|
||||
military: contract(['military'], ['getSnapshot']),
|
||||
vessels: contract(['ais-live-vessels'], ['getSnapshot']),
|
||||
cctv: contract(
|
||||
['cctv'],
|
||||
['getCatalog', 'getHealth', 'getFrameUrl', 'getMediaUrl'],
|
||||
),
|
||||
radio: contract(['radio'], ['getDirectory', 'recordClick']),
|
||||
traffic: contract(
|
||||
['traffic'],
|
||||
[
|
||||
'requestRoads',
|
||||
'getStatus',
|
||||
'fetchFlowForBounds',
|
||||
'getFlowSessionStats',
|
||||
'resetFlowTileCache',
|
||||
],
|
||||
),
|
||||
bikeshare: contract(['bikeshare'], ['getStations']),
|
||||
installations: contract(
|
||||
['military-installations'],
|
||||
['getMappedSites', 'searchNearby'],
|
||||
),
|
||||
satellites: contract(['satellites'], ['readGroup']),
|
||||
launches: contract(['rocket-launches'], ['getLaunches', 'getActiveTle']),
|
||||
alpr: contract(['alpr-cameras'], ['fetch']),
|
||||
firms: contract(['local-firms'], ['getSnapshot']),
|
||||
wind: contract(['wind'], ['getSnapshot']),
|
||||
weather: contract(
|
||||
['weather-radar', 'weather-satellite', 'weather-lightning'],
|
||||
['getSnapshot'],
|
||||
),
|
||||
cyclones: contract(['weather-cyclones'], ['getSnapshot']),
|
||||
earthquakes: contract(['earthquakes'], ['getSnapshot']),
|
||||
'fire-perimeters': contract(['fire-perimeters'], ['getSnapshot']),
|
||||
cables: contract(['telegeography-submarine-cables'], ['fetch']),
|
||||
transit: contract(['transit'], ['requestSnapshot', 'getHistory']),
|
||||
});
|
||||
|
||||
/** An omitted provider is unavailable, never an authoritative empty dataset. */
|
||||
export class SourceUnavailableError extends Error {
|
||||
constructor(name) {
|
||||
super(`Data source not configured: ${name}`);
|
||||
this.name = 'SourceUnavailableError';
|
||||
this.code = 'unavailable';
|
||||
this.unavailable = true;
|
||||
this.retryable = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate supplied contracts and keep omitted sources inert during construction. */
|
||||
export function resolveCatalogSources(supplied = {}) {
|
||||
if (!supplied || typeof supplied !== 'object' || Array.isArray(supplied))
|
||||
throw new TypeError('Catalog sources must be an object');
|
||||
const sources = { ...supplied };
|
||||
const missing = new Set();
|
||||
for (const [name, { methods }] of Object.entries(CATALOG_SOURCE_CONTRACTS)) {
|
||||
const source = sources[name];
|
||||
if (source == null) {
|
||||
missing.add(name);
|
||||
const unavailable = () => {
|
||||
throw new SourceUnavailableError(name);
|
||||
};
|
||||
sources[name] = Object.freeze({
|
||||
label: name,
|
||||
...Object.fromEntries(methods.map((method) => [method, unavailable])),
|
||||
});
|
||||
} else if (methods.some((method) => typeof source[method] !== 'function')) {
|
||||
throw new TypeError(`Invalid catalog source: ${name}`);
|
||||
}
|
||||
}
|
||||
return {
|
||||
sources,
|
||||
isConfigured: (name) => !missing.has(name),
|
||||
createAvailabilityLookup(layers) {
|
||||
const byId = new Map(layers.map((layer) => [layer.id, layer]));
|
||||
const availability = new Map();
|
||||
for (const [name, { layers: ids }] of Object.entries(
|
||||
CATALOG_SOURCE_CONTRACTS,
|
||||
)) {
|
||||
for (const id of ids) {
|
||||
const layer = byId.get(id);
|
||||
if (!layer) throw new Error(`Missing source consumer: ${id}`);
|
||||
availability.set(
|
||||
id,
|
||||
Object.freeze({
|
||||
available: !missing.has(name),
|
||||
source: name,
|
||||
reason: missing.has(name)
|
||||
? `${layer.name || id}: data source not configured`
|
||||
: null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
return (id) =>
|
||||
availability.get(id) ?? byId.get(id)?.getSourceAvailability?.();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
resolveCatalogSources,
|
||||
CATALOG_SOURCE_CONTRACTS,
|
||||
} from './sourceComposition.js';
|
||||
import { createApplicationCatalog } from './constructCatalog.js';
|
||||
import { createSurfaceServices } from './surfaceServices.js';
|
||||
import { createStandaloneLayerSources } from '../standalone/layerSources.js';
|
||||
import { LayerLifecycle } from '../data/lifecycle.js';
|
||||
|
||||
function catalog(sources, t) {
|
||||
const lifetime = new AbortController();
|
||||
t.after(() => lifetime.abort());
|
||||
return createApplicationCatalog({
|
||||
sources,
|
||||
signal: lifetime.signal,
|
||||
surface: createSurfaceServices({
|
||||
terrainSource: { getHeights: async () => [] },
|
||||
signal: lifetime.signal,
|
||||
eventTarget: null,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
test('every acquisition source can be omitted without losing catalog membership', (t) => {
|
||||
const baseline = catalog(createStandaloneLayerSources(), t);
|
||||
for (const name of Object.keys(CATALOG_SOURCE_CONTRACTS)) {
|
||||
const sources = createStandaloneLayerSources({ [name]: null });
|
||||
const actual = catalog(sources, t);
|
||||
assert.deepEqual(actual.metadata, baseline.metadata, name);
|
||||
for (const id of CATALOG_SOURCE_CONTRACTS[name].layers) {
|
||||
assert.equal(actual.getSourceAvailability(id).available, false, id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('all acquisition sources may be absent, and enable fails before initializing or fetching', async (t) => {
|
||||
const actual = catalog({}, t);
|
||||
assert.equal(actual.layers.length, 30);
|
||||
const manager = new LayerLifecycle(
|
||||
{},
|
||||
{ getSourceAvailability: actual.getSourceAvailability },
|
||||
);
|
||||
for (const layer of actual.layers) manager.register(layer);
|
||||
for (const { layers: ids } of Object.values(CATALOG_SOURCE_CONTRACTS)) {
|
||||
for (const id of ids) {
|
||||
assert.equal(await manager.setEnabled(id, true), false, id);
|
||||
assert.equal(manager.layers.get(id).initialized, false, id);
|
||||
const row = manager.getAll().find((entry) => entry.id === id);
|
||||
assert.equal(row.enabled, false);
|
||||
assert.equal(row.stats.status, 'unavailable');
|
||||
assert.match(row.stats.error, /data source not configured/);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('configured sources retain identity; invalid implementations never become unavailable silently', () => {
|
||||
const source = { getSnapshot: async () => [] };
|
||||
assert.equal(
|
||||
resolveCatalogSources({ earthquakes: source }).sources.earthquakes,
|
||||
source,
|
||||
);
|
||||
for (const name of Object.keys(CATALOG_SOURCE_CONTRACTS)) {
|
||||
assert.throws(
|
||||
() => resolveCatalogSources({ [name]: {} }),
|
||||
/Invalid catalog source/,
|
||||
);
|
||||
}
|
||||
const missing = resolveCatalogSources({}).sources.earthquakes;
|
||||
assert.throws(() => missing.getSnapshot(), { code: 'unavailable' });
|
||||
});
|
||||
|
||||
test('standalone defaults allow exact replacements and explicit removal, and reject typos', () => {
|
||||
const source = { getSnapshot: async () => [] };
|
||||
const sources = createStandaloneLayerSources({
|
||||
flights: source,
|
||||
military: null,
|
||||
});
|
||||
assert.equal(sources.flights, source);
|
||||
assert.equal(sources.military, null);
|
||||
assert.equal(typeof sources.cctv.getCatalog, 'function');
|
||||
assert.throws(
|
||||
() => createStandaloneLayerSources({ fligts: source }),
|
||||
/Unknown source/,
|
||||
);
|
||||
});
|
||||
|
||||
test('empty Street Level uses the shared unavailable lifecycle without initialization', async (t) => {
|
||||
const actual = catalog({}, t);
|
||||
const manager = new LayerLifecycle(
|
||||
{},
|
||||
{ getSourceAvailability: actual.getSourceAvailability },
|
||||
);
|
||||
manager.register(actual.get('street-level'));
|
||||
assert.equal(await manager.setEnabled('street-level', true), false);
|
||||
assert.equal(manager.layers.get('street-level').initialized, false);
|
||||
const row = manager.getAll()[0];
|
||||
assert.equal(row.stats.status, 'unavailable');
|
||||
assert.equal(row.stats.error, 'No street-level imagery providers configured');
|
||||
assert.equal(await manager.setEnabled('street-level', false), true);
|
||||
const configured = catalog(createStandaloneLayerSources(), t);
|
||||
assert.equal(
|
||||
configured.get('street-level').getSourceAvailability().available,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('availability lookup accepts frozen layers and uses their display names', () => {
|
||||
const composition = resolveCatalogSources({});
|
||||
const layers = Object.values(CATALOG_SOURCE_CONTRACTS)
|
||||
.flatMap(({ layers }) => layers)
|
||||
.map((id) =>
|
||||
Object.freeze({
|
||||
id,
|
||||
name: id === 'fire-perimeters' ? 'Fire perimeters' : id,
|
||||
}),
|
||||
);
|
||||
const lookup = composition.createAvailabilityLookup(layers);
|
||||
assert.equal(
|
||||
lookup('fire-perimeters').reason,
|
||||
'Fire perimeters: data source not configured',
|
||||
);
|
||||
assert.equal(Object.hasOwn(layers[0], 'getSourceAvailability'), false);
|
||||
const manager = new LayerLifecycle({}, { getSourceAvailability: lookup });
|
||||
manager.register(layers.find(({ id }) => id === 'fire-perimeters'));
|
||||
assert.equal(
|
||||
manager.getAll()[0].stats.error,
|
||||
'Fire perimeters: data source not configured',
|
||||
);
|
||||
});
|
||||
|
||||
test('absent military acquisition does not poll, suppress flights, or erase supplied classification', async (t) => {
|
||||
let fetches = 0;
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
fetches++;
|
||||
throw new Error('Unexpected fallback acquisition');
|
||||
});
|
||||
const actual = catalog(createStandaloneLayerSources({ military: null }), t);
|
||||
const registry = actual.militaryRegistry;
|
||||
assert.equal(actual.getSourceAvailability('flights').available, true);
|
||||
assert.equal(registry.isMilitaryLayerActive(), false);
|
||||
assert.equal(registry.isMilitaryIcao('abc123'), false);
|
||||
registry.registerMilitaryIcaos([' ABC123 ']);
|
||||
await registry.refreshMilitaryRegistryIfStale();
|
||||
assert.equal(registry.isMilitaryIcao('abc123'), true);
|
||||
assert.equal(registry.isMilitaryIcao('def456'), false);
|
||||
const manager = new LayerLifecycle(
|
||||
{},
|
||||
{ getSourceAvailability: actual.getSourceAvailability },
|
||||
);
|
||||
manager.register(actual.get('military'));
|
||||
assert.equal(await manager.setEnabled('military', true), false);
|
||||
assert.equal(registry.isMilitaryLayerActive(), false);
|
||||
assert.equal(fetches, 0);
|
||||
});
|
||||
+13
-1
@@ -4,6 +4,7 @@ import { initDrawTool } from '../annotations/drawTool.js';
|
||||
import { initImageryBoxTool } from '../ui/imageryBoxTool.js';
|
||||
import { createRecentImageryPanel } from '../ui/recentImagery.js';
|
||||
import { initGevVoiceCommands } from '../voice/gevRealtime.js';
|
||||
import { installViews, isEmbeddedInline } from './embed.js';
|
||||
import { installScopeMask, destroyScopeMask } from '../scopeMask.js';
|
||||
import {
|
||||
installRenderGovernor,
|
||||
@@ -118,7 +119,9 @@ export function createApplicationTools({
|
||||
// GPU. Holder/data state is untouched, so return is seamless: restore
|
||||
// the loop, refresh the one DOM surface we gated, render a frame.
|
||||
const syncVisibilitySuspension = () => {
|
||||
const hidden = document.hidden;
|
||||
// A panel's host may report it hidden while it is on screen; the panel
|
||||
// keeps drawing itself (see keepPanelRendering in embed.js).
|
||||
const hidden = document.hidden && !isEmbeddedInline();
|
||||
viewer.useDefaultRenderLoop = !hidden;
|
||||
cockpitCloudEffects?.setSuspended?.(hidden);
|
||||
if (!hidden) {
|
||||
@@ -176,5 +179,14 @@ export function createApplicationTools({
|
||||
delete window.__gevVoiceCommands;
|
||||
});
|
||||
debug.voiceCommands = voiceCommands;
|
||||
defer(
|
||||
installViews({
|
||||
shell: styleManager,
|
||||
viewer,
|
||||
dataManager,
|
||||
run: (name, args) => voiceCommands.runner(name, args, { signal }),
|
||||
signal,
|
||||
}),
|
||||
);
|
||||
return { sceneDirector, annotations, voiceCommands };
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Camera ground guard: never leave an arrival buried in, or pressed against,
|
||||
* the rendered surface.
|
||||
*
|
||||
* Arrival framing is computed before the destination's tiles exist, so it has
|
||||
* to predict the ground height. `globe.getHeight()` answers nothing while the
|
||||
* globe is hidden under photoreal tiles, curated `groundElevation` presets
|
||||
* cover only a few cities, and the elevation service resolves asynchronously.
|
||||
* A missing prediction used to fall back to sea level, which buried the eye by
|
||||
* roughly the local elevation (Denver ~1,600 m; Austin ~170 m).
|
||||
*
|
||||
* After the flight lands and tiles have streamed in, this module measures the
|
||||
* rendered surface under the framed target and under the camera itself, and
|
||||
* lifts the eye if it sits below a usable clearance. Heading and pitch are
|
||||
* held, so the correction reads as the shot settling.
|
||||
*/
|
||||
import * as Cesium from 'cesium';
|
||||
import { ownCameraArrival } from './data/cameraArrival.js';
|
||||
|
||||
/** Minimum eye height above the rendered surface for a usable view. */
|
||||
export const MIN_EYE_CLEARANCE_M = 120;
|
||||
/** Retries while tiles stream in; sampling fails until the mesh exists. */
|
||||
export const GUARD_ATTEMPTS = 8;
|
||||
export const GUARD_INTERVAL_MS = 600;
|
||||
/** Ignore sub-metre noise rather than nudging the camera forever. */
|
||||
const GUARD_EPSILON_M = 2;
|
||||
|
||||
const probeScratch = new Cesium.Cartographic();
|
||||
|
||||
/**
|
||||
* Highest rendered surface across a few cells, with how many cells answered.
|
||||
* A cell whose tiles are not streamed yet contributes nothing.
|
||||
* @param {Cesium.Scene} scene Live scene.
|
||||
* @param {Array<{lat: number, lon: number}>} cells Cells to probe.
|
||||
* @returns {{heightM: number, sampled: number}} Reading.
|
||||
*/
|
||||
function sampleSurfaceM(scene, cells) {
|
||||
let heightM = Number.NaN;
|
||||
let sampled = 0;
|
||||
if (typeof scene?.sampleHeight !== 'function') return { heightM, sampled };
|
||||
for (const cell of cells) {
|
||||
try {
|
||||
const height = scene.sampleHeight(
|
||||
Cesium.Cartographic.fromDegrees(cell.lon, cell.lat, 0, probeScratch),
|
||||
);
|
||||
if (!Number.isFinite(height)) continue;
|
||||
sampled += 1;
|
||||
heightM = Number.isFinite(heightM) ? Math.max(heightM, height) : height;
|
||||
} catch {
|
||||
/* tiles not ready for this cell */
|
||||
}
|
||||
}
|
||||
return { heightM, sampled };
|
||||
}
|
||||
|
||||
/**
|
||||
* How far the camera must rise to clear the ground, given a measured surface.
|
||||
* Pure so the policy is testable without a scene.
|
||||
* @param {number} cameraHeightM Eye height above the ellipsoid.
|
||||
* @param {number} groundHeightM Measured surface height above the ellipsoid.
|
||||
* @param {number} [clearanceM] Desired eye height above that surface.
|
||||
* @returns {number} Metres to lift; 0 when the view is already clear.
|
||||
*/
|
||||
export function groundClearanceDeficitM(
|
||||
cameraHeightM,
|
||||
groundHeightM,
|
||||
clearanceM = MIN_EYE_CLEARANCE_M,
|
||||
) {
|
||||
if (!Number.isFinite(cameraHeightM) || !Number.isFinite(groundHeightM))
|
||||
return 0;
|
||||
const deficit = groundHeightM + clearanceM - cameraHeightM;
|
||||
return deficit > GUARD_EPSILON_M ? deficit : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch an arrival and lift the camera once the real surface can be measured.
|
||||
*
|
||||
* Stops quietly on a clear view, exhausted attempts, the user taking the
|
||||
* controls (pointer or wheel on the canvas), or `isStale()` reporting that a
|
||||
* newer arrival owns the camera.
|
||||
* @param {Cesium.Viewer} viewer Live viewer.
|
||||
* @param {{lat: number, lon: number}} at Destination being framed.
|
||||
* @param {{clearanceM?: number, attempts?: number, intervalMs?: number,
|
||||
* isStale?: () => boolean, onLift?: (liftM: number) => void}} [options]
|
||||
* @returns {() => void} Cancel function.
|
||||
*/
|
||||
export function guardCameraAboveGround(viewer, at, options = {}) {
|
||||
const {
|
||||
clearanceM = MIN_EYE_CLEARANCE_M,
|
||||
attempts = GUARD_ATTEMPTS,
|
||||
intervalMs = GUARD_INTERVAL_MS,
|
||||
isStale = () => false,
|
||||
onLift = null,
|
||||
} = options;
|
||||
if (!viewer?.scene || !Number.isFinite(at?.lat) || !Number.isFinite(at?.lon))
|
||||
return () => {};
|
||||
|
||||
let timer = null;
|
||||
let done = false;
|
||||
const canvas = viewer.scene.canvas;
|
||||
const documentRef = canvas?.ownerDocument;
|
||||
const removers = [];
|
||||
let release = () => {};
|
||||
const stop = () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
clearTimeout(timer);
|
||||
for (const type of ['pointerdown', 'wheel'])
|
||||
canvas?.removeEventListener?.(type, stop);
|
||||
documentRef?.removeEventListener?.('keydown', stop, true);
|
||||
for (const remove of removers) remove();
|
||||
release();
|
||||
};
|
||||
release = ownCameraArrival(viewer, stop);
|
||||
// Direct camera API callers (including duration-zero flights) must yield
|
||||
// before moving the eye. Restore the exact method ownership on completion.
|
||||
for (const name of [
|
||||
'flyTo',
|
||||
'flyToBoundingSphere',
|
||||
'setView',
|
||||
'lookAt',
|
||||
'cancelFlight',
|
||||
]) {
|
||||
const camera = viewer.camera;
|
||||
const original = camera[name];
|
||||
if (typeof original !== 'function') continue;
|
||||
const descriptor = Object.getOwnPropertyDescriptor(camera, name);
|
||||
const wrapped = function (...args) {
|
||||
stop();
|
||||
return original.apply(this, args);
|
||||
};
|
||||
camera[name] = wrapped;
|
||||
removers.push(() => {
|
||||
if (camera[name] !== wrapped) return;
|
||||
if (descriptor) Object.defineProperty(camera, name, descriptor);
|
||||
else delete camera[name];
|
||||
});
|
||||
}
|
||||
const removeTracking = viewer.trackedEntityChanged?.addEventListener(stop);
|
||||
if (removeTracking) removers.push(removeTracking);
|
||||
documentRef?.addEventListener?.('keydown', stop, true);
|
||||
// A manual gesture ends the guard, as it interrupts camera motion elsewhere.
|
||||
for (const type of ['pointerdown', 'wheel'])
|
||||
canvas?.addEventListener?.(type, stop, { once: true, passive: true });
|
||||
|
||||
let remaining = attempts;
|
||||
const attempt = () => {
|
||||
if (done) return;
|
||||
if (isStale() || viewer.isDestroyed?.()) return stop();
|
||||
remaining -= 1;
|
||||
const camera = viewer.camera;
|
||||
const carto = camera.positionCartographic;
|
||||
// The eye must clear both the framed target and the ground beneath it.
|
||||
const below = {
|
||||
lat: Cesium.Math.toDegrees(carto.latitude),
|
||||
lon: Cesium.Math.toDegrees(carto.longitude),
|
||||
};
|
||||
const { heightM, sampled } = sampleSurfaceM(viewer.scene, [
|
||||
{ lat: at.lat, lon: at.lon },
|
||||
below,
|
||||
]);
|
||||
if (sampled > 0 && Number.isFinite(heightM)) {
|
||||
const deficit = groundClearanceDeficitM(
|
||||
carto.height,
|
||||
heightM,
|
||||
clearanceM,
|
||||
);
|
||||
if (deficit > 0) {
|
||||
stop();
|
||||
camera.flyTo({
|
||||
destination: Cesium.Cartesian3.fromRadians(
|
||||
carto.longitude,
|
||||
carto.latitude,
|
||||
carto.height + deficit,
|
||||
),
|
||||
orientation: {
|
||||
heading: camera.heading,
|
||||
pitch: camera.pitch,
|
||||
roll: camera.roll,
|
||||
},
|
||||
duration: 0.9,
|
||||
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
|
||||
});
|
||||
try {
|
||||
onLift?.(deficit);
|
||||
} catch {
|
||||
/* observer only */
|
||||
}
|
||||
}
|
||||
// Both samples answered: the surface is measured, nothing left to wait for.
|
||||
if (sampled === 2 || deficit > 0) return stop();
|
||||
}
|
||||
if (remaining <= 0) return stop();
|
||||
timer = setTimeout(attempt, intervalMs);
|
||||
};
|
||||
timer = setTimeout(attempt, intervalMs);
|
||||
return stop;
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
// Camera ground-guard policy tests: pure arithmetic plus a fake scene, no network.
|
||||
//
|
||||
// Arrival framing is computed before the destination's tiles exist, so it
|
||||
// works from a predicted ground height. The guard measures the real surface
|
||||
// after arrival and lifts the eye; these tests pin when it acts.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
MIN_EYE_CLEARANCE_M,
|
||||
groundClearanceDeficitM,
|
||||
guardCameraAboveGround,
|
||||
} from './cameraGroundGuard.js';
|
||||
|
||||
test('a buried camera is lifted clear of the surface', () => {
|
||||
// Framed near sea level, arriving over ground at ~1,609 m (Denver).
|
||||
const lift = groundClearanceDeficitM(140, 1609);
|
||||
assert.equal(lift, 1609 + MIN_EYE_CLEARANCE_M - 140);
|
||||
});
|
||||
|
||||
test('a camera resting on the surface is lifted to a usable height', () => {
|
||||
// Camp Mabry field report: eye 1 m above the mesh at 171 m.
|
||||
assert.equal(groundClearanceDeficitM(172, 171), MIN_EYE_CLEARANCE_M - 1);
|
||||
});
|
||||
|
||||
test('a well-framed arrival is left alone', () => {
|
||||
assert.equal(groundClearanceDeficitM(1755, 1609), 0);
|
||||
assert.equal(groundClearanceDeficitM(2960, 1609), 0);
|
||||
});
|
||||
|
||||
test('sub-metre noise never triggers a nudge', () => {
|
||||
assert.equal(groundClearanceDeficitM(1609 + MIN_EYE_CLEARANCE_M, 1609), 0);
|
||||
assert.equal(
|
||||
groundClearanceDeficitM(1609 + MIN_EYE_CLEARANCE_M - 1, 1609),
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('an unmeasurable surface is never acted on', () => {
|
||||
assert.equal(groundClearanceDeficitM(1000, Number.NaN), 0);
|
||||
assert.equal(groundClearanceDeficitM(Number.NaN, 1609), 0);
|
||||
assert.equal(groundClearanceDeficitM(1000, undefined), 0);
|
||||
});
|
||||
|
||||
test('the clearance frames a subject without turning into an overflight', () => {
|
||||
assert.ok(MIN_EYE_CLEARANCE_M >= 60);
|
||||
assert.ok(MIN_EYE_CLEARANCE_M <= 250);
|
||||
});
|
||||
|
||||
function fakeViewer({ cameraHeightM, surfaceM }) {
|
||||
const flights = [];
|
||||
const position = Cesium.Cartographic.fromDegrees(-97.7603, 30.3141, cameraHeightM);
|
||||
return {
|
||||
flights,
|
||||
scene: {
|
||||
canvas: null,
|
||||
sampleHeight: (carto) =>
|
||||
typeof surfaceM === 'function' ? surfaceM(carto) : surfaceM,
|
||||
},
|
||||
camera: {
|
||||
positionCartographic: position,
|
||||
heading: 0.5,
|
||||
pitch: -0.55,
|
||||
roll: 0,
|
||||
flyTo: (options) => flights.push(options),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('the guard lifts a buried arrival once the surface answers', async () => {
|
||||
const viewer = fakeViewer({ cameraHeightM: 172, surfaceM: 171 });
|
||||
const lifts = [];
|
||||
guardCameraAboveGround(
|
||||
viewer,
|
||||
{ lat: 30.3125, lon: -97.765 },
|
||||
{ intervalMs: 1, onLift: (m) => lifts.push(m) },
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
assert.equal(viewer.flights.length, 1);
|
||||
const lifted = Cesium.Cartographic.fromCartesian(
|
||||
viewer.flights[0].destination,
|
||||
);
|
||||
assert.ok(Math.abs(lifted.height - (171 + MIN_EYE_CLEARANCE_M)) < 0.5);
|
||||
assert.equal(viewer.flights[0].orientation.heading, 0.5);
|
||||
assert.equal(lifts.length, 1);
|
||||
});
|
||||
|
||||
test('the guard waits for streaming tiles and yields to a newer arrival', async () => {
|
||||
let answer = Number.NaN;
|
||||
let stale = false;
|
||||
const viewer = fakeViewer({ cameraHeightM: 172, surfaceM: () => answer });
|
||||
guardCameraAboveGround(
|
||||
viewer,
|
||||
{ lat: 30.3125, lon: -97.765 },
|
||||
{ intervalMs: 2, attempts: 20, isStale: () => stale },
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 12));
|
||||
assert.equal(viewer.flights.length, 0, 'no guess while tiles stream in');
|
||||
stale = true;
|
||||
answer = 171;
|
||||
await new Promise((resolve) => setTimeout(resolve, 12));
|
||||
assert.equal(viewer.flights.length, 0, 'a newer arrival owns the camera');
|
||||
});
|
||||
|
||||
test('the guard leaves a clear view untouched', async () => {
|
||||
const viewer = fakeViewer({ cameraHeightM: 400, surfaceM: 171 });
|
||||
guardCameraAboveGround(viewer, { lat: 30.3125, lon: -97.765 }, { intervalMs: 1 });
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
assert.equal(viewer.flights.length, 0);
|
||||
});
|
||||
|
||||
// Handoffs act before the delayed probe, even when the next owner stays low.
|
||||
import { NavigationController } from './ui/navigationController.js';
|
||||
import { SceneDirector } from './scenes/director.js';
|
||||
import { LayerLifecycle } from './data/lifecycle.js';
|
||||
|
||||
function handoffFixture(t) {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const viewer = fakeViewer({ cameraHeightM: 172, surfaceM: 171 });
|
||||
viewer.scene.canvas = new EventTarget();
|
||||
viewer.scene.canvas.ownerDocument = new EventTarget();
|
||||
viewer.trackedEntityChanged = new Cesium.Event();
|
||||
viewer.camera.cancelFlight = () => {};
|
||||
viewer.camera.lookAtTransform = () => {};
|
||||
viewer.camera.flyToBoundingSphere = () => {};
|
||||
viewer.camera.setView = () => {};
|
||||
const originalFly = viewer.camera.flyTo;
|
||||
const navigation = new NavigationController({
|
||||
viewer,
|
||||
tracking: Object.fromEntries(['flightsLayer', 'militaryFlightsLayer',
|
||||
'satellitesLayer', 'aisLiveVesselsLayer', 'militaryAwarenessLayer',
|
||||
'rocketLaunchesLayer'].map((key) => [key, {}])),
|
||||
cancelOrientation() {}, clearLocation() {}, cancelShareSelection() {},
|
||||
getDataManager() {}, interruptCameraMotion() {}, stopOrbit() {},
|
||||
isCockpitActive: () => false, showToast() {},
|
||||
});
|
||||
guardCameraAboveGround(viewer, { lat: 30.3, lon: -97.7 });
|
||||
return { viewer, navigation, originalFly };
|
||||
}
|
||||
|
||||
for (const handoff of ['Director', 'tracking', 'follow', 'keyboard', 'UI',
|
||||
'flyTo', 'flyToBoundingSphere', 'setView', 'layer-disable', 'layer-destroy', 'app-stop']) {
|
||||
test(`arrival guard relinquishes and removes hooks on ${handoff}`, async (t) => {
|
||||
const { viewer, navigation, originalFly } = handoffFixture(t);
|
||||
if (handoff === 'Director') {
|
||||
SceneDirector.prototype._claimCameraOwnership.call({
|
||||
viewer, styleManager: { runImmediateNavigation: (noun, fn) =>
|
||||
navigation._runExplicitNavigation(noun, fn) },
|
||||
});
|
||||
} else if (handoff === 'tracking') {
|
||||
viewer.trackedEntityChanged.raiseEvent({ id: 'contact' });
|
||||
} else if (handoff === 'follow' || handoff === 'UI') {
|
||||
navigation._stampNavigation({ cancelPendingSelection: false });
|
||||
} else if (handoff === 'keyboard') {
|
||||
viewer.scene.canvas.ownerDocument.dispatchEvent(new Event('keydown'));
|
||||
} else if (handoff === 'app-stop') navigation.stop();
|
||||
else if (handoff.startsWith('layer-')) {
|
||||
const lifecycle = new LayerLifecycle(viewer);
|
||||
lifecycle.register({ id: 'test', init() {}, enable() {}, disable() {}, destroy() {} });
|
||||
await lifecycle.setEnabled('test', true);
|
||||
if (handoff === 'layer-disable') await lifecycle.setEnabled('test', false);
|
||||
else await lifecycle.destroyLayer('test');
|
||||
} else viewer.camera[handoff]({});
|
||||
const priorFlights = viewer.flights.length;
|
||||
t.mock.timers.tick(10000);
|
||||
assert.equal(viewer.flights.length, priorFlights, 'the old probe cannot fly');
|
||||
assert.equal(viewer.camera.flyTo, originalFly, 'direct-call hook is removed');
|
||||
assert.equal(viewer.trackedEntityChanged.numberOfListeners, 0);
|
||||
navigation.destroy();
|
||||
});
|
||||
}
|
||||
@@ -86,23 +86,47 @@ test('Cockpit heading tape leaves the bottom exit row unobstructed', () => {
|
||||
assert.doesNotMatch(css, /cockpit-compass-label/);
|
||||
});
|
||||
|
||||
test('Cockpit vision cycle exposes exactly five real visual styles without NONE', () => {
|
||||
assert.match(cycleVisionMode.toString(), /const modes = COCKPIT_VISION_MODES;/);
|
||||
assert.match(setVisionMode.toString(), /const labels = \{\s*optical: inherited,\s*crt: 'CRT',\s*nvg: 'NVG',\s*thermal: 'FLIR',\s*noir: 'NOIR',?\s*\};/);
|
||||
test('Cockpit vision cycle exposes every map preset exactly once', () => {
|
||||
assert.match(
|
||||
cycleVisionMode.toString(),
|
||||
/const modes = COCKPIT_VISION_MODES;/,
|
||||
);
|
||||
assert.match(
|
||||
setVisionMode.toString(),
|
||||
/const labels = \{\s*optical: 'NORMAL',\s*crt: 'CRT',\s*nvg: 'NVG',\s*thermal: 'FLIR',\s*anime: 'ANIME',\s*noir: 'NOIR',\s*snow: 'SNOW',?\s*\};/,
|
||||
);
|
||||
assert.doesNotMatch(setVisionMode.toString(), /none: 'NONE'/);
|
||||
assert.match(ui, /getInheritedVisionLabel: \(\) =>\s*\(?[\s\S]*?STYLE_STATUS_LABELS\[this\.activeStyle\]/);
|
||||
assert.doesNotMatch(ui, /getInheritedVisionLabel/);
|
||||
assert.match(html, /id="cockpit-vision-current-label"[^>]*>NORMAL<\/strong>/);
|
||||
assert.match(ui, /const target = applyCockpitVisionStageIntensities\(\s*this\.stages,\s*next,\s*this\._cockpitVisionRestore,?\s*\);/);
|
||||
assert.match(ui, /this\._cockpitVisionRestore = captureCockpitVisionBaseline\(\s*this\.stages,\s*this\.transitions,?\s*\);/);
|
||||
assert.match(
|
||||
cockpitEnter.toString(),
|
||||
/this\.setVisionMode\(this\.getInitialVisionMode\(\)\)/,
|
||||
'Cockpit must enter on the mode matching the active map preset',
|
||||
);
|
||||
assert.match(
|
||||
readRadioSource(
|
||||
new URL('./ui/cockpitCoordinator.js', import.meta.url),
|
||||
'utf8',
|
||||
),
|
||||
/getInitialVisionMode:\s*\(\)\s*=>\s*cockpitVisionModeForStyle\(this\.activeStyle\)/,
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
/if \(next === 'optical'\) \{[\s\S]*?applyCockpitVisionStageIntensities\(\s*this\.stages,\s*next,\s*this\._cockpitVisionRestore,?\s*\);[\s\S]*?return;[\s\S]*?const target = applyCockpitVisionStageIntensities/,
|
||||
'the inherited entry must restore the map shader while CRT, NVG, FLIR, and NOIR remain temporary Cockpit overrides',
|
||||
/const target = applyCockpitVisionStageIntensities\(\s*this\.stages,\s*next,?\s*\);/,
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
/this\._cockpitVisionRestore = captureCockpitVisionBaseline\(\s*this\.stages,\s*this\.transitions,?\s*\);/,
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
/if \(next === 'optical'\) \{[\s\S]*?applyCockpitVisionStageIntensities\(\s*this\.stages,\s*next,?\s*\);[\s\S]*?this\._updateSliderPanel\(null,[\s\S]*?return;[\s\S]*?const target = applyCockpitVisionStageIntensities/,
|
||||
'Normal must clear the inherited map shader while the other map presets remain temporary Cockpit overrides',
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
/_syncCockpitInheritedStyle\(\)[\s\S]*?name === this\.activeStyle \? 1 : 0[\s\S]*?this\.transitions\.delete\(name\)[\s\S]*?setVisionMode\(this\.cockpitView\.visionMode\)/,
|
||||
'changing the map preset in Cockpit must refresh both the inherited label and restore baseline',
|
||||
'changing the map preset in Cockpit must refresh the exit restore baseline without replacing Normal',
|
||||
);
|
||||
assert.match(ui, /setStyle\([\s\S]*?this\._syncCockpitInheritedStyle\(\);/);
|
||||
});
|
||||
|
||||
@@ -3,21 +3,40 @@ export const COCKPIT_VISION_MODES = Object.freeze([
|
||||
'crt',
|
||||
'nvg',
|
||||
'thermal',
|
||||
'anime',
|
||||
'noir',
|
||||
'snow',
|
||||
]);
|
||||
|
||||
const TARGET_STYLE_BY_MODE = Object.freeze({
|
||||
crt: 'retro',
|
||||
nvg: 'surveillance',
|
||||
thermal: 'thermal',
|
||||
anime: 'anime',
|
||||
noir: 'noir',
|
||||
snow: 'snow',
|
||||
});
|
||||
|
||||
/** Normalize a requested Cockpit vision mode to the inherited preset entry. */
|
||||
const COCKPIT_MODE_BY_STYLE = Object.freeze({
|
||||
normal: 'optical',
|
||||
retro: 'crt',
|
||||
surveillance: 'nvg',
|
||||
thermal: 'thermal',
|
||||
anime: 'anime',
|
||||
noir: 'noir',
|
||||
snow: 'snow',
|
||||
});
|
||||
|
||||
/** Normalize a requested Cockpit vision mode to the unfiltered Normal entry. */
|
||||
export function normalizeCockpitVisionMode(mode) {
|
||||
return COCKPIT_VISION_MODES.includes(mode) ? mode : 'optical';
|
||||
}
|
||||
|
||||
/** Start Cockpit on the equivalent supported map preset. */
|
||||
export function cockpitVisionModeForStyle(style) {
|
||||
return COCKPIT_MODE_BY_STYLE[String(style || '').toLowerCase()] || 'optical';
|
||||
}
|
||||
|
||||
/** Settle pending map-style crossfades and return their intended final intensities. */
|
||||
export function captureCockpitVisionBaseline(stages, transitions) {
|
||||
const baseline = {};
|
||||
@@ -34,12 +53,10 @@ export function captureCockpitVisionBaseline(stages, transitions) {
|
||||
* Apply Cockpit-only stage intensities without changing any shader parameters.
|
||||
* Returns the temporary style whose parameters should be shown, or null.
|
||||
*/
|
||||
export function applyCockpitVisionStageIntensities(stages, mode, restore = {}) {
|
||||
export function applyCockpitVisionStageIntensities(stages, mode) {
|
||||
const next = normalizeCockpitVisionMode(mode);
|
||||
if (next === 'optical') {
|
||||
for (const [name, intensity] of Object.entries(restore)) {
|
||||
if (stages[name]) stages[name].uniforms.intensity = intensity;
|
||||
}
|
||||
for (const stage of Object.values(stages)) stage.uniforms.intensity = 0;
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import test from 'node:test';
|
||||
import {
|
||||
applyCockpitVisionStageIntensities,
|
||||
captureCockpitVisionBaseline,
|
||||
cockpitVisionModeForStyle,
|
||||
COCKPIT_VISION_MODES,
|
||||
normalizeCockpitVisionMode,
|
||||
} from './cockpitVisionPolicy.js';
|
||||
@@ -14,12 +15,31 @@ const createStages = () => ({
|
||||
thermal: { uniforms: { intensity: 0, heat: 0.8 } },
|
||||
});
|
||||
|
||||
test('Cockpit vision order exposes inherited, CRT, NVG, FLIR, and NOIR modes', () => {
|
||||
assert.deepEqual(COCKPIT_VISION_MODES, ['optical', 'crt', 'nvg', 'thermal', 'noir']);
|
||||
test('Cockpit vision order mirrors the unique map preset order', () => {
|
||||
assert.deepEqual(COCKPIT_VISION_MODES, [
|
||||
'optical',
|
||||
'crt',
|
||||
'nvg',
|
||||
'thermal',
|
||||
'anime',
|
||||
'noir',
|
||||
'snow',
|
||||
]);
|
||||
assert.equal(normalizeCockpitVisionMode('none'), 'optical');
|
||||
assert.equal(normalizeCockpitVisionMode('unknown'), 'optical');
|
||||
});
|
||||
|
||||
test('Cockpit entry selects the vision mode equivalent to the active map style', () => {
|
||||
assert.equal(cockpitVisionModeForStyle('normal'), 'optical');
|
||||
assert.equal(cockpitVisionModeForStyle('retro'), 'crt');
|
||||
assert.equal(cockpitVisionModeForStyle('surveillance'), 'nvg');
|
||||
assert.equal(cockpitVisionModeForStyle('thermal'), 'thermal');
|
||||
assert.equal(cockpitVisionModeForStyle('anime'), 'anime');
|
||||
assert.equal(cockpitVisionModeForStyle('noir'), 'noir');
|
||||
assert.equal(cockpitVisionModeForStyle('snow'), 'snow');
|
||||
assert.equal(cockpitVisionModeForStyle('unsupported'), 'optical');
|
||||
});
|
||||
|
||||
test('Cockpit settles pending map crossfades before a temporary preset takes ownership', () => {
|
||||
const stages = createStages();
|
||||
const transitions = new Map([
|
||||
@@ -29,49 +49,81 @@ test('Cockpit settles pending map crossfades before a temporary preset takes own
|
||||
const restore = captureCockpitVisionBaseline(stages, transitions);
|
||||
assert.deepEqual(restore, { noir: 1, retro: 0, surveillance: 0, thermal: 0 });
|
||||
assert.equal(transitions.size, 0);
|
||||
applyCockpitVisionStageIntensities(stages, 'nvg', restore);
|
||||
applyCockpitVisionStageIntensities(stages, 'nvg');
|
||||
assert.equal(stages.surveillance.uniforms.intensity, 1);
|
||||
applyCockpitVisionStageIntensities(stages, 'optical', restore);
|
||||
assert.equal(stages.noir.uniforms.intensity, 1);
|
||||
applyCockpitVisionStageIntensities(stages, 'optical');
|
||||
assert.equal(stages.noir.uniforms.intensity, 0);
|
||||
assert.equal(stages.retro.uniforms.intensity, 0);
|
||||
});
|
||||
|
||||
test('returning from a temporary preset restores the exact inherited intensities', () => {
|
||||
test('Normal clears every temporary and inherited visual stage', () => {
|
||||
const stages = createStages();
|
||||
const restore = Object.fromEntries(
|
||||
Object.entries(stages).map(([name, stage]) => [name, stage.uniforms.intensity]),
|
||||
Object.entries(stages).map(([name, stage]) => [
|
||||
name,
|
||||
stage.uniforms.intensity,
|
||||
]),
|
||||
);
|
||||
applyCockpitVisionStageIntensities(stages, 'thermal', restore);
|
||||
applyCockpitVisionStageIntensities(stages, 'optical', restore);
|
||||
applyCockpitVisionStageIntensities(stages, 'thermal');
|
||||
applyCockpitVisionStageIntensities(stages, 'optical');
|
||||
assert.deepEqual(
|
||||
Object.fromEntries(Object.entries(stages).map(([name, stage]) => [name, stage.uniforms.intensity])),
|
||||
restore,
|
||||
Object.fromEntries(
|
||||
Object.entries(stages).map(([name, stage]) => [
|
||||
name,
|
||||
stage.uniforms.intensity,
|
||||
]),
|
||||
),
|
||||
{ noir: 0, retro: 0, surveillance: 0, thermal: 0 },
|
||||
);
|
||||
assert.deepEqual(restore, {
|
||||
noir: 0.72,
|
||||
retro: 0.18,
|
||||
surveillance: 0,
|
||||
thermal: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('temporary styles replace each other without changing the inherited restore snapshot', () => {
|
||||
const stages = createStages();
|
||||
const restore = Object.fromEntries(
|
||||
Object.entries(stages).map(([name, stage]) => [name, stage.uniforms.intensity]),
|
||||
Object.entries(stages).map(([name, stage]) => [
|
||||
name,
|
||||
stage.uniforms.intensity,
|
||||
]),
|
||||
);
|
||||
applyCockpitVisionStageIntensities(stages, 'thermal', restore);
|
||||
assert.equal(applyCockpitVisionStageIntensities(stages, 'crt', restore), 'retro');
|
||||
applyCockpitVisionStageIntensities(stages, 'thermal');
|
||||
assert.equal(applyCockpitVisionStageIntensities(stages, 'crt'), 'retro');
|
||||
assert.equal(stages.retro.uniforms.intensity, 1);
|
||||
assert.equal(stages.noir.uniforms.intensity, 0);
|
||||
applyCockpitVisionStageIntensities(stages, 'optical', restore);
|
||||
assert.equal(stages.noir.uniforms.intensity, 0.72);
|
||||
assert.equal(stages.retro.uniforms.intensity, 0.18);
|
||||
applyCockpitVisionStageIntensities(stages, 'optical');
|
||||
assert.equal(stages.noir.uniforms.intensity, 0);
|
||||
assert.equal(stages.retro.uniforms.intensity, 0);
|
||||
assert.deepEqual(restore, {
|
||||
noir: 0.72,
|
||||
retro: 0.18,
|
||||
surveillance: 0,
|
||||
thermal: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('NOIR is a temporary Cockpit override and inherited restores the captured map style', () => {
|
||||
test('NOIR is a temporary Cockpit override and Normal clears it', () => {
|
||||
const stages = createStages();
|
||||
const restore = Object.fromEntries(
|
||||
Object.entries(stages).map(([name, stage]) => [name, stage.uniforms.intensity]),
|
||||
Object.entries(stages).map(([name, stage]) => [
|
||||
name,
|
||||
stage.uniforms.intensity,
|
||||
]),
|
||||
);
|
||||
assert.equal(applyCockpitVisionStageIntensities(stages, 'noir', restore), 'noir');
|
||||
assert.equal(applyCockpitVisionStageIntensities(stages, 'noir'), 'noir');
|
||||
assert.equal(stages.noir.uniforms.intensity, 1);
|
||||
assert.equal(stages.retro.uniforms.intensity, 0);
|
||||
applyCockpitVisionStageIntensities(stages, 'optical', restore);
|
||||
assert.equal(stages.noir.uniforms.intensity, 0.72);
|
||||
assert.equal(stages.retro.uniforms.intensity, 0.18);
|
||||
applyCockpitVisionStageIntensities(stages, 'optical');
|
||||
assert.equal(stages.noir.uniforms.intensity, 0);
|
||||
assert.equal(stages.retro.uniforms.intensity, 0);
|
||||
assert.deepEqual(restore, {
|
||||
noir: 0.72,
|
||||
retro: 0.18,
|
||||
surveillance: 0,
|
||||
thermal: 0,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,273 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import test from 'node:test';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
codexAuthPath,
|
||||
preferCodexOAuth,
|
||||
readCodexOAuthAccessToken,
|
||||
resolveCodexExecutable,
|
||||
startCodexChatGptLogin,
|
||||
} from '../server/providers/openai/codex-auth.js';
|
||||
|
||||
const nowMs = Date.UTC(2026, 8, 27);
|
||||
const jwt = (payload) =>
|
||||
`${Buffer.from('{}').toString('base64url')}.${Buffer.from(JSON.stringify(payload)).toString('base64url')}.signature`;
|
||||
const auth = (token, extras = {}) =>
|
||||
JSON.stringify({
|
||||
auth_mode: 'chatgpt',
|
||||
tokens: { access_token: token, refresh_token: 'refresh-fixture' },
|
||||
...extras,
|
||||
});
|
||||
|
||||
test('optional Codex OAuth pin fails closed on ambiguous values', () => {
|
||||
assert.equal(preferCodexOAuth({}), false);
|
||||
assert.equal(preferCodexOAuth({ GEV_PREFER_CODEX_OAUTH: 'true' }), true);
|
||||
assert.equal(preferCodexOAuth({ GEV_PREFER_CODEX_OAUTH: 'off' }), false);
|
||||
assert.throws(
|
||||
() => preferCodexOAuth({ GEV_PREFER_CODEX_OAUTH: 'maybe' }),
|
||||
/must be true or false/,
|
||||
);
|
||||
});
|
||||
|
||||
test('Codex auth path follows explicit auth file, CODEX_HOME, then home directory', () => {
|
||||
assert.equal(
|
||||
codexAuthPath({
|
||||
environment: { CODEX_AUTH_JSON: '/tmp/custom-auth.json' },
|
||||
home: '/home/fixture',
|
||||
}),
|
||||
path.resolve('/tmp/custom-auth.json'),
|
||||
);
|
||||
assert.equal(
|
||||
codexAuthPath({
|
||||
environment: { CODEX_HOME: '/tmp/codex-home' },
|
||||
home: '/home/fixture',
|
||||
}),
|
||||
path.join(path.resolve('/tmp/codex-home'), 'auth.json'),
|
||||
);
|
||||
assert.equal(
|
||||
codexAuthPath({ environment: {}, home: '/home/fixture' }),
|
||||
path.join('/home/fixture', '.codex', 'auth.json'),
|
||||
);
|
||||
});
|
||||
|
||||
test('Codex OAuth reader accepts a current ChatGPT token and rejects unusable credentials', () => {
|
||||
const current = jwt({ exp: nowMs / 1000 + 3600 });
|
||||
assert.equal(
|
||||
readCodexOAuthAccessToken({
|
||||
authPath: '/unused',
|
||||
nowMs,
|
||||
readFile: () => auth(current),
|
||||
}),
|
||||
current,
|
||||
);
|
||||
assert.equal(
|
||||
readCodexOAuthAccessToken({
|
||||
authPath: '/unused',
|
||||
nowMs,
|
||||
readFile: () =>
|
||||
JSON.stringify({
|
||||
auth_mode: 'chatgpt',
|
||||
tokens: { access_token: current },
|
||||
}),
|
||||
}),
|
||||
current,
|
||||
'GEV does not require or consume the vendor refresh token',
|
||||
);
|
||||
for (const readFile of [
|
||||
() => '{bad json',
|
||||
() => JSON.stringify({ tokens: {} }),
|
||||
() => auth(current, { auth_mode: 'apikey' }),
|
||||
() =>
|
||||
JSON.stringify({
|
||||
OPENAI_API_KEY: 'sk-fixture',
|
||||
tokens: { access_token: current, refresh_token: 'refresh-fixture' },
|
||||
}),
|
||||
() => auth('opaque-token'),
|
||||
() => auth('header.not-json.signature'),
|
||||
() => auth(jwt({ exp: 'tomorrow' })),
|
||||
() => {
|
||||
throw new Error('missing');
|
||||
},
|
||||
]) {
|
||||
assert.throws(
|
||||
() => readCodexOAuthAccessToken({ authPath: '/unused', readFile, nowMs }),
|
||||
/ChatGPT sign-in is unavailable/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('Codex OAuth reader enforces expiry margin and bounds tokens without exp by file age', () => {
|
||||
for (const exp of [nowMs / 1000 - 1, nowMs / 1000 + 59]) {
|
||||
assert.throws(
|
||||
() => {
|
||||
readCodexOAuthAccessToken({
|
||||
authPath: '/unused',
|
||||
nowMs,
|
||||
readFile: () => auth(jwt({ exp })),
|
||||
});
|
||||
},
|
||||
(error) => {
|
||||
assert.match(error.message, /sign-in has expired/);
|
||||
assert.equal(error.code, 'CODEX_OAUTH_REAUTH_REQUIRED');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
}
|
||||
const noExp = jwt({ sub: 'fixture' });
|
||||
const base = { authPath: '/unused', nowMs, readFile: () => auth(noExp) };
|
||||
assert.equal(
|
||||
readCodexOAuthAccessToken({
|
||||
...base,
|
||||
statFile: () => ({ mtimeMs: nowMs }),
|
||||
}),
|
||||
noExp,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
readCodexOAuthAccessToken({
|
||||
...base,
|
||||
statFile: () => ({ mtimeMs: nowMs - 3600_000 }),
|
||||
}),
|
||||
/sign-in has expired/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
readCodexOAuthAccessToken({
|
||||
...base,
|
||||
statFile: () => {
|
||||
throw new Error('missing');
|
||||
},
|
||||
}),
|
||||
/sign-in is unavailable/,
|
||||
);
|
||||
});
|
||||
|
||||
test('Codex login resolves an installed executable before PATH and starts browser auth detached', async () => {
|
||||
const checked = [];
|
||||
assert.equal(
|
||||
resolveCodexExecutable({
|
||||
environment: {},
|
||||
home: '/home/fixture',
|
||||
access(candidate) {
|
||||
checked.push(candidate);
|
||||
if (candidate.endsWith('/.local/bin/codex')) return;
|
||||
throw new Error('missing');
|
||||
},
|
||||
}),
|
||||
path.join('/home/fixture', '.local', 'bin', 'codex'),
|
||||
);
|
||||
assert.equal(checked.length, 1);
|
||||
|
||||
const child = new EventEmitter();
|
||||
let unref = 0;
|
||||
child.unref = () => {
|
||||
unref += 1;
|
||||
};
|
||||
const calls = [];
|
||||
const pending = startCodexChatGptLogin({
|
||||
executable: '/fixture/codex',
|
||||
home: '/home/fixture',
|
||||
environment: {
|
||||
PATH: '/fixture',
|
||||
https_proxy: 'http://proxy.fixture:8080',
|
||||
OPENAI_API_KEY: 'fixture-api-key',
|
||||
GOOGLE_MAPS_SERVER_API_KEY: 'fixture-google-key',
|
||||
OPENSKY_CLIENT_SECRET: 'fixture-opensky-secret',
|
||||
},
|
||||
spawnImpl(command, args, options) {
|
||||
calls.push({ command, args, options });
|
||||
queueMicrotask(() => child.emit('spawn'));
|
||||
return child;
|
||||
},
|
||||
});
|
||||
const login = await pending;
|
||||
assert.equal(unref, 1);
|
||||
assert.equal(calls[0].command, '/fixture/codex');
|
||||
assert.deepEqual(calls[0].args, ['login']);
|
||||
assert.equal(calls[0].options.detached, true);
|
||||
assert.equal(calls[0].options.stdio, 'ignore');
|
||||
assert.deepEqual(
|
||||
calls[0].options.env,
|
||||
{
|
||||
PATH: '/fixture',
|
||||
https_proxy: 'http://proxy.fixture:8080',
|
||||
CODEX_HOME: path.join('/home/fixture', '.codex'),
|
||||
},
|
||||
'provider secrets stay out of the login process',
|
||||
);
|
||||
child.emit('exit', 1);
|
||||
assert.deepEqual(await login.completion, { exitCode: 1 });
|
||||
});
|
||||
|
||||
test('Codex login refuses a read/write storage mismatch before spawning', async () => {
|
||||
await assert.rejects(
|
||||
startCodexChatGptLogin({
|
||||
environment: { CODEX_AUTH_JSON: '/tmp/another-auth.json' },
|
||||
home: '/home/fixture',
|
||||
executable: 'codex',
|
||||
spawnImpl: () => assert.fail('mismatched storage must not launch login'),
|
||||
}),
|
||||
{ code: 'CODEX_OAUTH_STORAGE_MISMATCH' },
|
||||
);
|
||||
});
|
||||
|
||||
test('Codex login resolves relative CODEX_HOME before changing the child directory', async () => {
|
||||
const child = new EventEmitter();
|
||||
const environment = { CODEX_HOME: './fixture-codex' };
|
||||
const login = await startCodexChatGptLogin({
|
||||
environment,
|
||||
executable: 'codex',
|
||||
spawnImpl(_command, _args, options) {
|
||||
assert.equal(
|
||||
options.env.CODEX_HOME,
|
||||
path.resolve(environment.CODEX_HOME),
|
||||
);
|
||||
queueMicrotask(() => child.emit('spawn'));
|
||||
return child;
|
||||
},
|
||||
});
|
||||
assert.equal(
|
||||
environment.CODEX_HOME,
|
||||
'./fixture-codex',
|
||||
'caller environment is unchanged',
|
||||
);
|
||||
child.emit('exit', 0);
|
||||
assert.deepEqual(await login.completion, { exitCode: 0 });
|
||||
});
|
||||
|
||||
test('Codex login hides spawn errors and can cancel only its owned child', async () => {
|
||||
const child = new EventEmitter();
|
||||
await assert.rejects(
|
||||
startCodexChatGptLogin({
|
||||
executable: 'codex',
|
||||
spawnImpl() {
|
||||
queueMicrotask(() =>
|
||||
child.emit('error', new Error('private-token-fixture')),
|
||||
);
|
||||
return child;
|
||||
},
|
||||
}),
|
||||
(error) =>
|
||||
error.code === 'CODEX_OAUTH_LOGIN_FAILED' &&
|
||||
!error.message.includes('private-token-fixture'),
|
||||
);
|
||||
|
||||
const running = new EventEmitter();
|
||||
let cancelled = 0;
|
||||
running.kill = (signal) => {
|
||||
assert.equal(signal, 'SIGTERM');
|
||||
cancelled += 1;
|
||||
running.emit('exit', null);
|
||||
};
|
||||
const login = await startCodexChatGptLogin({
|
||||
executable: 'codex',
|
||||
spawnImpl() {
|
||||
queueMicrotask(() => running.emit('spawn'));
|
||||
return running;
|
||||
},
|
||||
});
|
||||
login.cancel();
|
||||
assert.equal(cancelled, 1);
|
||||
assert.deepEqual(await login.completion, { exitCode: null });
|
||||
});
|
||||
@@ -0,0 +1,281 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { Readable } from 'node:stream';
|
||||
import { readPinokioEnvironment } from '../scripts/pinokio-environment.mjs';
|
||||
import { openAiRealtimeProxy } from 'gods-eye-view/server/providers/openai';
|
||||
import {
|
||||
makeCostRateLimiter,
|
||||
resolvePerMinuteCap,
|
||||
} from '../server/providers/common/rate-limit.js';
|
||||
import { OPENAI_DEFAULT_PER_MIN } from '../server/providers/openai/rate-limit.js';
|
||||
import { handleHudSummary } from '../server/providers/openai/hud-summary.js';
|
||||
import {
|
||||
googlePlacesContextProxy,
|
||||
GOOGLE_DEFAULT_PER_MIN,
|
||||
} from '../server/providers/places/google.js';
|
||||
|
||||
// Both limiters are cached at module scope on first use, so the env value that
|
||||
// matters is the one in place when a route is first exercised — restoring the
|
||||
// variable afterwards does not rebuild the limiter. Each route is therefore
|
||||
// driven by exactly one test here; a second test wanting a different cap would
|
||||
// need its own process, not just a different env value.
|
||||
|
||||
/** Set an env var for one test and put the old value back afterwards. */
|
||||
function env(t, name, value) {
|
||||
const old = process.env[name];
|
||||
if (value === undefined) delete process.env[name];
|
||||
else process.env[name] = value;
|
||||
t.after(() => {
|
||||
if (old === undefined) delete process.env[name];
|
||||
else process.env[name] = old;
|
||||
});
|
||||
}
|
||||
|
||||
function install(plugin) {
|
||||
const routes = new Map();
|
||||
plugin.configureServer({
|
||||
middlewares: { use: (route, handler) => routes.set(route, handler) },
|
||||
restart: async () => {},
|
||||
});
|
||||
return routes;
|
||||
}
|
||||
|
||||
function request(
|
||||
handler,
|
||||
{ url = '/', method = 'POST', remoteAddress = '127.0.0.1' } = {},
|
||||
) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = Readable.from([]);
|
||||
Object.assign(req, {
|
||||
method,
|
||||
url,
|
||||
headers: { host: 'localhost:4173', 'content-type': 'application/json' },
|
||||
socket: { remoteAddress },
|
||||
});
|
||||
const headers = {};
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
setHeader(name, value) {
|
||||
headers[name.toLowerCase()] = value;
|
||||
},
|
||||
end(body = '') {
|
||||
resolve({ status: this.statusCode, headers, body: String(body) });
|
||||
},
|
||||
};
|
||||
Promise.resolve(handler(req, res)).catch(reject);
|
||||
});
|
||||
}
|
||||
|
||||
test('an unset throttle resolves to the documented default, not to unlimited', () => {
|
||||
// The defect #16 reports: the cap was opt-in, so an unconfigured server let
|
||||
// anyone who could reach it mint tokens on the operator's key without bound.
|
||||
assert.equal(resolvePerMinuteCap(undefined, 30), 30);
|
||||
assert.equal(resolvePerMinuteCap('', 30), 30);
|
||||
assert.equal(resolvePerMinuteCap(' ', 30), 30);
|
||||
assert.ok(
|
||||
typeof makeCostRateLimiter(undefined, 30) === 'function',
|
||||
'an unconfigured cost endpoint must still be throttled',
|
||||
);
|
||||
});
|
||||
|
||||
test('an explicit 0 is the documented way to run unthrottled', () => {
|
||||
assert.equal(resolvePerMinuteCap('0', 30), 0);
|
||||
assert.equal(
|
||||
makeCostRateLimiter('0', 30),
|
||||
null,
|
||||
'operators who mean "no throttle" must still be able to say so',
|
||||
);
|
||||
});
|
||||
|
||||
test('a configured value wins, and a fractional one floors', () => {
|
||||
assert.equal(resolvePerMinuteCap('45', 30), 45);
|
||||
assert.equal(resolvePerMinuteCap(45, 30), 45);
|
||||
assert.equal(resolvePerMinuteCap('30.7', 30), 30);
|
||||
});
|
||||
|
||||
test('a positive fraction keeps the guard rather than flooring to unlimited', () => {
|
||||
for (const fraction of ['0.5', '0.99', 0.01, '1e-9']) {
|
||||
assert.equal(resolvePerMinuteCap(fraction, 30), 1, String(fraction));
|
||||
assert.equal(
|
||||
typeof makeCostRateLimiter(fraction, 30),
|
||||
'function',
|
||||
`${fraction} must still throttle`,
|
||||
);
|
||||
}
|
||||
// Only an explicit zero turns the limiter off.
|
||||
assert.equal(makeCostRateLimiter('0', 30), null);
|
||||
assert.equal(makeCostRateLimiter('0.0', 30), null);
|
||||
});
|
||||
|
||||
test('an unreadable value keeps the guard instead of removing it', () => {
|
||||
// `3O` (letter O) and `60/min` are the shapes a hand-edited .env produces.
|
||||
// Reading either as "unlimited" would disarm the spend guard on a typo —
|
||||
// the opposite of what the operator was reaching for when they typed it.
|
||||
for (const typo of ['3O', '60/min', 'thirty', '-5', 'NaN']) {
|
||||
assert.equal(
|
||||
resolvePerMinuteCap(typo, 30),
|
||||
30,
|
||||
`${typo} must fall back to the default, not to unlimited`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('the default cap clears the app’s own demand with room to spare', () => {
|
||||
// src/hud.js HUD_SUMMARY_INTERVAL_MS = 15000 -> 4 summaries/min per open
|
||||
// tab, plus one token mint per voice session. The cap has to sit well above
|
||||
// that or it would throttle the app rather than an abuser.
|
||||
const hudRequestsPerMinutePerTab = 60_000 / 15_000;
|
||||
assert.ok(
|
||||
OPENAI_DEFAULT_PER_MIN >= hudRequestsPerMinutePerTab * 4,
|
||||
`default ${OPENAI_DEFAULT_PER_MIN}/min leaves too little headroom over ${hudRequestsPerMinutePerTab}/min of ordinary use`,
|
||||
);
|
||||
});
|
||||
|
||||
test('the realtime token route throttles by default, and says so in a 429', async (t) => {
|
||||
env(t, 'GEV_RATELIMIT_OPENAI_PER_MIN', undefined);
|
||||
env(t, 'OPENAI_API_KEY', 'fixture-upstream-secret');
|
||||
let upstreamCalls = 0;
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
upstreamCalls += 1;
|
||||
return Response.json({ value: 'fixture-ephemeral' });
|
||||
});
|
||||
|
||||
const handler = install(openAiRealtimeProxy({})).get('/api/realtime/token');
|
||||
const statuses = [];
|
||||
for (let i = 0; i < OPENAI_DEFAULT_PER_MIN + 1; i += 1) {
|
||||
statuses.push((await request(handler)).status);
|
||||
}
|
||||
|
||||
assert.deepEqual(
|
||||
statuses.slice(0, OPENAI_DEFAULT_PER_MIN),
|
||||
Array(OPENAI_DEFAULT_PER_MIN).fill(200),
|
||||
'the default must not throttle ordinary use',
|
||||
);
|
||||
const overLimit = await request(handler);
|
||||
assert.equal(overLimit.status, 429);
|
||||
assert.equal(overLimit.headers['retry-after'], '5');
|
||||
assert.equal(JSON.parse(overLimit.body).error, 'Rate limit exceeded');
|
||||
assert.equal(
|
||||
overLimit.body.includes('fixture-upstream-secret'),
|
||||
false,
|
||||
'a throttled reply must not leak the server-held key',
|
||||
);
|
||||
assert.ok(
|
||||
upstreamCalls <= OPENAI_DEFAULT_PER_MIN,
|
||||
'a throttled request must be refused before it reaches OpenAI, or it still costs money',
|
||||
);
|
||||
});
|
||||
|
||||
test('the HUD summary route throttles the paid path, never the free one', async (t) => {
|
||||
env(t, 'OPENAI_API_KEY', 'fixture-upstream-secret');
|
||||
let upstreamCalls = 0;
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
upstreamCalls += 1;
|
||||
return Response.json({ output_text: 'five word hud summary here' });
|
||||
});
|
||||
|
||||
// Its own client address: the limiter is cached at module scope and shared
|
||||
// across the OpenAI routes, so the realtime case above has already spent
|
||||
// 127.0.0.1's budget inside this process.
|
||||
const from = '10.9.9.9';
|
||||
const statuses = [];
|
||||
for (let i = 0; i < OPENAI_DEFAULT_PER_MIN; i += 1)
|
||||
statuses.push(
|
||||
(await request(handleHudSummary, { remoteAddress: from })).status,
|
||||
);
|
||||
assert.deepEqual(
|
||||
statuses,
|
||||
Array(OPENAI_DEFAULT_PER_MIN).fill(200),
|
||||
'the default must not throttle ordinary HUD use',
|
||||
);
|
||||
|
||||
const overLimit = await request(handleHudSummary, { remoteAddress: from });
|
||||
assert.equal(overLimit.status, 429);
|
||||
assert.equal(overLimit.headers['retry-after'], '5');
|
||||
assert.equal(JSON.parse(overLimit.body).error, 'Rate limit exceeded');
|
||||
assert.equal(
|
||||
overLimit.body.includes('fixture-upstream-secret'),
|
||||
false,
|
||||
'a throttled reply must not leak the server-held key',
|
||||
);
|
||||
assert.ok(
|
||||
upstreamCalls <= OPENAI_DEFAULT_PER_MIN,
|
||||
'a throttled request must be refused before it reaches OpenAI, or it still costs money',
|
||||
);
|
||||
|
||||
// The keyless HUD answer is deliberately NOT throttled: it is composed
|
||||
// locally, spends nothing upstream, and capping it would only take the free
|
||||
// HUD away from an unconfigured install. Same exhausted client, no key.
|
||||
delete process.env.OPENAI_API_KEY;
|
||||
const free = await request(handleHudSummary, { remoteAddress: from });
|
||||
assert.equal(free.status, 200, 'the free path answers past a spent budget');
|
||||
assert.equal(JSON.parse(free.body).configured, false);
|
||||
assert.equal(
|
||||
upstreamCalls <= OPENAI_DEFAULT_PER_MIN,
|
||||
true,
|
||||
'the free path reaches no provider',
|
||||
);
|
||||
});
|
||||
|
||||
test('the Google places route throttles by default and keeps its places[] contract', async (t) => {
|
||||
env(t, 'GEV_RATELIMIT_GOOGLE_PER_MIN', undefined);
|
||||
let upstreamCalls = 0;
|
||||
const handler = install(
|
||||
googlePlacesContextProxy({
|
||||
resolveApiKey: () => 'fixture-google-server-key',
|
||||
fetchImpl: async () => {
|
||||
upstreamCalls += 1;
|
||||
return Response.json({ places: [] });
|
||||
},
|
||||
}),
|
||||
).get('/api/google/nearby-places');
|
||||
|
||||
const url = '/?lat=37.77493&lon=-122.41942';
|
||||
const statuses = [];
|
||||
for (let i = 0; i < GOOGLE_DEFAULT_PER_MIN; i += 1) {
|
||||
statuses.push((await request(handler, { url, method: 'GET' })).status);
|
||||
}
|
||||
assert.ok(
|
||||
statuses.every((s) => s === 200),
|
||||
'the default must not throttle ordinary use',
|
||||
);
|
||||
|
||||
const overLimit = await request(handler, { url, method: 'GET' });
|
||||
assert.equal(overLimit.status, 429);
|
||||
assert.equal(overLimit.headers['retry-after'], '5');
|
||||
// Every error response on this route carries places[]; the client reads it
|
||||
// unconditionally, so a throttled reply must not be the one shape that omits it.
|
||||
assert.deepEqual(JSON.parse(overLimit.body).places, []);
|
||||
assert.equal(
|
||||
upstreamCalls,
|
||||
GOOGLE_DEFAULT_PER_MIN,
|
||||
'a throttled request must be refused before it reaches Google, or it still costs money',
|
||||
);
|
||||
});
|
||||
|
||||
test('the code defaults match the caps the Pinokio build ships', () => {
|
||||
// These two numbers used to live only in pinokio/_ENVIRONMENT, and the value
|
||||
// this repo suggested elsewhere had already drifted away from them. Now that
|
||||
// they are the server's defaults, the packaged app and an unconfigured
|
||||
// checkout must agree, or a Pinokio user's throttle silently stops matching
|
||||
// the one everybody else gets. If you deliberately want them to differ, this
|
||||
// assertion is the place to say so.
|
||||
const shipped = readPinokioEnvironment(
|
||||
path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'..',
|
||||
'pinokio',
|
||||
'_ENVIRONMENT',
|
||||
),
|
||||
);
|
||||
assert.equal(
|
||||
Number(shipped.GEV_RATELIMIT_OPENAI_PER_MIN),
|
||||
OPENAI_DEFAULT_PER_MIN,
|
||||
);
|
||||
assert.equal(
|
||||
Number(shipped.GEV_RATELIMIT_GOOGLE_PER_MIN),
|
||||
GOOGLE_DEFAULT_PER_MIN,
|
||||
);
|
||||
});
|
||||
@@ -614,3 +614,9 @@ test('the credit line is never suppressed to make room', () => {
|
||||
}
|
||||
assert.match(css, /body\.ui-clean-view #cesium-credits,\s*\n\s*body\.recording-mode #cesium-credits \{[^}]*bottom: 36px;/);
|
||||
});
|
||||
|
||||
|
||||
test('OSM-derived layers never force the desktop credit row into a stack', () => {
|
||||
assert.doesNotMatch(css, /body:has\([^)]*alpr-cameras[^}]*#cesium-credits/);
|
||||
assert.doesNotMatch(css, /#cesium-credits\s+\.cesium-credit-expand-link\s*\{[^}]*display:\s*block/);
|
||||
});
|
||||
@@ -0,0 +1,803 @@
|
||||
/**
|
||||
* Bundled administrative boundaries — countries and states/provinces (Natural
|
||||
* Earth admin-0/admin-1) and US counties (US Census Bureau cartographic boundaries),
|
||||
* so "outline Texas", "outline Bavaria" or "outline Travis County" draws the
|
||||
* real boundary without a network lookup.
|
||||
*
|
||||
* Data: `local_data/natural_earth/{countries,states_provinces}.json` and
|
||||
* `local_data/us_census_counties/counties.json`, public domain and built
|
||||
* by `scripts/build-admin-packs.mjs` (provenance in each folder's README).
|
||||
* Rings are delta-encoded integers; see `decodeRing`.
|
||||
*
|
||||
* PURE data module — no Cesium, node-testable. Each pack is fetched through
|
||||
* `loadBundledJson` on the first lookup that needs it (never at app start),
|
||||
* indexed once, and kept; a failed load is retried later rather than cached
|
||||
* (`createRetryableLoader`). The county pack is only loaded for asks that
|
||||
* name a county (or a county-equivalent city) or that a geocoder typed as one.
|
||||
*
|
||||
* Two entry points:
|
||||
* - `findAdminArea(query, { near })` — name-only. Resolves asks that are
|
||||
* unambiguous from the words alone ("Texas", "TX", "State of Texas",
|
||||
* "Bavaria", "Travis County, Texas", "Orleans Parish"); duplicates are
|
||||
* settled by a state/country qualifier, then by the camera (`near`).
|
||||
* Country/state homonyms such as Georgia use the qualifier or camera;
|
||||
* city/state homonyms such as New York still defer to the geocoder.
|
||||
* - `findAdminAreaAt(names, lat, lon, scope)` — geocoder-confirmed. The
|
||||
* geocoder already typed the place as a country, state or county; the unit must
|
||||
* carry one of the names AND contain the geocoded point.
|
||||
*/
|
||||
|
||||
import { loadBundledJson } from './bundledJson.js';
|
||||
import { createRetryableLoader } from './retryableLoad.js';
|
||||
|
||||
const FOLD = {
|
||||
ß: 'ss',
|
||||
ø: 'o',
|
||||
æ: 'ae',
|
||||
ł: 'l',
|
||||
đ: 'd',
|
||||
ı: 'i',
|
||||
œ: 'oe',
|
||||
þ: 'th',
|
||||
};
|
||||
|
||||
/** Fold a name for matching: case, accents, punctuation, "the", "saint". */
|
||||
export function normalizeAdminName(value) {
|
||||
return String(value || '')
|
||||
.toLowerCase()
|
||||
.replace(/[ßøæłđıœþ]/g, (c) => FOLD[c])
|
||||
.normalize('NFD')
|
||||
.replace(/\p{M}/gu, '')
|
||||
.replace(/[^a-z0-9]+/g, ' ')
|
||||
.trim()
|
||||
.replace(/^the /, '')
|
||||
.replace(/\bsaint\b/g, 'st')
|
||||
.replace(/\bsainte\b/g, 'ste');
|
||||
}
|
||||
|
||||
const PACKS = {
|
||||
countries: {
|
||||
url: new URL('./local_data/natural_earth/countries.json', import.meta.url),
|
||||
},
|
||||
admin1: {
|
||||
url: new URL(
|
||||
'./local_data/natural_earth/states_provinces.json',
|
||||
import.meta.url,
|
||||
),
|
||||
},
|
||||
counties: {
|
||||
url: new URL(
|
||||
'./local_data/us_census_counties/counties.json',
|
||||
import.meta.url,
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
/** Test seam: count pack loads (lazy-loading contract). */
|
||||
export const packLoads = { admin1: 0, counties: 0, countries: 0 };
|
||||
|
||||
const EARTH_RADIUS_KM = 6371;
|
||||
const toRad = (d) => (d * Math.PI) / 180;
|
||||
|
||||
/** Spherical ring area (km²) — same formula as naturalEarthRegions.js. */
|
||||
function ringAreaKm2(ring) {
|
||||
const n = ring.length;
|
||||
if (n < 3) return 0;
|
||||
let sum = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const [lon1, lat1] = ring[i];
|
||||
const [lon2, lat2] = ring[(i + 1) % n];
|
||||
sum +=
|
||||
toRad(lon2 - lon1) * (2 + Math.sin(toRad(lat1)) + Math.sin(toRad(lat2)));
|
||||
}
|
||||
return Math.abs((sum * EARTH_RADIUS_KM * EARTH_RADIUS_KM) / 2);
|
||||
}
|
||||
|
||||
function haversineKm(lat1, lon1, lat2, lon2) {
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLon = toRad(lon2 - lon1);
|
||||
const h =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
||||
return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(h)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode one packed ring: integers in units of 10^-decimals degrees, the
|
||||
* first vertex absolute and each later one a delta from the previous.
|
||||
* @param {number[]} encoded
|
||||
* @param {number} decimals
|
||||
* @returns {Array<[number, number]>} open ring of [lon, lat]
|
||||
*/
|
||||
export function decodeRing(encoded, decimals) {
|
||||
const factor = 10 ** decimals;
|
||||
const ring = [];
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
for (let i = 0; i + 1 < encoded.length; i += 2) {
|
||||
x += encoded[i];
|
||||
y += encoded[i + 1];
|
||||
ring.push([x / factor, y / factor]);
|
||||
}
|
||||
return ring;
|
||||
}
|
||||
|
||||
/** Even-odd point-in-ring test; ring is [[lon, lat], …], open or closed. */
|
||||
export function pointInRing(ring, lat, lon) {
|
||||
if (!Array.isArray(ring) || ring.length < 3) return false;
|
||||
let inside = false;
|
||||
for (let i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) {
|
||||
const [xi, yi] = ring[i];
|
||||
const [xj, yj] = ring[j];
|
||||
if (
|
||||
yi > lat !== yj > lat &&
|
||||
lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi
|
||||
)
|
||||
inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether polygons ([outer, ...holes][]) contain the point: inside an outer
|
||||
* ring and not inside any of that part's holes.
|
||||
*/
|
||||
export function polygonsContain(polygons, lat, lon) {
|
||||
for (const [outer, ...holes] of polygons || []) {
|
||||
if (!pointInRing(outer, lat, lon)) continue;
|
||||
if (!holes.some((hole) => pointInRing(hole, lat, lon))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* A unit's geometry, decoded on first use. Longitudes are unwrapped around
|
||||
* the largest part for the box so units cut at the antimeridian (Alaska,
|
||||
* Chukotka) get a box on the right side of the globe.
|
||||
*/
|
||||
function geometryOf(entry) {
|
||||
if (entry.geometry) return entry.geometry;
|
||||
const decimals = entry.feature.d ?? entry.decimals;
|
||||
const polygons = entry.feature.polygons.map((poly) =>
|
||||
poly.map((ring) => decodeRing(ring, decimals)),
|
||||
);
|
||||
const ref = polygons[0]?.[0]?.[0]?.[0] ?? 0;
|
||||
let west = Infinity;
|
||||
let south = Infinity;
|
||||
let east = -Infinity;
|
||||
let north = -Infinity;
|
||||
let areaKm2 = 0;
|
||||
for (const [outer, ...holes] of polygons) {
|
||||
areaKm2 += ringAreaKm2(outer);
|
||||
for (const hole of holes) areaKm2 -= ringAreaKm2(hole);
|
||||
for (const [lon, lat] of outer) {
|
||||
const x = lon - 360 * Math.round((lon - ref) / 360);
|
||||
if (x < west) west = x;
|
||||
if (x > east) east = x;
|
||||
if (lat < south) south = lat;
|
||||
if (lat > north) north = lat;
|
||||
}
|
||||
}
|
||||
const label = entry.feature.label
|
||||
? { lon: entry.feature.label[0], lat: entry.feature.label[1] }
|
||||
: ringCentroid(polygons[0][0]);
|
||||
entry.geometry = {
|
||||
polygons,
|
||||
bbox: [west, south, east, north],
|
||||
areaKm2: Math.max(0, areaKm2),
|
||||
label,
|
||||
};
|
||||
return entry.geometry;
|
||||
}
|
||||
|
||||
/** Area-weighted centroid of an open ring (planar; fine at unit scale). */
|
||||
function ringCentroid(ring) {
|
||||
let a = 0;
|
||||
let cx = 0;
|
||||
let cy = 0;
|
||||
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
||||
const [x0, y0] = ring[j];
|
||||
const [x1, y1] = ring[i];
|
||||
const f = x0 * y1 - x1 * y0;
|
||||
a += f;
|
||||
cx += (x0 + x1) * f;
|
||||
cy += (y0 + y1) * f;
|
||||
}
|
||||
if (!a) return { lon: ring[0][0], lat: ring[0][1] };
|
||||
return { lon: cx / (3 * a), lat: cy / (3 * a) };
|
||||
}
|
||||
|
||||
/** Km from a point to a unit's box (0 inside it). */
|
||||
function boxDistanceKm(bbox, lat, lon) {
|
||||
const [west, south, east, north] = bbox;
|
||||
const mid = (west + east) / 2;
|
||||
const x = lon - 360 * Math.round((lon - mid) / 360);
|
||||
const clampedLon = Math.min(east, Math.max(west, x));
|
||||
const clampedLat = Math.min(north, Math.max(south, lat));
|
||||
return haversineKm(lat, x, clampedLat, clampedLon);
|
||||
}
|
||||
|
||||
/**
|
||||
* Natural Earth `min_label` (the zoom at which a unit is labelled) up to which
|
||||
* a unit may be named alone: every US state, Canadian province, Mexican and
|
||||
* German state; not English counties, French départements or small districts.
|
||||
*/
|
||||
const BARE_NAME_MAX_RANK = 7;
|
||||
|
||||
/**
|
||||
* Words that name a unit somewhere (Cameroon's "North", Paraguay's
|
||||
* "Central") but, said alone, name a direction or a feature.
|
||||
*/
|
||||
const GENERIC_NAMES = new Set([
|
||||
'north',
|
||||
'south',
|
||||
'east',
|
||||
'west',
|
||||
'northern',
|
||||
'southern',
|
||||
'eastern',
|
||||
'western',
|
||||
'north east',
|
||||
'north west',
|
||||
'south east',
|
||||
'south west',
|
||||
'northeast',
|
||||
'northwest',
|
||||
'southeast',
|
||||
'southwest',
|
||||
'far north',
|
||||
'far west',
|
||||
'central',
|
||||
'centre',
|
||||
'center',
|
||||
'capital',
|
||||
'coast',
|
||||
'island',
|
||||
'islands',
|
||||
'lakes',
|
||||
'highland',
|
||||
'highlands',
|
||||
'interior',
|
||||
'upper',
|
||||
'lower',
|
||||
'valley',
|
||||
'plateau',
|
||||
'plateaux',
|
||||
'littoral',
|
||||
'oriental',
|
||||
'occidental',
|
||||
]);
|
||||
|
||||
/** State codes that, alone, more often mean a city ("LA", "NY"). */
|
||||
const CITY_ABBREVIATIONS = new Set(['la', 'ny']);
|
||||
|
||||
/** Postal abbreviations accepted alone ("TX", "ON"): US states and Canada. */
|
||||
const POSTAL_COUNTRIES = new Set(['US', 'CA']);
|
||||
|
||||
function addKey(index, key, entry, tier) {
|
||||
if (!key) return;
|
||||
entry.keys?.add(key);
|
||||
const list = index.get(key);
|
||||
const item = { entry, tier };
|
||||
if (!list) index.set(key, [item]);
|
||||
else if (!list.some((i) => i.entry === entry)) list.push(item);
|
||||
}
|
||||
|
||||
const loadAdmin1 = createRetryableLoader(async () => {
|
||||
packLoads.admin1 += 1;
|
||||
const pack = await loadBundledJson(PACKS.admin1.url);
|
||||
const decimals = pack.meta?.decimals ?? 3;
|
||||
const index = new Map();
|
||||
for (const feature of pack.features || []) {
|
||||
const entry = {
|
||||
kind: 'state',
|
||||
feature,
|
||||
decimals,
|
||||
amb: new Set(feature.amb || []),
|
||||
ambAbroad: new Set(feature.ambAbroad || []),
|
||||
keys: new Set(),
|
||||
country: normalizeAdminName(feature.country),
|
||||
iso2: String(feature.iso2 || '').toLowerCase(),
|
||||
};
|
||||
addKey(index, normalizeAdminName(feature.name), entry, 0);
|
||||
addKey(index, normalizeAdminName(feature.nameEn), entry, 1);
|
||||
for (const alt of feature.alt || [])
|
||||
addKey(index, normalizeAdminName(alt), entry, 2);
|
||||
if (feature.postal && POSTAL_COUNTRIES.has(feature.iso2))
|
||||
addKey(index, `postal:${feature.postal.toLowerCase()}`, entry, 2);
|
||||
}
|
||||
return { index };
|
||||
});
|
||||
|
||||
const loadCounties = createRetryableLoader(async () => {
|
||||
packLoads.counties += 1;
|
||||
const pack = await loadBundledJson(PACKS.counties.url);
|
||||
const decimals = pack.meta?.decimals ?? 4;
|
||||
const byFull = new Map();
|
||||
const byName = new Map();
|
||||
const states = new Map();
|
||||
for (const feature of pack.features || []) {
|
||||
const entry = {
|
||||
kind: 'county',
|
||||
feature,
|
||||
decimals,
|
||||
state: normalizeAdminName(feature.state),
|
||||
st: String(feature.st || '').toLowerCase(),
|
||||
};
|
||||
addKey(byFull, normalizeAdminName(feature.full), entry, 0);
|
||||
addKey(byName, normalizeAdminName(feature.name), entry, 1);
|
||||
states.set(entry.state, entry.st);
|
||||
}
|
||||
return { byFull, byName, states };
|
||||
});
|
||||
|
||||
const loadCountries = createRetryableLoader(async () => {
|
||||
packLoads.countries += 1;
|
||||
const pack = await loadBundledJson(PACKS.countries.url);
|
||||
const index = new Map();
|
||||
for (const feature of pack.features || []) {
|
||||
const entry = {
|
||||
kind: 'country',
|
||||
feature,
|
||||
decimals: pack.meta.decimals,
|
||||
keys: new Set(),
|
||||
country: normalizeAdminName(feature.country),
|
||||
iso2: feature.iso2?.toLowerCase(),
|
||||
};
|
||||
for (const name of [feature.name, ...(feature.alt || [])])
|
||||
addKey(index, normalizeAdminName(name), entry, 0);
|
||||
}
|
||||
return { index };
|
||||
});
|
||||
|
||||
async function countryCandidates(parsed) {
|
||||
if (!['any', 'country'].includes(parsed.kind)) return [];
|
||||
const { index } = await loadCountries();
|
||||
const key = COUNTRY_TARGET_ALIASES.get(parsed.name) || parsed.name;
|
||||
return (index.get(key) || []).filter(({ entry }) =>
|
||||
parsed.qualifiers.every((q) => qualifierMatches(entry, q)),
|
||||
);
|
||||
}
|
||||
|
||||
/** Country target aliases never broaden constituent-country requests. */
|
||||
const COUNTRY_TARGET_ALIASES = new Map([
|
||||
['usa', 'united states of america'],
|
||||
['us', 'united states of america'],
|
||||
['u s', 'united states of america'],
|
||||
['u s a', 'united states of america'],
|
||||
['united states', 'united states of america'],
|
||||
['america', 'united states of america'],
|
||||
['uk', 'united kingdom'],
|
||||
['u k', 'united kingdom'],
|
||||
['great britain', 'united kingdom'],
|
||||
['britain', 'united kingdom'],
|
||||
['russian federation', 'russia'],
|
||||
['korea', 'south korea'],
|
||||
['czechia', 'czech republic'],
|
||||
['tanzania', 'united republic of tanzania'],
|
||||
['drc', 'democratic republic of the congo'],
|
||||
]);
|
||||
|
||||
const COUNTRY_ALIASES = new Map([
|
||||
...COUNTRY_TARGET_ALIASES,
|
||||
['england', 'united kingdom'],
|
||||
['scotland', 'united kingdom'],
|
||||
['wales', 'united kingdom'],
|
||||
['northern ireland', 'united kingdom'],
|
||||
]);
|
||||
|
||||
/** County-equivalent words, longest first so "city and borough" wins. */
|
||||
const COUNTY_WORDS = [
|
||||
'city and borough',
|
||||
'planning region',
|
||||
'census area',
|
||||
'municipality',
|
||||
'municipio',
|
||||
'borough',
|
||||
'parish',
|
||||
'county',
|
||||
];
|
||||
const COUNTY_WORD_RE = new RegExp(`\\b(${COUNTY_WORDS.join('|')})\\b`);
|
||||
const STATE_PREFIX_RE =
|
||||
/^(?:free state|state|commonwealth|province|territory|prefecture|region|land|canton|department) of (.+)$/;
|
||||
const STATE_SUFFIX_RE = /^(.+) (?:state|province|prefecture|territory)$/;
|
||||
|
||||
/**
|
||||
* Split an ask into what it names and how.
|
||||
* @param {string} text
|
||||
* @returns {{kind:'country'|'county'|'state'|'any', name:string, qualifiers:string[],
|
||||
* postal:string|null, countyWord:string|null}|null}
|
||||
*/
|
||||
export function parseAdminQuery(text) {
|
||||
const raw = String(text || '').trim();
|
||||
if (!raw) return null;
|
||||
const segments = raw.split(',').map(normalizeAdminName).filter(Boolean);
|
||||
if (!segments.length) return null;
|
||||
const head = segments[0];
|
||||
const country =
|
||||
/^(?:country|nation) of (.+)$/.exec(head) ||
|
||||
/^(.+) (?:country|nation)$/.exec(head);
|
||||
if (country)
|
||||
return {
|
||||
kind: 'country',
|
||||
name: country[1],
|
||||
qualifiers: segments.slice(1),
|
||||
postal: null,
|
||||
countyWord: null,
|
||||
full: head,
|
||||
};
|
||||
const qualifiers = segments.slice(1);
|
||||
const postal = /^[A-Za-z]{2}$/.test(raw) ? raw.toLowerCase() : null;
|
||||
// "TX", "DC", "D.C.": written as an abbreviation, not a short word.
|
||||
const abbreviation = /^[A-Z]{2,3}$/.test(raw.replace(/[.\s]/g, ''));
|
||||
|
||||
// "County Cork", "County of Los Angeles"
|
||||
const countyPrefix = /^county (?:of )?(.+)$/.exec(head);
|
||||
if (countyPrefix)
|
||||
return {
|
||||
kind: 'county',
|
||||
name: countyPrefix[1],
|
||||
qualifiers,
|
||||
postal: null,
|
||||
countyWord: 'county',
|
||||
full: head,
|
||||
};
|
||||
// "Travis County", "Travis County Texas", "Orleans Parish, LA"
|
||||
const county = COUNTY_WORD_RE.exec(head);
|
||||
if (county && county.index > 0) {
|
||||
const name = head.slice(0, county.index).trim();
|
||||
const rest = head.slice(county.index + county[1].length).trim();
|
||||
if (rest) qualifiers.unshift(rest);
|
||||
return {
|
||||
kind: 'county',
|
||||
name,
|
||||
qualifiers,
|
||||
postal: null,
|
||||
countyWord: county[1],
|
||||
full: head.slice(0, county.index + county[1].length),
|
||||
};
|
||||
}
|
||||
const prefix = STATE_PREFIX_RE.exec(head);
|
||||
if (prefix)
|
||||
return {
|
||||
kind: 'state',
|
||||
name: prefix[1],
|
||||
qualifiers,
|
||||
postal: null,
|
||||
countyWord: null,
|
||||
full: head,
|
||||
form: 'prefix',
|
||||
};
|
||||
const suffix = STATE_SUFFIX_RE.exec(head);
|
||||
if (suffix)
|
||||
return {
|
||||
kind: 'state',
|
||||
name: suffix[1],
|
||||
qualifiers,
|
||||
postal: null,
|
||||
countyWord: null,
|
||||
full: head,
|
||||
form: 'suffix',
|
||||
};
|
||||
return {
|
||||
kind: 'any',
|
||||
name: head,
|
||||
qualifiers,
|
||||
postal,
|
||||
abbreviation,
|
||||
countyWord: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** A qualifier names the unit's country ("Bavaria, Germany") or the unit
|
||||
* itself ("Washington, DC"). */
|
||||
function qualifierMatches(entry, qualifier) {
|
||||
const q = COUNTRY_ALIASES.get(qualifier) || qualifier;
|
||||
return (
|
||||
entry.country === q ||
|
||||
entry.iso2 === q ||
|
||||
entry.keys.has(qualifier) ||
|
||||
entry.keys.has(`postal:${qualifier}`)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rank candidates: a unit containing `near` first, then the match tier,
|
||||
* then distance from `near`, then larger area. Returns the ranked list.
|
||||
*/
|
||||
function rank(candidates, near) {
|
||||
const hasNear = Number.isFinite(near?.lat) && Number.isFinite(near?.lon);
|
||||
const scored = candidates.map(({ entry, tier }) => {
|
||||
const geometry = geometryOf(entry);
|
||||
const contains = hasNear
|
||||
? polygonsContain(geometry.polygons, near.lat, near.lon)
|
||||
: false;
|
||||
const distanceKm = hasNear
|
||||
? contains
|
||||
? 0
|
||||
: boxDistanceKm(geometry.bbox, near.lat, near.lon)
|
||||
: 0;
|
||||
return { entry, tier, contains, distanceKm, areaKm2: geometry.areaKm2 };
|
||||
});
|
||||
scored.sort(
|
||||
(a, b) =>
|
||||
Number(b.contains) - Number(a.contains) ||
|
||||
a.tier - b.tier ||
|
||||
a.distanceKm - b.distanceKm ||
|
||||
b.areaKm2 - a.areaKm2,
|
||||
);
|
||||
return scored;
|
||||
}
|
||||
|
||||
function toResult(scored, candidateCount) {
|
||||
const { entry } = scored;
|
||||
const { feature } = entry;
|
||||
const geometry = geometryOf(entry);
|
||||
// Parts are stored largest first, so polygons[0][0] is the main outline.
|
||||
const polygons = geometry.polygons;
|
||||
if (entry.kind === 'county') {
|
||||
return {
|
||||
kind: 'county',
|
||||
name: feature.full,
|
||||
region: feature.state,
|
||||
regionCode: feature.st,
|
||||
country: 'United States of America',
|
||||
id: feature.geoid,
|
||||
source: 'us-census',
|
||||
polygons,
|
||||
ring: polygons[0][0],
|
||||
bbox: geometry.bbox,
|
||||
areaKm2: geometry.areaKm2,
|
||||
label: geometry.label,
|
||||
candidates: candidateCount,
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: entry.kind,
|
||||
name: feature.nameEn || feature.name,
|
||||
localName: feature.name,
|
||||
type: feature.type,
|
||||
region: null,
|
||||
regionCode: feature.postal || null,
|
||||
country: feature.country,
|
||||
id: feature.iso || null,
|
||||
source: 'natural-earth',
|
||||
polygons,
|
||||
ring: polygons[0][0],
|
||||
bbox: geometry.bbox,
|
||||
areaKm2: geometry.areaKm2,
|
||||
label: geometry.label,
|
||||
candidates: candidateCount,
|
||||
};
|
||||
}
|
||||
|
||||
async function countyCandidates(parsed) {
|
||||
const { byFull, byName, states } = await loadCounties();
|
||||
// An exact full name ("Orleans Parish") wins outright; without one, the
|
||||
// name with ANY county-equivalent word ("Anchorage Borough" finds
|
||||
// Anchorage Municipality). An independent city is only ever its full name
|
||||
// ("Fairfax city", "Carson City").
|
||||
let list = parsed.countyWord
|
||||
? [
|
||||
...(byFull.get(parsed.full || `${parsed.name} ${parsed.countyWord}`) ||
|
||||
byName.get(parsed.name) ||
|
||||
[]),
|
||||
]
|
||||
: [...(byFull.get(parsed.name) || [])];
|
||||
if (!list.length) return [];
|
||||
// Every qualifier must be a state or the US: "Travis County, TX, USA"
|
||||
// qualifies, "Travis County Courthouse, Texas" names something else.
|
||||
const stateByCode = new Map([...states].map(([name, st]) => [st, name]));
|
||||
const stateQualifiers = [];
|
||||
for (const q of parsed.qualifiers) {
|
||||
const state = states.has(q) ? q : stateByCode.get(q);
|
||||
if (state) stateQualifiers.push(state);
|
||||
else if (!isUnitedStates(q)) return [];
|
||||
}
|
||||
if (stateQualifiers.length)
|
||||
list = list.filter((i) => stateQualifiers.includes(i.entry.state));
|
||||
return list;
|
||||
}
|
||||
|
||||
function isUnitedStates(qualifier) {
|
||||
return (
|
||||
(COUNTRY_ALIASES.get(qualifier) || qualifier) === 'united states of america'
|
||||
);
|
||||
}
|
||||
|
||||
async function stateCandidates(parsed, { allowAmbiguous = false } = {}) {
|
||||
const { index } = await loadAdmin1();
|
||||
const keys = [];
|
||||
if (parsed.full) keys.push(parsed.full);
|
||||
keys.push(parsed.name);
|
||||
if (parsed.postal) keys.push(`postal:${parsed.postal}`);
|
||||
const seen = new Set();
|
||||
let list = [];
|
||||
for (const key of keys) {
|
||||
for (const item of index.get(key) || []) {
|
||||
if (seen.has(item.entry)) continue;
|
||||
seen.add(item.entry);
|
||||
list.push({ ...item, key, tier: key === parsed.full ? 0 : item.tier });
|
||||
}
|
||||
}
|
||||
// Every qualifier must place the unit: "Bavaria, Germany", "Washington,
|
||||
// DC" — not "Santa Barbara, California" (a city the pack does not hold).
|
||||
if (parsed.qualifiers.length)
|
||||
list = list.filter((i) =>
|
||||
parsed.qualifiers.every((q) => qualifierMatches(i.entry, q)),
|
||||
);
|
||||
if (allowAmbiguous) return list;
|
||||
// A bare name answers alone only for a prominent unit — a US state, a
|
||||
// Canadian province, Bavaria — never a French département, an English
|
||||
// county or a Bahamas district ("Long Island", "Santa Barbara", "Kent" are
|
||||
// likelier the places a geocoder knows). It must not also be a country or
|
||||
// a large city ("Georgia", "New York"), and a short code must be written
|
||||
// as one ("TX", not "tx"). Otherwise a qualifier, admin wording or the
|
||||
// geocoder decides.
|
||||
// A country qualifier places a name without making it a state: "New York,
|
||||
// USA" and "Madrid, Spain" stay the geocoder's; "Washington, DC" names the
|
||||
// unit itself.
|
||||
if (parsed.kind === 'any') {
|
||||
const short = parsed.name.replace(/ /g, '').length <= 3;
|
||||
if (short && (!parsed.abbreviation || CITY_ABBREVIATIONS.has(parsed.name)))
|
||||
return [];
|
||||
const namesUnit = (entry) =>
|
||||
parsed.qualifiers.some(
|
||||
(q) => entry.keys.has(q) || entry.keys.has(`postal:${q}`),
|
||||
);
|
||||
if (!parsed.qualifiers.length) {
|
||||
if (GENERIC_NAMES.has(parsed.name)) return [];
|
||||
return list.filter(
|
||||
(i) =>
|
||||
!i.entry.amb.has(i.key) &&
|
||||
!i.entry.ambAbroad.has(i.key) &&
|
||||
((i.entry.feature.rank ?? Infinity) <= BARE_NAME_MAX_RANK ||
|
||||
i.entry.feature.cityState),
|
||||
);
|
||||
}
|
||||
// Qualified: a shared name abroad is settled by the country ("Victoria,
|
||||
// Australia"); one shared at home only by the unit's own name.
|
||||
return list.filter((i) => !i.entry.amb.has(i.key) || namesUnit(i.entry));
|
||||
}
|
||||
// "X State" only settles a name that is ambiguous on its own ("New York
|
||||
// State"); otherwise it is as likely a university ("Ohio State") and a
|
||||
// geocoder decides. A unit literally named so ("Free State") matches.
|
||||
if (parsed.form === 'suffix')
|
||||
return list.filter((i) => i.key === parsed.full || i.entry.amb.has(i.key));
|
||||
return list;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an administrative unit from the ask's words alone.
|
||||
*
|
||||
* @param {string} query e.g. "Texas", "TX", "State of Texas", "Bavaria",
|
||||
* "Bavaria, Germany", "Travis County", "Travis County, TX", "Orleans Parish"
|
||||
* @param {{near?: {lat:number, lon:number}|null}} [options] the camera's
|
||||
* view centre — the tie-break between same-named units
|
||||
* @returns {Promise<AdminArea|null>}
|
||||
*
|
||||
* @typedef {{kind:'country'|'state'|'county', name:string, region:string|null,
|
||||
* regionCode:string|null, country:string, id:string|null,
|
||||
* source:'natural-earth'|'us-census',
|
||||
* polygons:Array<Array<Array<[number,number]>>>, ring:Array<[number,number]>,
|
||||
* bbox:number[], areaKm2:number, label:{lat:number, lon:number},
|
||||
* candidates:number}} AdminArea
|
||||
*/
|
||||
export async function findAdminArea(query, { near = null } = {}) {
|
||||
const parsed = parseAdminQuery(query);
|
||||
if (!parsed || !parsed.name) return null;
|
||||
let candidates = [];
|
||||
if (parsed.kind === 'country') {
|
||||
candidates = await countryCandidates(parsed);
|
||||
} else if (parsed.kind === 'county') {
|
||||
// County wording also names admin-1 units abroad. Neither pack gets
|
||||
// first refusal: qualifiers exclude ineligible countries before ranking.
|
||||
const [counties, admin1] = await Promise.all([
|
||||
countyCandidates(parsed),
|
||||
stateCandidates({ ...parsed, kind: 'state' }),
|
||||
]);
|
||||
candidates = [...counties, ...admin1];
|
||||
} else {
|
||||
candidates = await stateCandidates(parsed);
|
||||
if (!candidates.length || COUNTRY_TARGET_ALIASES.has(parsed.name)) {
|
||||
const countries = await countryCandidates(parsed);
|
||||
if (countries.length) {
|
||||
const states = await stateCandidates(parsed, { allowAmbiguous: true });
|
||||
if (
|
||||
states.length &&
|
||||
!Number.isFinite(near?.lat) &&
|
||||
!parsed.qualifiers.length
|
||||
)
|
||||
return null;
|
||||
candidates = [...countries, ...states];
|
||||
}
|
||||
}
|
||||
// A county-equivalent city: "Baltimore city", "Fairfax city, Virginia".
|
||||
if (!candidates.length && / city$/.test(parsed.name))
|
||||
candidates = await countyCandidates(parsed);
|
||||
}
|
||||
if (!candidates.length) return null;
|
||||
const ranked = rank(candidates, near);
|
||||
return toResult(ranked[0], candidates.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Geocoder-confirmed lookup: the geocoder typed a country, state or
|
||||
* county, so the unit must carry one of `names` AND contain the geocoded
|
||||
* point. Ambiguity marks do not apply — the geocoder already chose.
|
||||
*
|
||||
* @param {string[]} names the ask and the geocoder's own name for it
|
||||
* @param {number} lat
|
||||
* @param {number} lon
|
||||
* @param {'country'|'state'|'county'} scope
|
||||
* @returns {Promise<AdminArea|null>}
|
||||
*/
|
||||
export async function findAdminAreaAt(names, lat, lon, scope) {
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
||||
const parsedList = [...new Set((names || []).filter(Boolean))]
|
||||
.map(parseAdminQuery)
|
||||
.filter((p) => p?.name);
|
||||
if (!parsedList.length) return null;
|
||||
const containing = (items) => {
|
||||
const out = [];
|
||||
for (const item of items)
|
||||
if (
|
||||
!out.some((c) => c.entry === item.entry) &&
|
||||
polygonsContain(geometryOf(item.entry).polygons, lat, lon)
|
||||
)
|
||||
out.push(item);
|
||||
return out;
|
||||
};
|
||||
if (scope === 'country') {
|
||||
const countries = [];
|
||||
for (const parsed of parsedList)
|
||||
countries.push(
|
||||
...(await countryCandidates({
|
||||
...parsed,
|
||||
kind: 'country',
|
||||
qualifiers: [],
|
||||
})),
|
||||
);
|
||||
const found = containing(countries);
|
||||
return found.length
|
||||
? toResult(rank(found, { lat, lon })[0], found.length)
|
||||
: null;
|
||||
}
|
||||
// The geocoder's scope says what kind of unit this is; the ask's wording
|
||||
// may omit it ("Travis" typed as a county). A US county that matches wins
|
||||
// outright — "New York County" is Manhattan, not the state.
|
||||
if (scope === 'county' && inUnitedStatesBox(lat, lon)) {
|
||||
const counties = [];
|
||||
for (const parsed of parsedList)
|
||||
counties.push(
|
||||
...(await countyCandidates({
|
||||
...parsed,
|
||||
kind: 'county',
|
||||
countyWord: parsed.countyWord || 'county',
|
||||
qualifiers: [],
|
||||
})),
|
||||
);
|
||||
const found = containing(counties);
|
||||
if (found.length)
|
||||
return toResult(rank(found, { lat, lon })[0], found.length);
|
||||
}
|
||||
const states = [];
|
||||
for (const parsed of parsedList)
|
||||
states.push(
|
||||
...(await stateCandidates(
|
||||
{ ...parsed, qualifiers: [] },
|
||||
{ allowAmbiguous: true },
|
||||
)),
|
||||
);
|
||||
const found = containing(states);
|
||||
if (!found.length) return null;
|
||||
return toResult(rank(found, { lat, lon })[0], found.length);
|
||||
}
|
||||
|
||||
/** Rough box around the US and its territories (skips the county pack elsewhere). */
|
||||
function inUnitedStatesBox(lat, lon) {
|
||||
return (
|
||||
(lat >= 13 && lat <= 72 && (lon <= -64 || lon >= 144)) ||
|
||||
(lat >= -15 && lat <= -10 && lon <= -168)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
// Bundled state/province and US county boundaries: name matching,
|
||||
// disambiguation, multipolygons and holes, and lazy loading.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, statSync } from 'node:fs';
|
||||
import {
|
||||
decodeRing,
|
||||
findAdminArea,
|
||||
findAdminAreaAt,
|
||||
normalizeAdminName,
|
||||
packLoads,
|
||||
parseAdminQuery,
|
||||
polygonsContain,
|
||||
} from './adminBoundaries.js';
|
||||
|
||||
const ADMIN1 = new URL(
|
||||
'./local_data/natural_earth/states_provinces.json',
|
||||
import.meta.url,
|
||||
);
|
||||
const COUNTIES = new URL(
|
||||
'./local_data/us_census_counties/counties.json',
|
||||
import.meta.url,
|
||||
);
|
||||
|
||||
const AUSTIN = { lat: 30.2672, lon: -97.7431 };
|
||||
const PORTLAND = { lat: 45.5152, lon: -122.6784 };
|
||||
|
||||
// Runs first: nothing loads until a lookup needs it, and a state ask never
|
||||
// pulls the county pack.
|
||||
test('packs load lazily, once, and only the pack an ask needs', async () => {
|
||||
assert.deepEqual(packLoads, { admin1: 0, counties: 0, countries: 0 });
|
||||
assert.equal(parseAdminQuery('Texas').kind, 'any');
|
||||
assert.deepEqual(
|
||||
packLoads,
|
||||
{ admin1: 0, counties: 0, countries: 0 },
|
||||
'parsing is free',
|
||||
);
|
||||
await findAdminArea('Texas');
|
||||
await findAdminArea('Bavaria');
|
||||
assert.deepEqual(packLoads, { admin1: 1, counties: 0, countries: 0 });
|
||||
await findAdminArea('Travis County');
|
||||
await findAdminArea('Orleans Parish');
|
||||
assert.deepEqual(packLoads, { admin1: 1, counties: 1, countries: 0 });
|
||||
});
|
||||
|
||||
test('Texas, TX and "State of Texas" name the same unit', async () => {
|
||||
for (const query of [
|
||||
'Texas',
|
||||
'texas',
|
||||
'TX',
|
||||
'State of Texas',
|
||||
'the State of Texas',
|
||||
'Texas, USA',
|
||||
'Texas, United States',
|
||||
]) {
|
||||
const unit = await findAdminArea(query, { near: AUSTIN });
|
||||
assert.equal(unit?.id, 'US-TX', query);
|
||||
assert.equal(unit.name, 'Texas');
|
||||
assert.equal(unit.kind, 'state');
|
||||
assert.equal(unit.source, 'natural-earth');
|
||||
}
|
||||
const texas = await findAdminArea('Texas');
|
||||
assert.ok(
|
||||
texas.areaKm2 > 600_000 && texas.areaKm2 < 750_000,
|
||||
`Texas area ${texas.areaKm2}`,
|
||||
);
|
||||
assert.ok(polygonsContain(texas.polygons, AUSTIN.lat, AUSTIN.lon));
|
||||
});
|
||||
|
||||
test('provinces and states abroad resolve by English and local names', async () => {
|
||||
for (const query of [
|
||||
'Bavaria',
|
||||
'Bayern',
|
||||
'Bavaria, Germany',
|
||||
'Free State of Bavaria',
|
||||
]) {
|
||||
const unit = await findAdminArea(query);
|
||||
assert.equal(unit?.id, 'DE-BY', query);
|
||||
assert.equal(unit.name, 'Bavaria');
|
||||
assert.equal(unit.country, 'Germany');
|
||||
}
|
||||
assert.equal((await findAdminArea('Ontario'))?.id, 'CA-ON');
|
||||
assert.equal((await findAdminArea('ON'))?.id, 'CA-ON');
|
||||
assert.equal((await findAdminArea('Province of Ontario'))?.id, 'CA-ON');
|
||||
assert.equal((await findAdminArea('Free State'))?.country, 'South Africa');
|
||||
// Same name in two countries: the qualifier decides.
|
||||
assert.equal((await findAdminArea('Punjab, India'))?.id, 'IN-PB');
|
||||
assert.equal((await findAdminArea('Punjab, Pakistan'))?.id, 'PK-PB');
|
||||
// A qualifier may name the unit itself.
|
||||
assert.equal((await findAdminArea('Washington, DC'))?.id, 'US-DC');
|
||||
});
|
||||
|
||||
test('"Washington" is the state; "Washington County" is a county near the camera', async () => {
|
||||
const state = await findAdminArea('Washington', { near: AUSTIN });
|
||||
assert.equal(state?.id, 'US-WA');
|
||||
const fromAustin = await findAdminArea('Washington County', { near: AUSTIN });
|
||||
assert.equal(fromAustin.kind, 'county');
|
||||
assert.equal(fromAustin.name, 'Washington County');
|
||||
assert.equal(fromAustin.region, 'Texas');
|
||||
assert.ok(fromAustin.candidates > 20, 'dozens of Washington Counties');
|
||||
const fromPortland = await findAdminArea('Washington County', {
|
||||
near: PORTLAND,
|
||||
});
|
||||
assert.equal(fromPortland.region, 'Oregon');
|
||||
const arkansas = await findAdminArea('Washington County, Arkansas', {
|
||||
near: PORTLAND,
|
||||
});
|
||||
assert.equal(arkansas.region, 'Arkansas');
|
||||
assert.equal(arkansas.candidates, 1);
|
||||
assert.equal(
|
||||
(await findAdminArea('Washington County, AR'))?.region,
|
||||
'Arkansas',
|
||||
);
|
||||
});
|
||||
|
||||
test('Travis County with and without its state', async () => {
|
||||
for (const query of [
|
||||
'Travis County',
|
||||
'Travis County, Texas',
|
||||
'Travis County, TX',
|
||||
'Travis County Texas',
|
||||
'travis county, texas, usa',
|
||||
]) {
|
||||
const unit = await findAdminArea(query, { near: PORTLAND });
|
||||
assert.equal(unit?.id, '48453', query);
|
||||
assert.equal(unit.name, 'Travis County');
|
||||
assert.equal(unit.region, 'Texas');
|
||||
assert.equal(unit.source, 'us-census');
|
||||
}
|
||||
assert.equal(await findAdminArea('Travis County, Ohio'), null);
|
||||
assert.equal(await findAdminArea('Travis County Courthouse'), null);
|
||||
// Every qualifier must place the county, not name something in it.
|
||||
assert.equal(await findAdminArea('Travis County Courthouse, Texas'), null);
|
||||
assert.equal(
|
||||
await findAdminArea('Orange County Great Park, California'),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('county equivalents: parishes, boroughs, independent cities, and non-US counties', async () => {
|
||||
const orleans = await findAdminArea('Orleans Parish', { near: AUSTIN });
|
||||
assert.equal(orleans?.id, '22071');
|
||||
assert.equal(orleans.region, 'Louisiana');
|
||||
assert.equal((await findAdminArea('Orleans Parish, LA'))?.id, '22071');
|
||||
// The wrong county word still finds the only unit with that name.
|
||||
assert.equal(
|
||||
(await findAdminArea('Anchorage Borough'))?.name,
|
||||
'Anchorage Municipality',
|
||||
);
|
||||
// Outside the US a county is an admin-1 unit.
|
||||
assert.equal((await findAdminArea('County Cork'))?.country, 'Ireland');
|
||||
assert.equal(
|
||||
(await findAdminArea('Kent County, England'))?.country,
|
||||
'United Kingdom',
|
||||
);
|
||||
assert.equal(
|
||||
(await findAdminArea('Kent County', { near: AUSTIN }))?.region,
|
||||
'Texas',
|
||||
);
|
||||
});
|
||||
|
||||
test('a bare name that is also a country or a large city is left to a geocoder', async () => {
|
||||
for (const query of ['New York', 'Georgia', 'Madrid']) {
|
||||
assert.equal(await findAdminArea(query), null, query);
|
||||
}
|
||||
// Admin wording settles it.
|
||||
assert.equal((await findAdminArea('New York State'))?.id, 'US-NY');
|
||||
assert.equal((await findAdminArea('State of New York'))?.id, 'US-NY');
|
||||
assert.equal((await findAdminArea('State of Mexico'))?.country, 'Mexico');
|
||||
// City-states are the city itself.
|
||||
assert.equal((await findAdminArea('Berlin'))?.id, 'DE-BE');
|
||||
assert.equal((await findAdminArea('Vienna'))?.country, 'Austria');
|
||||
// A country qualifier places a name; it does not make a city a state.
|
||||
assert.equal(await findAdminArea('New York, USA'), null);
|
||||
assert.equal(await findAdminArea('Madrid, Spain'), null);
|
||||
// A namesake city abroad: ambiguous alone, settled by naming the country.
|
||||
const santaFe = { lat: 35.687, lon: -105.938 };
|
||||
assert.equal(await findAdminArea('Santa Fe', { near: santaFe }), null);
|
||||
assert.equal(await findAdminArea('Victoria'), null);
|
||||
assert.equal((await findAdminArea('Victoria, Australia'))?.id, 'AU-VIC');
|
||||
});
|
||||
|
||||
test('bare names: only prominent units, no generic words, codes as codes', async () => {
|
||||
// Places a geocoder knows better than a small admin unit of that name.
|
||||
for (const query of [
|
||||
'Long Island',
|
||||
'Santa Barbara',
|
||||
'Kent',
|
||||
'Hamilton',
|
||||
'Milan',
|
||||
])
|
||||
assert.equal(await findAdminArea(query, { near: AUSTIN }), null, query);
|
||||
for (const query of ['North', 'Central', 'Capital', 'Lakes'])
|
||||
assert.equal(await findAdminArea(query), null, query);
|
||||
// Two-letter codes must be written as codes, and not the city ones.
|
||||
assert.equal(await findAdminArea('tx'), null);
|
||||
assert.equal(
|
||||
await findAdminArea('LA', { near: { lat: 34.05, lon: -118.24 } }),
|
||||
null,
|
||||
);
|
||||
assert.equal(await findAdminArea('NY'), null);
|
||||
assert.equal((await findAdminArea('OK'))?.id, 'US-OK');
|
||||
});
|
||||
|
||||
test('non-admin asks never match', async () => {
|
||||
for (const query of [
|
||||
'Texas Capitol',
|
||||
'the Texas State Capitol',
|
||||
'Zilker Park',
|
||||
'Ohio State',
|
||||
'Texas State',
|
||||
'Alps',
|
||||
'',
|
||||
null,
|
||||
]) {
|
||||
assert.equal(await findAdminArea(query), null, String(query));
|
||||
}
|
||||
});
|
||||
|
||||
test('geocoder-confirmed lookup: the named unit that contains the point', async () => {
|
||||
// Atlanta — the geocoder said "state", so the US state wins over the country.
|
||||
const georgia = await findAdminAreaAt(['Georgia'], 33.749, -84.388, 'state');
|
||||
assert.equal(georgia?.id, 'US-GA');
|
||||
// Tbilisi — Georgia the country has no admin-1 unit of that name.
|
||||
assert.equal(await findAdminAreaAt(['Georgia'], 41.7, 44.8, 'state'), null);
|
||||
const newYork = await findAdminAreaAt(['New York'], 42.9, -75.5, 'state');
|
||||
assert.equal(newYork?.id, 'US-NY');
|
||||
// A county the geocoder typed, named without the county word.
|
||||
const travis = await findAdminAreaAt(
|
||||
['Travis', 'Travis County'],
|
||||
30.33,
|
||||
-97.78,
|
||||
'county',
|
||||
);
|
||||
assert.equal(travis?.id, '48453');
|
||||
// A county the geocoder typed stays a county even where a state shares
|
||||
// its name: Manhattan, the Big Island, Arkansas County.
|
||||
assert.equal(
|
||||
(await findAdminAreaAt(['New York County'], 40.783, -73.966, 'county'))?.id,
|
||||
'36061',
|
||||
);
|
||||
assert.equal(
|
||||
(await findAdminAreaAt(['Hawaii County'], 19.6, -155.5, 'county'))?.name,
|
||||
'Hawaii County',
|
||||
);
|
||||
// A geocoded point outside the named unit is not that unit.
|
||||
assert.equal(await findAdminAreaAt(['Texas'], 35.0, -106.6, 'state'), null);
|
||||
// Outside the US a geocoder "county" is an admin-1 unit (Kent).
|
||||
assert.equal(
|
||||
(await findAdminAreaAt(['Kent'], 51.2, 0.7, 'county'))?.country,
|
||||
'United Kingdom',
|
||||
);
|
||||
});
|
||||
|
||||
test('multipolygons: every island is a part, and containment follows the parts', async () => {
|
||||
const hawaii = await findAdminArea('Hawaii');
|
||||
assert.ok(
|
||||
hawaii.polygons.length >= 7,
|
||||
`Hawaii parts ${hawaii.polygons.length}`,
|
||||
);
|
||||
assert.ok(polygonsContain(hawaii.polygons, 19.6, -155.5), 'Big Island');
|
||||
assert.ok(polygonsContain(hawaii.polygons, 20.8, -156.3), 'Maui');
|
||||
assert.ok(polygonsContain(hawaii.polygons, 21.45, -158.0), 'Oahu');
|
||||
assert.ok(
|
||||
!polygonsContain(hawaii.polygons, 21.0, -157.3),
|
||||
'the channel between',
|
||||
);
|
||||
// The main ring is the largest part (the Big Island).
|
||||
assert.equal(hawaii.ring, hawaii.polygons[0][0]);
|
||||
|
||||
// Alaska is cut at the antimeridian: its box stays on one side of the globe.
|
||||
const alaska = await findAdminArea('Alaska');
|
||||
const [west, , east] = alaska.bbox;
|
||||
assert.ok(east - west < 60, `Alaska box width ${east - west}°`);
|
||||
assert.ok(alaska.polygons.length > 10, 'Aleutians and the southeast islands');
|
||||
});
|
||||
|
||||
test('holes: Berlin is cut out of Brandenburg', async () => {
|
||||
const brandenburg = await findAdminArea('Brandenburg');
|
||||
assert.ok(
|
||||
brandenburg.polygons.some((part) => part.length > 1),
|
||||
'Brandenburg has a hole',
|
||||
);
|
||||
assert.ok(!polygonsContain(brandenburg.polygons, 52.52, 13.405), 'Berlin');
|
||||
assert.ok(polygonsContain(brandenburg.polygons, 52.39, 13.06), 'Potsdam');
|
||||
const berlin = await findAdminArea('Berlin');
|
||||
assert.ok(polygonsContain(berlin.polygons, 52.52, 13.405));
|
||||
});
|
||||
|
||||
test('holes: an independent city is cut out of its county', async () => {
|
||||
const fairfax = await findAdminArea('Fairfax County, Virginia');
|
||||
assert.ok(fairfax.polygons[0].length > 1, 'Fairfax County has holes');
|
||||
assert.ok(
|
||||
!polygonsContain(fairfax.polygons, 38.846, -77.306),
|
||||
'Fairfax city',
|
||||
);
|
||||
assert.ok(polygonsContain(fairfax.polygons, 38.9, -77.2), 'Fairfax County');
|
||||
const city = await findAdminArea('Fairfax city, Virginia');
|
||||
assert.ok(polygonsContain(city.polygons, 38.846, -77.306));
|
||||
assert.equal((await findAdminArea('Carson City'))?.region, 'Nevada');
|
||||
for (const query of ['New York City', 'Mexico City', 'Quebec City, Canada'])
|
||||
assert.equal(await findAdminArea(query), null, query);
|
||||
});
|
||||
|
||||
test('rings decode from deltas and are open', () => {
|
||||
assert.deepEqual(decodeRing([-97743, 30267, 10, -5, -3, 8], 3), [
|
||||
[-97.743, 30.267],
|
||||
[-97.733, 30.262],
|
||||
[-97.736, 30.27],
|
||||
]);
|
||||
});
|
||||
|
||||
test('names normalize across accents, punctuation and "Saint"', () => {
|
||||
assert.equal(normalizeAdminName('Québec'), 'quebec');
|
||||
assert.equal(normalizeAdminName(' The State of TEXAS '), 'state of texas');
|
||||
assert.equal(normalizeAdminName('Saint Louis County'), 'st louis county');
|
||||
assert.equal(normalizeAdminName('St. Louis County'), 'st louis county');
|
||||
assert.equal(normalizeAdminName('Schleswig-Holstein'), 'schleswig holstein');
|
||||
});
|
||||
|
||||
test('packs: public-domain provenance, schema, and size budgets', () => {
|
||||
const admin1 = JSON.parse(readFileSync(ADMIN1, 'utf8'));
|
||||
const counties = JSON.parse(readFileSync(COUNTIES, 'utf8'));
|
||||
for (const pack of [admin1, counties]) {
|
||||
assert.match(pack.meta.license, /public domain/i);
|
||||
assert.match(pack.meta.sha256, /^[0-9a-f]{64}$/);
|
||||
assert.equal(pack.meta.script, 'scripts/build-admin-packs.mjs');
|
||||
for (const feature of pack.features) {
|
||||
assert.ok(feature.name, 'named');
|
||||
assert.ok(feature.polygons.length >= 1, `${feature.name} has a part`);
|
||||
for (const poly of feature.polygons)
|
||||
for (const ring of poly) {
|
||||
assert.equal(ring.length % 2, 0);
|
||||
assert.ok(ring.length >= 8, `${feature.name}: ring has 4+ vertices`);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.ok(
|
||||
admin1.features.length > 4500,
|
||||
`${admin1.features.length} states/provinces`,
|
||||
);
|
||||
assert.ok(
|
||||
counties.features.length > 3200,
|
||||
`${counties.features.length} counties`,
|
||||
);
|
||||
assert.ok(statSync(ADMIN1).size <= 4 * 1024 * 1024, 'admin-1 pack ≤ 4 MB');
|
||||
assert.ok(statSync(COUNTIES).size <= 2 * 1024 * 1024, 'county pack ≤ 2 MB');
|
||||
});
|
||||
|
||||
test('county queries compare US and international names before choosing a geography', async () => {
|
||||
const england = { lat: 54.78, lon: -1.58 };
|
||||
const nc = { lat: 36.0, lon: -78.9 };
|
||||
assert.equal(
|
||||
(await findAdminArea('County Durham', { near: england }))?.country,
|
||||
'United Kingdom',
|
||||
);
|
||||
assert.equal(
|
||||
(await findAdminArea('County Durham'))?.country,
|
||||
'United Kingdom',
|
||||
'exact alias survives parsing',
|
||||
);
|
||||
assert.equal(
|
||||
(await findAdminArea('Durham County', { near: nc }))?.region,
|
||||
'North Carolina',
|
||||
);
|
||||
assert.equal(
|
||||
(await findAdminArea('County Durham, England', { near: nc }))?.country,
|
||||
'United Kingdom',
|
||||
);
|
||||
assert.equal(
|
||||
(await findAdminArea('County Durham, USA', { near: england }))?.region,
|
||||
'North Carolina',
|
||||
);
|
||||
assert.equal(
|
||||
(await findAdminArea('Durham County, NC', { near: england }))?.region,
|
||||
'North Carolina',
|
||||
);
|
||||
// Durham is also a city: without county wording leave city geocoding intact.
|
||||
assert.equal(await findAdminArea('Durham, NC', { near: england }), null);
|
||||
assert.equal(
|
||||
(await findAdminAreaAt(['Durham, NC'], nc.lat, nc.lon, 'county'))?.region,
|
||||
'North Carolina',
|
||||
);
|
||||
});
|
||||
|
||||
test('countries resolve aliases, keep substantial islands and disambiguate Georgia by scope and geography', async () => {
|
||||
for (const names of [
|
||||
['USA', 'United States', 'America'],
|
||||
['UK', 'United Kingdom', 'Britain'],
|
||||
['Iran', 'Islamic Republic of Iran'],
|
||||
['Czechia', 'Czech Republic'],
|
||||
['Ivory Coast', "Côte d'Ivoire"],
|
||||
['South Korea', 'Korea'],
|
||||
['Russia', 'Russian Federation'],
|
||||
['Switzerland'],
|
||||
['France'],
|
||||
]) {
|
||||
const resolved = await Promise.all(
|
||||
names.map((name) => findAdminArea(name, { near: AUSTIN })),
|
||||
);
|
||||
assert.ok(
|
||||
resolved.every((r) => r?.kind === 'country'),
|
||||
names.join('/'),
|
||||
);
|
||||
assert.equal(new Set(resolved.map((r) => r.id)).size, 1);
|
||||
}
|
||||
assert.equal(
|
||||
(await findAdminArea('Georgia', { near: AUSTIN })).kind,
|
||||
'state',
|
||||
);
|
||||
assert.equal(
|
||||
(await findAdminArea('Georgia', { near: { lat: 41.7, lon: 44.8 } })).kind,
|
||||
'country',
|
||||
);
|
||||
assert.equal(
|
||||
(await findAdminArea('the country of Georgia', { near: AUSTIN })).kind,
|
||||
'country',
|
||||
);
|
||||
assert.equal(await findAdminArea('New York', { near: AUSTIN }), null);
|
||||
assert.equal(
|
||||
(await findAdminAreaAt(['Iran'], 35.69, 51.39, 'country')).kind,
|
||||
'country',
|
||||
);
|
||||
assert.equal(
|
||||
await findAdminAreaAt(['Iran'], 46.9, 7.4, 'country'),
|
||||
null,
|
||||
'name alone cannot override a mismatched geocoded point',
|
||||
);
|
||||
const japan = await findAdminArea('Japan');
|
||||
for (const [lat, lon] of [
|
||||
[35.68, 139.69],
|
||||
[43.06, 141.35],
|
||||
[33.59, 130.4],
|
||||
[33.56, 133.53],
|
||||
])
|
||||
assert.ok(polygonsContain(japan.polygons, lat, lon));
|
||||
const usa = await findAdminArea('USA');
|
||||
for (const [lat, lon] of [
|
||||
[30.27, -97.74],
|
||||
[61.22, -149.9],
|
||||
[19.7, -155.1],
|
||||
])
|
||||
assert.ok(
|
||||
polygonsContain(usa.polygons, lat, lon),
|
||||
'USA includes mainland, Alaska and Hawaii',
|
||||
);
|
||||
const bytes = statSync(
|
||||
new URL('./local_data/natural_earth/countries.json', import.meta.url),
|
||||
).size;
|
||||
assert.ok(bytes < 1_000_000);
|
||||
});
|
||||
|
||||
test('UK constituent countries retain their own identity and extent from any camera', async () => {
|
||||
const targets = [
|
||||
['Scotland', 'SCT', [55.95, -3.19], [54.6, 61]],
|
||||
['England', 'ENG', [51.5, -0.12], [49.8, 55.9]],
|
||||
['Wales', 'WLS', [51.48, -3.18], [51.3, 53.6]],
|
||||
['Northern Ireland', 'NIR', [54.6, -5.93], [53.9, 55.4]],
|
||||
];
|
||||
for (const [name, id, point, extent] of targets) {
|
||||
const unit = await findAdminArea(name, { near: AUSTIN });
|
||||
assert.equal(unit.id, id);
|
||||
assert.equal(unit.name, name);
|
||||
assert.equal(unit.kind, 'country');
|
||||
assert.ok(
|
||||
unit.bbox[1] >= extent[0] && unit.bbox[3] <= extent[1],
|
||||
`${name}: ${unit.bbox}`,
|
||||
);
|
||||
assert.ok(polygonsContain(unit.polygons, ...point));
|
||||
for (const [other, , otherPoint] of targets)
|
||||
if (other !== name)
|
||||
assert.equal(polygonsContain(unit.polygons, ...otherPoint), false);
|
||||
assert.equal((await findAdminAreaAt([name], ...point, 'country')).id, id);
|
||||
assert.equal(await findAdminAreaAt([name], 30.27, -97.74, 'country'), null);
|
||||
}
|
||||
assert.equal((await findAdminArea('UK', { near: AUSTIN })).id, 'GBR');
|
||||
assert.equal(
|
||||
(await findAdminArea('County Durham, England', { near: AUSTIN })).country,
|
||||
'United Kingdom',
|
||||
);
|
||||
});
|
||||
@@ -28,7 +28,7 @@ test('ais analyst record: full record maps every contract field', () => {
|
||||
courseDeg: 214.0,
|
||||
shipType: 'Cargo',
|
||||
destination: 'OAKLAND',
|
||||
navStatus: null, // /api/ais-live does not surface NavigationalStatus
|
||||
navStatus: null, // /api/vessels does not surface NavigationalStatus
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { createApplicationVessels } from '../app/layers/aisLiveVessels.js';
|
||||
|
||||
import { createAisStreamSource } from '../sources/live/standalone.js';
|
||||
import { createVesselSource } from '../sources/live/standalone.js';
|
||||
|
||||
const aisLiveVesselsLayer = createApplicationVessels({
|
||||
source: createAisStreamSource({
|
||||
apiUrl: import.meta.env?.VITE_AIS_LIVE_API_URL || '/api/ais-live',
|
||||
source: createVesselSource({
|
||||
apiUrl: import.meta.env?.VITE_AIS_LIVE_API_URL || '/api/vessels',
|
||||
// Resolved against the document's address, which a panel host may serve
|
||||
// from its own scheme.
|
||||
origin: () => globalThis.document?.baseURI ?? 'http://localhost',
|
||||
}),
|
||||
options: {
|
||||
maxRows: import.meta.env?.VITE_AIS_LIVE_MAX_ROWS,
|
||||
|
||||
@@ -1668,7 +1668,7 @@ test('a vessel analyst record carries the MMSI the tracker keys on', () => {
|
||||
});
|
||||
|
||||
test('vessel selection passes the opaque source reference to optional history', async () => {
|
||||
const { createAisStreamSource } = await import('../sources/live/standalone.js');
|
||||
const { createVesselSource } = await import('../sources/live/standalone.js');
|
||||
_setVesselStateForTest({ enabled: false });
|
||||
const requests = [];
|
||||
aisLiveVesselsLayer.setSource({
|
||||
@@ -1688,6 +1688,6 @@ test('vessel selection passes the opaque source reference to optional history',
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
_setVesselStateForTest({ enabled: false });
|
||||
aisLiveVesselsLayer.setSource(createAisStreamSource());
|
||||
aisLiveVesselsLayer.setSource(createVesselSource());
|
||||
}
|
||||
});
|
||||
@@ -450,7 +450,7 @@ export function createAisWatchdog(options = {}) {
|
||||
return generation;
|
||||
}
|
||||
|
||||
/** Status metadata for /api/ais-live. */
|
||||
/** Status metadata for /api/vessels. */
|
||||
function snapshot() {
|
||||
const monoNow = clock.mono();
|
||||
const wallNow = clock.wall();
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { defaultSurface } from './surfaceServices.js';
|
||||
import { createApplicationAlpr } from '../app/layers/alprCameras.js';
|
||||
import { createSourceSlot } from '../sources/sourceSlot.js';
|
||||
import { createOverpassAlprSource } from '../layers/alpr/index.js';
|
||||
import { createAlprTileSource } from '../layers/alpr/index.js';
|
||||
export * from '../layers/alpr/index.js';
|
||||
const slot = createSourceSlot(
|
||||
createOverpassAlprSource(),
|
||||
createAlprTileSource(),
|
||||
['fetch'],
|
||||
'ALPR source',
|
||||
{ destroy: () => {}, detailZoom: () => null },
|
||||
);
|
||||
export const configureAlprSource = slot.configure;
|
||||
export default createApplicationAlpr({
|
||||
|
||||
+437
-70
@@ -3,7 +3,11 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import alprCamerasLayer, { createAlprCamerasLayer } from './alprCameras.js';
|
||||
import alprCamerasLayer, {
|
||||
createAlprCamerasLayer,
|
||||
configureAlprSource,
|
||||
} from './alprCameras.js';
|
||||
import { createAlprTileSource } from '../layers/alpr/source.js';
|
||||
import { DATA_CREDITS } from './dataCredits.js';
|
||||
import {
|
||||
clearSelectedEntityContextForLayer,
|
||||
@@ -262,9 +266,45 @@ function cameraHarness(layer = alprCamerasLayer) {
|
||||
let response = async () => cameraResponse();
|
||||
const requests = [];
|
||||
globalThis.fetch = (...args) => {
|
||||
// Marker floors resolve through the shared terrain service; these tests
|
||||
// count camera requests only.
|
||||
if (String(args[0]).includes('/api/terrain/heights'))
|
||||
return Promise.resolve(Response.json({ results: [] }));
|
||||
requests.push(args);
|
||||
return response(...args);
|
||||
};
|
||||
// Rendering tests use normalized records; tile transport has its own fixture suite.
|
||||
if (layer === alprCamerasLayer)
|
||||
configureAlprSource({
|
||||
...createAlprTileSource(),
|
||||
async fetch(box, signal) {
|
||||
const response = await globalThis.fetch('/test/cameras', {
|
||||
signal,
|
||||
body: `data=${encodeURIComponent(buildOverpassQuery(box.south, box.west, box.north, box.east))}`,
|
||||
});
|
||||
if (!response.ok)
|
||||
throw new Error('Camera source temporarily unavailable');
|
||||
const body = await response.json();
|
||||
if (!Array.isArray(body.elements) || body.remark)
|
||||
throw new Error('Incomplete fixture snapshot');
|
||||
signal?.throwIfAborted();
|
||||
return {
|
||||
records: [
|
||||
...new Map(
|
||||
body.elements
|
||||
.slice(0, QUERY_LIMIT)
|
||||
.map(normalizeAlprNode)
|
||||
.filter(Boolean)
|
||||
.map((r) => [r.id, r]),
|
||||
).values(),
|
||||
],
|
||||
stale: response.headers.get('x-overpass-cache') === 'STALE',
|
||||
saturated: body.elements.length >= QUERY_LIMIT,
|
||||
noCoverage: body.noCoverage,
|
||||
zoomIn: body.zoomIn,
|
||||
};
|
||||
},
|
||||
});
|
||||
let box = { south: 30.26, west: -97.75, north: 30.28, east: -97.73 };
|
||||
let source, click, picked;
|
||||
const credits = new Set();
|
||||
@@ -348,88 +388,28 @@ function cameraHarness(layer = alprCamerasLayer) {
|
||||
};
|
||||
}
|
||||
|
||||
test('OSM attribution introduces displayed data for five seconds, then stays discoverable in the overflow', async (t) => {
|
||||
test('OSM attribution stays inline while ALPR displays and follows toggles', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const h = cameraHarness();
|
||||
try {
|
||||
assert.equal(
|
||||
h.credits.size,
|
||||
0,
|
||||
'do not use up the introduction while data is still loading',
|
||||
);
|
||||
assert.equal(h.credits.size, 0);
|
||||
await alprCamerasLayer.update();
|
||||
assert.equal(h.credits.size, 1);
|
||||
const [credit] = h.credits;
|
||||
assert.equal(credit.showOnScreen, true);
|
||||
assert.match(credit.html, /class="gev-alpr-credit">ALPR:/);
|
||||
assert.match(
|
||||
credit.html,
|
||||
/href="https:\/\/www.openstreetmap.org\/copyright"/,
|
||||
);
|
||||
assert.match(credit.html, /© OpenStreetMap<\/a>/);
|
||||
t.mock.timers.tick(4999);
|
||||
assert.match(credit.html, />© OpenStreetMap</);
|
||||
assert.doesNotMatch(credit.html, /OpenMapTiles|Map and place data/);
|
||||
t.mock.timers.tick(6000);
|
||||
assert.equal(h.credits.size, 1);
|
||||
t.mock.timers.tick(1);
|
||||
assert.equal(h.credits.size, 0);
|
||||
const overflow = DATA_CREDITS.find((entry) => entry.key === 'alpr-osm');
|
||||
assert.match(
|
||||
overflow.html,
|
||||
/ALPR camera locations \(automatic license plate readers\)/,
|
||||
);
|
||||
assert.match(
|
||||
overflow.html,
|
||||
/href="https:\/\/www.openstreetmap.org\/copyright"/,
|
||||
);
|
||||
assert.match(overflow.html, /ODbL 1\.0/);
|
||||
await alprCamerasLayer.update();
|
||||
assert.equal(
|
||||
h.credits.size,
|
||||
0,
|
||||
'refresh must not keep reintroducing the credit',
|
||||
);
|
||||
alprCamerasLayer.disable();
|
||||
assert.equal(h.credits.size, 0);
|
||||
alprCamerasLayer.enable();
|
||||
await alprCamerasLayer.update();
|
||||
t.mock.timers.tick(4000);
|
||||
alprCamerasLayer.enable();
|
||||
assert.equal(h.credits.size, 1);
|
||||
t.mock.timers.tick(1000);
|
||||
assert.equal(
|
||||
h.credits.size,
|
||||
0,
|
||||
'duplicate enable must not restart the timer',
|
||||
assert.match(
|
||||
DATA_CREDITS.find((entry) => entry.key === 'openstreetmap').html,
|
||||
/OpenStreetMap contributors.*ODbL/,
|
||||
);
|
||||
alprCamerasLayer.disable();
|
||||
alprCamerasLayer.enable();
|
||||
await alprCamerasLayer.update();
|
||||
assert.equal(h.credits.size, 1);
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
assert.equal(h.credits.size, 0);
|
||||
t.mock.timers.tick(5000);
|
||||
assert.equal(h.credits.size, 0, 'destroy cancels the presentation timer');
|
||||
});
|
||||
|
||||
test('disabling midway through the introduction cancels its timer before a new enable', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const h = cameraHarness();
|
||||
try {
|
||||
await alprCamerasLayer.update();
|
||||
t.mock.timers.tick(3000);
|
||||
alprCamerasLayer.disable();
|
||||
assert.equal(h.credits.size, 0);
|
||||
alprCamerasLayer.enable();
|
||||
await alprCamerasLayer.update();
|
||||
t.mock.timers.tick(2000);
|
||||
assert.equal(
|
||||
h.credits.size,
|
||||
1,
|
||||
'old deadline must not hide the new presentation',
|
||||
);
|
||||
t.mock.timers.tick(3000);
|
||||
assert.equal(h.credits.size, 0);
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
@@ -765,7 +745,7 @@ test('empty mapped coverage, stale data and a saturated response remain distinct
|
||||
assert.equal(alprCamerasLayer.getStats().status, 'empty');
|
||||
assert.match(
|
||||
alprCamerasLayer.getStats().loadingLabel,
|
||||
/coverage is incomplete/,
|
||||
/No ALPR data for this area/,
|
||||
);
|
||||
h.expire();
|
||||
h.setFetch(async () =>
|
||||
@@ -928,6 +908,122 @@ test('two factories keep their requests, records and destruction independent', a
|
||||
}
|
||||
});
|
||||
|
||||
test('markers sit on the cached floor; unknown floors clamp only until they resolve', async () => {
|
||||
// Ground clamping re-samples every marker whenever a photoreal tile streams
|
||||
// in; a whole-city view (about 1,000 cameras) stuttered while zooming.
|
||||
const floors = new Map([['30.2672', 150]]);
|
||||
let resolveCalls = 0;
|
||||
let releaseResolve;
|
||||
const services = {
|
||||
render: { governorRequestRender() {} },
|
||||
picking: { registerPickOwner() {}, unregisterPickOwner() {} },
|
||||
groundFloor: {
|
||||
cachedGroundFloor: (lat) => floors.get(lat.toFixed(4)) ?? null,
|
||||
resolveGroundFloorCells: async () => {
|
||||
resolveCalls++;
|
||||
await new Promise((resolve) => {
|
||||
releaseResolve = resolve;
|
||||
});
|
||||
floors.set('30.2682', 160);
|
||||
},
|
||||
},
|
||||
context: {
|
||||
clearSelectedEntityContextForLayer() {},
|
||||
getSelectedEntityContext() {
|
||||
return null;
|
||||
},
|
||||
registerEntityContext() {},
|
||||
removeEntityContextsForLayer() {},
|
||||
selectEntityContext() {},
|
||||
},
|
||||
};
|
||||
const layer = createAlprCamerasLayer({
|
||||
services,
|
||||
source: {
|
||||
fetch: async () => ({
|
||||
records: [
|
||||
{ id: 'directory:1', latitude: 30.2672, longitude: -97.7431 },
|
||||
{ id: 'directory:2', latitude: 30.2682, longitude: -97.7431 },
|
||||
],
|
||||
stale: false,
|
||||
saturated: false,
|
||||
}),
|
||||
},
|
||||
});
|
||||
const h = cameraHarness(layer);
|
||||
try {
|
||||
await layer.update();
|
||||
const known = h.source.entities.getById('directory:1');
|
||||
const unknown = h.source.entities.getById('directory:2');
|
||||
assert.equal(
|
||||
known.billboard.heightReference.getValue(),
|
||||
Cesium.HeightReference.NONE,
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(
|
||||
Cesium.Cartographic.fromCartesian(known.position.getValue()).height -
|
||||
151.5,
|
||||
) < 0.01,
|
||||
);
|
||||
assert.equal(
|
||||
unknown.billboard.heightReference.getValue(),
|
||||
Cesium.HeightReference.CLAMP_TO_GROUND,
|
||||
);
|
||||
assert.equal(resolveCalls, 1);
|
||||
releaseResolve();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(
|
||||
unknown.billboard.heightReference.getValue(),
|
||||
Cesium.HeightReference.NONE,
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(
|
||||
Cesium.Cartographic.fromCartesian(unknown.position.getValue()).height -
|
||||
161.5,
|
||||
) < 0.01,
|
||||
);
|
||||
assert.equal(
|
||||
h.source.entities.getById('directory:2'),
|
||||
unknown,
|
||||
'placed in place, not rebuilt',
|
||||
);
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('loaded cameras outside the view say so instead of looking unloaded', async () => {
|
||||
// Field review: "nearby" spans twice the camera-to-ground range, so a view
|
||||
// could hold cameras with none on screen and read as a failed load.
|
||||
const h = cameraHarness();
|
||||
const project = Cesium.SceneTransforms.worldToWindowCoordinates;
|
||||
let inView = false;
|
||||
Cesium.SceneTransforms.worldToWindowCoordinates = () =>
|
||||
inView ? new Cesium.Cartesian2(100, 100) : new Cesium.Cartesian2(-500, 100);
|
||||
try {
|
||||
h.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees(
|
||||
-97.7431,
|
||||
30.2672,
|
||||
2000,
|
||||
);
|
||||
h.viewer.scene.canvas.clientWidth = 800;
|
||||
h.viewer.scene.canvas.clientHeight = 600;
|
||||
await alprCamerasLayer.update();
|
||||
let stats = alprCamerasLayer.getStats();
|
||||
assert.equal(stats.onScreen, 0);
|
||||
assert.match(stats.countLabel, /1 nearby · 0 on screen/);
|
||||
assert.match(stats.loadingLabel, /None on screen/);
|
||||
inView = true;
|
||||
h.viewer.camera.moveEnd.raiseEvent();
|
||||
stats = alprCamerasLayer.getStats();
|
||||
assert.equal(stats.onScreen, 1);
|
||||
assert.doesNotMatch(stats.loadingLabel, /None on screen/);
|
||||
} finally {
|
||||
Cesium.SceneTransforms.worldToWindowCoordinates = project;
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('orbit cache hits and metadata refreshes preserve marker geometry and selection events', async () => {
|
||||
const h = cameraHarness();
|
||||
try {
|
||||
@@ -943,6 +1039,10 @@ test('orbit cache hits and metadata refreshes preserve marker geometry and selec
|
||||
const entity = h.source.entities.getById('alpr:42');
|
||||
const position = entity.position,
|
||||
line = entity.polyline;
|
||||
// Ground-clamped native wedges cost ~40 ms per frame for a downtown view;
|
||||
// bearings are painted by the overlay for the nearest cameras instead.
|
||||
assert.equal(line, undefined);
|
||||
assert.equal(entity.polygon, undefined);
|
||||
let selections = 0,
|
||||
clears = 0,
|
||||
collectionChanges = 0;
|
||||
@@ -1126,3 +1226,270 @@ test('nearby count and discovery control frame a real loaded camera without fetc
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('a non-retryable capability error does not arm the ALPR retry timer', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const h = cameraHarness();
|
||||
try {
|
||||
h.setFetch(async () => {
|
||||
throw Object.assign(new Error('Unavailable'), {
|
||||
code: 'OVERPASS_NOT_CONFIGURED',
|
||||
retryable: false,
|
||||
});
|
||||
});
|
||||
await alprCamerasLayer.update();
|
||||
assert.equal(alprCamerasLayer.getStats().retryAt, 0);
|
||||
const requests = h.requests.length;
|
||||
t.mock.timers.tick(300_000);
|
||||
await Promise.resolve();
|
||||
assert.equal(h.requests.length, requests);
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('unsupported ALPR coverage suppresses the nearby count and names the extract region', async () => {
|
||||
const h = cameraHarness();
|
||||
try {
|
||||
h.setFetch(async () => ({
|
||||
...cameraResponse([]),
|
||||
json: async () => ({ elements: [], noCoverage: true }),
|
||||
}));
|
||||
await alprCamerasLayer.update();
|
||||
const stats = alprCamerasLayer.getStats();
|
||||
assert.equal(stats.noCoverage, true);
|
||||
assert.equal(stats.countLabel, '');
|
||||
assert.equal(
|
||||
stats.loadingLabel,
|
||||
'No ALPR data for this area — US and Canada only',
|
||||
);
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
import { createGroundFloor } from '../services/groundFloor.js';
|
||||
import { createTerrainHeights } from '../services/terrainHeights.js';
|
||||
import { createApplicationRequestServices } from '../services/requests.js';
|
||||
|
||||
test('slow ALPR floors share real terrain requests across preparation, render and pans; disable aborts', async () => {
|
||||
const requests = [];
|
||||
const sourceServices = createApplicationRequestServices({
|
||||
fetchImpl: (url, { signal }) =>
|
||||
new Promise((resolve, reject) => {
|
||||
assert.match(url, /^\/api\/terrain\/heights\?/);
|
||||
const points = new URL(url, 'http://localhost').searchParams
|
||||
.get('points')
|
||||
.split(';');
|
||||
requests.push({
|
||||
signal,
|
||||
points,
|
||||
release: () =>
|
||||
resolve(
|
||||
Response.json({
|
||||
results: points.map(() => ({ ellipsoid: 160 })),
|
||||
}),
|
||||
),
|
||||
});
|
||||
signal.addEventListener('abort', () => reject(signal.reason), {
|
||||
once: true,
|
||||
});
|
||||
}),
|
||||
});
|
||||
const terrain = createTerrainHeights({ source: sourceServices.terrain });
|
||||
const ground = createGroundFloor({ terrain });
|
||||
ground.FLOOR_RESOLVE_DEADLINE_MS = 1;
|
||||
const records = Array.from({ length: 130 }, (_, i) => ({
|
||||
id: `slow:${i}`,
|
||||
latitude: 30.261 + Math.floor(i / 15) * 0.001,
|
||||
longitude: -97.749 + (i % 15) * 0.001,
|
||||
}));
|
||||
const layer = createAlprCamerasLayer({
|
||||
source: {
|
||||
fetch: async () => ({ records, stale: false, saturated: false }),
|
||||
},
|
||||
services: {
|
||||
groundFloor: ground,
|
||||
render: { governorRequestRender() {} },
|
||||
picking: { registerPickOwner() {}, unregisterPickOwner() {} },
|
||||
context: {
|
||||
clearSelectedEntityContextForLayer() {},
|
||||
getSelectedEntityContext() {},
|
||||
registerEntityContext() {},
|
||||
removeEntityContextsForLayer() {},
|
||||
selectEntityContext() {},
|
||||
},
|
||||
},
|
||||
});
|
||||
const h = cameraHarness(layer);
|
||||
try {
|
||||
await layer.update();
|
||||
assert.equal(
|
||||
requests.length,
|
||||
1,
|
||||
'render joins the outstanding preparation batch',
|
||||
);
|
||||
assert.equal(
|
||||
requests[0].points.length,
|
||||
64,
|
||||
'one bounded batch runs at a time',
|
||||
);
|
||||
h.expire();
|
||||
await layer.update();
|
||||
await layer.update();
|
||||
assert.equal(
|
||||
requests.length,
|
||||
1,
|
||||
'further preparation and render reuse cold cell promises',
|
||||
);
|
||||
requests[0].release();
|
||||
await new Promise((done) => setTimeout(done, 0));
|
||||
assert.equal(requests.length, 2);
|
||||
const requested = requests.flatMap((request) => request.points);
|
||||
assert.equal(
|
||||
new Set(requested).size,
|
||||
requested.length,
|
||||
'no duplicate cells over HTTP',
|
||||
);
|
||||
layer.disable();
|
||||
assert.equal(
|
||||
requests[1].signal.aborted,
|
||||
true,
|
||||
'disable cancels the active HTTP request',
|
||||
);
|
||||
requests[1].release();
|
||||
await new Promise((done) => setTimeout(done, 0));
|
||||
assert.equal(requests.length, 2, 'queued final cells are discarded');
|
||||
assert.equal(h.source.entities.values.length, 0);
|
||||
layer.enable();
|
||||
await layer.update();
|
||||
assert.equal(
|
||||
requests.length,
|
||||
3,
|
||||
'a fresh consumer can resume unresolved cells',
|
||||
);
|
||||
layer.destroy();
|
||||
assert.equal(
|
||||
requests[2].signal.aborted,
|
||||
true,
|
||||
'destroy also aborts its consumer',
|
||||
);
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('map-source changes reposition all ALPR entities and saved native appearances in place', async () => {
|
||||
let floor = 180;
|
||||
let listener;
|
||||
let removed = 0;
|
||||
const records = Array.from({ length: 70 }, (_, i) => ({
|
||||
id: `surface:${i}`,
|
||||
latitude: 30.2672 + i * 0.00001,
|
||||
longitude: -97.7431,
|
||||
directionDeg: i === 0 ? null : 90,
|
||||
}));
|
||||
const layer = createAlprCamerasLayer({
|
||||
source: {
|
||||
fetch: async () => ({ records, stale: false, saturated: false }),
|
||||
},
|
||||
services: {
|
||||
groundFloor: { cachedGroundFloor: () => floor },
|
||||
overlays: {
|
||||
subscribeMapStack(fn) {
|
||||
listener = fn;
|
||||
return () => {
|
||||
removed++;
|
||||
};
|
||||
},
|
||||
},
|
||||
render: { governorRequestRender() {} },
|
||||
picking: { registerPickOwner() {}, unregisterPickOwner() {} },
|
||||
context: {
|
||||
clearSelectedEntityContextForLayer() {},
|
||||
getSelectedEntityContext() {},
|
||||
registerEntityContext() {},
|
||||
removeEntityContextsForLayer() {},
|
||||
selectEntityContext() {},
|
||||
},
|
||||
},
|
||||
});
|
||||
const h = cameraHarness(layer);
|
||||
try {
|
||||
await layer.update();
|
||||
const entities = [...h.source.entities.values];
|
||||
const saved = entities[1];
|
||||
saved.gevAlprNativeAppearance = {
|
||||
position: saved.position,
|
||||
heightReference: saved.billboard.heightReference,
|
||||
};
|
||||
saved.position = Cesium.Cartesian3.fromDegrees(-97.7431, 30.26721, 190);
|
||||
saved.gevAlprPickPosition = saved.position;
|
||||
for (const next of [150, 120, null, 180]) {
|
||||
floor = next;
|
||||
// Includes a provider switch with the same globe.show value.
|
||||
h.viewer.scene.globe.show = next !== 180;
|
||||
listener({ detail: { status: 'ready' } });
|
||||
for (const entity of entities) {
|
||||
assert.equal(h.source.entities.getById(entity.id), entity);
|
||||
assert.ok(
|
||||
Math.abs(
|
||||
Cesium.Cartographic.fromCartesian(entity.position.getValue())
|
||||
.height - (next == null ? 0 : next + 1.5),
|
||||
) < 0.01,
|
||||
`${entity.id} refreshes at ${next}`,
|
||||
);
|
||||
assert.equal(
|
||||
entity.billboard.heightReference.getValue(),
|
||||
next == null
|
||||
? Cesium.HeightReference.CLAMP_TO_GROUND
|
||||
: Cesium.HeightReference.NONE,
|
||||
);
|
||||
assert.equal(entity.gevAlprCanvasPosition, null);
|
||||
}
|
||||
assert.equal(
|
||||
saved.gevAlprNativeAppearance,
|
||||
null,
|
||||
'saved appearance restores at the new floor',
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
assert.equal(removed, 1, 'map listener is disposed');
|
||||
});
|
||||
|
||||
test('a repeated move inside an outstanding query keeps the displayed marker set until commit', async () => {
|
||||
const h = cameraHarness();
|
||||
try {
|
||||
await alprCamerasLayer.update();
|
||||
const previous = h.source.entities.getById('alpr:42');
|
||||
const revision = alprCamerasLayer.getStats().renderRevision;
|
||||
let release;
|
||||
h.setFetch(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
);
|
||||
h.setBox({ south: 31, west: -98, north: 31.02, east: -97.98 });
|
||||
const pending = alprCamerasLayer.update();
|
||||
await alprCamerasLayer.update();
|
||||
assert.equal(
|
||||
h.source.entities.getById('alpr:42'),
|
||||
previous,
|
||||
'do not show an intermediate filtered snapshot while the new query prepares',
|
||||
);
|
||||
assert.equal(alprCamerasLayer.getStats().renderRevision, revision);
|
||||
assert.equal(h.requests.length, 2, 'both moves share one new query');
|
||||
release(cameraResponse([cameraNode(43, { lat: 31.01, lon: -97.99 })]));
|
||||
await pending;
|
||||
assert.equal(alprCamerasLayer.getStats().renderRevision, revision + 1);
|
||||
assert.deepEqual(
|
||||
h.source.entities.values.map((e) => e.id),
|
||||
['alpr:43'],
|
||||
);
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
+879
-144
File diff suppressed because it is too large.
Load diff
@@ -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');
|
||||
});
|
||||
@@ -7,6 +7,8 @@ import { governorRequestRender } from '../renderGovernor.js';
|
||||
import {
|
||||
BHOTE_KOSHI_LOCATOR_CREDIT,
|
||||
registerDynamicCredit,
|
||||
showOsmCredit,
|
||||
hideOsmCredit,
|
||||
} from './dataCredits.js';
|
||||
import { BHOTE_KOSHI_FLOOD_PATH } from './bhoteKoshiFloodPath.js';
|
||||
import { INCIDENT_OVERVIEW_PLACES } from './bhoteKoshiIncidentPlaces.js';
|
||||
@@ -1035,6 +1037,7 @@ export function createBhoteKoshiLocatorLayer({
|
||||
renderPresentation();
|
||||
viewer.dataSources.add(dataSource);
|
||||
registerDynamicCredit(viewer, BHOTE_KOSHI_LOCATOR_CREDIT);
|
||||
showOsmCredit(viewer, BHOTE_KOSHI_LOCATOR_LAYER_ID);
|
||||
requestRender('bhote-koshi-locator-callout');
|
||||
|
||||
try {
|
||||
@@ -1070,6 +1073,7 @@ export function createBhoteKoshiLocatorLayer({
|
||||
}
|
||||
|
||||
async function disable() {
|
||||
hideOsmCredit(_viewer, BHOTE_KOSHI_LOCATOR_LAYER_ID);
|
||||
_enabled = false;
|
||||
_boundaryReady = false;
|
||||
_boundaryResolved = false;
|
||||
|
||||
@@ -27,7 +27,7 @@ function viewerFixture() {
|
||||
const added = [];
|
||||
const removed = [];
|
||||
return {
|
||||
creditDisplay: { addStaticCredit() {} },
|
||||
creditDisplay: { addStaticCredit() {}, removeStaticCredit() {} },
|
||||
dataSources: {
|
||||
add(source) { added.push(source); },
|
||||
remove(source) { removed.push(source); },
|
||||
|
||||
+24
-9
@@ -4,24 +4,39 @@
|
||||
* The browser fetches the file from `url` — a `new URL('./….json',
|
||||
* import.meta.url)` written in the calling module, which Vite serves as-is in
|
||||
* dev and emits as an asset in the production build. Under Node (node:test)
|
||||
* that URL is a `file:` URL, which `fetch` cannot read, so the caller's
|
||||
* JSON-attributed import runs instead.
|
||||
* that URL is a `file:` URL, which `fetch` cannot read, so the file is read
|
||||
* from disk instead.
|
||||
*
|
||||
* The attributed import cannot serve the browser: the Vite dev server answers
|
||||
* it with a JavaScript module, which the browser rejects for a JSON import.
|
||||
* Nothing here imports the JSON as a module. A JSON-attributed import cannot
|
||||
* serve the browser (the Vite dev server answers it with a JavaScript module,
|
||||
* which the browser rejects), and a literal `import('./….json')` makes the
|
||||
* production build emit a JavaScript copy of every pack that the browser
|
||||
* never loads.
|
||||
*
|
||||
* @module data/bundledJson
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {URL} url - Resolved against the calling module's `import.meta.url`.
|
||||
* @param {() => Promise<{default: any}>} importJson - The caller's literal
|
||||
* `import('./….json', { with: { type: 'json' } })`, used only under Node.
|
||||
* @param {{signal?: AbortSignal}} [options]
|
||||
* @returns {Promise<any>} The parsed JSON.
|
||||
*/
|
||||
export async function loadBundledJson(url, importJson) {
|
||||
if (url.protocol === 'file:') return (await importJson()).default;
|
||||
const response = await fetch(url);
|
||||
export async function loadBundledJson(url, { signal } = {}) {
|
||||
signal?.throwIfAborted();
|
||||
if (url.protocol === 'file:') {
|
||||
// Node's built-in, reached without an import, so the browser build never
|
||||
// bundles or rewrites it; only Node, reading `file:` URLs, gets here. The
|
||||
// read is synchronous so the result settles as promptly as the cached
|
||||
// module import it replaces; callers already memoize each pack.
|
||||
const getBuiltinModule = globalThis.process?.getBuiltinModule;
|
||||
if (typeof getBuiltinModule !== 'function')
|
||||
throw new Error(
|
||||
`God's Eye View needs Node 24.14 or newer to read ${url.pathname.split('/').pop()} (running ${globalThis.process?.version ?? 'an unknown runtime'})`,
|
||||
);
|
||||
const { readFileSync } = getBuiltinModule('node:fs');
|
||||
return JSON.parse(readFileSync(url, 'utf8'));
|
||||
}
|
||||
const response = await fetch(url, { signal });
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status} for ${url.pathname}`);
|
||||
}
|
||||
|
||||
@@ -2,13 +2,14 @@ import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { loadBundledJson } from './bundledJson.js';
|
||||
|
||||
const MARINE = new URL('./local_data/natural_earth/marine.json', import.meta.url);
|
||||
const importMarine = () =>
|
||||
import('./local_data/natural_earth/marine.json', { with: { type: 'json' } });
|
||||
const MARINE = new URL(
|
||||
'./local_data/natural_earth/marine.json',
|
||||
import.meta.url,
|
||||
);
|
||||
|
||||
test('bundled JSON: a file URL uses the JSON import under Node', async (t) => {
|
||||
test('bundled JSON: a file URL is read from disk under Node', async (t) => {
|
||||
const fetchMock = t.mock.method(globalThis, 'fetch');
|
||||
const pack = await loadBundledJson(MARINE, importMarine);
|
||||
const pack = await loadBundledJson(MARINE);
|
||||
assert.ok(pack.features.some((ft) => ft.name === 'Gulf of Mexico'));
|
||||
assert.equal(fetchMock.mock.callCount(), 0);
|
||||
});
|
||||
@@ -21,16 +22,62 @@ test('bundled JSON: a served URL is fetched as plain JSON', async (t) => {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
});
|
||||
const url = new URL('http://localhost/src/data/local_data/natural_earth/marine.json');
|
||||
const importJson = () => assert.fail('the browser path must not import JSON');
|
||||
assert.deepEqual(await loadBundledJson(url, importJson), { features: [] });
|
||||
const url = new URL(
|
||||
'http://localhost/src/data/local_data/natural_earth/marine.json',
|
||||
);
|
||||
assert.deepEqual(await loadBundledJson(url), { features: [] });
|
||||
assert.deepEqual(requests, [url.href]);
|
||||
});
|
||||
|
||||
test('bundled JSON: an HTTP error rejects so the retryable loader can retry', async (t) => {
|
||||
t.mock.method(globalThis, 'fetch', async () => new Response('', { status: 404 }));
|
||||
t.mock.method(
|
||||
globalThis,
|
||||
'fetch',
|
||||
async () => new Response('', { status: 404 }),
|
||||
);
|
||||
await assert.rejects(
|
||||
loadBundledJson(new URL('http://localhost/missing.json'), importMarine),
|
||||
loadBundledJson(new URL('http://localhost/missing.json')),
|
||||
/HTTP 404/,
|
||||
);
|
||||
});
|
||||
|
||||
test('bundled JSON: an aborted load rejects before reading', async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await assert.rejects(loadBundledJson(MARINE, { signal: controller.signal }), {
|
||||
name: 'AbortError',
|
||||
});
|
||||
});
|
||||
|
||||
test('bundled JSON: no data pack is imported as a module', async () => {
|
||||
// A literal JSON import in app code makes the production build emit a
|
||||
// JavaScript copy of the pack beside the fetched JSON, which the browser
|
||||
// never loads. Tests are not bundled, so they may still import JSON.
|
||||
const { spawnSync } = await import('node:child_process');
|
||||
const result = spawnSync(
|
||||
'git',
|
||||
[
|
||||
'grep',
|
||||
'-nE',
|
||||
String.raw`import\(\s*['"][^'"]*local_data/[^'"]*\.json`,
|
||||
'--',
|
||||
'src',
|
||||
':!*.test.mjs',
|
||||
],
|
||||
{ cwd: new URL('../../', import.meta.url), encoding: 'utf8' },
|
||||
);
|
||||
// git grep exits 1 when nothing matches.
|
||||
assert.equal(result.status, 1, result.stdout);
|
||||
});
|
||||
|
||||
test('bundled JSON: an older Node says which version is needed', async (t) => {
|
||||
// Node before 20.16 / 22.3 has no process.getBuiltinModule.
|
||||
const original = process.getBuiltinModule;
|
||||
t.after(() => {
|
||||
process.getBuiltinModule = original;
|
||||
});
|
||||
process.getBuiltinModule = undefined;
|
||||
await assert.rejects(loadBundledJson(MARINE), {
|
||||
message: /needs Node 24\.14 or newer to read marine\.json/,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
/** Arrival corrections belong to one viewer and yield to its next camera owner. */
|
||||
const arrivals = new WeakMap();
|
||||
|
||||
/** Cancel an outstanding arrival correction before navigation or teardown. */
|
||||
export function cancelCameraArrival(viewer) {
|
||||
if (viewer) arrivals.get(viewer)?.();
|
||||
}
|
||||
|
||||
/** Register a correction and return an idempotent ownership release. */
|
||||
export function ownCameraArrival(viewer, cancel) {
|
||||
cancelCameraArrival(viewer);
|
||||
arrivals.set(viewer, cancel);
|
||||
return () => {
|
||||
if (arrivals.get(viewer) === cancel) arrivals.delete(viewer);
|
||||
};
|
||||
}
|
||||
@@ -128,6 +128,14 @@ test('the catalog applies the fair cap to configured packs end to end', async ()
|
||||
sources.slice(0, 3).map((s) => s.id),
|
||||
['env-0', 'env-1', 'env-2'],
|
||||
);
|
||||
assert.equal(sources[0].pack, 'env');
|
||||
const [trimmed] = getSources.trimmedPacks();
|
||||
assert.deepEqual(
|
||||
{ ...trimmed, region: undefined },
|
||||
{ pack: 'env', available: 12, served: 9, region: undefined },
|
||||
);
|
||||
assert.equal(trimmed.region.south, 30);
|
||||
assert.ok(Math.abs(trimmed.region.north - 30.11) < 1e-9);
|
||||
} finally {
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in saved)) delete process.env[key];
|
||||
@@ -135,3 +143,20 @@ test('the catalog applies the fair cap to configured packs end to end', async ()
|
||||
Object.assign(process.env, saved);
|
||||
}
|
||||
});
|
||||
|
||||
test('a pack trimmed to its nearest cameras reports how many it offered', async () => {
|
||||
const { prioritizeSources } =
|
||||
await import('../../server/providers/cctv/normalize.js');
|
||||
const cameras = Array.from({ length: 5 }, (_, i) => ({
|
||||
id: `c${i}`,
|
||||
lat: i,
|
||||
lon: 0,
|
||||
}));
|
||||
const kept = prioritizeSources(cameras, 2, [{ lat: 0, lon: 0 }]);
|
||||
assert.deepEqual(
|
||||
kept.map((camera) => camera.id),
|
||||
['c0', 'c1'],
|
||||
);
|
||||
assert.equal(kept.available, 5);
|
||||
assert.deepEqual(kept.region, { west: 0, south: 0, east: 0, north: 4 });
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
loadVegvesenSourcesFromOpenData,
|
||||
vegvesenCameraToSource,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
import {
|
||||
DEFAULT_VEGVESEN_CCTV_URL,
|
||||
VEGVESEN_IMAGE_ORIGIN,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
|
||||
|
||||
/** One `datex_3_1:CctvSimple` feature, shaped like the live OGC payload. */
|
||||
const feature = (props = {}, coordinates = [5.459189, 61.832706]) => ({
|
||||
type: 'Feature',
|
||||
geometry: { type: 'Point', coordinates },
|
||||
properties: {
|
||||
stillImageUrl: 'https://kamera.atlas.vegvesen.no/api/images/3000063_1',
|
||||
cameraId: '3000063_1',
|
||||
roadNumber: 'F614',
|
||||
'status.stillImageAvailability': 'videoOrImagesAvailable',
|
||||
orientationDescription: 'Svelgen',
|
||||
description: 'Langesi',
|
||||
...props,
|
||||
},
|
||||
});
|
||||
|
||||
test('a Vegvesen feature maps to a source on the pinned frame host', () => {
|
||||
const source = vegvesenCameraToSource(feature());
|
||||
assert.equal(source.id, 'no-vegvesen-3000063_1');
|
||||
assert.equal(source.name, 'F614 Langesi → Svelgen');
|
||||
assert.equal(source.city, 'Norway');
|
||||
assert.equal(source.cityId, 'norway');
|
||||
assert.equal(source.provider, 'Statens vegvesen');
|
||||
assert.equal(source.lat, 61.832706);
|
||||
assert.equal(source.lon, 5.459189);
|
||||
assert.equal(source.headingConfidence, 'low');
|
||||
assert.equal(source.sourceKind, 'vegvesen-datex');
|
||||
assert.equal(source.code, 'LANGESI');
|
||||
assert.ok(source.url.startsWith(VEGVESEN_IMAGE_ORIGIN));
|
||||
assert.equal(source.snapshotUrl, source.url);
|
||||
});
|
||||
|
||||
test('cameras that publish HLS get live video with the still as fallback', (t) => {
|
||||
const video = {
|
||||
videoServiceLevel: 1,
|
||||
videoEncodingStandard: 'hls',
|
||||
videoUrl: 'https://kamera.vegvesen.no/public/3000063_1/manifest.m3u8',
|
||||
};
|
||||
const source = vegvesenCameraToSource(feature(video));
|
||||
assert.equal(source.feedType, 'hls');
|
||||
assert.equal(source.url, video.videoUrl);
|
||||
assert.equal(
|
||||
source.snapshotUrl,
|
||||
'https://kamera.atlas.vegvesen.no/api/images/3000063_1',
|
||||
);
|
||||
// A manifest anywhere but the camera's own path stays a still.
|
||||
const offPath = vegvesenCameraToSource(
|
||||
feature({ ...video, videoUrl: 'https://evil.test/x/manifest.m3u8' }),
|
||||
);
|
||||
assert.equal(offPath.feedType, 'image');
|
||||
assert.equal(offPath.url, offPath.snapshotUrl);
|
||||
// The kill switch keeps every camera on stills.
|
||||
const saved = process.env.CCTV_VEGVESEN_VIDEO;
|
||||
t.after(() => {
|
||||
if (saved === undefined) delete process.env.CCTV_VEGVESEN_VIDEO;
|
||||
else process.env.CCTV_VEGVESEN_VIDEO = saved;
|
||||
});
|
||||
process.env.CCTV_VEGVESEN_VIDEO = '0';
|
||||
assert.equal(vegvesenCameraToSource(feature(video)).feedType, 'image');
|
||||
});
|
||||
|
||||
test('faulty cameras, off-host frames and bad geometry are dropped', () => {
|
||||
assert.equal(
|
||||
vegvesenCameraToSource(
|
||||
feature({
|
||||
'status.stillImageAvailability':
|
||||
'videoOrImagesUnavailableDueToCameraFault',
|
||||
}),
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
vegvesenCameraToSource(
|
||||
feature({ stillImageUrl: 'https://evil.test/api/images/3000063_1' }),
|
||||
),
|
||||
null,
|
||||
);
|
||||
// The frame URL must be exactly the camera's own image path.
|
||||
assert.equal(
|
||||
vegvesenCameraToSource(
|
||||
feature({
|
||||
stillImageUrl: 'https://kamera.atlas.vegvesen.no/api/images/999_1',
|
||||
}),
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(vegvesenCameraToSource(feature({ cameraId: '../x' })), null);
|
||||
// Copenhagen: a plausible coordinate, but outside the Norway box.
|
||||
assert.equal(vegvesenCameraToSource(feature({}, [12.57, 55.68])), null);
|
||||
assert.equal(vegvesenCameraToSource(feature({}, [5.4])), null);
|
||||
assert.equal(vegvesenCameraToSource(null), null);
|
||||
});
|
||||
|
||||
test('the loader dedupes, and failures degrade to an empty pack', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const requested = [];
|
||||
const fetchMock = t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
requested.push(String(url));
|
||||
return Response.json({
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
feature(),
|
||||
feature(),
|
||||
feature(
|
||||
{
|
||||
cameraId: '0629001_1',
|
||||
stillImageUrl:
|
||||
'https://kamera.atlas.vegvesen.no/api/images/0629001_1',
|
||||
description: 'Oslo S',
|
||||
},
|
||||
[10.752, 59.911],
|
||||
),
|
||||
],
|
||||
});
|
||||
});
|
||||
const cameras = await loadVegvesenSourcesFromOpenData();
|
||||
assert.deepEqual(requested, [DEFAULT_VEGVESEN_CCTV_URL]);
|
||||
// Nearest-to-anchor first: Oslo leads.
|
||||
assert.deepEqual(
|
||||
cameras.map((c) => c.id),
|
||||
['no-vegvesen-0629001_1', 'no-vegvesen-3000063_1'],
|
||||
);
|
||||
|
||||
fetchMock.mock.mockImplementation(
|
||||
async () => new Response('nope', { status: 503 }),
|
||||
);
|
||||
assert.deepEqual(await loadVegvesenSourcesFromOpenData(), []);
|
||||
fetchMock.mock.mockImplementation(async () => {
|
||||
throw new Error('offline');
|
||||
});
|
||||
assert.deepEqual(await loadVegvesenSourcesFromOpenData(), []);
|
||||
});
|
||||
|
||||
const runCatalog = async (t) => {
|
||||
const requested = [];
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
const href = String(url);
|
||||
requested.push(href);
|
||||
if (href === DEFAULT_VEGVESEN_CCTV_URL) {
|
||||
return Response.json({
|
||||
type: 'FeatureCollection',
|
||||
features: [feature()],
|
||||
});
|
||||
}
|
||||
return Response.json([]);
|
||||
});
|
||||
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
|
||||
return { requested, sources };
|
||||
};
|
||||
|
||||
const withEnv = async (patch, fn) => {
|
||||
const saved = { ...process.env };
|
||||
try {
|
||||
delete process.env.CCTV_SOURCES_FILE;
|
||||
delete process.env.CCTV_SOURCES_JSON;
|
||||
delete process.env.CCTV_VEGVESEN_ENABLED;
|
||||
Object.assign(process.env, patch);
|
||||
await fn();
|
||||
} finally {
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in saved)) delete process.env[key];
|
||||
}
|
||||
Object.assign(process.env, saved);
|
||||
}
|
||||
};
|
||||
|
||||
test('the Vegvesen lane is wired into the catalog', async (t) => {
|
||||
await withEnv({}, async () => {
|
||||
const { requested, sources } = await runCatalog(t);
|
||||
assert.ok(requested.includes(DEFAULT_VEGVESEN_CCTV_URL));
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.cityId === 'norway').map((s) => s.id),
|
||||
['no-vegvesen-3000063_1'],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('CCTV_VEGVESEN_ENABLED=0 keeps the lane from being loaded', async (t) => {
|
||||
await withEnv({ CCTV_VEGVESEN_ENABLED: '0' }, async () => {
|
||||
const { requested, sources } = await runCatalog(t);
|
||||
assert.equal(requested.includes(DEFAULT_VEGVESEN_CCTV_URL), false);
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.cityId === 'norway'),
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
+93
-54
@@ -25,6 +25,72 @@ import * as Cesium from 'cesium';
|
||||
* follows DATA_SOURCES.md (live sources, then bundled snapshots).
|
||||
* @type {{ key: string, html: string }[]}
|
||||
*/
|
||||
export const OSM_CREDIT = {
|
||||
key: 'openstreetmap',
|
||||
html: 'Map and place data <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> (<a href="https://opendatacommons.org/licenses/odbl/1-0/" target="_blank" rel="noopener">ODbL</a>)',
|
||||
};
|
||||
export const OPENMAPTILES_CREDIT = {
|
||||
key: 'openfreemap',
|
||||
html: 'Vector tiles: <a href="https://openfreemap.org" target="_blank" rel="noopener">OpenFreeMap</a> © <a href="https://openmaptiles.org" target="_blank" rel="noopener">OpenMapTiles</a>',
|
||||
};
|
||||
|
||||
const osmDisplays = new WeakMap();
|
||||
|
||||
function updateOsmDisplay(viewer, state) {
|
||||
const osm = state.owners.size > 0;
|
||||
let tiles = false;
|
||||
for (const usesTiles of state.owners.values()) tiles ||= usesTiles;
|
||||
for (const [key, visible] of [
|
||||
['osm', osm],
|
||||
['tiles', tiles],
|
||||
]) {
|
||||
if (state[key].visible === visible) continue;
|
||||
state[key].visible = visible;
|
||||
viewer.creditDisplay[visible ? 'addStaticCredit' : 'removeStaticCredit'](
|
||||
state[key].credit,
|
||||
);
|
||||
viewer.scene?.requestRender?.();
|
||||
}
|
||||
}
|
||||
|
||||
/** Keep one short inline credit while an OSM display owner has visible data. */
|
||||
export function showOsmCredit(viewer, owner, { openMapTiles = false } = {}) {
|
||||
if (!viewer?.creditDisplay?.addStaticCredit || !owner) return false;
|
||||
let state = osmDisplays.get(viewer);
|
||||
if (!state) {
|
||||
state = {
|
||||
owners: new Map(),
|
||||
osm: {
|
||||
visible: false,
|
||||
credit: new Cesium.Credit(
|
||||
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap</a>',
|
||||
true,
|
||||
),
|
||||
},
|
||||
tiles: {
|
||||
visible: false,
|
||||
credit: new Cesium.Credit(
|
||||
'<a href="https://openmaptiles.org" target="_blank" rel="noopener">© OpenMapTiles</a>',
|
||||
true,
|
||||
),
|
||||
},
|
||||
};
|
||||
osmDisplays.set(viewer, state);
|
||||
}
|
||||
if (state.owners.get(owner) === openMapTiles) return false;
|
||||
state.owners.set(owner, openMapTiles);
|
||||
updateOsmDisplay(viewer, state);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Release only this owner's credit; other displayed OSM sources retain theirs. */
|
||||
export function hideOsmCredit(viewer, owner) {
|
||||
const state = osmDisplays.get(viewer);
|
||||
if (!state?.owners.delete(owner)) return false;
|
||||
updateOsmDisplay(viewer, state);
|
||||
return true;
|
||||
}
|
||||
|
||||
export const DATA_CREDITS = [
|
||||
// ── Live sources ────────────────────────────────────────────────
|
||||
{
|
||||
@@ -94,42 +160,15 @@ export const DATA_CREDITS = [
|
||||
' · Incident information: ' +
|
||||
'<a href="https://inciweb.wildfire.gov/" target="_blank" rel="noopener">InciWeb</a>',
|
||||
},
|
||||
OSM_CREDIT,
|
||||
OPENMAPTILES_CREDIT,
|
||||
{
|
||||
key: 'overpass',
|
||||
html:
|
||||
'Road geometry (traffic): ' +
|
||||
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> ' +
|
||||
'(ODbL 1.0)',
|
||||
key: 'overture-military-names',
|
||||
html: 'Military area names: <a href="https://overturemaps.org" target="_blank" rel="noopener">Overture Maps Foundation</a> (ODbL)',
|
||||
},
|
||||
{
|
||||
key: 'photon-geocoder',
|
||||
html:
|
||||
'Keyless place search: ' +
|
||||
'<a href="https://photon.komoot.io" target="_blank" rel="noopener">Photon</a> (komoot) over ' +
|
||||
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> ' +
|
||||
'(ODbL 1.0)',
|
||||
},
|
||||
{
|
||||
key: 'alpr-osm',
|
||||
html:
|
||||
'ALPR camera locations (automatic license plate readers): ' +
|
||||
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> ' +
|
||||
'(<a href="https://opendatacommons.org/licenses/odbl/1-0/" target="_blank" rel="noopener">ODbL 1.0</a>); ' +
|
||||
'community mapping includes <a href="https://deflock.org" target="_blank" rel="noopener">DeFlock</a>',
|
||||
},
|
||||
{
|
||||
key: 'military-installations-osm',
|
||||
html:
|
||||
'Mapped installation context: ' +
|
||||
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> ' +
|
||||
'(ODbL 1.0; incomplete mapped context)',
|
||||
},
|
||||
{
|
||||
key: 'cockpit-place-osm',
|
||||
html:
|
||||
'Cockpit place context and last-resort place search: ' +
|
||||
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> ' +
|
||||
'via Nominatim (ODbL 1.0)',
|
||||
html: 'Keyless place search: <a href="https://photon.komoot.io" target="_blank" rel="noopener">Photon</a> (komoot)',
|
||||
},
|
||||
{
|
||||
key: 'open-meteo',
|
||||
@@ -201,6 +240,13 @@ export const DATA_CREDITS = [
|
||||
'Traffic cameras (Calgary): contains information licensed under the ' +
|
||||
'<a href="https://data.calgary.ca/stories/s/Open-Calgary-Terms-of-Use/u45n-7awa" target="_blank" rel="noopener">Open Government Licence – City of Calgary</a>',
|
||||
},
|
||||
{
|
||||
key: 'vegvesen-cctv',
|
||||
html:
|
||||
'Road cameras (Norway): contains data under the ' +
|
||||
'<a href="https://data.norge.no/nlod/en/2.0" target="_blank" rel="noopener">Norwegian licence for Open Government data (NLOD)</a> ' +
|
||||
'distributed by Statens vegvesen',
|
||||
},
|
||||
{
|
||||
key: 'gbfs',
|
||||
html: 'Bikeshare availability: GBFS operator feeds (e.g. Austin BCycle)',
|
||||
@@ -212,7 +258,6 @@ export const DATA_CREDITS = [
|
||||
html:
|
||||
'Routing (voice routes and Directions): OSRM on the FOSSGIS servers — ' +
|
||||
'<a href="https://routing.openstreetmap.de/about.html" target="_blank" rel="noopener">routing.openstreetmap.de</a> · ' +
|
||||
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> (ODbL) · ' +
|
||||
'<a href="https://www.openstreetmap.org/fixthemap" target="_blank" rel="noopener">fix the map</a>',
|
||||
},
|
||||
{
|
||||
@@ -262,19 +307,9 @@ export const DATA_CREDITS = [
|
||||
'ECMWF does not accept any liability whatsoever for any error or omission in the data, their availability, or for any loss or damage arising from their use.',
|
||||
},
|
||||
// ── Bundled snapshots ───────────────────────────────────────────
|
||||
{
|
||||
key: 'datacenters',
|
||||
html:
|
||||
'Datacenters: ' +
|
||||
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> ' +
|
||||
'(ODbL 1.0)',
|
||||
},
|
||||
{
|
||||
key: 'dams',
|
||||
html:
|
||||
'Dams: ' +
|
||||
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> ' +
|
||||
'(ODbL 1.0) + Open Infrastructure Map',
|
||||
html: 'Dams: Open Infrastructure Map',
|
||||
},
|
||||
{
|
||||
key: 'firms',
|
||||
@@ -307,9 +342,7 @@ export const DATA_CREDITS = [
|
||||
},
|
||||
{
|
||||
key: 'warendorf-cctv',
|
||||
html:
|
||||
'Webcam (Warendorf): <a href="https://www.warendorf.de/" target="_blank" rel="noopener">Stadt Warendorf</a> (courtesy); ' +
|
||||
'camera poses derived from OpenStreetMap geometry, © OpenStreetMap contributors (ODbL)',
|
||||
html: 'Webcam (Warendorf): <a href="https://www.warendorf.de/" target="_blank" rel="noopener">Stadt Warendorf</a> (courtesy)',
|
||||
},
|
||||
{
|
||||
key: 'nsw-cctv',
|
||||
@@ -342,15 +375,24 @@ export const TOMTOM_CREDIT = {
|
||||
'<a href="https://www.tomtom.com" target="_blank" rel="noopener">TomTom</a>',
|
||||
};
|
||||
|
||||
/** Registered when the first Natural Earth region outline resolves (public
|
||||
* domain — no attribution required; credited as a courtesy). */
|
||||
/** Registered when the first Natural Earth region or state/province outline
|
||||
* resolves (public domain — no attribution required; credited as a courtesy). */
|
||||
export const NATURAL_EARTH_CREDIT = {
|
||||
key: 'natural-earth',
|
||||
html:
|
||||
'Physical region boundaries from ' +
|
||||
'Country, state/province and physical region boundaries from ' +
|
||||
'<a href="https://www.naturalearthdata.com" target="_blank" rel="noopener">Natural Earth</a> (public domain)',
|
||||
};
|
||||
|
||||
/** Registered when the first bundled US county outline resolves (public
|
||||
* domain — credited as a courtesy). */
|
||||
export const US_CENSUS_CREDIT = {
|
||||
key: 'us-census-counties',
|
||||
html:
|
||||
'US county boundaries from the ' +
|
||||
'<a href="https://www.census.gov/geographies/mapping-files/time-series/geo/cartographic-boundary.html" target="_blank" rel="noopener">U.S. Census Bureau</a> (public domain)',
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-feed transit credit, registered the first time that feed's vehicles
|
||||
* render (see `src/data/transitFeeds.js` for the license of each).
|
||||
@@ -386,10 +428,7 @@ export const BHOTE_KOSHI_CREDIT = {
|
||||
export const BHOTE_KOSHI_LOCATOR_CREDIT = {
|
||||
key: 'bhote-koshi-locator',
|
||||
html:
|
||||
'Nepal administrative boundary and nearby-city locations: ' +
|
||||
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> ' +
|
||||
'(ODbL 1.0); incident-place anchors use an owner-curated, source-verified geolocation union; ' +
|
||||
'flood corridor derived from the ' +
|
||||
'Flood corridor derived from the ' +
|
||||
'<a href="https://github.com/geo-pera/bhotekoshi-2026-reconstruction/blob/main/vectors/river_centerline.geojson" target="_blank" rel="noopener">GeoPera river centerline</a> ' +
|
||||
'(CC BY-NC 4.0)',
|
||||
};
|
||||
|
||||
@@ -37,3 +37,86 @@ test('adsbdb is credited and carries its published route-data restriction', () =
|
||||
assert.match(credit.html, /Guillaume Michel/);
|
||||
assert.match(credit.html, /href="https:\/\/www\.adsbdb\.com"/);
|
||||
});
|
||||
|
||||
test('OpenStreetMap has one generic data credit with separate tile and names distributors', () => {
|
||||
const osm = DATA_CREDITS.filter((entry) =>
|
||||
entry.html.includes('openstreetmap.org/copyright'),
|
||||
);
|
||||
assert.equal(osm.length, 1);
|
||||
assert.equal(osm[0].key, 'openstreetmap');
|
||||
assert.match(osm[0].html, /Map and place data/);
|
||||
assert.match(osm[0].html, /© OpenStreetMap contributors/);
|
||||
assert.match(
|
||||
DATA_CREDITS.find((entry) => entry.key === 'openfreemap').html,
|
||||
/Vector tiles:/,
|
||||
);
|
||||
assert.match(
|
||||
DATA_CREDITS.find((entry) => entry.key === 'overture-military-names').html,
|
||||
/Overture Maps Foundation/,
|
||||
);
|
||||
});
|
||||
|
||||
test('inline OSM attribution persists until its last display owner leaves', async (t) => {
|
||||
const { showOsmCredit, hideOsmCredit } = await import('./dataCredits.js');
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const visible = new Set();
|
||||
const viewer = {
|
||||
creditDisplay: {
|
||||
addStaticCredit: (c) => visible.add(c),
|
||||
removeStaticCredit: (c) => visible.delete(c),
|
||||
},
|
||||
scene: { requestRender() {} },
|
||||
};
|
||||
assert.equal(showOsmCredit(viewer, 'alpr'), true);
|
||||
assert.equal(showOsmCredit(viewer, 'alpr'), false);
|
||||
assert.equal(visible.size, 1);
|
||||
assert.match(
|
||||
[...visible][0].html,
|
||||
/href="https:\/\/www.openstreetmap.org\/copyright"/,
|
||||
);
|
||||
assert.match([...visible][0].html, />© OpenStreetMap</);
|
||||
assert.ok([...visible][0].showOnScreen);
|
||||
t.mock.timers.tick(60000);
|
||||
assert.equal(visible.size, 1);
|
||||
showOsmCredit(viewer, 'traffic', { openMapTiles: true });
|
||||
assert.equal(visible.size, 2);
|
||||
hideOsmCredit(viewer, 'alpr');
|
||||
assert.equal(visible.size, 2, 'traffic still owns both credits');
|
||||
showOsmCredit(viewer, 'datacenters');
|
||||
hideOsmCredit(viewer, 'traffic');
|
||||
assert.equal(
|
||||
visible.size,
|
||||
1,
|
||||
'bundled data does not retain the tile distributor',
|
||||
);
|
||||
hideOsmCredit(viewer, 'datacenters');
|
||||
assert.equal(visible.size, 0);
|
||||
assert.equal(hideOsmCredit(viewer, 'datacenters'), false);
|
||||
showOsmCredit(viewer, 'alpr');
|
||||
assert.equal(visible.size, 1, 'reenabling restores the short credit');
|
||||
});
|
||||
|
||||
test('tile attribution follows source changes and viewers have independent ownership', async () => {
|
||||
const { showOsmCredit, hideOsmCredit } = await import('./dataCredits.js');
|
||||
const create = () => {
|
||||
const credits = new Set();
|
||||
return {
|
||||
credits,
|
||||
creditDisplay: {
|
||||
addStaticCredit: (c) => credits.add(c),
|
||||
removeStaticCredit: (c) => credits.delete(c),
|
||||
},
|
||||
};
|
||||
};
|
||||
const a = create(),
|
||||
b = create();
|
||||
showOsmCredit(a, 'installations', { openMapTiles: true });
|
||||
showOsmCredit(b, 'installations');
|
||||
assert.equal(a.credits.size, 2);
|
||||
assert.equal(b.credits.size, 1);
|
||||
showOsmCredit(a, 'installations');
|
||||
assert.equal(a.credits.size, 1, 'wide names no longer use tiles');
|
||||
hideOsmCredit(a, 'installations');
|
||||
assert.equal(a.credits.size, 0);
|
||||
assert.equal(b.credits.size, 1);
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createDirectionsSource } from '../layers/directions/source.js';
|
||||
/**
|
||||
* @module directions
|
||||
* @description Service bundle for the Directions layer.
|
||||
@@ -10,6 +11,7 @@
|
||||
* the layer's pure helpers for the tests and the share-link code.
|
||||
*/
|
||||
|
||||
import * as credits from './dataCredits.js';
|
||||
import * as render from '../renderGovernor.js';
|
||||
import * as sprites from './spriteOrder.js';
|
||||
import * as picking from './pickRegistry.js';
|
||||
@@ -23,6 +25,7 @@ import { createDirectionsLayer } from '../layers/directions/index.js';
|
||||
|
||||
/** The shared scene owners every Directions instance runs on. */
|
||||
export const directionsServices = Object.freeze({
|
||||
credits,
|
||||
render,
|
||||
sprites,
|
||||
picking,
|
||||
@@ -35,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 {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,3 +20,24 @@
|
||||
time is interpolated between the frames whose decoded position equals a
|
||||
dump1090 fix, timed as dump1090's `now − seen_pos`. Used by the decoder's
|
||||
category, speed-check and track-accuracy tests; never loaded at runtime.
|
||||
- `ofm-austin-{14-3743-6745,12-935-1686}.pbf` and
|
||||
`ofm-camp-mabry-12-935-1685.pbf` — OpenFreeMap / OpenMapTiles tiles from
|
||||
`https://tiles.openfreemap.org/planet/20260913_164504_pt/{z}/{x}/{y}.pbf`,
|
||||
retrieved 2026-09-23. Trimmed to one transportation feature per class/direction
|
||||
and one military landuse feature, preserving original geometry and dictionaries.
|
||||
OpenFreeMap © OpenMapTiles Data from OpenStreetMap; © OpenStreetMap
|
||||
contributors, ODbL 1.0 (https://www.openstreetmap.org/copyright).
|
||||
These modified data fixtures retain ODbL attribution and database share-alike;
|
||||
commercial use and redistribution are permitted under that license. Test-only.
|
||||
- `osm-alpr-austin-11-467-843.pbf` — one OSM camera feature (original geometry
|
||||
and attribute dictionary) from the community-hosted hourly US extract,
|
||||
`https://tiles.dontgetflocked.com/cameras-us-hourly/11/467/843.mvt`,
|
||||
retrieved 2026-09-23. © OpenStreetMap contributors, ODbL 1.0; modified by
|
||||
trimming to one feature, test-only. Same attribution/share-alike terms above.
|
||||
|
||||
- `traffic-road-access.json` — representative transportation properties and two
|
||||
original vertices per feature near the Texas Capitol, Camp Mabry greenbelt,
|
||||
and Austin commercial car parks, from OpenFreeMap z14 tiles, retrieved
|
||||
2026-09-25. Each record names its XYZ tile and expected traffic eligibility.
|
||||
Modified, test-only OpenStreetMap data; same ODbL attribution and database
|
||||
share-alike terms as the OpenFreeMap fixtures above.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large.
Load diff
+2
-2
@@ -1,12 +1,12 @@
|
||||
import { defaultSurface } from './surfaceServices.js';
|
||||
import { createApplicationFlights } from '../app/layers/flights.js';
|
||||
|
||||
import { createOpenSkySource } from '../sources/live/standalone.js';
|
||||
import { createFlightSource } from '../sources/live/standalone.js';
|
||||
import * as militaryRegistry from './militaryRegistry.js';
|
||||
|
||||
const flightsLayer = createApplicationFlights({
|
||||
surface: defaultSurface,
|
||||
source: createOpenSkySource(),
|
||||
source: createFlightSource(),
|
||||
militaryRegistry,
|
||||
});
|
||||
export { TRACKED_MODEL_MAX_PX } from '../layers/flights/policy.js';
|
||||
|
||||
+1070
-237
File diff suppressed because it is too large.
Load diff
+125
-82
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* @file Flow→road matching: assign TomTom congestion levels to Overpass roads.
|
||||
* @file Flow→road matching: assign TomTom congestion levels to OpenStreetMap roads.
|
||||
*
|
||||
* The traffic layer renders dots along OSM road polylines (Overpass), while
|
||||
* In OpenStreetMap road mode the layer renders OpenFreeMap polylines, while
|
||||
* TomTom flow tiles carry their own (differently segmented) polylines with
|
||||
* `traffic_level`. This module snaps flow onto roads geometrically:
|
||||
*
|
||||
@@ -11,8 +11,10 @@
|
||||
* projected to local meters (degree cell size cos(lat)-adjusted).
|
||||
* 2. Each road is sampled at up to 7 evenly-spaced points along its length;
|
||||
* each sample looks for the nearest flow segment within 35 m whose
|
||||
* bearing agrees within 30° (folded mod 180° so two-way roads match
|
||||
* opposite-direction flow lines).
|
||||
* bearing agrees with the road travel direction within 30°. Two-way
|
||||
* roads are represented as separate travel directions by the layer. A
|
||||
* flow line whose `coverage` is `full` describes both directions of its
|
||||
* road, so either travel bearing may match it.
|
||||
* 3. A road matches when at least half its samples (minimum 2) matched; its
|
||||
* level is the MEDIAN of the matched samples' trafficLevels, and closure
|
||||
* is true if ANY matched segment is closed.
|
||||
@@ -29,7 +31,7 @@ const M_PER_DEG_LAT = 111320;
|
||||
const CELL_SIZE_M = 100;
|
||||
/** @const {number} Max snap distance from a road sample to a flow segment. */
|
||||
const MATCH_RADIUS_M = 35;
|
||||
/** @const {number} Max bearing disagreement (degrees, folded mod 180). */
|
||||
/** @const {number} Max travel-bearing disagreement (degrees). */
|
||||
const BEARING_TOLERANCE_DEG = 30;
|
||||
/** @const {number} Evenly-spaced samples per road. */
|
||||
const ROAD_SAMPLES = 7;
|
||||
@@ -56,15 +58,14 @@ function bearingDeg(dx, dy) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Bearing disagreement folded mod 180° — a flow line drawn in the opposite
|
||||
* direction of travel still describes the same two-way road.
|
||||
* Travel-bearing disagreement; opposing flow never matches.
|
||||
* @param {number} a - Bearing (degrees). @param {number} b - Bearing (degrees).
|
||||
* @returns {number} min(|Δ|, 180 − |Δ|) in [0, 90].
|
||||
* @returns {number} Angular difference in [0, 180].
|
||||
*/
|
||||
function bearingDiffDeg(a, b) {
|
||||
let d = Math.abs(a - b) % 360;
|
||||
if (d > 180) d = 360 - d;
|
||||
return Math.min(d, 180 - d);
|
||||
return d;
|
||||
}
|
||||
|
||||
/** Squared distance from point (px,py) to segment (ax,ay)-(bx,by), meters². */
|
||||
@@ -80,61 +81,45 @@ function pointSegDist2(px, py, ax, ay, bx, by) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Match flow segments onto roads.
|
||||
* Index flow polylines for nearest-segment queries in local meters.
|
||||
*
|
||||
* @param {Array<{coords:number[][], type:string}>} roads
|
||||
* Parsed Overpass road objects ([[lon,lat],…] polylines).
|
||||
* @param {Array<{coords:number[][], trafficLevel:number, roadType:string, closure:boolean}>} flowSegments
|
||||
* Decoded flow polylines from `flowTiles.js`.
|
||||
* @returns {{
|
||||
* matches: Array<{level:number, closure:boolean}|null>,
|
||||
* matchedCount: number,
|
||||
* candidateCount: number,
|
||||
* }}
|
||||
* `matches` is PARALLEL to `roads` (index i describes roads[i]; null = no
|
||||
* flow data for that road, render it exactly as today). `candidateCount` is
|
||||
* the number of roads with at least one flow segment inside the 35 m search
|
||||
* radius regardless of bearing — the denominator for coverage stats.
|
||||
* Shared by congestion matching and the Hybrid road-source dedupe, so both
|
||||
* use one distance, bearing and direction rule.
|
||||
*
|
||||
* @param {Array<{coords:number[][], trafficLevel:number, closure:boolean, coverage?:string}>} flowSegments
|
||||
* @returns {null|{
|
||||
* project: (lon:number, lat:number) => [number, number],
|
||||
* nearest: (x:number, y:number, bearing:number, radiusFor?:(segment:object) => number) => {best:object|null, ambiguous:boolean, candidate:boolean},
|
||||
* }} Null when no usable segment exists.
|
||||
*/
|
||||
export function matchFlowToRoads(roads, flowSegments) {
|
||||
const roadCount = Array.isArray(roads) ? roads.length : 0;
|
||||
const matches = new Array(roadCount).fill(null);
|
||||
const empty = { matches, matchedCount: 0, candidateCount: 0 };
|
||||
if (
|
||||
roadCount === 0 ||
|
||||
!Array.isArray(flowSegments) ||
|
||||
flowSegments.length === 0
|
||||
) {
|
||||
return empty;
|
||||
}
|
||||
|
||||
export function createFlowSegmentIndex(flowSegments) {
|
||||
if (!Array.isArray(flowSegments)) return null;
|
||||
// Local equirectangular projection anchored at the first flow coordinate —
|
||||
// over a ≤0.05° fetch box the distortion is negligible.
|
||||
const anchor = flowSegments.find(
|
||||
(f) => Array.isArray(f?.coords) && f.coords.length >= 2,
|
||||
);
|
||||
if (!anchor) return empty;
|
||||
if (!anchor) return null;
|
||||
const [refLon, refLat] = anchor.coords[0];
|
||||
const mPerDegLon = M_PER_DEG_LAT * Math.cos((refLat * Math.PI) / 180);
|
||||
const projX = (lon) => (lon - refLon) * mPerDegLon;
|
||||
const projY = (lat) => (lat - refLat) * M_PER_DEG_LAT;
|
||||
const project = (lon, lat) => [
|
||||
(lon - refLon) * mPerDegLon,
|
||||
(lat - refLat) * M_PER_DEG_LAT,
|
||||
];
|
||||
|
||||
// ── 1. Spatial hash of flow segments (midpoint-keyed 100 m cells) ──
|
||||
/** @type {Map<string, Array<{ax:number,ay:number,bx:number,by:number,bearing:number,level:number,closure:boolean}>>} */
|
||||
// Spatial hash of flow segments (midpoint-keyed 100 m cells).
|
||||
const grid = new Map();
|
||||
const cellOf = (x, y) =>
|
||||
`${Math.floor(x / CELL_SIZE_M)},${Math.floor(y / CELL_SIZE_M)}`;
|
||||
|
||||
const cellKey = (cx, cy) => (cx + 0x8000) * 0x10000 + (cy + 0x8000);
|
||||
for (const flow of flowSegments) {
|
||||
const coords = flow?.coords;
|
||||
if (!Array.isArray(coords) || coords.length < 2) continue;
|
||||
const level = flow.trafficLevel;
|
||||
const closure = flow.closure === true;
|
||||
const bothDirections = flow.coverage === 'full';
|
||||
const roadType = flow.roadType;
|
||||
for (let i = 0; i < coords.length - 1; i++) {
|
||||
const ax = projX(coords[i][0]);
|
||||
const ay = projY(coords[i][1]);
|
||||
const bx = projX(coords[i + 1][0]);
|
||||
const by = projY(coords[i + 1][1]);
|
||||
const [ax, ay] = project(coords[i][0], coords[i][1]);
|
||||
const [bx, by] = project(coords[i + 1][0], coords[i + 1][1]);
|
||||
const segLen = Math.hypot(bx - ax, by - ay);
|
||||
if (!(segLen > 0)) continue;
|
||||
const bearing = bearingDeg(bx - ax, by - ay);
|
||||
@@ -150,10 +135,15 @@ export function matchFlowToRoads(roads, flowSegments) {
|
||||
bx: ax + (bx - ax) * t1,
|
||||
by: ay + (by - ay) * t1,
|
||||
bearing,
|
||||
bothDirections,
|
||||
roadType,
|
||||
level,
|
||||
closure,
|
||||
};
|
||||
const key = cellOf((seg.ax + seg.bx) / 2, (seg.ay + seg.by) / 2);
|
||||
const key = cellKey(
|
||||
Math.floor((seg.ax + seg.bx) / 2 / CELL_SIZE_M),
|
||||
Math.floor((seg.ay + seg.by) / 2 / CELL_SIZE_M),
|
||||
);
|
||||
let bucket = grid.get(key);
|
||||
if (!bucket) {
|
||||
bucket = [];
|
||||
@@ -163,13 +153,87 @@ export function matchFlowToRoads(roads, flowSegments) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (grid.size === 0) return empty;
|
||||
if (grid.size === 0) return null;
|
||||
|
||||
const radius2 = MATCH_RADIUS_M * MATCH_RADIUS_M;
|
||||
/**
|
||||
* Nearest direction-compatible segment within 35 m of a projected point.
|
||||
* `radiusFor(segment)` may tighten the radius per segment (never widen it).
|
||||
*/
|
||||
function nearest(px, py, bearing, radiusFor = null) {
|
||||
const cx = Math.floor(px / CELL_SIZE_M);
|
||||
const cy = Math.floor(py / CELL_SIZE_M);
|
||||
let best = null;
|
||||
let bestDist2 = radius2;
|
||||
let ambiguous = false;
|
||||
let candidate = false;
|
||||
for (let gy = cy - 1; gy <= cy + 1; gy++) {
|
||||
for (let gx = cx - 1; gx <= cx + 1; gx++) {
|
||||
const bucket = grid.get(cellKey(gx, gy));
|
||||
if (!bucket) continue;
|
||||
for (const seg of bucket) {
|
||||
const d2 = pointSegDist2(px, py, seg.ax, seg.ay, seg.bx, seg.by);
|
||||
if (d2 > radius2) continue;
|
||||
candidate = true; // within radius, bearing not yet checked
|
||||
if (radiusFor) {
|
||||
const radius = Math.min(MATCH_RADIUS_M, radiusFor(seg));
|
||||
if (d2 > radius * radius) continue;
|
||||
}
|
||||
const diff = bearingDiffDeg(seg.bearing, bearing);
|
||||
if (
|
||||
diff >= BEARING_TOLERANCE_DEG &&
|
||||
!(seg.bothDirections && 180 - diff < BEARING_TOLERANCE_DEG)
|
||||
)
|
||||
continue;
|
||||
if (d2 < bestDist2 - 1) {
|
||||
bestDist2 = d2;
|
||||
best = seg;
|
||||
ambiguous = false;
|
||||
} else if (Math.abs(d2 - bestDist2) <= 1) {
|
||||
if (
|
||||
best &&
|
||||
(best.level !== seg.level || best.closure !== seg.closure)
|
||||
)
|
||||
ambiguous = true;
|
||||
else if (!best) best = seg;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { best, ambiguous, candidate };
|
||||
}
|
||||
return { project, nearest };
|
||||
}
|
||||
|
||||
/**
|
||||
* Match flow segments onto roads.
|
||||
*
|
||||
* @param {Array<{coords:number[][], type:string, oneway?:number}>} roads
|
||||
* Parsed OpenStreetMap road objects ([[lon,lat],…] polylines).
|
||||
* @param {Array<{coords:number[][], trafficLevel:number, roadType:string, closure:boolean, coverage?:string}>} flowSegments
|
||||
* Decoded flow polylines from `flowDecode.js`.
|
||||
* @returns {{
|
||||
* matches: Array<{level:number, closure:boolean}|null>,
|
||||
* matchedCount: number,
|
||||
* candidateCount: number,
|
||||
* }}
|
||||
* `matches` is PARALLEL to `roads` (index i describes roads[i]; null = no
|
||||
* flow data for that road, render it exactly as today). `candidateCount` is
|
||||
* the number of roads with at least one flow segment inside the 35 m search
|
||||
* radius regardless of bearing before bearing rejection.
|
||||
*/
|
||||
export function matchFlowToRoads(roads, flowSegments) {
|
||||
const roadCount = Array.isArray(roads) ? roads.length : 0;
|
||||
const matches = new Array(roadCount).fill(null);
|
||||
const empty = { matches, matchedCount: 0, candidateCount: 0 };
|
||||
if (roadCount === 0) return empty;
|
||||
const index = createFlowSegmentIndex(flowSegments);
|
||||
if (!index) return empty;
|
||||
|
||||
let matchedCount = 0;
|
||||
let candidateCount = 0;
|
||||
|
||||
// ── 2+3. Sample each road and vote ──
|
||||
// Sample each road and vote.
|
||||
for (let r = 0; r < roadCount; r++) {
|
||||
const coords = roads[r]?.coords;
|
||||
if (!Array.isArray(coords) || coords.length < 2) continue;
|
||||
@@ -180,8 +244,7 @@ export function matchFlowToRoads(roads, flowSegments) {
|
||||
const cum = new Array(coords.length);
|
||||
cum[0] = 0;
|
||||
for (let i = 0; i < coords.length; i++) {
|
||||
xs[i] = projX(coords[i][0]);
|
||||
ys[i] = projY(coords[i][1]);
|
||||
[xs[i], ys[i]] = index.project(coords[i][0], coords[i][1]);
|
||||
if (i > 0)
|
||||
cum[i] = cum[i - 1] + Math.hypot(xs[i] - xs[i - 1], ys[i] - ys[i - 1]);
|
||||
}
|
||||
@@ -202,37 +265,17 @@ export function matchFlowToRoads(roads, flowSegments) {
|
||||
(target - cum[cursor - 1]) / (cum[cursor] - cum[cursor - 1] || 1);
|
||||
const px = xs[cursor - 1] + (xs[cursor] - xs[cursor - 1]) * segT;
|
||||
const py = ys[cursor - 1] + (ys[cursor] - ys[cursor - 1]) * segT;
|
||||
const sampleBearing = bearingDeg(
|
||||
xs[cursor] - xs[cursor - 1],
|
||||
ys[cursor] - ys[cursor - 1],
|
||||
);
|
||||
const sampleBearing =
|
||||
(roads[r].oneway === -1 ? 180 : 0) +
|
||||
bearingDeg(xs[cursor] - xs[cursor - 1], ys[cursor] - ys[cursor - 1]);
|
||||
|
||||
// 3×3 cell probe around the sample.
|
||||
const cx = Math.floor(px / CELL_SIZE_M);
|
||||
const cy = Math.floor(py / CELL_SIZE_M);
|
||||
let best = null;
|
||||
let bestDist2 = radius2;
|
||||
for (let gy = cy - 1; gy <= cy + 1; gy++) {
|
||||
for (let gx = cx - 1; gx <= cx + 1; gx++) {
|
||||
const bucket = grid.get(`${gx},${gy}`);
|
||||
if (!bucket) continue;
|
||||
for (const seg of bucket) {
|
||||
const d2 = pointSegDist2(px, py, seg.ax, seg.ay, seg.bx, seg.by);
|
||||
if (d2 > radius2) continue;
|
||||
hadCandidate = true; // within radius, bearing not yet checked
|
||||
if (
|
||||
bearingDiffDeg(seg.bearing, sampleBearing) >=
|
||||
BEARING_TOLERANCE_DEG
|
||||
)
|
||||
continue;
|
||||
if (d2 <= bestDist2) {
|
||||
bestDist2 = d2;
|
||||
best = seg;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (best) {
|
||||
const { best, ambiguous, candidate } = index.nearest(
|
||||
px,
|
||||
py,
|
||||
sampleBearing,
|
||||
);
|
||||
if (candidate) hadCandidate = true;
|
||||
if (best && !ambiguous) {
|
||||
matchedLevels.push(best.level);
|
||||
if (best.closure) matchedClosure = true;
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ test('coincident parallel road matches with the flow level', () => {
|
||||
assert.equal(matches[0].closure, false);
|
||||
});
|
||||
|
||||
test('opposite-direction flow still matches (two-way bearing fold)', () => {
|
||||
test('opposite-direction flow matches only the reverse travel direction)', () => {
|
||||
// Same line drawn south->north vs road north->south equivalence.
|
||||
const reversed = line({ lengthM: 500, bearingDeg: 0, eastM: 4 }).reverse();
|
||||
const { matches } = matchFlowToRoads([ROAD_NS], [flow(reversed, 0.3)]);
|
||||
const { matches } = matchFlowToRoads([{...ROAD_NS, oneway:-1}], [flow(reversed, 0.3)]);
|
||||
assert.ok(matches[0], 'reversed flow should still match');
|
||||
assert.ok(Math.abs(matches[0].level - 0.3) < 1e-9);
|
||||
});
|
||||
@@ -159,3 +159,28 @@ test('median: single value and empty', () => {
|
||||
assert.equal(median([0.7]), 0.7);
|
||||
assert.equal(median([]), null);
|
||||
});
|
||||
|
||||
test('one-way rejects opposite closure and two travel directions retain separate congestion', () => {
|
||||
const coords = [[0,0],[0.002,0]];
|
||||
const forward = {coords, oneway:1};
|
||||
const reverse = {coords, oneway:-1};
|
||||
const east = {coords, trafficLevel:1, closure:false};
|
||||
const west = {coords:[...coords].reverse(), trafficLevel:0, closure:true};
|
||||
assert.equal(matchFlowToRoads([forward],[west]).matches[0], null);
|
||||
for (const flows of [[east,west],[west,east]]) {
|
||||
assert.deepEqual(matchFlowToRoads([forward,reverse],flows).matches,
|
||||
[{level:1,closure:false},{level:0,closure:true}]);
|
||||
}
|
||||
assert.equal(matchFlowToRoads([forward],[east,{...east,trafficLevel:0,closure:true}]).matches[0], null);
|
||||
});
|
||||
|
||||
test('a full-coverage flow line matches both travel directions; one-side does not', () => {
|
||||
const coords = [[0,0],[0.002,0]];
|
||||
const directions = [{coords, oneway:1}, {coords, oneway:-1}];
|
||||
const full = {coords, trafficLevel:0.4, closure:false, coverage:'full'};
|
||||
assert.deepEqual(matchFlowToRoads(directions, [full]).matches,
|
||||
[{level:0.4,closure:false},{level:0.4,closure:false}]);
|
||||
const oneSide = {...full, coverage:'one_side'};
|
||||
assert.deepEqual(matchFlowToRoads(directions, [oneSide]).matches,
|
||||
[{level:0.4,closure:false}, null]);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,12 +1,5 @@
|
||||
import { createLocalGeoJsonLayer } from './localGeojsonCore.js';
|
||||
|
||||
// Resolved by Vite in builds and relative to this module in other consumers.
|
||||
const datacentersUrl = new URL(
|
||||
'./local_data/datacenters/datacenters.geojsonl',
|
||||
import.meta.url,
|
||||
).href;
|
||||
const damsUrl = new URL('./local_data/dams/dams.geojsonl', import.meta.url)
|
||||
.href;
|
||||
import { INFRASTRUCTURE_DATA_URLS } from '../sources/infrastructureData.js';
|
||||
|
||||
/**
|
||||
* Create fresh datacenter and dam layers without starting or loading them.
|
||||
@@ -14,6 +7,8 @@ const damsUrl = new URL('./local_data/dams/dams.geojsonl', import.meta.url)
|
||||
* @returns {object[]} Datacenters then dams, with stable standalone identities.
|
||||
*/
|
||||
export function createInfrastructureLayers(services) {
|
||||
const datacentersUrl = INFRASTRUCTURE_DATA_URLS['local-datacenters'];
|
||||
const damsUrl = INFRASTRUCTURE_DATA_URLS['local-dams'];
|
||||
const datacenters = createLocalGeoJsonLayer(
|
||||
{
|
||||
id: 'local-datacenters',
|
||||
@@ -22,6 +17,7 @@ export function createInfrastructureLayers(services) {
|
||||
color: '#00ffff', // Cyan
|
||||
icon: '▣',
|
||||
source: 'Local',
|
||||
osmDerived: true,
|
||||
labels: true,
|
||||
labelMax: 700,
|
||||
labelGridPx: 138,
|
||||
@@ -37,6 +33,7 @@ export function createInfrastructureLayers(services) {
|
||||
color: '#0088ff', // Blue
|
||||
icon: '▰',
|
||||
source: 'USACE',
|
||||
osmDerived: true,
|
||||
labels: true,
|
||||
labelMax: 900,
|
||||
labelGridPx: 132,
|
||||
|
||||
@@ -8,9 +8,12 @@ import { createLocalGeoJsonLayer } from 'gods-eye-view/infrastructure/geojson';
|
||||
|
||||
function services() {
|
||||
const records = new Map();
|
||||
const credited = [];
|
||||
let selection;
|
||||
return {
|
||||
records,
|
||||
credited,
|
||||
showOsmCredit: (viewer) => credited.push(viewer),
|
||||
overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} },
|
||||
registerEntityContext(entity, metadata) {
|
||||
records.set(metadata.id, { entity, ...metadata });
|
||||
@@ -121,6 +124,7 @@ test('two viewers use their supplied contexts and dispose independently', async
|
||||
{
|
||||
id: 'local-dams',
|
||||
name: 'Dams',
|
||||
osmDerived: true,
|
||||
color: '#0088ff',
|
||||
url: '/dams.geojsonl',
|
||||
screenSpaceEventHandlerFactory: () => ({
|
||||
@@ -150,6 +154,10 @@ test('two viewers use their supplied contexts and dispose independently', async
|
||||
);
|
||||
await Promise.all(instances.map(({ layer, viewer }) => layer.enable(viewer)));
|
||||
assert.equal(hosts[0].records.size, 1);
|
||||
assert.deepEqual(
|
||||
hosts.map((host) => host.credited),
|
||||
instances.map(({ viewer }) => [viewer]),
|
||||
);
|
||||
assert.equal(hosts[1].records.size, 1);
|
||||
instances[0].click();
|
||||
assert.ok(hosts[0].selection());
|
||||
@@ -184,6 +192,8 @@ test('consumer build includes only infrastructure code and resolves assets under
|
||||
const sources = Object.keys(entry.modules).filter((id) => id.endsWith('.js'));
|
||||
assert.deepEqual(sources.map((id) => id.split('/').at(-1)).sort(), [
|
||||
'infrastructure.js',
|
||||
'infrastructureData.js',
|
||||
'infrastructureOverlayEntry.js',
|
||||
'localGeojsonCore.js',
|
||||
'localGeojsonLod.js',
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
const LOCAL_OVERLAY_MAX_DISTANCE_M = 14000000;
|
||||
const LOCAL_OVERLAY_FADE_START_M = 250000;
|
||||
const LOCAL_OVERLAY_FADE_START_RATIO =
|
||||
LOCAL_OVERLAY_FADE_START_M / LOCAL_OVERLAY_MAX_DISTANCE_M;
|
||||
|
||||
/** Shared ambient infrastructure card contract; the host owns projection and persistence. */
|
||||
export function createInfrastructureOverlayEntry({
|
||||
id,
|
||||
source,
|
||||
position,
|
||||
title,
|
||||
details = [],
|
||||
accent,
|
||||
priority,
|
||||
}) {
|
||||
return {
|
||||
id: String(id),
|
||||
source: source,
|
||||
position,
|
||||
variant: 'card',
|
||||
title: title,
|
||||
details: details,
|
||||
accent,
|
||||
priority,
|
||||
collisionGroup: 'ambient-card',
|
||||
zIndex: 30,
|
||||
interactive: false,
|
||||
minDistance: 0,
|
||||
maxDistance: LOCAL_OVERLAY_MAX_DISTANCE_M,
|
||||
distanceFadeStartRatio: LOCAL_OVERLAY_FADE_START_RATIO,
|
||||
distanceScale: {
|
||||
near: 250000,
|
||||
nearValue: 1,
|
||||
far: 9000000,
|
||||
farValue: 0.62,
|
||||
},
|
||||
edgeFade: 'keyhole',
|
||||
horizonCull: true,
|
||||
terrainOcclusion: false,
|
||||
gapPx: 15,
|
||||
placement: 'above',
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,8 @@ export function installationFeedback(stats = {}, now = Date.now()) {
|
||||
rate_limited: 'Overpass rate-limited',
|
||||
timeout: 'Overpass timed out',
|
||||
query_failed: 'Overpass could not complete the query',
|
||||
tiles_unavailable: 'Map tiles temporarily unavailable',
|
||||
names_unavailable: 'Mapped names temporarily unavailable',
|
||||
};
|
||||
const reason =
|
||||
reasons[stats.failureReason] || 'Overpass temporarily unavailable';
|
||||
@@ -18,5 +20,16 @@ export function installationFeedback(stats = {}, now = Date.now()) {
|
||||
return 'Zoom in to search mapped installations';
|
||||
if (stats.stale) return 'Showing cached mapped sites';
|
||||
if (stats.status === 'idle') return 'Mapped sites not loaded';
|
||||
// With a count, say what was found and where; an empty area is not "loaded".
|
||||
if (Number.isFinite(stats.count)) {
|
||||
const km =
|
||||
stats.coverage?.kind === 'subject' &&
|
||||
Number.isFinite(stats.coverage.radiusM)
|
||||
? Math.round(stats.coverage.radiusM / 1000)
|
||||
: null;
|
||||
const where = km ? ` within ${km} km of the contact` : ' in view';
|
||||
if (stats.count === 0) return `No mapped sites${where}`;
|
||||
return `${stats.count} mapped site${stats.count === 1 ? '' : 's'}${where}`;
|
||||
}
|
||||
return 'Mapped sites loaded';
|
||||
}
|
||||
@@ -19,3 +19,16 @@ test('first fetch, retry, cached data and success have distinct copy', () => {
|
||||
assert.equal(installationFeedback({ status: 'ready' }), 'Mapped sites loaded');
|
||||
assert.equal(installationFeedback({ stale: true }), 'Showing cached mapped sites');
|
||||
});
|
||||
test('a counted result names what was found and where', () => {
|
||||
assert.equal(installationFeedback({ status: 'ready', count: 3 }), '3 mapped sites in view');
|
||||
assert.equal(installationFeedback({ status: 'empty', count: 0 }), 'No mapped sites in view');
|
||||
const coverage = { kind: 'subject', radiusM: 100_000 };
|
||||
assert.equal(
|
||||
installationFeedback({ status: 'ready', count: 1, coverage }),
|
||||
'1 mapped site within 100 km of the contact',
|
||||
);
|
||||
assert.equal(
|
||||
installationFeedback({ status: 'unavailable', failureReason: 'tiles_unavailable' }),
|
||||
'Map tiles temporarily unavailable',
|
||||
);
|
||||
});
|
||||
@@ -10,6 +10,9 @@ const FADE_IN_MS = 150;
|
||||
const FADE_OUT_MS = 300;
|
||||
const MIN_LIFETIME_MS = 2500;
|
||||
const COOLDOWN_MS = 1200;
|
||||
function zeroCount(_value, key, map) {
|
||||
map.set(key, 0);
|
||||
}
|
||||
|
||||
function clampInt(value, min, max) {
|
||||
return Math.max(min, Math.min(max, Math.floor(Number(value) || 0)));
|
||||
@@ -757,6 +760,8 @@ export class LabelArbiter {
|
||||
this._layerBuckets = new Map();
|
||||
this._demand = new Map();
|
||||
this._quotas = new Map();
|
||||
this._acceptedByLayer = new Map();
|
||||
this._paintedByLayer = new Map();
|
||||
this._weightedQuotaScratch = [];
|
||||
this._quotaPriorityScratch = [];
|
||||
this._quotaRemainderScratch = [];
|
||||
@@ -770,6 +775,12 @@ export class LabelArbiter {
|
||||
this._queue = new SpatialCandidateQueue();
|
||||
}
|
||||
|
||||
/** Release an identity when its source changes between ambient and protected paint. */
|
||||
remove(key) {
|
||||
if (this.states.delete(key)) this._stateListDirty = true;
|
||||
this.selectedKeys.delete(key);
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.states.clear();
|
||||
this.selectedKeys.clear();
|
||||
@@ -781,6 +792,8 @@ export class LabelArbiter {
|
||||
this._candidates.length = 0;
|
||||
this._layerBuckets.clear();
|
||||
this._quotas.clear();
|
||||
this._acceptedByLayer.clear();
|
||||
this._paintedByLayer.clear();
|
||||
this._selectedCandidates.length = 0;
|
||||
this._selectedPlacements.length = 0;
|
||||
this._droppedKeys.length = 0;
|
||||
@@ -889,6 +902,12 @@ export class LabelArbiter {
|
||||
demand.set(layerId, bucket.count);
|
||||
});
|
||||
|
||||
const limits = options.limitsByLayer;
|
||||
demand.forEach((count, layerId) => {
|
||||
demand.set(layerId, Math.min(count, limits?.get(layerId) ?? Infinity));
|
||||
});
|
||||
const acceptedByLayer = this._acceptedByLayer;
|
||||
acceptedByLayer.forEach(zeroCount);
|
||||
const activeLayers = this._activeLayersScratch;
|
||||
let activeCount = 0;
|
||||
let totalDemand = 0;
|
||||
@@ -937,6 +956,9 @@ export class LabelArbiter {
|
||||
)
|
||||
return false;
|
||||
attemptStamps.set(candidate.key, stamp);
|
||||
const layerCount = acceptedByLayer.get(candidate.layerId) || 0;
|
||||
if (layerCount >= (limits?.get(candidate.layerId) ?? Infinity))
|
||||
return false;
|
||||
const previous = this.states.get(candidate.key);
|
||||
const stateless = candidate.stateless === true;
|
||||
// Stateless candidates ignore the re-entry cooldown (they return on the
|
||||
@@ -955,6 +977,7 @@ export class LabelArbiter {
|
||||
selectedCandidates[selectedCount] = candidate;
|
||||
selectedPlacements[selectedCount] = placement;
|
||||
selectedCount++;
|
||||
acceptedByLayer.set(candidate.layerId, layerCount + 1);
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -968,7 +991,8 @@ export class LabelArbiter {
|
||||
if (preserveIncumbents) {
|
||||
for (let i = 0; i < layerCount && accepted < target; i++) {
|
||||
const candidate = layerList[i];
|
||||
if (!this.states.get(candidate.key)?.selected) continue;
|
||||
if (candidate.stateless || !this.states.get(candidate.key)?.selected)
|
||||
continue;
|
||||
if (attempt(candidate, true)) accepted++;
|
||||
}
|
||||
}
|
||||
@@ -1139,7 +1163,7 @@ export class LabelArbiter {
|
||||
* Reproject accepted/fading identities from the current frame candidate map.
|
||||
* A caller-owned output array enables allocation-free per-frame rendering.
|
||||
*/
|
||||
renderEntries(currentCandidates, now = Date.now(), out = []) {
|
||||
renderEntries(currentCandidates, now = Date.now(), out = [], limits = null) {
|
||||
const current =
|
||||
currentCandidates instanceof Map
|
||||
? currentCandidates
|
||||
@@ -1151,6 +1175,20 @@ export class LabelArbiter {
|
||||
);
|
||||
const states = this._refreshStateList();
|
||||
let outIndex = 0;
|
||||
const painted = this._paintedByLayer;
|
||||
painted.forEach(zeroCount);
|
||||
// Reserve the live winners before spending any remaining slots on exit fades.
|
||||
if (limits)
|
||||
for (let i = 0; i < states.length; i++) {
|
||||
const state = states[i];
|
||||
if (!state.selected) continue;
|
||||
const candidate = current.get(state.key) || state.lastCandidate;
|
||||
if (candidate)
|
||||
painted.set(
|
||||
candidate.layerId,
|
||||
(painted.get(candidate.layerId) || 0) + 1,
|
||||
);
|
||||
}
|
||||
for (let i = 0; i < states.length; i++) {
|
||||
const state = states[i];
|
||||
let temporalAlpha;
|
||||
@@ -1171,9 +1209,14 @@ export class LabelArbiter {
|
||||
if (temporalAlpha <= 0) continue;
|
||||
const candidate = current.get(state.key) || state.lastCandidate;
|
||||
if (!candidate) continue;
|
||||
if (limits && !state.selected) {
|
||||
const count = painted.get(candidate.layerId) || 0;
|
||||
if (count >= (limits.get(candidate.layerId) ?? Infinity)) continue;
|
||||
painted.set(candidate.layerId, count + 1);
|
||||
}
|
||||
const placement = renderPlacement(
|
||||
candidate,
|
||||
state.stateless ? undefined : state.corner,
|
||||
state.corner,
|
||||
state.lastPlacement,
|
||||
);
|
||||
if (!placement) continue;
|
||||
|
||||
@@ -345,6 +345,9 @@ test('a stateless candidate keeps no corner, no cooldown, and no fades', () => {
|
||||
arbiter.solve([blocker, mover], { capacity: 2, now: 1000 });
|
||||
assert.equal(arbiter.states.get('cctv:mover').corner, 'below',
|
||||
'it took the free corner while the preferred one was blocked');
|
||||
const paint = arbiter.renderEntries(new Map([[blocker.key, blocker], [mover.key, mover]]), 1000, []);
|
||||
assert.equal(paint.find((entry) => entry.candidate.key === mover.key).placement.corner, 'below',
|
||||
'painting honors this solve’s collision-free corner even without sticky placement');
|
||||
|
||||
// Corner is re-decided from geometry, NOT remembered: with the blocker gone the
|
||||
// card returns to its preferred placement immediately.
|
||||
@@ -504,3 +507,44 @@ test('live identities expose selected and temporal fading membership by layer/so
|
||||
]));
|
||||
assert.equal(arbiter.liveIdentities({ includeFading: true, now: 1501 }).size, 0);
|
||||
});
|
||||
|
||||
|
||||
test('stateless labels yield occupied slots and re-rank by area on each solve', () => {
|
||||
const make = (key, x, priority) => ({ key, layerId: 'installations', sourceId: key,
|
||||
priority, keyholeAlpha: 1, stateless: true, screenX: x, screenY: 100,
|
||||
placements: [{ corner: 'above', rect: { x, y: 100, w: 100, h: 20 } }],
|
||||
});
|
||||
const large = make('large', 100, 100), small = make('small', 300, 10);
|
||||
const arbiter = new LabelArbiter();
|
||||
arbiter.solve([large, small], { capacity: 2, now: 1000 });
|
||||
assert.equal(arbiter.selectedKeys.size, 2);
|
||||
small.placements[0].rect.x = 100;
|
||||
arbiter.solve([large, small], { capacity: 2, now: 1016 });
|
||||
assert.deepEqual([...arbiter.selectedKeys], ['large'], 'incumbency never forces a stateless overlap');
|
||||
const larger = make('larger', 100, 1000);
|
||||
arbiter.solve([large, small, larger], { capacity: 2, now: 1032 });
|
||||
assert.deepEqual([...arbiter.selectedKeys], ['larger'], 'area priority outranks a smaller incumbent');
|
||||
});
|
||||
|
||||
|
||||
test('a source ceiling bounds shared-domain winners and exit fades without evicting incumbents', () => {
|
||||
const arbiter = new LabelArbiter();
|
||||
const sites = Array.from({ length: 40 }, (_, i) => candidate('installations', i));
|
||||
const centers = Array.from({ length: 30 }, (_, i) => candidate('flights', i));
|
||||
const limits = new Map([['installations', 24]]);
|
||||
const solve = (list, now) => arbiter.solve(list, { capacity: 54, limitsByLayer: limits, now });
|
||||
solve([...sites, ...centers], 0);
|
||||
const first = new Set(arbiter.selectedKeys);
|
||||
assert.equal([...first].filter(key => key.startsWith('installations:')).length, 24);
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
for (const site of sites) site.placements[0].rect.x += 0.25;
|
||||
solve([...sites, ...centers], i * 16);
|
||||
assert.deepEqual(arbiter.selectedKeys, first);
|
||||
}
|
||||
const replacements = sites.map((site, i) => ({ ...site, key: `installations:new${i}` }));
|
||||
const current = [...replacements, ...centers];
|
||||
solve(current, 2000);
|
||||
const rendered = arbiter.renderEntries(new Map(current.map(c => [c.key, c])), 2100, [], limits);
|
||||
assert.equal(rendered.filter(r => r.candidate.layerId === 'installations').length, 24);
|
||||
assert.equal(rendered.filter(r => r.candidate.layerId === 'flights').length, 30);
|
||||
});
|
||||
+43
-724
@@ -1,3 +1,4 @@
|
||||
import { normalizeIdSwitches, patchRequestedIdSwitches } from './idSwitches.js';
|
||||
import reservationRows from './layerStateTokenReservations.json' with { type: 'json' };
|
||||
|
||||
const VALID_DISPOSITIONS = new Set([
|
||||
@@ -11,8 +12,6 @@ export const LAYER_STATE_TOKEN_ALPHABET =
|
||||
'0123456789abcdefghijklmnopqrstuvwxyz';
|
||||
const LAYER_STATE_TOKEN_PATTERN = /^[a-z0-9]{1,2}$/;
|
||||
const NEW_SINGLE_CHARACTER_TOKEN_PATTERN = /^[0-9]$/;
|
||||
/** Re-check cadence while a shared subject waits for its feed row to arrive. */
|
||||
const PENDING_TRACKING_POLL_MS = 1_000;
|
||||
/**
|
||||
* A tracking ID is a transponder address, not free text: 6 hex digits for an
|
||||
* ICAO24, with slack for TIS-B (`~abc123`) and similar prefixed forms. Bounding
|
||||
@@ -298,6 +297,13 @@ function imageryPinOption(key, token) {
|
||||
}
|
||||
|
||||
const OPTION_GROUPS = Object.freeze({
|
||||
traffic: Object.freeze([
|
||||
enumOption('roadMode', 'r', null, ['tomtom', 'osm', 'hybrid'], {
|
||||
tomtom: 't',
|
||||
osm: 'o',
|
||||
hybrid: 'h',
|
||||
}),
|
||||
]),
|
||||
'weather-lightning': Object.freeze([
|
||||
enumOption('opacity', 'o', 'strong', ['light', 'strong'], {
|
||||
light: 'l',
|
||||
@@ -405,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',
|
||||
@@ -426,12 +453,6 @@ const OPTION_GROUPS = Object.freeze({
|
||||
]),
|
||||
});
|
||||
|
||||
const TRACKING_OPTION_KEY_BY_LAYER = Object.freeze({
|
||||
flights: 'selectedFlightsTrackingId',
|
||||
military: 'selectedMilitaryTrackingId',
|
||||
satellites: 'selectedSatTrackingId',
|
||||
});
|
||||
|
||||
export const SHARE_TRACKING_RESTORE_POLICIES = Object.freeze({
|
||||
flights: Object.freeze({
|
||||
optionOwner: 'flights',
|
||||
@@ -632,12 +653,23 @@ 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',
|
||||
disposition: 'enabled-only',
|
||||
}),
|
||||
Object.freeze({ id: 'traffic', token: 't', disposition: 'enabled-only' }),
|
||||
Object.freeze({
|
||||
id: 'traffic',
|
||||
token: 't',
|
||||
disposition: 'enabled+options',
|
||||
optionOwner: 'traffic',
|
||||
}),
|
||||
Object.freeze({ id: 'transit', token: 'j', disposition: 'enabled-only' }),
|
||||
Object.freeze({
|
||||
id: 'weather-cyclones',
|
||||
@@ -728,7 +760,7 @@ export function validateLayerStateAllocations(
|
||||
return true;
|
||||
}
|
||||
|
||||
const REGISTRY_BY_ID = new Map(
|
||||
export const REGISTRY_BY_ID = new Map(
|
||||
LAYER_STATE_REGISTRY.map((entry) => [entry.id, entry]),
|
||||
);
|
||||
const REGISTRY_BY_TOKEN = new Map(
|
||||
@@ -740,7 +772,7 @@ const OPTION_OWNER_IDS = Object.freeze([
|
||||
),
|
||||
]);
|
||||
|
||||
function optionSpecs(ownerId) {
|
||||
export function optionSpecs(ownerId) {
|
||||
return OPTION_GROUPS[ownerId] || [];
|
||||
}
|
||||
|
||||
@@ -1043,716 +1075,3 @@ export function layerOptionsForRestore(state, layerId) {
|
||||
if (!entry?.optionOwner) return null;
|
||||
return { ...normalizeLayerState(state).options[entry.optionOwner] };
|
||||
}
|
||||
|
||||
function safeStorage() {
|
||||
try {
|
||||
return globalThis.localStorage || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function currentLayerOutcome(dataManager, layerId) {
|
||||
const state = dataManager.getLayerLifecycleState?.(layerId);
|
||||
return {
|
||||
settledEnabled: Boolean(state?.enabled),
|
||||
lifecycleState: state?.lifecycleState || 'missing',
|
||||
lifecycleUncertain: Boolean(state?.uncertain),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns durable user layer preferences independently from transient runtime
|
||||
* choreography, and coordinates passive post-registration restoration.
|
||||
*/
|
||||
export class LayerStateCoordinator {
|
||||
constructor(
|
||||
dataManager,
|
||||
shareLinkManager,
|
||||
{
|
||||
storage = safeStorage(),
|
||||
restoreGate = null,
|
||||
onDurableStateChange = null,
|
||||
onTrackingRestoreStatus = null,
|
||||
now = () => Date.now(),
|
||||
// Injectable so the pending-window behavior is deterministically testable
|
||||
// without sleeping out a 90 s expiry.
|
||||
setTimer = (fn, ms) => setTimeout(fn, ms),
|
||||
clearTimer = (handle) => clearTimeout(handle),
|
||||
} = {},
|
||||
) {
|
||||
if (!dataManager?.registrationsFinalized) {
|
||||
throw new Error(
|
||||
'Layer state requires finalized data-layer registrations',
|
||||
);
|
||||
}
|
||||
this.dataManager = dataManager;
|
||||
this.shareLinkManager = shareLinkManager || null;
|
||||
this.storage = storage;
|
||||
this.restoreGate = restoreGate;
|
||||
this.onDurableStateChange = onDurableStateChange;
|
||||
this.onTrackingRestoreStatus = onTrackingRestoreStatus;
|
||||
this.now = now;
|
||||
this.setTimer = setTimer;
|
||||
this.clearTimer = clearTimer;
|
||||
this._durableState = createDefaultLayerState();
|
||||
this._source = 'defaults';
|
||||
this._destroyed = false;
|
||||
this._restoreControllers = new Map();
|
||||
this._shareCreatedAtMs = null;
|
||||
this._trackingRestoreController = null;
|
||||
this._trackingRestoreGeneration = 0;
|
||||
this._pendingTrackingTimer = null;
|
||||
this._pendingTrackingContext = null;
|
||||
this._unsubscribe = this.dataManager.subscribe((change) =>
|
||||
this._handleManagerChange(change),
|
||||
);
|
||||
this._unsubscribeVisibilityRequests =
|
||||
this.dataManager.subscribeVisibilityRequests((change) =>
|
||||
this._handleVisibilityRequest(change),
|
||||
);
|
||||
this.restorePromise = Promise.resolve([]);
|
||||
this.lastRestoreResults = [];
|
||||
}
|
||||
|
||||
start({
|
||||
shareLayerState = null,
|
||||
allowLocalState = true,
|
||||
shareCreatedAtMs = null,
|
||||
} = {}) {
|
||||
if (this._destroyed)
|
||||
throw new Error('Layer-state coordinator is destroyed');
|
||||
let selected = shareLayerState
|
||||
? normalizeLayerState(shareLayerState)
|
||||
: null;
|
||||
if (selected) {
|
||||
this._source = 'share';
|
||||
this._shareCreatedAtMs = Number.isFinite(shareCreatedAtMs)
|
||||
? shareCreatedAtMs
|
||||
: null;
|
||||
} else if (allowLocalState) {
|
||||
let stored = null;
|
||||
try {
|
||||
stored = parseStoredLayerState(
|
||||
this.storage?.getItem?.(LAYER_STATE_STORAGE_KEY),
|
||||
);
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
if (stored) {
|
||||
selected = stored;
|
||||
this._source = 'local';
|
||||
}
|
||||
} else {
|
||||
// A valid historical camera/style share with no v2 layer payload keeps
|
||||
// the exact legacy default-layer behavior. It must not inherit an
|
||||
// unrelated recipient's saved local layer preferences.
|
||||
this._source = 'legacy-share';
|
||||
}
|
||||
this._durableState = selected || createDefaultLayerState();
|
||||
this.shareLinkManager?.setLayerStateProvider?.(() =>
|
||||
this.getDurableState(),
|
||||
);
|
||||
this.shareLinkManager?.onLayerStateChange?.();
|
||||
this._notifyDurableState();
|
||||
if (!selected) return this.restorePromise;
|
||||
this.restorePromise = this._restoreSelectedState(
|
||||
this._source === 'share'
|
||||
? LAYER_RESTORE_ORIGINS.share
|
||||
: LAYER_RESTORE_ORIGINS.local,
|
||||
);
|
||||
return this.restorePromise;
|
||||
}
|
||||
|
||||
get source() {
|
||||
return this._source;
|
||||
}
|
||||
|
||||
getDurableState() {
|
||||
return cloneLayerState(this._durableState);
|
||||
}
|
||||
|
||||
_notifyDurableState() {
|
||||
try {
|
||||
this.onDurableStateChange?.(this.getDurableState());
|
||||
} catch {
|
||||
/* UI sync is best effort */
|
||||
}
|
||||
}
|
||||
|
||||
_handleVisibilityRequest(change) {
|
||||
if (!isExplicitLayerStateOrigin(change?.origin)) return;
|
||||
this._restoreControllers
|
||||
.get(change.layerId)
|
||||
?.abort('superseded-by-explicit-visibility');
|
||||
if (SHARE_TRACKING_RESTORE_POLICIES[change.layerId]) {
|
||||
this._revokePendingTrackingWatch('superseded-by-explicit-visibility');
|
||||
}
|
||||
}
|
||||
|
||||
/** Revoke every passive restore before explicit navigation can be reclaimed. */
|
||||
cancelPendingRestores(reason = 'superseded-by-explicit-navigation') {
|
||||
for (const controller of this._restoreControllers.values())
|
||||
controller.abort(reason);
|
||||
this._revokePendingTrackingWatch(reason);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a pending shared Follow. Physical navigation may also clear only
|
||||
* the exact passive selection, without writing recipient preferences.
|
||||
*/
|
||||
cancelPendingShareTracking(
|
||||
reason = 'superseded-by-explicit-navigation',
|
||||
{ clearSelection = false } = {},
|
||||
) {
|
||||
this._revokePendingTrackingWatch(reason);
|
||||
if (!clearSelection) return false;
|
||||
const selected = this._selectedShareTrackingTarget();
|
||||
return selected ? this._passivelyClearTrackingSelection(selected) : false;
|
||||
}
|
||||
|
||||
_handleManagerChange(change) {
|
||||
if (!change || this._destroyed) return;
|
||||
// Parameter and visibility ownership are independent. A newer explicit
|
||||
// option request may replace passive share options, but it must not abort
|
||||
// the same layer's visibility lifecycle.
|
||||
if (change.type === 'params-requested') {
|
||||
if (
|
||||
isExplicitLayerStateOrigin(change.origin) &&
|
||||
SHARE_TRACKING_RESTORE_POLICIES[change.layerId]
|
||||
) {
|
||||
this._revokePendingTrackingWatch('superseded-by-explicit-params');
|
||||
}
|
||||
return;
|
||||
}
|
||||
// A layer that goes away takes its latch with it, at ANY origin — a
|
||||
// programmatic disable or teardown never reaches the explicit-intent path
|
||||
// below, so revoke here before that early return.
|
||||
if (
|
||||
change.type === 'visibility' &&
|
||||
change.enabled === false &&
|
||||
SHARE_TRACKING_RESTORE_POLICIES[change.layerId]
|
||||
) {
|
||||
this._revokePendingTrackingWatch('owner-layer-disabled');
|
||||
}
|
||||
if (!isExplicitLayerStateOrigin(change.origin)) return;
|
||||
if (change.type === 'visibility') {
|
||||
this._restoreControllers
|
||||
.get(change.layerId)
|
||||
?.abort('superseded-by-explicit-visibility');
|
||||
if (SHARE_TRACKING_RESTORE_POLICIES[change.layerId]) {
|
||||
this._revokePendingTrackingWatch('superseded-by-explicit-visibility');
|
||||
}
|
||||
const enabled = new Set(this._durableState.enabledLayerIds);
|
||||
if (change.enabled) enabled.add(change.layerId);
|
||||
else enabled.delete(change.layerId);
|
||||
this._commitExplicit({
|
||||
...this._durableState,
|
||||
enabledLayerIds: [...enabled],
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (change.type !== 'params') return;
|
||||
const entry = REGISTRY_BY_ID.get(change.layerId);
|
||||
if (!entry?.optionOwner) return;
|
||||
const ownerId = entry.optionOwner;
|
||||
const nextOwnerOptions = { ...this._durableState.options[ownerId] };
|
||||
const requestedParams = change.requestedParams || {};
|
||||
const trackingOptionKey =
|
||||
TRACKING_OPTION_KEY_BY_LAYER[change.layerId] || null;
|
||||
let changed = false;
|
||||
for (const spec of optionSpecs(ownerId)) {
|
||||
// Only persist keys present in this explicit request. getLayerParams()
|
||||
// can return a wider live snapshot containing transient or passively
|
||||
// changed values that this user action did not own. Tracking is the one
|
||||
// exception: an unrelated explicit option cancels a pending restoration
|
||||
// in that family, so its wider live value (active ID or null) must replace
|
||||
// the formerly durable pending ID instead of allowing reload resurrection.
|
||||
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]);
|
||||
if (value === null) {
|
||||
if (implicitTrackingSync) {
|
||||
nextOwnerOptions[spec.key] = null;
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
if (spec.defaultValue !== null || change.params[spec.key] !== null)
|
||||
continue;
|
||||
}
|
||||
nextOwnerOptions[spec.key] = value;
|
||||
changed = true;
|
||||
}
|
||||
if (!changed) return;
|
||||
this._commitExplicit({
|
||||
...this._durableState,
|
||||
options: { ...this._durableState.options, [ownerId]: nextOwnerOptions },
|
||||
});
|
||||
}
|
||||
|
||||
_commitExplicit(candidate) {
|
||||
this._durableState = normalizeLayerState(candidate);
|
||||
const serialized = serializeStoredLayerState(this._durableState);
|
||||
try {
|
||||
if (this.storage?.getItem?.(LAYER_STATE_STORAGE_KEY) !== serialized) {
|
||||
this.storage?.setItem?.(LAYER_STATE_STORAGE_KEY, serialized);
|
||||
}
|
||||
} catch {
|
||||
/* storage can be unavailable or quota-limited */
|
||||
}
|
||||
this.shareLinkManager?.onLayerStateChange?.();
|
||||
this._notifyDurableState();
|
||||
}
|
||||
|
||||
async _waitForRestoreGate() {
|
||||
if (!this.restoreGate) return;
|
||||
await (typeof this.restoreGate === 'function'
|
||||
? this.restoreGate()
|
||||
: this.restoreGate);
|
||||
}
|
||||
|
||||
async _restoreSelectedState(origin) {
|
||||
for (const entry of LAYER_STATE_REGISTRY) {
|
||||
this._restoreControllers.set(entry.id, new AbortController());
|
||||
}
|
||||
try {
|
||||
await this._waitForRestoreGate();
|
||||
const enabled = new Set(this._durableState.enabledLayerIds);
|
||||
const settled = await Promise.allSettled(
|
||||
LAYER_STATE_REGISTRY.map(async (entry) => {
|
||||
const controller = this._restoreControllers.get(entry.id);
|
||||
const targetEnabled = enabled.has(entry.id);
|
||||
const options = layerOptionsForRestore(this._durableState, entry.id);
|
||||
if (origin === LAYER_RESTORE_ORIGINS.share && options) {
|
||||
for (const trackingKey of Object.values(
|
||||
TRACKING_OPTION_KEY_BY_LAYER,
|
||||
)) {
|
||||
delete options[trackingKey];
|
||||
}
|
||||
}
|
||||
if (this._destroyed || controller?.signal.aborted) {
|
||||
return {
|
||||
layerId: entry.id,
|
||||
targetEnabled,
|
||||
origin,
|
||||
phase: 'reserved',
|
||||
...currentLayerOutcome(this.dataManager, entry.id),
|
||||
appliedOptions: {},
|
||||
cancellationReason: this._destroyed ? 'destroyed' : 'superseded',
|
||||
errorClass: 'cancelled',
|
||||
persistenceWrite: false,
|
||||
succeeded: false,
|
||||
};
|
||||
}
|
||||
// Reserve passive option state before any asynchronous lifecycle work.
|
||||
// A later explicit params intent then wins on its own lane without
|
||||
// cancelling or being overwritten by the visibility restore.
|
||||
const paramsSucceeded =
|
||||
!options ||
|
||||
Object.keys(options).length === 0 ||
|
||||
this.dataManager.setLayerParams(entry.id, options, { origin });
|
||||
return this.dataManager
|
||||
.restoreLayerState(
|
||||
entry.id,
|
||||
{
|
||||
enabled: targetEnabled,
|
||||
params: null,
|
||||
},
|
||||
{ origin, signal: controller.signal },
|
||||
)
|
||||
.then((result) => ({
|
||||
...result,
|
||||
appliedOptions: paramsSucceeded && options ? options : {},
|
||||
errorClass: paramsSucceeded
|
||||
? result.errorClass
|
||||
: 'ParamsRejected',
|
||||
succeeded: paramsSucceeded && result.succeeded,
|
||||
}));
|
||||
}),
|
||||
);
|
||||
this.lastRestoreResults = settled.map((result, index) => {
|
||||
if (result.status === 'fulfilled') return result.value;
|
||||
const entry = LAYER_STATE_REGISTRY[index];
|
||||
return {
|
||||
layerId: entry.id,
|
||||
targetEnabled: enabled.has(entry.id),
|
||||
origin,
|
||||
phase: 'coordinator',
|
||||
...currentLayerOutcome(this.dataManager, entry.id),
|
||||
appliedOptions: {},
|
||||
cancellationReason: null,
|
||||
errorClass: result.reason?.name || 'Error',
|
||||
error: String(result.reason?.message || result.reason),
|
||||
persistenceWrite: false,
|
||||
succeeded: false,
|
||||
};
|
||||
});
|
||||
return this.lastRestoreResults.map((result) => ({ ...result }));
|
||||
} finally {
|
||||
this._restoreControllers.clear();
|
||||
this._notifyDurableState();
|
||||
}
|
||||
}
|
||||
|
||||
_selectedShareTrackingTarget() {
|
||||
if (this._source !== 'share') return null;
|
||||
for (const [layerId, policy] of Object.entries(
|
||||
SHARE_TRACKING_RESTORE_POLICIES,
|
||||
)) {
|
||||
const targetId =
|
||||
this._durableState.options?.[policy.optionOwner]?.[policy.optionKey];
|
||||
if (targetId !== null && targetId !== undefined && targetId !== '') {
|
||||
return { layerId, targetId, ...policy };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
_passivelyClearTrackingSelection(selected) {
|
||||
const current =
|
||||
this._durableState.options?.[selected.optionOwner]?.[selected.optionKey];
|
||||
if (String(current) !== String(selected.targetId)) return false;
|
||||
const ownerOptions = {
|
||||
...this._durableState.options[selected.optionOwner],
|
||||
[selected.optionKey]: null,
|
||||
};
|
||||
this._durableState = normalizeLayerState({
|
||||
...this._durableState,
|
||||
options: {
|
||||
...this._durableState.options,
|
||||
[selected.optionOwner]: ownerOptions,
|
||||
},
|
||||
});
|
||||
this.dataManager.setLayerParams?.(
|
||||
selected.layerId,
|
||||
{ [selected.optionKey]: null },
|
||||
{ origin: LAYER_RESTORE_ORIGINS.share },
|
||||
);
|
||||
this.shareLinkManager?.onLayerStateChange?.();
|
||||
this._notifyDurableState();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* `atMs` is the moment the subject was first found ABSENT, not the moment the
|
||||
* verdict is delivered. Waiting out the pending window must not by itself
|
||||
* push a fresh link into the "expired" wording — that word describes the
|
||||
* SHARE's age, not how long this client watched for the subject.
|
||||
*/
|
||||
_classifyMissingTrackingTarget(selected, atMs = this.now()) {
|
||||
const copiedAt = this._shareCreatedAtMs;
|
||||
if (!Number.isFinite(copiedAt)) return 'unavailable';
|
||||
const ageMs = atMs - copiedAt;
|
||||
return ageMs > selected.expiryWindowMs ? 'expired' : 'unavailable';
|
||||
}
|
||||
|
||||
/** Whether the owning layer currently follows the shared subject. */
|
||||
_trackingTargetLatched(selected) {
|
||||
const params = this.dataManager.getLayerParams?.(selected.layerId);
|
||||
const active = params?.[selected.optionKey];
|
||||
return (
|
||||
active !== null &&
|
||||
active !== undefined &&
|
||||
String(active) === String(selected.targetId)
|
||||
);
|
||||
}
|
||||
|
||||
/** Stop watching a pending shared subject without deciding its fate. */
|
||||
_cancelPendingTrackingWatch() {
|
||||
if (this._pendingTrackingTimer !== null)
|
||||
this.clearTimer(this._pendingTrackingTimer);
|
||||
this._pendingTrackingTimer = null;
|
||||
const pending = this._pendingTrackingContext;
|
||||
if (pending?.signal && pending.abortHandler) {
|
||||
pending.signal.removeEventListener('abort', pending.abortHandler);
|
||||
pending.abortHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Publish a share-follow lifecycle update without allowing UI errors to own state. */
|
||||
_publishTrackingRestoreStatus(status) {
|
||||
try {
|
||||
this.onTrackingRestoreStatus?.(status);
|
||||
} catch {
|
||||
/* status UI is best effort */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a pending shared Follow wholesale.
|
||||
*
|
||||
* The watch and the LAYER's own deferred-restore latch are two halves of one
|
||||
* mechanism, so they must die together. Aborting only the restore controller
|
||||
* left the timer alive: the controller has already settled by the time the
|
||||
* watch exists, so the abort was a no-op and the orphaned timer went on to
|
||||
* announce "Shared … unavailable" for a subject whose latch had been
|
||||
* cancelled — a notice about work no longer being attempted.
|
||||
*/
|
||||
_revokePendingTrackingWatch(reason) {
|
||||
this._trackingRestoreController?.abort(reason);
|
||||
this._trackingRestoreGeneration += 1;
|
||||
this._cancelPendingTrackingWatch();
|
||||
const pending = this._pendingTrackingContext;
|
||||
this._pendingTrackingContext = null;
|
||||
if (pending) {
|
||||
this.dataManager.cancelPendingLayerRestore?.(pending.selected.layerId, {
|
||||
origin: LAYER_RESTORE_ORIGINS.share,
|
||||
reason: String(reason || 'cancelled'),
|
||||
});
|
||||
this._publishTrackingRestoreStatus({
|
||||
...pending.probe,
|
||||
...pending.selected,
|
||||
status: 'cancelled',
|
||||
classification: 'cancelled',
|
||||
reason: String(reason || 'cancelled'),
|
||||
cleared: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hold a not-yet-arrived shared subject PENDING instead of declaring it gone.
|
||||
*
|
||||
* A recipient's first authoritative refresh routinely lands without a given
|
||||
* contact — the feed is polled, coverage is partial, and rendering trails the
|
||||
* snapshot by a poll. Reload-from-local already survives this: its restore
|
||||
* arms the layer's own deferred-restore latch, which re-attempts on every
|
||||
* later poll. The shared path used to decide on that single refresh, clear
|
||||
* the subject from durable state AND from the URL, then post a failure notice
|
||||
* seconds into startup — so the same link healed on reload but never on the
|
||||
* share.
|
||||
*
|
||||
* Arm the SAME latch, then watch it in the background: the caller is never
|
||||
* blocked (startup must not wait out a 90 s window before it may write the
|
||||
* URL again), and the terminal verdict is deferred until the source-specific
|
||||
* window has genuinely expired. The existing wordings are unchanged.
|
||||
*/
|
||||
async _beginPendingTrackingRestore(selected, probe, signal = null) {
|
||||
const generation = this._trackingRestoreGeneration;
|
||||
const absentAtMs = this.now();
|
||||
// Arm the layer's deferred-restore latch under the passive share origin, so
|
||||
// it re-attempts each poll and never rewrites recipient preferences.
|
||||
let armed = false;
|
||||
let armError = null;
|
||||
try {
|
||||
armed =
|
||||
(await this.dataManager.setLayerParams?.(
|
||||
selected.layerId,
|
||||
{ [selected.optionKey]: selected.targetId },
|
||||
{ origin: LAYER_RESTORE_ORIGINS.share },
|
||||
)) === true;
|
||||
} catch (error) {
|
||||
armError = error;
|
||||
}
|
||||
if (
|
||||
this._destroyed ||
|
||||
generation !== this._trackingRestoreGeneration ||
|
||||
signal?.aborted
|
||||
) {
|
||||
if (armed) {
|
||||
this.dataManager.cancelPendingLayerRestore?.(selected.layerId, {
|
||||
origin: LAYER_RESTORE_ORIGINS.share,
|
||||
reason: String(signal?.reason || 'superseded'),
|
||||
});
|
||||
}
|
||||
return {
|
||||
...probe,
|
||||
...selected,
|
||||
status: 'cancelled',
|
||||
classification: 'cancelled',
|
||||
reason: String(signal?.reason || 'superseded'),
|
||||
cleared: false,
|
||||
};
|
||||
}
|
||||
if (!armed) {
|
||||
const terminal = {
|
||||
...probe,
|
||||
...selected,
|
||||
status: 'source-unavailable',
|
||||
classification: 'source-unavailable',
|
||||
reason: String(
|
||||
armError?.message || armError || 'tracking restore latch rejected',
|
||||
),
|
||||
cleared: this._passivelyClearTrackingSelection(selected),
|
||||
};
|
||||
this._publishTrackingRestoreStatus(terminal);
|
||||
return terminal;
|
||||
}
|
||||
const deadline = this.now() + selected.expiryWindowMs;
|
||||
const settle = (terminal) => {
|
||||
if (this._pendingTrackingContext?.generation !== generation) return;
|
||||
this._cancelPendingTrackingWatch();
|
||||
this._pendingTrackingContext = null;
|
||||
this._publishTrackingRestoreStatus(terminal);
|
||||
};
|
||||
const poll = () => {
|
||||
this._pendingTrackingTimer = null;
|
||||
if (this._destroyed || generation !== this._trackingRestoreGeneration)
|
||||
return;
|
||||
// The layer that owns the latch may have gone away since the last tick
|
||||
// (disable, teardown, replacement). There is nothing left attempting this
|
||||
// restore, so abandon it silently rather than announcing a verdict.
|
||||
if (this.dataManager.isEffectivelyEnabled?.(selected.layerId) === false) {
|
||||
this._revokePendingTrackingWatch('owner-layer-disabled');
|
||||
return;
|
||||
}
|
||||
if (this._trackingTargetLatched(selected)) {
|
||||
settle({
|
||||
...probe,
|
||||
...selected,
|
||||
status: 'found',
|
||||
classification: 'followed',
|
||||
cleared: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (this.now() >= deadline) {
|
||||
// The window really has elapsed — only now does the verdict apply.
|
||||
const classification =
|
||||
probe.status === 'missing'
|
||||
? this._classifyMissingTrackingTarget(selected, absentAtMs)
|
||||
: 'source-unavailable';
|
||||
const cleared = this._passivelyClearTrackingSelection(selected);
|
||||
settle({ ...probe, ...selected, classification, cleared });
|
||||
return;
|
||||
}
|
||||
this._pendingTrackingTimer = this.setTimer(
|
||||
poll,
|
||||
PENDING_TRACKING_POLL_MS,
|
||||
);
|
||||
this._pendingTrackingTimer?.unref?.();
|
||||
};
|
||||
this._cancelPendingTrackingWatch();
|
||||
const pending = {
|
||||
...probe,
|
||||
...selected,
|
||||
status: 'pending',
|
||||
classification: 'pending',
|
||||
cleared: false,
|
||||
};
|
||||
const pendingContext = {
|
||||
generation,
|
||||
selected,
|
||||
probe,
|
||||
signal,
|
||||
abortHandler: null,
|
||||
};
|
||||
if (signal) {
|
||||
pendingContext.abortHandler = () => {
|
||||
if (this._pendingTrackingContext?.generation !== generation) return;
|
||||
this._revokePendingTrackingWatch(signal.reason || 'aborted');
|
||||
};
|
||||
signal.addEventListener('abort', pendingContext.abortHandler, {
|
||||
once: true,
|
||||
});
|
||||
}
|
||||
this._pendingTrackingContext = pendingContext;
|
||||
this._publishTrackingRestoreStatus(pending);
|
||||
this._pendingTrackingTimer = this.setTimer(poll, PENDING_TRACKING_POLL_MS);
|
||||
this._pendingTrackingTimer?.unref?.();
|
||||
return pending;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh and restore the one shareable tracked target after destination
|
||||
* camera and ordinary layer restoration have settled.
|
||||
*/
|
||||
async restoreShareTrackingSelection({ signal = null } = {}) {
|
||||
const selected = this._selectedShareTrackingTarget();
|
||||
if (!selected || this._destroyed)
|
||||
return { status: 'skipped', reason: 'no-shared-target' };
|
||||
this._revokePendingTrackingWatch('superseded-by-newer-restore');
|
||||
const controller = new AbortController();
|
||||
const combinedSignal = signal
|
||||
? AbortSignal.any([signal, controller.signal])
|
||||
: controller.signal;
|
||||
this._trackingRestoreController = controller;
|
||||
const generation = ++this._trackingRestoreGeneration;
|
||||
let result;
|
||||
try {
|
||||
result = await this.dataManager.resolveLayerTrackingTarget(
|
||||
selected.layerId,
|
||||
selected.targetId,
|
||||
{ signal: combinedSignal, origin: LAYER_RESTORE_ORIGINS.share },
|
||||
);
|
||||
} catch (error) {
|
||||
result = combinedSignal.aborted
|
||||
? {
|
||||
status: 'cancelled',
|
||||
reason: String(combinedSignal.reason || 'aborted'),
|
||||
}
|
||||
: {
|
||||
status: 'source-unavailable',
|
||||
reason: String(error?.message || error),
|
||||
};
|
||||
}
|
||||
if (
|
||||
generation !== this._trackingRestoreGeneration ||
|
||||
this._destroyed ||
|
||||
combinedSignal.aborted
|
||||
) {
|
||||
if (this._trackingRestoreController === controller)
|
||||
this._trackingRestoreController = null;
|
||||
return {
|
||||
...result,
|
||||
status: 'cancelled',
|
||||
reason: String(combinedSignal.reason || 'superseded'),
|
||||
};
|
||||
}
|
||||
if (this._trackingRestoreController === controller)
|
||||
this._trackingRestoreController = null;
|
||||
|
||||
if (result.status === 'found') {
|
||||
const terminal = {
|
||||
...result,
|
||||
...selected,
|
||||
classification: 'followed',
|
||||
cleared: false,
|
||||
};
|
||||
this._publishTrackingRestoreStatus(terminal);
|
||||
return terminal;
|
||||
}
|
||||
if (['cancelled', 'superseded', 'destroyed'].includes(result.status))
|
||||
return result;
|
||||
|
||||
// A subject that is simply not here YET is not a subject that is gone. Hold
|
||||
// it on the layer's own deferred-restore latch for its source-specific
|
||||
// window before any verdict is reached or shown. `unsupported` layers have
|
||||
// no latch to arm, so they still decide immediately.
|
||||
if (result.status === 'missing' || result.status === 'source-unavailable') {
|
||||
return this._beginPendingTrackingRestore(
|
||||
selected,
|
||||
result,
|
||||
combinedSignal,
|
||||
);
|
||||
}
|
||||
|
||||
const classification =
|
||||
result.status === 'missing'
|
||||
? this._classifyMissingTrackingTarget(selected)
|
||||
: 'source-unavailable';
|
||||
const cleared = this._passivelyClearTrackingSelection(selected);
|
||||
const terminal = { ...result, ...selected, classification, cleared };
|
||||
this._publishTrackingRestoreStatus(terminal);
|
||||
return terminal;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this._destroyed) return;
|
||||
this._destroyed = true;
|
||||
for (const controller of this._restoreControllers.values())
|
||||
controller.abort('coordinator-destroyed');
|
||||
this._restoreControllers.clear();
|
||||
this._revokePendingTrackingWatch('coordinator-destroyed');
|
||||
this._trackingRestoreController = null;
|
||||
this._unsubscribe?.();
|
||||
this._unsubscribe = null;
|
||||
this._unsubscribeVisibilityRequests?.();
|
||||
this._unsubscribeVisibilityRequests = null;
|
||||
this.shareLinkManager?.setLayerStateProvider?.(null);
|
||||
this.onDurableStateChange = null;
|
||||
this.onTrackingRestoreStatus = null;
|
||||
}
|
||||
}
|
||||
+173
-28
@@ -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';
|
||||
@@ -11,7 +12,6 @@ import {
|
||||
LAYER_STATE_STORAGE_KEY,
|
||||
LAYER_STATE_TOKEN_ALPHABET,
|
||||
LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
LayerStateCoordinator,
|
||||
REGISTERED_LAYER_IDS,
|
||||
SHARE_TRACKING_RESTORE_POLICIES,
|
||||
createDefaultLayerState,
|
||||
@@ -24,8 +24,11 @@ import {
|
||||
validateLayerStateAllocations,
|
||||
validateLayerStateRegistry,
|
||||
} from './layerState.js';
|
||||
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();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,756 @@
|
||||
/**
|
||||
* Applies layer state to the running application: restores shared and stored
|
||||
* layer choices and persists changes to browser storage.
|
||||
*/
|
||||
|
||||
import {
|
||||
LAYER_RESTORE_ORIGINS,
|
||||
LAYER_STATE_REGISTRY,
|
||||
LAYER_STATE_STORAGE_KEY,
|
||||
REGISTRY_BY_ID,
|
||||
SHARE_TRACKING_RESTORE_POLICIES,
|
||||
cloneLayerState,
|
||||
createDefaultLayerState,
|
||||
isExplicitLayerStateOrigin,
|
||||
layerOptionsForRestore,
|
||||
normalizeLayerState,
|
||||
optionSpecs,
|
||||
parseStoredLayerState,
|
||||
serializeStoredLayerState,
|
||||
} from './layerState.js';
|
||||
|
||||
/** Re-check cadence while a shared subject waits for its feed row to arrive. */
|
||||
const PENDING_TRACKING_POLL_MS = 1_000;
|
||||
|
||||
const TRACKING_OPTION_KEY_BY_LAYER = Object.freeze({
|
||||
flights: 'selectedFlightsTrackingId',
|
||||
military: 'selectedMilitaryTrackingId',
|
||||
satellites: 'selectedSatTrackingId',
|
||||
});
|
||||
|
||||
function safeStorage() {
|
||||
try {
|
||||
return globalThis.localStorage || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function currentLayerOutcome(dataManager, layerId) {
|
||||
const state = dataManager.getLayerLifecycleState?.(layerId);
|
||||
return {
|
||||
settledEnabled: Boolean(state?.enabled),
|
||||
lifecycleState: state?.lifecycleState || 'missing',
|
||||
lifecycleUncertain: Boolean(state?.uncertain),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns durable user layer preferences independently from transient runtime
|
||||
* choreography, and coordinates passive post-registration restoration.
|
||||
*/
|
||||
export class LayerStateCoordinator {
|
||||
constructor(
|
||||
dataManager,
|
||||
shareLinkManager,
|
||||
{
|
||||
storage = safeStorage(),
|
||||
restoreGate = null,
|
||||
onDurableStateChange = null,
|
||||
onTrackingRestoreStatus = null,
|
||||
now = () => Date.now(),
|
||||
// Injectable so the pending-window behavior is deterministically testable
|
||||
// without sleeping out a 90 s expiry.
|
||||
setTimer = (fn, ms) => setTimeout(fn, ms),
|
||||
clearTimer = (handle) => clearTimeout(handle),
|
||||
} = {},
|
||||
) {
|
||||
if (!dataManager?.registrationsFinalized) {
|
||||
throw new Error(
|
||||
'Layer state requires finalized data-layer registrations',
|
||||
);
|
||||
}
|
||||
this.dataManager = dataManager;
|
||||
this.shareLinkManager = shareLinkManager || null;
|
||||
this.storage = storage;
|
||||
this.restoreGate = restoreGate;
|
||||
this.onDurableStateChange = onDurableStateChange;
|
||||
this.onTrackingRestoreStatus = onTrackingRestoreStatus;
|
||||
this.now = now;
|
||||
this.setTimer = setTimer;
|
||||
this.clearTimer = clearTimer;
|
||||
this._durableState = createDefaultLayerState();
|
||||
this._source = 'defaults';
|
||||
this._destroyed = false;
|
||||
this._restoreControllers = new Map();
|
||||
this._shareCreatedAtMs = null;
|
||||
this._trackingRestoreController = null;
|
||||
this._trackingRestoreGeneration = 0;
|
||||
this._pendingTrackingTimer = null;
|
||||
this._pendingTrackingContext = null;
|
||||
this._unsubscribe = this.dataManager.subscribe((change) =>
|
||||
this._handleManagerChange(change),
|
||||
);
|
||||
this._unsubscribeVisibilityRequests =
|
||||
this.dataManager.subscribeVisibilityRequests((change) =>
|
||||
this._handleVisibilityRequest(change),
|
||||
);
|
||||
this.restorePromise = Promise.resolve([]);
|
||||
this.lastRestoreResults = [];
|
||||
}
|
||||
|
||||
start({
|
||||
shareLayerState = null,
|
||||
allowLocalState = true,
|
||||
shareCreatedAtMs = null,
|
||||
} = {}) {
|
||||
if (this._destroyed)
|
||||
throw new Error('Layer-state coordinator is destroyed');
|
||||
let selected = shareLayerState
|
||||
? normalizeLayerState(shareLayerState)
|
||||
: null;
|
||||
if (selected) {
|
||||
this._source = 'share';
|
||||
this._shareCreatedAtMs = Number.isFinite(shareCreatedAtMs)
|
||||
? shareCreatedAtMs
|
||||
: null;
|
||||
} else if (allowLocalState) {
|
||||
let stored = null;
|
||||
try {
|
||||
stored = parseStoredLayerState(
|
||||
this.storage?.getItem?.(LAYER_STATE_STORAGE_KEY),
|
||||
);
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
if (stored) {
|
||||
selected = stored;
|
||||
this._source = 'local';
|
||||
}
|
||||
} else {
|
||||
// A valid historical camera/style share with no v2 layer payload keeps
|
||||
// the exact legacy default-layer behavior. It must not inherit an
|
||||
// unrelated recipient's saved local layer preferences.
|
||||
this._source = 'legacy-share';
|
||||
}
|
||||
this._durableState = selected || createDefaultLayerState();
|
||||
this.shareLinkManager?.setLayerStateProvider?.(() =>
|
||||
this.getDurableState(),
|
||||
);
|
||||
this.shareLinkManager?.onLayerStateChange?.();
|
||||
this._notifyDurableState();
|
||||
if (!selected) return this.restorePromise;
|
||||
this.restorePromise = this._restoreSelectedState(
|
||||
this._source === 'share'
|
||||
? LAYER_RESTORE_ORIGINS.share
|
||||
: LAYER_RESTORE_ORIGINS.local,
|
||||
);
|
||||
return this.restorePromise;
|
||||
}
|
||||
|
||||
get source() {
|
||||
return this._source;
|
||||
}
|
||||
|
||||
getDurableState() {
|
||||
return cloneLayerState(this._durableState);
|
||||
}
|
||||
|
||||
_notifyDurableState() {
|
||||
try {
|
||||
this.onDurableStateChange?.(this.getDurableState());
|
||||
} catch {
|
||||
/* UI sync is best effort */
|
||||
}
|
||||
}
|
||||
|
||||
_handleVisibilityRequest(change) {
|
||||
if (!isExplicitLayerStateOrigin(change?.origin)) return;
|
||||
this._restoreControllers
|
||||
.get(change.layerId)
|
||||
?.abort('superseded-by-explicit-visibility');
|
||||
if (SHARE_TRACKING_RESTORE_POLICIES[change.layerId]) {
|
||||
this._revokePendingTrackingWatch('superseded-by-explicit-visibility');
|
||||
}
|
||||
}
|
||||
|
||||
/** Revoke every passive restore before explicit navigation can be reclaimed. */
|
||||
cancelPendingRestores(reason = 'superseded-by-explicit-navigation') {
|
||||
for (const controller of this._restoreControllers.values())
|
||||
controller.abort(reason);
|
||||
this._revokePendingTrackingWatch(reason);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a pending shared Follow. Physical navigation may also clear only
|
||||
* the exact passive selection, without writing recipient preferences.
|
||||
*/
|
||||
cancelPendingShareTracking(
|
||||
reason = 'superseded-by-explicit-navigation',
|
||||
{ clearSelection = false } = {},
|
||||
) {
|
||||
this._revokePendingTrackingWatch(reason);
|
||||
if (!clearSelection) return false;
|
||||
const selected = this._selectedShareTrackingTarget();
|
||||
return selected ? this._passivelyClearTrackingSelection(selected) : false;
|
||||
}
|
||||
|
||||
_handleManagerChange(change) {
|
||||
if (!change || this._destroyed) return;
|
||||
// Parameter and visibility ownership are independent. A newer explicit
|
||||
// option request may replace passive share options, but it must not abort
|
||||
// the same layer's visibility lifecycle.
|
||||
if (change.type === 'params-requested') {
|
||||
if (
|
||||
isExplicitLayerStateOrigin(change.origin) &&
|
||||
SHARE_TRACKING_RESTORE_POLICIES[change.layerId]
|
||||
) {
|
||||
this._revokePendingTrackingWatch('superseded-by-explicit-params');
|
||||
}
|
||||
return;
|
||||
}
|
||||
// A layer that goes away takes its latch with it, at ANY origin — a
|
||||
// programmatic disable or teardown never reaches the explicit-intent path
|
||||
// below, so revoke here before that early return.
|
||||
if (
|
||||
change.type === 'visibility' &&
|
||||
change.enabled === false &&
|
||||
SHARE_TRACKING_RESTORE_POLICIES[change.layerId]
|
||||
) {
|
||||
this._revokePendingTrackingWatch('owner-layer-disabled');
|
||||
}
|
||||
if (!isExplicitLayerStateOrigin(change.origin)) return;
|
||||
if (change.type === 'visibility') {
|
||||
this._restoreControllers
|
||||
.get(change.layerId)
|
||||
?.abort('superseded-by-explicit-visibility');
|
||||
if (SHARE_TRACKING_RESTORE_POLICIES[change.layerId]) {
|
||||
this._revokePendingTrackingWatch('superseded-by-explicit-visibility');
|
||||
}
|
||||
const enabled = new Set(this._durableState.enabledLayerIds);
|
||||
if (change.enabled) enabled.add(change.layerId);
|
||||
else enabled.delete(change.layerId);
|
||||
this._commitExplicit({
|
||||
...this._durableState,
|
||||
enabledLayerIds: [...enabled],
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (change.type !== 'params') return;
|
||||
const entry = REGISTRY_BY_ID.get(change.layerId);
|
||||
if (!entry?.optionOwner) return;
|
||||
const ownerId = entry.optionOwner;
|
||||
const nextOwnerOptions = { ...this._durableState.options[ownerId] };
|
||||
const requestedParams = change.requestedParams || {};
|
||||
const trackingOptionKey =
|
||||
TRACKING_OPTION_KEY_BY_LAYER[change.layerId] || null;
|
||||
let changed = false;
|
||||
for (const spec of optionSpecs(ownerId)) {
|
||||
// Only persist keys present in this explicit request. getLayerParams()
|
||||
// can return a wider live snapshot containing transient or passively
|
||||
// changed values that this user action did not own. Tracking is the one
|
||||
// exception: an unrelated explicit option cancels a pending restoration
|
||||
// in that family, so its wider live value (active ID or null) must replace
|
||||
// the formerly durable pending ID instead of allowing reload resurrection.
|
||||
const explicitlyRequested = Object.hasOwn(requestedParams, spec.key);
|
||||
const implicitTrackingSync =
|
||||
!explicitlyRequested && spec.key === trackingOptionKey;
|
||||
// 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;
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
if (spec.defaultValue !== null || change.params[spec.key] !== null)
|
||||
continue;
|
||||
}
|
||||
nextOwnerOptions[spec.key] = value;
|
||||
changed = true;
|
||||
}
|
||||
if (!changed) return;
|
||||
this._commitExplicit({
|
||||
...this._durableState,
|
||||
options: { ...this._durableState.options, [ownerId]: nextOwnerOptions },
|
||||
});
|
||||
}
|
||||
|
||||
_commitExplicit(candidate) {
|
||||
this._durableState = normalizeLayerState(candidate);
|
||||
const serialized = serializeStoredLayerState(this._durableState);
|
||||
try {
|
||||
if (this.storage?.getItem?.(LAYER_STATE_STORAGE_KEY) !== serialized) {
|
||||
this.storage?.setItem?.(LAYER_STATE_STORAGE_KEY, serialized);
|
||||
}
|
||||
} catch {
|
||||
/* storage can be unavailable or quota-limited */
|
||||
}
|
||||
this.shareLinkManager?.onLayerStateChange?.();
|
||||
this._notifyDurableState();
|
||||
}
|
||||
|
||||
async _waitForRestoreGate() {
|
||||
if (!this.restoreGate) return;
|
||||
await (typeof this.restoreGate === 'function'
|
||||
? this.restoreGate()
|
||||
: this.restoreGate);
|
||||
}
|
||||
|
||||
async _restoreSelectedState(origin) {
|
||||
for (const entry of LAYER_STATE_REGISTRY) {
|
||||
this._restoreControllers.set(entry.id, new AbortController());
|
||||
}
|
||||
try {
|
||||
await this._waitForRestoreGate();
|
||||
const enabled = new Set(this._durableState.enabledLayerIds);
|
||||
const settled = await Promise.allSettled(
|
||||
LAYER_STATE_REGISTRY.map(async (entry) => {
|
||||
const controller = this._restoreControllers.get(entry.id);
|
||||
const targetEnabled = enabled.has(entry.id);
|
||||
const options = layerOptionsForRestore(this._durableState, entry.id);
|
||||
if (origin === LAYER_RESTORE_ORIGINS.share && options) {
|
||||
for (const trackingKey of Object.values(
|
||||
TRACKING_OPTION_KEY_BY_LAYER,
|
||||
)) {
|
||||
delete options[trackingKey];
|
||||
}
|
||||
}
|
||||
if (this._destroyed || controller?.signal.aborted) {
|
||||
return {
|
||||
layerId: entry.id,
|
||||
targetEnabled,
|
||||
origin,
|
||||
phase: 'reserved',
|
||||
...currentLayerOutcome(this.dataManager, entry.id),
|
||||
appliedOptions: {},
|
||||
cancellationReason: this._destroyed ? 'destroyed' : 'superseded',
|
||||
errorClass: 'cancelled',
|
||||
persistenceWrite: false,
|
||||
succeeded: false,
|
||||
};
|
||||
}
|
||||
// Reserve passive option state before any asynchronous lifecycle work.
|
||||
// A later explicit params intent then wins on its own lane without
|
||||
// cancelling or being overwritten by the visibility restore.
|
||||
const paramsSucceeded =
|
||||
!options ||
|
||||
Object.keys(options).length === 0 ||
|
||||
this.dataManager.setLayerParams(entry.id, options, { origin });
|
||||
return this.dataManager
|
||||
.restoreLayerState(
|
||||
entry.id,
|
||||
{
|
||||
enabled: targetEnabled,
|
||||
params: null,
|
||||
},
|
||||
{ origin, signal: controller.signal },
|
||||
)
|
||||
.then((result) => ({
|
||||
...result,
|
||||
appliedOptions: paramsSucceeded && options ? options : {},
|
||||
errorClass: paramsSucceeded
|
||||
? result.errorClass
|
||||
: 'ParamsRejected',
|
||||
succeeded: paramsSucceeded && result.succeeded,
|
||||
}));
|
||||
}),
|
||||
);
|
||||
this.lastRestoreResults = settled.map((result, index) => {
|
||||
if (result.status === 'fulfilled') return result.value;
|
||||
const entry = LAYER_STATE_REGISTRY[index];
|
||||
return {
|
||||
layerId: entry.id,
|
||||
targetEnabled: enabled.has(entry.id),
|
||||
origin,
|
||||
phase: 'coordinator',
|
||||
...currentLayerOutcome(this.dataManager, entry.id),
|
||||
appliedOptions: {},
|
||||
cancellationReason: null,
|
||||
errorClass: result.reason?.name || 'Error',
|
||||
error: String(result.reason?.message || result.reason),
|
||||
persistenceWrite: false,
|
||||
succeeded: false,
|
||||
};
|
||||
});
|
||||
return this.lastRestoreResults.map((result) => ({ ...result }));
|
||||
} finally {
|
||||
this._restoreControllers.clear();
|
||||
this._notifyDurableState();
|
||||
}
|
||||
}
|
||||
|
||||
_selectedShareTrackingTarget() {
|
||||
if (this._source !== 'share') return null;
|
||||
for (const [layerId, policy] of Object.entries(
|
||||
SHARE_TRACKING_RESTORE_POLICIES,
|
||||
)) {
|
||||
const targetId =
|
||||
this._durableState.options?.[policy.optionOwner]?.[policy.optionKey];
|
||||
if (targetId !== null && targetId !== undefined && targetId !== '') {
|
||||
return { layerId, targetId, ...policy };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
_passivelyClearTrackingSelection(selected) {
|
||||
const current =
|
||||
this._durableState.options?.[selected.optionOwner]?.[selected.optionKey];
|
||||
if (String(current) !== String(selected.targetId)) return false;
|
||||
const ownerOptions = {
|
||||
...this._durableState.options[selected.optionOwner],
|
||||
[selected.optionKey]: null,
|
||||
};
|
||||
this._durableState = normalizeLayerState({
|
||||
...this._durableState,
|
||||
options: {
|
||||
...this._durableState.options,
|
||||
[selected.optionOwner]: ownerOptions,
|
||||
},
|
||||
});
|
||||
this.dataManager.setLayerParams?.(
|
||||
selected.layerId,
|
||||
{ [selected.optionKey]: null },
|
||||
{ origin: LAYER_RESTORE_ORIGINS.share },
|
||||
);
|
||||
this.shareLinkManager?.onLayerStateChange?.();
|
||||
this._notifyDurableState();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* `atMs` is the moment the subject was first found ABSENT, not the moment the
|
||||
* verdict is delivered. Waiting out the pending window must not by itself
|
||||
* push a fresh link into the "expired" wording — that word describes the
|
||||
* SHARE's age, not how long this client watched for the subject.
|
||||
*/
|
||||
_classifyMissingTrackingTarget(selected, atMs = this.now()) {
|
||||
const copiedAt = this._shareCreatedAtMs;
|
||||
if (!Number.isFinite(copiedAt)) return 'unavailable';
|
||||
const ageMs = atMs - copiedAt;
|
||||
return ageMs > selected.expiryWindowMs ? 'expired' : 'unavailable';
|
||||
}
|
||||
|
||||
/** Whether the owning layer currently follows the shared subject. */
|
||||
_trackingTargetLatched(selected) {
|
||||
const params = this.dataManager.getLayerParams?.(selected.layerId);
|
||||
const active = params?.[selected.optionKey];
|
||||
return (
|
||||
active !== null &&
|
||||
active !== undefined &&
|
||||
String(active) === String(selected.targetId)
|
||||
);
|
||||
}
|
||||
|
||||
/** Stop watching a pending shared subject without deciding its fate. */
|
||||
_cancelPendingTrackingWatch() {
|
||||
if (this._pendingTrackingTimer !== null)
|
||||
this.clearTimer(this._pendingTrackingTimer);
|
||||
this._pendingTrackingTimer = null;
|
||||
const pending = this._pendingTrackingContext;
|
||||
if (pending?.signal && pending.abortHandler) {
|
||||
pending.signal.removeEventListener('abort', pending.abortHandler);
|
||||
pending.abortHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Publish a share-follow lifecycle update without allowing UI errors to own state. */
|
||||
_publishTrackingRestoreStatus(status) {
|
||||
try {
|
||||
this.onTrackingRestoreStatus?.(status);
|
||||
} catch {
|
||||
/* status UI is best effort */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a pending shared Follow wholesale.
|
||||
*
|
||||
* The watch and the LAYER's own deferred-restore latch are two halves of one
|
||||
* mechanism, so they must die together. Aborting only the restore controller
|
||||
* left the timer alive: the controller has already settled by the time the
|
||||
* watch exists, so the abort was a no-op and the orphaned timer went on to
|
||||
* announce "Shared … unavailable" for a subject whose latch had been
|
||||
* cancelled — a notice about work no longer being attempted.
|
||||
*/
|
||||
_revokePendingTrackingWatch(reason) {
|
||||
this._trackingRestoreController?.abort(reason);
|
||||
this._trackingRestoreGeneration += 1;
|
||||
this._cancelPendingTrackingWatch();
|
||||
const pending = this._pendingTrackingContext;
|
||||
this._pendingTrackingContext = null;
|
||||
if (pending) {
|
||||
this.dataManager.cancelPendingLayerRestore?.(pending.selected.layerId, {
|
||||
origin: LAYER_RESTORE_ORIGINS.share,
|
||||
reason: String(reason || 'cancelled'),
|
||||
});
|
||||
this._publishTrackingRestoreStatus({
|
||||
...pending.probe,
|
||||
...pending.selected,
|
||||
status: 'cancelled',
|
||||
classification: 'cancelled',
|
||||
reason: String(reason || 'cancelled'),
|
||||
cleared: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hold a not-yet-arrived shared subject PENDING instead of declaring it gone.
|
||||
*
|
||||
* A recipient's first authoritative refresh routinely lands without a given
|
||||
* contact — the feed is polled, coverage is partial, and rendering trails the
|
||||
* snapshot by a poll. Reload-from-local already survives this: its restore
|
||||
* arms the layer's own deferred-restore latch, which re-attempts on every
|
||||
* later poll. The shared path used to decide on that single refresh, clear
|
||||
* the subject from durable state AND from the URL, then post a failure notice
|
||||
* seconds into startup — so the same link healed on reload but never on the
|
||||
* share.
|
||||
*
|
||||
* Arm the SAME latch, then watch it in the background: the caller is never
|
||||
* blocked (startup must not wait out a 90 s window before it may write the
|
||||
* URL again), and the terminal verdict is deferred until the source-specific
|
||||
* window has genuinely expired. The existing wordings are unchanged.
|
||||
*/
|
||||
async _beginPendingTrackingRestore(selected, probe, signal = null) {
|
||||
const generation = this._trackingRestoreGeneration;
|
||||
const absentAtMs = this.now();
|
||||
// Arm the layer's deferred-restore latch under the passive share origin, so
|
||||
// it re-attempts each poll and never rewrites recipient preferences.
|
||||
let armed = false;
|
||||
let armError = null;
|
||||
try {
|
||||
armed =
|
||||
(await this.dataManager.setLayerParams?.(
|
||||
selected.layerId,
|
||||
{ [selected.optionKey]: selected.targetId },
|
||||
{ origin: LAYER_RESTORE_ORIGINS.share },
|
||||
)) === true;
|
||||
} catch (error) {
|
||||
armError = error;
|
||||
}
|
||||
if (
|
||||
this._destroyed ||
|
||||
generation !== this._trackingRestoreGeneration ||
|
||||
signal?.aborted
|
||||
) {
|
||||
if (armed) {
|
||||
this.dataManager.cancelPendingLayerRestore?.(selected.layerId, {
|
||||
origin: LAYER_RESTORE_ORIGINS.share,
|
||||
reason: String(signal?.reason || 'superseded'),
|
||||
});
|
||||
}
|
||||
return {
|
||||
...probe,
|
||||
...selected,
|
||||
status: 'cancelled',
|
||||
classification: 'cancelled',
|
||||
reason: String(signal?.reason || 'superseded'),
|
||||
cleared: false,
|
||||
};
|
||||
}
|
||||
if (!armed) {
|
||||
const terminal = {
|
||||
...probe,
|
||||
...selected,
|
||||
status: 'source-unavailable',
|
||||
classification: 'source-unavailable',
|
||||
reason: String(
|
||||
armError?.message || armError || 'tracking restore latch rejected',
|
||||
),
|
||||
cleared: this._passivelyClearTrackingSelection(selected),
|
||||
};
|
||||
this._publishTrackingRestoreStatus(terminal);
|
||||
return terminal;
|
||||
}
|
||||
const deadline = this.now() + selected.expiryWindowMs;
|
||||
const settle = (terminal) => {
|
||||
if (this._pendingTrackingContext?.generation !== generation) return;
|
||||
this._cancelPendingTrackingWatch();
|
||||
this._pendingTrackingContext = null;
|
||||
this._publishTrackingRestoreStatus(terminal);
|
||||
};
|
||||
const poll = () => {
|
||||
this._pendingTrackingTimer = null;
|
||||
if (this._destroyed || generation !== this._trackingRestoreGeneration)
|
||||
return;
|
||||
// The layer that owns the latch may have gone away since the last tick
|
||||
// (disable, teardown, replacement). There is nothing left attempting this
|
||||
// restore, so abandon it silently rather than announcing a verdict.
|
||||
if (this.dataManager.isEffectivelyEnabled?.(selected.layerId) === false) {
|
||||
this._revokePendingTrackingWatch('owner-layer-disabled');
|
||||
return;
|
||||
}
|
||||
if (this._trackingTargetLatched(selected)) {
|
||||
settle({
|
||||
...probe,
|
||||
...selected,
|
||||
status: 'found',
|
||||
classification: 'followed',
|
||||
cleared: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (this.now() >= deadline) {
|
||||
// The window really has elapsed — only now does the verdict apply.
|
||||
const classification =
|
||||
probe.status === 'missing'
|
||||
? this._classifyMissingTrackingTarget(selected, absentAtMs)
|
||||
: 'source-unavailable';
|
||||
const cleared = this._passivelyClearTrackingSelection(selected);
|
||||
settle({ ...probe, ...selected, classification, cleared });
|
||||
return;
|
||||
}
|
||||
this._pendingTrackingTimer = this.setTimer(
|
||||
poll,
|
||||
PENDING_TRACKING_POLL_MS,
|
||||
);
|
||||
this._pendingTrackingTimer?.unref?.();
|
||||
};
|
||||
this._cancelPendingTrackingWatch();
|
||||
const pending = {
|
||||
...probe,
|
||||
...selected,
|
||||
status: 'pending',
|
||||
classification: 'pending',
|
||||
cleared: false,
|
||||
};
|
||||
const pendingContext = {
|
||||
generation,
|
||||
selected,
|
||||
probe,
|
||||
signal,
|
||||
abortHandler: null,
|
||||
};
|
||||
if (signal) {
|
||||
pendingContext.abortHandler = () => {
|
||||
if (this._pendingTrackingContext?.generation !== generation) return;
|
||||
this._revokePendingTrackingWatch(signal.reason || 'aborted');
|
||||
};
|
||||
signal.addEventListener('abort', pendingContext.abortHandler, {
|
||||
once: true,
|
||||
});
|
||||
}
|
||||
this._pendingTrackingContext = pendingContext;
|
||||
this._publishTrackingRestoreStatus(pending);
|
||||
this._pendingTrackingTimer = this.setTimer(poll, PENDING_TRACKING_POLL_MS);
|
||||
this._pendingTrackingTimer?.unref?.();
|
||||
return pending;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh and restore the one shareable tracked target after destination
|
||||
* camera and ordinary layer restoration have settled.
|
||||
*/
|
||||
async restoreShareTrackingSelection({ signal = null } = {}) {
|
||||
const selected = this._selectedShareTrackingTarget();
|
||||
if (!selected || this._destroyed)
|
||||
return { status: 'skipped', reason: 'no-shared-target' };
|
||||
this._revokePendingTrackingWatch('superseded-by-newer-restore');
|
||||
const controller = new AbortController();
|
||||
const combinedSignal = signal
|
||||
? AbortSignal.any([signal, controller.signal])
|
||||
: controller.signal;
|
||||
this._trackingRestoreController = controller;
|
||||
const generation = ++this._trackingRestoreGeneration;
|
||||
let result;
|
||||
try {
|
||||
result = await this.dataManager.resolveLayerTrackingTarget(
|
||||
selected.layerId,
|
||||
selected.targetId,
|
||||
{ signal: combinedSignal, origin: LAYER_RESTORE_ORIGINS.share },
|
||||
);
|
||||
} catch (error) {
|
||||
result = combinedSignal.aborted
|
||||
? {
|
||||
status: 'cancelled',
|
||||
reason: String(combinedSignal.reason || 'aborted'),
|
||||
}
|
||||
: {
|
||||
status: 'source-unavailable',
|
||||
reason: String(error?.message || error),
|
||||
};
|
||||
}
|
||||
if (
|
||||
generation !== this._trackingRestoreGeneration ||
|
||||
this._destroyed ||
|
||||
combinedSignal.aborted
|
||||
) {
|
||||
if (this._trackingRestoreController === controller)
|
||||
this._trackingRestoreController = null;
|
||||
return {
|
||||
...result,
|
||||
status: 'cancelled',
|
||||
reason: String(combinedSignal.reason || 'superseded'),
|
||||
};
|
||||
}
|
||||
if (this._trackingRestoreController === controller)
|
||||
this._trackingRestoreController = null;
|
||||
|
||||
if (result.status === 'found') {
|
||||
const terminal = {
|
||||
...result,
|
||||
...selected,
|
||||
classification: 'followed',
|
||||
cleared: false,
|
||||
};
|
||||
this._publishTrackingRestoreStatus(terminal);
|
||||
return terminal;
|
||||
}
|
||||
if (['cancelled', 'superseded', 'destroyed'].includes(result.status))
|
||||
return result;
|
||||
|
||||
// A subject that is simply not here YET is not a subject that is gone. Hold
|
||||
// it on the layer's own deferred-restore latch for its source-specific
|
||||
// window before any verdict is reached or shown. `unsupported` layers have
|
||||
// no latch to arm, so they still decide immediately.
|
||||
if (result.status === 'missing' || result.status === 'source-unavailable') {
|
||||
return this._beginPendingTrackingRestore(
|
||||
selected,
|
||||
result,
|
||||
combinedSignal,
|
||||
);
|
||||
}
|
||||
|
||||
const classification =
|
||||
result.status === 'missing'
|
||||
? this._classifyMissingTrackingTarget(selected)
|
||||
: 'source-unavailable';
|
||||
const cleared = this._passivelyClearTrackingSelection(selected);
|
||||
const terminal = { ...result, ...selected, classification, cleared };
|
||||
this._publishTrackingRestoreStatus(terminal);
|
||||
return terminal;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this._destroyed) return;
|
||||
this._destroyed = true;
|
||||
for (const controller of this._restoreControllers.values())
|
||||
controller.abort('coordinator-destroyed');
|
||||
this._restoreControllers.clear();
|
||||
this._revokePendingTrackingWatch('coordinator-destroyed');
|
||||
this._trackingRestoreController = null;
|
||||
this._unsubscribe?.();
|
||||
this._unsubscribe = null;
|
||||
this._unsubscribeVisibilityRequests?.();
|
||||
this._unsubscribeVisibilityRequests = null;
|
||||
this.shareLinkManager?.setLayerStateProvider?.(null);
|
||||
this.onDurableStateChange = null;
|
||||
this.onTrackingRestoreStatus = null;
|
||||
}
|
||||
}
|
||||
@@ -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"],
|
||||
|
||||
+27
-1
@@ -1,3 +1,5 @@
|
||||
import { cancelCameraArrival } from './cameraArrival.js';
|
||||
|
||||
function cloneLayerParams(value) {
|
||||
if (Array.isArray(value)) return value.map(cloneLayerParams);
|
||||
if (value && typeof value === 'object') {
|
||||
@@ -72,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 }
|
||||
@@ -226,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';
|
||||
@@ -233,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,
|
||||
@@ -1074,6 +1088,7 @@ export class LayerLifecycle {
|
||||
const entry = this.layers.get(layerId);
|
||||
if (!entry) return { intentEpoch: null, promise: Promise.resolve() };
|
||||
const desiredState = Boolean(shouldEnable);
|
||||
if (!desiredState) cancelCameraArrival(this.viewer);
|
||||
if (entry.destroying) {
|
||||
return {
|
||||
intentEpoch: null,
|
||||
@@ -2095,6 +2110,7 @@ export class LayerLifecycle {
|
||||
async destroyLayer(layerId) {
|
||||
const entry = this.layers.get(layerId);
|
||||
if (!entry || entry.destroying) return false;
|
||||
cancelCameraArrival(this.viewer);
|
||||
entry.destroying = true;
|
||||
this._invalidateRefresh(layerId, entry, 'layer-destroyed');
|
||||
// Teardown becomes authoritative before the first await. Advancing the
|
||||
@@ -2278,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);
|
||||
|
||||
+16
-107
@@ -1,4 +1,10 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
layerTitle,
|
||||
mapAnalystRecord,
|
||||
parseGeojsonLines,
|
||||
} from '../sources/infrastructureData.js';
|
||||
import { createInfrastructureOverlayEntry } from './infrastructureOverlayEntry.js';
|
||||
import { isPointerFree } from './inputOwnership.js';
|
||||
import {
|
||||
selectInfraLod,
|
||||
@@ -14,10 +20,6 @@ const VISIBILITY_UPDATE_MS = 450;
|
||||
export const LOCAL_OVERLAY_COHORT_LIMIT = 160;
|
||||
const LOCAL_OVERLAY_COLLISION_CAPACITY = 96;
|
||||
const LOCAL_OVERLAY_CELL_SURPLUS = 2;
|
||||
const LOCAL_OVERLAY_MAX_DISTANCE_M = 14000000;
|
||||
const LOCAL_OVERLAY_FADE_START_M = 250000;
|
||||
const LOCAL_OVERLAY_FADE_START_RATIO =
|
||||
LOCAL_OVERLAY_FADE_START_M / LOCAL_OVERLAY_MAX_DISTANCE_M;
|
||||
// Stems are anchored at ellipsoid height 0, but high-elevation features
|
||||
// (e.g. dams in river canyons) sit hundreds of meters above the ellipsoid,
|
||||
// burying the short close-in stem inside the photoreal mesh. Once the
|
||||
@@ -89,78 +91,6 @@ export function localInfrastructureOverlayCopy(properties, layerId) {
|
||||
return { title, details };
|
||||
}
|
||||
|
||||
/**
|
||||
* Map one local infrastructure feature to a JSON-safe analyst record
|
||||
* (analyst query engine seam). Pure — no Cesium types. Missing/unknown
|
||||
* fields are null, never NaN/undefined. Layer-specific fields that do not
|
||||
* apply (river/output on datacenters, capacity on dams) stay null so a
|
||||
* shared compact payload can copy them without inventing values.
|
||||
* Names are unclamped — overlay cards shorten for paint; queries need the
|
||||
* full source string ("Usina Hidrelétrica de Itaipu").
|
||||
* @param {Object|null|undefined} raw - {id, lat, lon, properties}.
|
||||
* @param {string} [layerId] Local layer id (`local-datacenters` / `local-dams`).
|
||||
* @returns {{id: string, name: string|null, lat: number|null, lon: number|null,
|
||||
* operator: string|null, capacity: string|null, river: string|null,
|
||||
* output: string|null}}
|
||||
*/
|
||||
export function mapAnalystRecord(raw, layerId = '') {
|
||||
const num = (v) => (Number.isFinite(v) ? v : null);
|
||||
const text = (v) => {
|
||||
const t = String(v ?? '').trim();
|
||||
return t && t !== 'undefined' && t !== 'null' ? t : null;
|
||||
};
|
||||
const props =
|
||||
raw?.properties &&
|
||||
typeof raw.properties === 'object' &&
|
||||
!Array.isArray(raw.properties)
|
||||
? raw.properties
|
||||
: {};
|
||||
const tags =
|
||||
props.tags && typeof props.tags === 'object' && !Array.isArray(props.tags)
|
||||
? props.tags
|
||||
: {};
|
||||
const name =
|
||||
text(props.name) ||
|
||||
text(tags.name) ||
|
||||
text(tags['name:en']) ||
|
||||
text(tags.official_name) ||
|
||||
null;
|
||||
const operator =
|
||||
text(tags.operator) ||
|
||||
text(props.operator) ||
|
||||
text(tags['operator:short']) ||
|
||||
null;
|
||||
const capacity =
|
||||
layerId === 'local-datacenters'
|
||||
? text(tags['capacity:it_load']) ||
|
||||
text(tags.it_load) ||
|
||||
text(tags.capacity) ||
|
||||
text(props.capacity)
|
||||
: null;
|
||||
const river =
|
||||
layerId === 'local-dams'
|
||||
? text(tags.associated_river) ||
|
||||
text(props.associated_river) ||
|
||||
text(tags.river) ||
|
||||
text(props.river) ||
|
||||
text(tags['river:name'])
|
||||
: null;
|
||||
const output =
|
||||
layerId === 'local-dams'
|
||||
? text(props.output) || text(tags['plant:output:electricity'])
|
||||
: null;
|
||||
return {
|
||||
id: name || text(raw?.id) || layerTitle(layerId),
|
||||
name,
|
||||
lat: num(raw?.lat),
|
||||
lon: num(raw?.lon),
|
||||
operator,
|
||||
capacity,
|
||||
river,
|
||||
output,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Produce one normalized-contract input owned by a local infrastructure layer.
|
||||
* The host revalidates the authoritative `source` value while normalizing it.
|
||||
@@ -182,33 +112,15 @@ export function createLocalInfrastructureOverlayEntry({
|
||||
accent,
|
||||
}) {
|
||||
const copy = localInfrastructureOverlayCopy(properties, layerId);
|
||||
return {
|
||||
id: String(id),
|
||||
return createInfrastructureOverlayEntry({
|
||||
id,
|
||||
source: layerId,
|
||||
position,
|
||||
variant: 'card',
|
||||
title: copy.title,
|
||||
details: copy.details,
|
||||
accent,
|
||||
priority,
|
||||
collisionGroup: 'ambient-card',
|
||||
zIndex: 30,
|
||||
interactive: false,
|
||||
minDistance: 0,
|
||||
maxDistance: LOCAL_OVERLAY_MAX_DISTANCE_M,
|
||||
distanceFadeStartRatio: LOCAL_OVERLAY_FADE_START_RATIO,
|
||||
distanceScale: {
|
||||
near: 250000,
|
||||
nearValue: 1,
|
||||
far: 9000000,
|
||||
farValue: 0.62,
|
||||
},
|
||||
edgeFade: 'keyhole',
|
||||
horizonCull: true,
|
||||
terrainOcclusion: false,
|
||||
gapPx: 15,
|
||||
placement: 'above',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -394,6 +306,7 @@ export function createLocalGeoJsonLayer(
|
||||
color,
|
||||
icon = '📍',
|
||||
source = 'Local JSONL',
|
||||
osmDerived = false,
|
||||
labels = true,
|
||||
labelMax = DEFAULT_LABEL_MAX,
|
||||
labelGridPx = DEFAULT_LABEL_GRID_PX,
|
||||
@@ -409,6 +322,8 @@ export function createLocalGeoJsonLayer(
|
||||
clearSelectedEntityContextForLayer,
|
||||
removeEntityContextsForLayer,
|
||||
governorRequestRender,
|
||||
showOsmCredit,
|
||||
hideOsmCredit,
|
||||
},
|
||||
) {
|
||||
let _dataSource = null;
|
||||
@@ -538,6 +453,7 @@ export function createLocalGeoJsonLayer(
|
||||
};
|
||||
|
||||
const disableLayer = (viewer) => {
|
||||
hideOsmCredit?.(viewer, id);
|
||||
_enabled = false;
|
||||
clearGroundRetryRender();
|
||||
_activeLodIds = new Set();
|
||||
@@ -681,11 +597,7 @@ export function createLocalGeoJsonLayer(
|
||||
}
|
||||
const text = await response.text();
|
||||
if (_destroyed) return;
|
||||
const lines = text
|
||||
.split('\n')
|
||||
.filter((l) => l.trim().length > 0);
|
||||
|
||||
features = lines.map((line) => JSON.parse(line));
|
||||
features = parseGeojsonLines(text);
|
||||
_cachedFeatures = features;
|
||||
}
|
||||
|
||||
@@ -942,6 +854,7 @@ export function createLocalGeoJsonLayer(
|
||||
}
|
||||
|
||||
if (_destroyed) return;
|
||||
if (_enabled && _count > 0 && osmDerived) showOsmCredit?.(viewer, id);
|
||||
// 3. Add an incredibly fast pre-render occluder to hide points behind the globe
|
||||
if (_enabled && !_preRenderRemover) {
|
||||
_preRenderRemover = viewer.scene.preRender.addEventListener(() => {
|
||||
@@ -1376,8 +1289,4 @@ function clampCardLine(value) {
|
||||
return text.length > 48 ? `${text.slice(0, 45)}...` : text;
|
||||
}
|
||||
|
||||
function layerTitle(layerId) {
|
||||
if (layerId === 'local-datacenters') return 'Datacenter';
|
||||
if (layerId === 'local-dams') return 'Dam';
|
||||
return 'Feature';
|
||||
}
|
||||
export { mapAnalystRecord };
|
||||
@@ -1,4 +1,4 @@
|
||||
# Natural Earth physical regions pack
|
||||
# Natural Earth packs
|
||||
|
||||
Offline named-region polygons for the voice-annotation resolver
|
||||
(`src/data/naturalEarthRegions.js`) — "outline the Alps" resolves to the real
|
||||
@@ -8,6 +8,8 @@ range geometry with no network dependency.
|
||||
|------|----------------|----------|
|
||||
| `regions.json` | `ne_10m_geography_regions_polys` (ranges, deserts, plateaus, peninsulas, islands, …) | 1,046 named |
|
||||
| `marine.json` | `ne_10m_geography_marine_polys` (seas, gulfs, straits, bays, …) | 292 named |
|
||||
| `countries.json` | `ne_10m_admin_0_countries` (countries) | 256 |
|
||||
| `states_provinces.json` | `ne_10m_admin_1_states_provinces` (states, provinces and other first-level units) | 4,587 named |
|
||||
|
||||
**Source:** Natural Earth 10m physical vectors, via the canonical
|
||||
[nvkelso/natural-earth-vector](https://github.com/nvkelso/natural-earth-vector)
|
||||
@@ -34,3 +36,70 @@ See DATA_SOURCES.md.
|
||||
|
||||
Duplicate names exist upstream (two "Cordillera Oriental", a sliver + real
|
||||
"Canadian Shield", …); the lookup module resolves ties by largest area.
|
||||
|
||||
## States and provinces (`states_provinces.json`)
|
||||
|
||||
Worldwide first-level administrative units for area annotations ("outline
|
||||
Texas", "outline Bavaria"), read by `src/data/adminBoundaries.js`.
|
||||
|
||||
**Source:** `geojson/ne_10m_admin_1_states_provinces.geojson` from
|
||||
[nvkelso/natural-earth-vector](https://github.com/nvkelso/natural-earth-vector)
|
||||
at commit `ca96624a56bd078437bca8184e78163e5039ad19` (SHA-256
|
||||
`22d0e3ad85eb3e27f17cabf8ba2d50e554fbc27a87796ff891d958185da62fb5`), fetched
|
||||
2026-09-25. `geojson/ne_10m_populated_places_simple.geojson` from the same
|
||||
commit (SHA-256
|
||||
`fd3fa867a320cbd5c5b6bb5bc550afeec2939fb2cef688e508007282a55ac42f`) is read
|
||||
only to mark ambiguous names; none of it is bundled.
|
||||
|
||||
**License:** public domain, as above.
|
||||
|
||||
**Regenerate:** `node scripts/build-admin-packs.mjs --only admin1` downloads
|
||||
the pinned files (to the system temp directory, or `--cache <dir>`), checks
|
||||
their SHA-256 and rewrites the pack byte for byte. Parameters are in the
|
||||
script's `PARAMS` and in the pack's `meta.curation`.
|
||||
|
||||
**Schema:** `meta` (source, URLs, hashes, license, curation) and `features[]`
|
||||
with `name`, `nameEn` (when it differs), `alt[]` (Latin-script `name_alt`
|
||||
variants and abbreviations), `postal`, `type` (`type_en`), `rank`
|
||||
(`min_label`), `country` (`admin`), `iso2`, `iso` (ISO 3166-2), `amb[]`,
|
||||
`ambAbroad[]`, `cityState`, `label` ([lon, lat] label point) and
|
||||
`polygons`. `polygons` is a list of parts, largest first, each
|
||||
`[outer, ...holes]`; every ring is open and stored as integers in units of
|
||||
10^-3 degrees (10^-`d` when a feature carries `d`), the first vertex absolute
|
||||
and each later one as a `[dLon, dLat]` delta.
|
||||
|
||||
**Curation:**
|
||||
|
||||
- The 7 unnamed features and 2 degenerate Nauru districts are dropped
|
||||
(4,596 → 4,587).
|
||||
- Douglas-Peucker per ring at 0.004 × √(unit area in km²) km, clamped to
|
||||
0.005°–0.02° (Texas ≈ 0.02°, Bavaria ≈ 0.01°); coordinates rounded to
|
||||
3 decimals. The 75 units too small to survive (Maldives atolls, Malta's
|
||||
councils) keep 4 decimals at 0.001° (`d: 4`); 3 smaller still (Vatican
|
||||
City) keep their source rings at 5 decimals (`d: 5`).
|
||||
- Parts under 20 km² or under 0.03% of the unit's largest part are dropped;
|
||||
holes under 20 km² are dropped. Outers are counter-clockwise, holes
|
||||
clockwise.
|
||||
- `amb` lists names that are also a country (`admin`/`geonunit` of any
|
||||
unit), a populated place of 200,000+ (`pop_max`) inside the unit's box
|
||||
when the unit is larger than 1,000 km², or a populated place of 50,000+
|
||||
elsewhere in the same country; `ambAbroad` lists names shared only with a
|
||||
populated place of 50,000+ in another country. `cityState` marks a unit of
|
||||
at most 1,000 km² that contains its namesake city (Berlin, Vienna, Paris).
|
||||
548 units carry `amb`, 108 `ambAbroad`, 138 `cityState`.
|
||||
- Result: 3,648,916 bytes (1,260,545 with gzip -9); 6,492 parts, 80 holes,
|
||||
407,339 vertices.
|
||||
|
||||
## Countries (`countries.json`)
|
||||
|
||||
Natural Earth 10m admin-0 countries, public domain under the terms above.
|
||||
Source: `geojson/ne_10m_admin_0_countries.geojson` at the same pinned commit;
|
||||
SHA-256 `239eec57ac17f100a11e2536cffc56752c318b50ae765b0918ff7aab4ce8f255`.
|
||||
Regenerate with `node scripts/build-admin-packs.mjs --only countries`.
|
||||
Uses the state/province encoding and simplification, retaining parts of at least
|
||||
20 km² and 0.003% of the largest part (including Alaska and Hawaii); the largest
|
||||
part always stays. Holes under 20 km² are dropped.
|
||||
Includes England, Scotland, Wales and Northern Ireland from
|
||||
`ne_10m_admin_0_map_units.geojson` at the same commit (SHA-256
|
||||
`57da82be755f4afccd8f3b14251bb2752f5df1395f47d2d86f817470c4a48862`).
|
||||
Result: 945,843 bytes; 260 features, 2,088 parts, 122,788 vertices.
|
||||
@@ -0,0 +1 @@
|
||||
{"meta":{"title":"Natural Earth admin-0 countries","mapUnits":{"url":"https://raw.githubusercontent.com/nvkelso/natural-earth-vector/ca96624a56bd078437bca8184e78163e5039ad19/geojson/ne_10m_admin_0_map_units.geojson","sha256":"57da82be755f4afccd8f3b14251bb2752f5df1395f47d2d86f817470c4a48862"},"source":"ne_10m_admin_0_countries","url":"https://raw.githubusercontent.com/nvkelso/natural-earth-vector/ca96624a56bd078437bca8184e78163e5039ad19/geojson/ne_10m_admin_0_countries.geojson","sha256":"239eec57ac17f100a11e2536cffc56752c318b50ae765b0918ff7aab4ce8f255","commit":"ca96624a56bd078437bca8184e78163e5039ad19","license":"Public domain (https://www.naturalearthdata.com/about/terms-of-use/)","decimals":3,"curation":{"toleranceFactor":0.004,"minToleranceDeg":0.005,"maxToleranceDeg":0.02,"decimals":3,"minPartKm2":20,"minPartShare":0.00003,"minHoleKm2":20},"script":"scripts/build-admin-packs.mjs"},"features":[{"name":"Afghanistan","alt":["Islamic State of Afghanistan"],"country":"Afghanistan","iso":"AFG","iso2":"AF","type":"Country","polygons":[[[74383,37127,-15,40,89,10,43,55,123,-1,98,67,73,-84,98,17,-103,100,-129,63,-139,-18,-205,51,-12,-27,-80,3,-36,-65,-479,-121,-112,45,142,76,14,90,-268,53,-189,-16,-164,-81,-64,-69,-277,-95,-133,-191,-398,-62,-423,-268,-184,-12,-100,83,-122,297,63,241,36,453,60,55,8,82,-61,46,-196,-51,-83,33,101,258,-1,67,-407,222,-15,-40,-159,13,-112,-41,20,-18,-85,-40,-17,-72,-36,-12,-77,-142,-216,-148,8,-34,-90,7,-7,-56,82,-71,36,-114,-35,-82,-90,-82,-150,8,-89,68,-117,-35,-293,-3,-131,-142,32,-191,32,-22,-130,-107,-66,-13,-122,65,-139,151,-97,18,28,-59,-80,50,-26,-17,10,-61,-151,27,-42,-72,-223,-54,-12,-47,-104,-2,-10,-93,-92,8,-166,-92,-142,138,-105,46,8,79,-34,40,-114,25,-72,-34,-58,62,-244,-97,-136,97,-26,59,-91,43,-712,-53,-151,54,-63,56,-225,67,-94,70,-22,-49,-80,-19,-38,-80,3,-85,-87,-88,-474,-24,-284,-115,-23,-64,23,-116,-174,-277,-11,-237,-116,-152,-174,-60,-38,-60,-107,8,-102,-55,-12,-106,-155,31,-124,-53,-184,-18,-152,-88,-326,-15,-20,-30,28,-56,102,-71,-137,-74,24,-100,-21,-88,-162,-49,-224,-140,-159,-21,-103,54,-146,-140,-52,153,-227,145,-402,-8,-212,123,-41,74,-82,-10,20,-70,-95,-158,-8,-90,-85,-43,-6,-64,51,-91,-72,-169,-10,-118,-105,-92,-12,-85,-248,-122,190,-197,-246,-12,-151,-168,35,-298,-41,-130,25,-73,144,-78,190,-4,75,-42,-89,-30,-3,-68,-261,-265,-5,-93,267,-809,-15,-161,-40,-61,47,-532,865,-122,55,-52,1,-81,83,-225,-24,-168,-958,-989,1634,-450,1091,89,517,-110,87,97,305,86,558,-30,1160,295,145,122,-122,101,86,187,17,192,-54,164,98,322,184,54,113,106,34,113,246,119,74,-6,18,-73,311,-28,419,126,5,61,-159,16,-42,119,127,-9,147,102,123,15,81,50,92,137,116,-59,243,-41,52,29,-131,29,108,40,149,-48,29,-74,99,-98,236,70,259,265,-48,193,17,170,-35,162,193,192,-50,117,101,85,11,171,60,47,224,40,109,-26,114,38,53,67,77,5,169,120,-8,64,-131,124,19,125,-56,85,-160,36,-111,174,26,90,130,27,325,-87,194,-18,340,26,22,42,56,-6,123,53,45,111,14,192,-130,86,-35,89,109,26,15,115,122,75,86,127,181,75,41,59,-2,63,129,118,-108,111,91,109,-39,83,11,56,-100,61,22,91,-159,232,-185,94,52,98,335,203,-11,44,63,86,173,-61,-10,83,102,19,164,81,20,47,113,27,-19,48,61,24,240,32,176,75,238,-3,324,47,581,15,262,-76,59,15,36,67,106,4,158,92,63,10,80,-42,5,60]]]},{"name":"Akrotiri Sovereign Base Area","alt":["Akrotiri"],"country":"Akrotiri Sovereign Base Area","iso":"WSB","iso2":"-99","type":"Country","polygons":[[[32970,34632,-7,-7,-9,18,12,9,24,-6,-2,27,-33,4,-19,-19,-19,9,-3,-7,-48,19,-6,22,-28,0,-13,-13,-13,-2,-7,-15,-32,5,-7,-7,1,-16,73,18,80,-14,2,-18,29,-45,-4,-25,89,0,-21,28,7,37]]]},{"name":"Aland","alt":["Åland Islands"],"country":"Aland","iso":"ALD","iso2":"AX","type":"Country","polygons":[[[20236,60232,42,47,-76,14,-21,-7,11,26,-14,11,-52,11,-33,21,-21,-5,-48,11,13,-27,-41,22,-68,0,-22,46,-24,4,-44,-6,-61,-45,42,-8,39,-34,31,32,35,-31,10,-32,-28,-10,-13,35,-13,-48,-21,-14,-7,-28,-34,15,5,-24,-9,-17,-22,9,-27,49,32,51,-48,7,22,-21,-62,-17,-21,-31,3,19,-8,7,-36,2,24,-49,11,-5,13,-42,68,-96,26,22,18,4,17,-6,-1,-10,9,-5,13,0,23,7,5,10,14,5,23,0,14,-9,17,-32,-3,-10,10,-2,10,6,-9,30,57,10,39,-7,5,11,-19,17,13,61,-34,-20,-23,17,-43,13,-27,22,-2,10,40,7,-7,-21,68,-20,-1,18,16,15,-37,24,-18,38,27,-12,22,9,9,18,-10,19,37,-3,-4,-17,-33,-38,8,-19,29,-21,-3,-24,7,0,17,32,29,16,77,6,-16,-12,-49,-2,-21,-10,-13,-25,-2,-14,8,-6,42,0,25,-9,13,3,26,29,29,5,-21,21]],[[20025,60042,79,-14,17,-11,-16,-21,18,-7,48,10,13,-6,-9,-22,50,34,-32,61,-30,22,-62,7,-33,-22,-34,-9]],[[19634,60240,-26,17,-40,-2,-31,-23,0,-31,-24,-17,14,-35,38,-19,20,5,18,29,-11,33,55,-41,24,2,0,33,-32,30]],[[20284,59958,23,-4,15,9,3,14,74,4,48,14,-10,10,-98,15,-37,-21,-21,-22]],[[20256,60145,8,-27,-6,-10,-36,42,-24,-46,4,-16,64,-28,12,6,-9,20,29,36,-20,20]],[[20411,60260,-31,10,-21,-4,-12,-13,-2,-15,8,-9,30,12,5,-19,-7,-23,12,-2,48,48,-2,13,-11,8]],[[20583,60059,-38,-3,-44,-43,26,-8,23,14,37,-11,19,15,2,13,-7,3,17,3,-8,3]]]},{"name":"Albania","alt":["Republic of Albania"],"country":"Albania","iso":"ALB","iso2":"AL","type":"Country","polygons":[[[2Line truncated
|
||||
@@ -0,0 +1 @@
|
||||
{"meta":{"title":"Natural Earth admin-1 states and provinces","source":"ne_10m_admin_1_states_provinces","url":"https://raw.githubusercontent.com/nvkelso/natural-earth-vector/ca96624a56bd078437bca8184e78163e5039ad19/geojson/ne_10m_admin_1_states_provinces.geojson","sha256":"22d0e3ad85eb3e27f17cabf8ba2d50e554fbc27a87796ff891d958185da62fb5","ambiguitySource":"https://raw.githubusercontent.com/nvkelso/natural-earth-vector/ca96624a56bd078437bca8184e78163e5039ad19/geojson/ne_10m_populated_places_simple.geojson","ambiguitySha256":"fd3fa867a320cbd5c5b6bb5bc550afeec2939fb2cef688e508007282a55ac42f","commit":"ca96624a56bd078437bca8184e78163e5039ad19","license":"Public domain (https://www.naturalearthdata.com/about/terms-of-use/)","schema":"features[]: name, nameEn?, alt?[], postal?, type, rank? (min_label: the zoom at which Natural Earth labels the unit; lower is more prominent), country, iso2, iso (ISO 3166-2), amb?[] (normalized names that are also a country or a city — alone or with the country named), ambAbroad?[] (names shared only with a city in another country — ambiguous alone, settled by naming the country), cityState? (the unit is its own city), label? [lon, lat] label point, d? (decimals when not meta.decimals), polygons[][ring] (first ring outer, rest holes; each ring open and encoded as integers in 10^-d degrees, first vertex absolute then [dLon, dLat] deltas)","decimals":3,"curation":{"toleranceFactor":0.004,"minToleranceDeg":0.005,"maxToleranceDeg":0.02,"decimals":3,"minPartKm2":20,"minPartShare":0.0003,"minHoleKm2":20,"ambiguity":{"insideMinPopulation":200000,"elsewhereMinPopulation":50000,"cityStateMaxKm2":1000}},"script":"scripts/build-admin-packs.mjs"},"features":[{"name":"Badakhshan","alt":["Badahšan"],"postal":"BD","type":"Province","rank":6.7,"country":"Afghanistan","iso2":"AF","iso":"AF-BDS","label":[70.804,36.781],"polygons":[[[74892,37231,-29,14,-74,86,-129,63,-29,-13,-110,-5,-66,18,-77,0,-34,27,-23,-7,-5,13,-12,-27,-80,3,-16,-13,-2,-34,-18,-18,-352,-81,-38,-28,-52,2,-37,-14,-29,25,-62,-9,-16,8,-5,21,34,34,59,9,49,33,14,90,-78,3,-190,50,-44,-1,-63,-27,-82,12,-36,-15,-49,-42,-41,0,-38,-24,-64,-69,-72,-6,-94,-55,-111,-34,-133,-191,-149,-18,-34,-14,-69,11,-146,-41,-133,-94,-290,-174,-88,-20,-96,8,-42,18,-58,65,-25,86,-65,92,-3,63,-29,56,19,150,37,50,7,41,-7,101,24,77,-14,81,5,43,20,28,21,82,-13,41,8,18,52,37,8,82,-61,46,-36,2,-160,-53,-83,33,15,72,29,44,57,142,-1,67,-24,30,-116,45,-101,73,-67,10,-52,57,-47,7,-15,-40,-65,20,-28,-13,-66,6,-35,-27,-77,-14,20,-18,-44,-33,-41,-7,-17,-72,-36,-12,-77,-142,-55,-26,-131,-113,-30,-9,-4,-17,12,-17,-47,-10,-43,17,-11,-25,4,-31,31,-50,51,-21,28,-45,8,-69,-35,-82,-32,-6,-17,-38,-41,-38,-29,-9,-125,19,-5,-20,6,-123,37,-21,-24,-28,-14,-83,30,-88,-1,-53,91,-92,-28,-47,-40,-16,-19,-39,-30,-19,-3,-307,33,-100,1,-53,102,-57,84,-78,137,-23,80,0,31,-20,3,-134,-46,-112,33,-67,-161,-91,-28,-30,-19,-45,-23,-135,63,-9,90,37,95,-31,20,-45,56,-55,-19,-69,2,-39,6,-13,33,-4,58,24,16,45,36,26,43,-40,28,-8,106,-5,19,32,-17,84,12,26,82,16,54,57,-45,41,4,35,47,24,42,56,23,87,85,89,-4,15,42,42,15,37,79,38,17,38,84,30,76,70,79,27,-16,28,5,16,33,30,30,56,39,-5,87,-57,47,1,11,11,-19,40,-2,32,102,19,102,64,62,17,16,13,-5,26,9,8,113,27,-22,35,20,22,132,32,152,15,63,43,113,32,238,-3,78,22,45,-10,51,22,150,13,75,-10,65,15,177,-3,131,18,133,-5,92,-19,82,-42,88,-15,59,15,21,59,15,8,106,4,158,92,63,10,64,-46,16,4,10,54,-28,14,-42,53,-94,44,-17,21,2,19,89,10,43,55,90,11,33,-12,28,29,25,4,45,34,16,-2,57,-82]]]},{"name":"Badghis","alt":["Badghes","Badghisat","Badgis"],"postal":"BG","type":"Province","rank":6.7,"country":"Afghanistan","iso2":"AF","iso":"AF-BDG","label":[63.85,35.27],"polygons":[[[63125,35860,-21,-4,-15,-13,-5,-17,28,-56,72,-58,25,-8,5,-12,-34,-26,-77,-20,-26,-21,0,-66,24,-34,-21,-88,-13,-11,-82,-11,-67,-27,-95,-60,-39,-101,-84,-115,-28,-70,-20,-148,16,-51,49,-17,34,-1,69,-50,46,-14,47,-29,82,-6,31,-35,8,-45,77,-11,62,-24,71,34,116,-10,73,-22,99,-52,76,-13,42,10,77,-19,58,-30,227,53,67,45,45,10,26,39,52,24,107,0,57,-15,110,6,20,29,33,11,123,-4,57,-12,78,30,179,40,48,29,21,56,10,100,-46,76,-74,50,-57,58,-22,9,-32,-1,-49,25,-35,46,-116,-14,-102,12,-101,29,-5,17,6,79,13,53,-1,22,-8,7,-118,-20,-47,21,11,27,-15,22,-220,19,-24,15,-2,107,-12,37,-43,46,-51,18,-8,11,28,54,3,44,26,86,3,83,-11,30,-30,14,-16,-2,-124,-53,-184,-18,-71,-57,-81,-31,-87,-15]]]},{"name":"Baghlan","alt":["Baglan","Bughlan"],"postal":"BL","type":"Province","rank":6.7,"country":"Afghanistan","iso2":"AF","iso":"AF-BGL","amb":["baghlan"],"label":[68.983,35.859],"polygons":[[[69956,35887,-32,41,-3,19,-20,20,-32,67,-81,65,-10,115,-12,19,-54,8,-11,61,-20,48,-10,3,-56,-32,-92,-26,-79,-48,-99,-17,-23,9,-21,-11,-33,47,-35,8,-35,41,-117,31,-82,62,-84,16,-53,-20,-20,7,-43,28,-64,64,-12,22,-88,-13,-96,20,-217,7,-82,23,-38,-14,-19,-14,-10,-28,152,-201,46,-50,120,-67,31,-26,9,-18,-64,-80,-129,-85,-14,-26,-12,-108,-156,-177,-4,-35,42,-29,-5,-30,-36,-42,-10,-34,-54,-46,-3,-25,20,-60,-12,-32,7,-36,-40,-99,-31,-16,-20,-28,-5,Line truncated
|
||||
@@ -0,0 +1,23 @@
|
||||
# Military area names
|
||||
|
||||
This database is distributed under [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/), separately from the MIT code.
|
||||
© [OpenStreetMap contributors](https://www.openstreetmap.org/copyright).
|
||||
Distribution: [Overture Maps Foundation](https://docs.overturemaps.org/attribution/), base/land_use release **2026-09-23.1**.
|
||||
OSM snapshots: **2026-09-06**, with two named records from **2026-09-12**.
|
||||
|
||||
Selected named military areas; geometry simplified to interior label points,
|
||||
bounds and area, coordinates rounded and records re-encoded. Names are unchanged.
|
||||
Rows: `[typedOsmId, name, lon, lat, west, south, east, north, classIndex, areaM2]`.
|
||||
Labels use five decimal places; bounds use four. Area is measured on the WGS84
|
||||
ellipsoid. Class indices refer to `classes`. Output SHA-256: [`names.sha256`](names.sha256).
|
||||
|
||||
Rebuild (maintainer tool; requires Python and `duckdb==1.5.5`):
|
||||
|
||||
```sh
|
||||
python scripts/build-military-names.py --cache /tmp/military-names
|
||||
python scripts/build-military-names.py --cache /tmp/military-names --verify
|
||||
```
|
||||
|
||||
The pinned parquet file list and query are in `scripts/military-names-files.json`
|
||||
and `scripts/military-names.sql`. The first build downloads DuckDB extensions and
|
||||
scans source columns; subsequent builds reuse the cached selected rows.
|
||||
@@ -0,0 +1 @@
|
||||
{"release":"2026-09-23.1","snapshots":["2026-09-06","2026-09-12"],"classes":["military_land","airfield","naval_base","range","barracks","base","training_area"],"records":[["r122949","Scott Air Force Base",-89.85277,38.54384,-89.8799,38.5292,-89.8041,38.5588,1,12413875],["r130448","Fort Campbell",-87.61569,36.63134,-87.8047,36.5355,-87.4231,36.7268,0,419957267],["r133445","Fort George G Meade",-76.74626,39.10227,-76.7766,39.0732,-76.7092,39.131,0,18817854],["r133514","Joint Base Andrews",-76.87745,38.80571,-76.9034,38.7823,-76.8491,38.829,5,17812387],["r139794","Whiteman AFB",-93.56134,38.72992,-93.5959,38.7024,-93.5308,38.7575,1,19580209],["r139848","Fort Leonard Wood",-92.16933,37.70114,-92.2496,37.6052,-92.0411,37.798,0,248751778],["r147157","Camp du Valdahon",6.32589,47.18508,6.2731,47.151,6.375,47.2189,0,34786523],["r169792","Offutt Air Force Base",-95.90817,41.1172,-95.9664,41.0986,-95.8781,41.1364,1,11200098],["r170440","McGuire Air Force Base",-74.57822,40.03444,-74.6026,40.0267,-74.5593,40.0586,5,5388371],["r171293","Cannon AFB",-103.31633,34.38639,-103.3371,34.3653,-103.2955,34.4054,1,13976465],["r171528","White Sands",-106.49662,32.38378,-106.5139,32.3657,-106.4793,32.4031,0,8047394],["r175474","West Point",-74.00783,41.35584,-74.1043,41.3163,-73.9467,41.413,0,64474104],["r176921","Pope Field",-79.01404,35.17337,-79.0319,35.1587,-78.9888,35.1886,1,7327036],["r176940","Fort Bragg",-79.15415,35.14031,-79.3814,35.0306,-78.9447,35.2512,5,554150015],["r206824","Fort Belvoir",-77.14369,38.7108,-77.1739,38.6729,-77.1208,38.7486,0,23156083],["r237502","Joint Base Lewis-McChord",-122.50741,47.03083,-122.802,46.9015,-122.3698,47.1595,0,358289411],["r252043","Francis E. Warren Air Force Base",-104.86328,41.16587,-104.8859,41.1317,-104.8394,41.2012,1,23635348],["r253786","Naval Base Point Loma",-117.24939,32.6941,-117.2562,32.6648,-117.2341,32.7131,5,4741248],["r253814","Marine Corps Air Station Miramar",-117.08249,32.87638,-117.1986,32.8331,-116.9871,32.9208,0,87161025],["r308744","Schietveld",5.44866,51.04934,5.4361,51.0439,5.4672,51.0548,0,1200107],["r309689","Ādažu poligons",24.46976,57.15377,24.3461,57.0837,24.563,57.2226,6,133266211],["r309926","Roberts Army Heliport",-120.74307,35.81448,-120.7467,35.8091,-120.7404,35.8197,1,331999],["r317709","Naval Reservation San Clemente Island",-118.51541,32.92142,-118.6206,32.8004,-118.3487,33.0422,0,163093405],["r317710","Naval Air Warfare Center China Lake",-117.62946,35.75329,-117.8897,35.272,-116.9246,36.2318,0,4495435805],["r317712","Marine Corps Base Camp Pendleton",-117.42402,33.35453,-117.5963,33.2056,-117.2554,33.5036,0,507618719],["r317716","Camp Roberts",-120.77817,35.79079,-120.8594,35.6884,-120.6887,35.8934,6,173959948],["r317718","Camp San Luis Obispo",-120.69818,35.33796,-120.7635,35.3048,-120.6642,35.3716,0,23703563],["r379564","US Coast Guard - Base Portsmouth",-76.35878,36.88311,-76.3678,36.8801,-76.3518,36.8859,0,636553],["r383103","Patuxent River NAS (Trapnell Field)",-76.40977,38.28455,-76.4423,38.2641,-76.3817,38.3042,1,9064162],["r389913","Helmut-Schmidt-Universität – Universität der Bundeswehr Hamburg",10.11122,53.56877,10.1059,53.5663,10.1165,53.5709,4,255487],["r392208","Joint Expeditionary Base Little Creek - Fort Story East East",-76.01769,36.92062,-76.0464,36.9092,-75.9918,36.9323,0,6066899],["r392665","Naval Station Norfolk",-76.29734,36.94167,-76.3307,36.9193,-76.2646,36.964,0,13143152],["r416869","Naval Base Kitsap-Bremerton",-122.64202,47.5574,-122.6622,47.5511,-122.6257,47.5649,5,2487880],["r417668","Camp Murray",-122.56814,47.11741,-122.5809,47.111,-122.5577,47.1229,0,1105917],["r420625","Yakima Training Center",-120.18138,46.75205,-120.4667,46.5473,-119.9159,46.9563,0,1322423534],["r437075","Vojenský újezd Libavá",17.54728,49.67197,17.4068,49.5838,17.6984,49.7602,6,235507050],["r438525","Vojenský újezd Boletice",14.07264,48.8478,13.947,48.7872,14.2431,48.9085,6,165484736],["r439469","Vojenský újezd Hradiště",13.13,50.26224,13.0045,50.1629,13.2555,50.3615,6,280845474],["r440731","Vojenský újezd Březina",16.96557,49.40648,16.8378,49.313,17.0541,49.5,6,149643828],["r451539","Naval Magazine Indian Island",-122.72867,48.05303,-122.7481,48.0187,-122.7014,48.0872,0,10831143],["r451698","Naval Radio Station Jim Creek",-121.92018,48.2028,-121.9584,48.167,-121.8873,48.2249,0,19804554],["r454382","National Security Investigative Service",31.31037,30.05187,31.31,30.0508,31.3117,30.0528,0,14562],["r533411","Naval Outlying Landing Field Coupeville",-122.63203,48.18948,-122.6479,48.171,-122.6152,48.2067,1,2626295],["r533644","Eglin Air Force Base",-86.51396,30.5777,-86.9829,30.3814,-86.1073,30.7498,5,1873152593],["r534267","Saufley Field",-87.34183,30.46763,-87.3512,30.4582,-87.3321,30.4773,0,2666728],["r541791","Whidbey Island Naval Air Station Seaplane Base",-122.56089,48.28799,-122.6453,48.2676,-122.5559,48.3083,0,9647535],["r543980","U.S. Coast Guard - Base Seattle",-122.34035,47.58933,-122.343,47.5879,-122.3375,47.5908,Line truncated
|
||||
@@ -0,0 +1 @@
|
||||
74cc07f12c496a622f2f6c5a81922c293bca5eb44e0901c967bbf94e15ee514f names.json
|
||||
@@ -0,0 +1,45 @@
|
||||
# US Census Bureau counties pack
|
||||
|
||||
County and county-equivalent outlines for area annotations ("outline Travis
|
||||
County", "outline Orleans Parish"), read by `src/data/adminBoundaries.js`.
|
||||
|
||||
| File | Source dataset | Features |
|
||||
|------|----------------|----------|
|
||||
| `counties.json` | `cb_2025_us_county_5m` (cartographic boundary file, 1:5,000,000) | 3,235 |
|
||||
|
||||
**Source:** U.S. Census Bureau cartographic boundary files,
|
||||
<https://www2.census.gov/geo/tiger/GENZ2025/shp/cb_2025_us_county_5m.zip>
|
||||
(SHA-256 `faec522080681e79be5be435c981009a77891206ff8a7f1d142f3bf5da9ebd74`),
|
||||
fetched 2026-09-25. Covers the 50 states, the District of Columbia, Puerto
|
||||
Rico and the Island Areas (American Samoa, Guam, the Northern Mariana Islands,
|
||||
the US Virgin Islands).
|
||||
|
||||
**License:** public domain — a work of the U.S. Government (17 U.S.C. § 105).
|
||||
No attribution is required; the app credits the U.S. Census Bureau. See
|
||||
DATA_SOURCES.md.
|
||||
|
||||
**Regenerate:** `node scripts/build-admin-packs.mjs --only counties`
|
||||
downloads the pinned zip (to the system temp directory, or `--cache <dir>`),
|
||||
checks its SHA-256, reads the shapefile and rewrites the pack byte for byte.
|
||||
Parameters are in the script's `PARAMS` and in the pack's `meta.curation`.
|
||||
|
||||
**Schema:** `meta` (source, URL, hash, license, curation) and `features[]`
|
||||
with `name` (`NAME`), `full` (`NAMELSAD`, e.g. "Travis County"), `lsad` (the
|
||||
county word: County, Parish, Borough, Census Area, Municipality, city, …),
|
||||
`state` (`STATE_NAME`), `st` (`STUSPS`), `geoid` (`GEOID`) and `polygons`.
|
||||
`polygons` is a list of parts, largest first, each `[outer, ...holes]`; every
|
||||
ring is open and stored as integers in units of 10^-4 degrees, the first
|
||||
vertex absolute and each later one as a `[dLon, dLat]` delta.
|
||||
|
||||
**Curation:**
|
||||
|
||||
- Shapefile rings are grouped into parts (clockwise outers) and holes
|
||||
(counter-clockwise rings, assigned to the outer that contains them), so an
|
||||
independent city inside a county (Fairfax city in Fairfax County) is a hole.
|
||||
- Douglas-Peucker per ring at 0.003 × √(county area in km²) km, clamped to
|
||||
0.0005°–0.01° (Travis County ≈ 0.0014°); coordinates rounded to 4 decimals.
|
||||
- Parts under 1 km² or under 0.1% of the county's largest part are dropped;
|
||||
holes under 1 km² are dropped. Outers are counter-clockwise, holes
|
||||
clockwise.
|
||||
- Result: 1,541,464 bytes (533,326 with gzip -9); 3,524 parts, 18 holes,
|
||||
139,904 vertices.
|
||||
@@ -0,0 +1 @@
|
||||
{"meta":{"title":"US Census Bureau cartographic boundary counties","source":"cb_2025_us_county_5m","url":"https://www2.census.gov/geo/tiger/GENZ2025/shp/cb_2025_us_county_5m.zip","sha256":"faec522080681e79be5be435c981009a77891206ff8a7f1d142f3bf5da9ebd74","license":"Public domain (U.S. Government work, 17 U.S.C. § 105)","schema":"features[]: name, full (NAMELSAD), lsad? (County, Parish, Borough, …), state, st (USPS), geoid, d? (decimals when not meta.decimals), polygons[][ring] (first ring outer, rest holes; each ring open and encoded as integers in 10^-d degrees, first vertex absolute then [dLon, dLat] deltas)","decimals":4,"curation":{"toleranceFactor":0.003,"minToleranceDeg":0.0005,"maxToleranceDeg":0.01,"decimals":4,"minPartKm2":1,"minPartShare":0.001,"minHoleKm2":1},"script":"scripts/build-admin-packs.mjs"},"features":[{"name":"Autauga","full":"Autauga County","lsad":"County","state":"Alabama","st":"AL","geoid":"01001","polygons":[[[-869212,326575,103,-284,72,-457,50,-60,-44,-368,241,-533,189,-364,-66,-131,114,7,302,-128,-203,-103,16,-150,284,-296,80,-164,-135,-212,48,-232,178,-14,251,321,-90,296,8,222,314,-34,228,154,93,-17,-11,-307,77,-113,278,-74,279,227,22,211,354,85,45,-71,-13,-243,195,-130,134,137,340,-58,122,-288,156,-1,233,41,43,191,158,9,142,137,62,277,116,-60,335,101,-19,2975,-3011,-17,8,-440,-1032,-16,-1010,41]]]},{"name":"Baldwin","full":"Baldwin County","lsad":"County","state":"Alabama","st":"AL","geoid":"01003","polygons":[[[-880146,302224,523,71,1238,-23,379,22,651,113,786,90,988,247,398,60,-1,35,90,83,425,101,28,95,-216,-29,-72,29,-33,62,56,52,276,108,107,101,9,231,127,136,-20,107,111,150,69,36,192,1,78,74,245,129,46,61,-42,103,-291,41,-80,54,-68,9,-104,83,-45,50,-10,60,-50,39,50,106,-6,47,-85,108,-80,38,27,45,-16,122,112,220,75,50,19,60,73,17,20,45,-17,37,57,32,40,105,21,162,73,57,43,97,14,156,-26,195,-97,162,-11,85,-125,49,-109,86,-94,19,-116,93,-56,-23,-93,33,-81,58,-68,99,-211,51,-94,119,-209,100,-39,179,-97,106,11,66,-269,219,-99,119,-186,82,-53,107,-101,34,-14,56,-100,76,11,92,-87,88,54,142,87,74,-14,100,62,39,82,231,157,270,29,129,-96,330,-166,0,-5,2471,-163,-16,-442,191,-379,401,-509,-48,-64,-36,-240,90,-79,163,-55,-124,232,-237,3,-71,-160,-60,-167,88,-101,-33,-54,-162,-156,9,122,-127,-230,-119,-35,-93,151,-177,-114,-38,-99,112,-94,-178,-104,63,-166,-51,-74,-118,-330,55,53,-298,-316,-4,66,-144,262,-11,-65,-255,-296,-322,124,-212,151,-4,75,-197,-162,-188,69,-73,-143,-139,82,-184,-86,-220,216,-60,193,-264,-280,-105,-65,-209,-247,-204,-29,-117,54,-100,265,-125,96,-124,-18,-134,-404,-122,-35,-172,-261,-131,-55,-89,-41,-188,253,-551,-74,-133,272,-99,252,-162,193,-15,174,-213,68,-203,-25,-299,108,-199,25,-151,-94,-251,-223,-384,15,-63,119,-105,137,-612,413,-259,199,-61,79,-81,73,-155,206,-211,365,-210,173,-205,2,-139,-113,-149,-211,-82,-300,1,-424,-140,-192,-17,-212,29,-355,200,-130,-49,-178,-210,-572,-52,-53,-77]]]},{"name":"Barbour","full":"Barbour County","lsad":"County","state":"Alabama","st":"AL","geoid":"01005","polygons":[[[-857483,316180,1629,-4,1184,30,506,-11,4,872,1829,-36,236,74,35,313,406,182,111,139,256,-100,-78,251,53,263,-106,240,120,442,99,98,378,514,283,688,45,489,-1358,-7,-69,31,-5,112,-187,183,-179,84,-51,169,-256,189,21,98,-1525,-16,-172,-73,-24,-934,13,-310,-340,-6,-86,79,5,-81,-420,-1,2,-144,-749,-23,-55,-35,-120,-330,-155,-154,-285,-339,-90,-231,1,-82,-204,-243,98,-215,-58,-264,116,-235,-87,-237,-301,-199,-77,-293,33,-29,-122,-108,18,-375,-253,-409]]]},{"name":"Bibb","full":"Bibb County","lsad":"County","state":"Alabama","st":"AL","geoid":"01007","polygons":[[[-874212,328745,1017,6,3,-436,859,7,208,30,1933,18,1431,-7,-8,2136,-216,-1,-178,98,-177,45,0,147,-177,-1,8,199,-184,-2,-3,597,-560,77,0,133,43,123,-55,551,-498,3,-275,-253,-174,2,-4,141,-336,-60,-437,-332,-1,-659,-826,24,-297,-392,-69,-877,-1034,-28]]]},{"name":"Blount","full":"Blount County","lsad":"County","state":"Alabama","st":"AL","geoid":"01009","polygons":[[[-869634,338582,91,-133,6,-296,172,0,526,279,1248,-26,1138,-676,-25,-71,700,-6,3,367,557,-1,93,89,637,132,186,-45,898,712,-75,205,256,210,-33,79,-446,-3,309,317,66,146,3,604,97,257,192,269,-102,127,-953,932,-361,445,10,96,-418,11,-138,-112,-152,54,-85,-271,-160,-13,-126,-200,67,-199,-287,-207,-207,-300,-64,-164,-230,32,-293,-150,-34,-156,-298,-120,-18,130,-158,-111,200,-260,-27,-35,-91,96,-135,-139,78,-121,-189,-36,-23,-123,-263,-221,-109,-17,-39,-217,-277,-106,-3,-112,-281,51,-12,-160,-149,91,-319,-179,-181,3,2,-156,-393,-93,16,-359,-392,-46]]]},{"name":"Bullock","full":"Bullock County","lsad":"County","state":"Alabama","st":"AL","geoid":"01011","polygons":[[[-859969,320510,466,36,-43,72,83,-72,345,2,181,-74,10,-583,84,-218,943,0,-10,-869,1333,-1,-1,82,90,231,285,339,155,154,120,330,55,35,749,23,-2,144,420,1,-5,81,86,-79,340,6,-13,310,24,934,172,73,-172,-1,-61,880,-4215,-26,-211,143,-18,452,-185,137,-23,-302,-879,-27,1,-217,-127,-1]]]},{"name":"Butler","full":"Butler County","lsad":"County","state":"Alabama","st":"AL","geoid":"01013","polygonLine truncated
|
||||
@@ -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,
|
||||
@@ -476,7 +489,7 @@ test('pending mapped installations render as unknown instead of a false zero', a
|
||||
// the dependencies reach it by different routes:
|
||||
//
|
||||
// - ais-live-vessels is its REACHABLE producer. enable()/update() both resolve
|
||||
// as soon as the first /api/ais-live poll answers, so the manager's
|
||||
// as soon as the first /api/vessels poll answers, so the manager's
|
||||
// lifecycle settles to `enabled` — but until the server-side socket delivers
|
||||
// a position, firstConnectPhase stays 'loading' and the module reports busy,
|
||||
// no lastUpdate, count 0, and an UNDEFINED status, so the status list alone
|
||||
@@ -1631,6 +1644,20 @@ test('installation summaries disclose viewport-scoped coverage', () => {
|
||||
assert.equal(retrying.reason, 'Overpass temporarily unavailable — retrying in 30s');
|
||||
});
|
||||
|
||||
test('installation summaries name a subject window instead of the viewport', () => {
|
||||
const stats = { coverage: { kind: 'subject', radiusM: 100_000 } };
|
||||
const empty = summarizeInstallationViewport([], { available: true, stale: false, stats });
|
||||
assert.equal(empty.count, 0);
|
||||
assert.match(empty.reason, /none mapped within 100 km/);
|
||||
assert.match(empty.reason, /not a complete 250 km survey/);
|
||||
const found = summarizeInstallationViewport(
|
||||
[{ id: 'ofm:installation:1', label: 'Military area', distanceM: 1200 }],
|
||||
{ available: true, stale: false, stats },
|
||||
);
|
||||
assert.equal(found.count, 1);
|
||||
assert.equal(found.reason, 'mapped matches within 100 km of the subject');
|
||||
});
|
||||
|
||||
test('compact Context snapshots retain installation coverage', () => {
|
||||
const snapshot = buildAwarenessContextSnapshot({
|
||||
subject: { layerId: 'flights', id: 'abc' },
|
||||
@@ -2540,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.
|
||||
@@ -2547,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);
|
||||
@@ -3060,3 +3139,27 @@ test('a moving camera refreshes Contacts more than once inside one parked interv
|
||||
restoreCollections();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
test('a selected installation card keeps every member name and escapes source text', () => {
|
||||
const runtime = installAwarenessRuntime();
|
||||
const restores = [];
|
||||
try {
|
||||
for (const layer of [aisLiveVesselsLayer, flightsLayer, militaryFlightsLayer, militaryInstallationsLayer])
|
||||
replaceMethod(layer, 'getNearby', () => [], restores);
|
||||
militaryAwarenessLayer.setParams({ passive: false });
|
||||
runtime.dispatch('gev:entity-selected', {
|
||||
layerId: 'military-installations', id: 'osm:military:w1', label: 'Parent base',
|
||||
latitude: 31.13, longitude: -97.78,
|
||||
properties: { memberNames: ['Area one', 'Area two', 'Area three', '<Area four>'] },
|
||||
});
|
||||
militaryAwarenessLayer.update();
|
||||
const html = runtime.panel().innerHTML;
|
||||
assert.match(html, /Named areas \(4\)/);
|
||||
for (const name of ['Area one', 'Area two', 'Area three', '<Area four>']) assert.ok(html.includes(name));
|
||||
assert.ok(!html.includes('<Area four>'));
|
||||
} finally {
|
||||
restores.reverse().forEach((restore) => restore());
|
||||
runtime.restore();
|
||||
}
|
||||
});
|
||||
@@ -1,12 +1,12 @@
|
||||
import { defaultSurface } from './surfaceServices.js';
|
||||
import { createApplicationMilitary } from '../app/layers/militaryFlights.js';
|
||||
|
||||
import { createAdsbLolSource } from '../sources/live/standalone.js';
|
||||
import { createMilitarySource } from '../sources/live/standalone.js';
|
||||
import * as militaryRegistry from './militaryRegistry.js';
|
||||
|
||||
const militaryFlightsLayer = createApplicationMilitary({
|
||||
surface: defaultSurface,
|
||||
source: createAdsbLolSource(),
|
||||
source: createMilitarySource(),
|
||||
militaryRegistry,
|
||||
});
|
||||
export { TRACKED_MODEL_MAX_PX } from '../layers/military/policy.js';
|
||||
|
||||
+327
-102
@@ -24,8 +24,12 @@ test('share-Follow absence requires an accepted adsb.lol snapshot', async () =>
|
||||
(await militaryFlightsLayer.resolveTrackingRestoreTarget('ae1234')).status,
|
||||
'source-unavailable',
|
||||
);
|
||||
_setMilitaryTrackingRefreshOutcomeForTest({ status: 'accepted', ids: ['different'] });
|
||||
const missing = await militaryFlightsLayer.resolveTrackingRestoreTarget('ae1234');
|
||||
_setMilitaryTrackingRefreshOutcomeForTest({
|
||||
status: 'accepted',
|
||||
ids: ['different'],
|
||||
});
|
||||
const missing =
|
||||
await militaryFlightsLayer.resolveTrackingRestoreTarget('ae1234');
|
||||
assert.equal(missing.status, 'missing');
|
||||
assert.equal(missing.reason, 'target-absent-from-snapshot');
|
||||
});
|
||||
@@ -44,9 +48,9 @@ const FULL_INFO = {
|
||||
operator: 'United States Air Force',
|
||||
};
|
||||
|
||||
test('military stats identify adsb.lol as the primary feed, not a fallback', () => {
|
||||
test('military stats name the feed until the server names its provider, not a fallback', () => {
|
||||
const stats = militaryFlightsLayer.getStats();
|
||||
assert.equal(stats.source, 'adsb.lol');
|
||||
assert.equal(stats.source, 'Military aircraft');
|
||||
assert.equal(stats.fallback, false);
|
||||
});
|
||||
|
||||
@@ -81,8 +85,14 @@ test('military analyst record: military is ALWAYS true, routes/country always nu
|
||||
});
|
||||
|
||||
test('military analyst record: no callsign falls back to registration, then icao24', () => {
|
||||
assert.equal(mapAnalystRecord('ae01ce', { ...FULL_INFO, callsign: '' }).id, '05-8152');
|
||||
assert.equal(mapAnalystRecord('ae01ce', { callsign: '', registration: ' ' }).id, 'ae01ce');
|
||||
assert.equal(
|
||||
mapAnalystRecord('ae01ce', { ...FULL_INFO, callsign: '' }).id,
|
||||
'05-8152',
|
||||
);
|
||||
assert.equal(
|
||||
mapAnalystRecord('ae01ce', { callsign: '', registration: ' ' }).id,
|
||||
'ae01ce',
|
||||
);
|
||||
});
|
||||
|
||||
test('military analyst record: empty info yields nulls, never NaN/undefined', () => {
|
||||
@@ -91,7 +101,8 @@ test('military analyst record: empty info yields nulls, never NaN/undefined', ()
|
||||
assert.equal(r.onGround, false);
|
||||
for (const [key, value] of Object.entries(r)) {
|
||||
assert.notEqual(value, undefined, `${key} must not be undefined`);
|
||||
if (typeof value === 'number') assert.ok(Number.isFinite(value), `${key} must not be NaN`);
|
||||
if (typeof value === 'number')
|
||||
assert.ok(Number.isFinite(value), `${key} must not be NaN`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -103,12 +114,17 @@ test('military analyst record: output is JSON-safe (no Cesium types)', () => {
|
||||
test('military first update forwards caller cancellation into the feed request', async () => {
|
||||
const realFetch = globalThis.fetch;
|
||||
let observedSignal = null;
|
||||
globalThis.fetch = (_url, options = {}) => new Promise((_resolve, reject) => {
|
||||
observedSignal = options.signal;
|
||||
options.signal?.addEventListener('abort', () => {
|
||||
reject(new DOMException('aborted', 'AbortError'));
|
||||
}, { once: true });
|
||||
});
|
||||
globalThis.fetch = (_url, options = {}) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
observedSignal = options.signal;
|
||||
options.signal?.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
reject(new DOMException('aborted', 'AbortError'));
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const work = militaryFlightsLayer.update({}, { signal: controller.signal });
|
||||
@@ -138,11 +154,22 @@ test('nonempty adsb.lol payload with zero usable rows cannot prove share target
|
||||
json: async () => ({ ac: [null, {}, { hex: 'ae1234' }] }),
|
||||
});
|
||||
try {
|
||||
await militaryFlightsLayer.update({ camera: { positionCartographic: null }, scene: {} });
|
||||
const resolution = await militaryFlightsLayer.resolveTrackingRestoreTarget('ae1234');
|
||||
await militaryFlightsLayer.update({
|
||||
camera: { positionCartographic: null },
|
||||
scene: {},
|
||||
});
|
||||
const resolution =
|
||||
await militaryFlightsLayer.resolveTrackingRestoreTarget('ae1234');
|
||||
assert.equal(resolution.status, 'source-unavailable');
|
||||
assert.match(militaryFlightsLayer.getStats().error, /Malformed adsb\.lol aircraft rows/);
|
||||
assert.equal(militaryFlightsLayer.getAnalystRecords().length, 1, 'warm aircraft data is preserved');
|
||||
assert.match(
|
||||
militaryFlightsLayer.getStats().error,
|
||||
/Malformed adsb\.lol aircraft rows/,
|
||||
);
|
||||
assert.equal(
|
||||
militaryFlightsLayer.getAnalystRecords().length,
|
||||
1,
|
||||
'warm aircraft data is preserved',
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = realFetch;
|
||||
}
|
||||
@@ -188,21 +215,26 @@ test('military poll refreshes tracked callsign/altitude/kts and marks a missed p
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
ac: poll++ === 0 ? [{
|
||||
hex: icao24,
|
||||
lon: -96.9,
|
||||
lat: 31.1,
|
||||
alt_baro: 28_000,
|
||||
alt_geom: 28_100,
|
||||
track: 95,
|
||||
gs: 400,
|
||||
seen: 0,
|
||||
seen_pos: 0,
|
||||
flight: 'RCH451 ',
|
||||
t: 'C17',
|
||||
r: '05-8152',
|
||||
ownOp: 'United States Air Force',
|
||||
}] : [],
|
||||
ac:
|
||||
poll++ === 0
|
||||
? [
|
||||
{
|
||||
hex: icao24,
|
||||
lon: -96.9,
|
||||
lat: 31.1,
|
||||
alt_baro: 28_000,
|
||||
alt_geom: 28_100,
|
||||
track: 95,
|
||||
gs: 400,
|
||||
seen: 0,
|
||||
seen_pos: 0,
|
||||
flight: 'RCH451 ',
|
||||
t: 'C17',
|
||||
r: '05-8152',
|
||||
ownOp: 'United States Air Force',
|
||||
},
|
||||
]
|
||||
: [],
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -227,7 +259,10 @@ test('cached military snapshots retain their observation time and expose stale f
|
||||
_setTrackedMilitaryRefreshStateForTest({
|
||||
icao24: 'ae01ce',
|
||||
entity: { gevLabelModel: { title: 'RCH451', details: [] } },
|
||||
billboard: { position: Cesium.Cartesian3.fromDegrees(-97, 31, 8000), show: false },
|
||||
billboard: {
|
||||
position: Cesium.Cartesian3.fromDegrees(-97, 31, 8000),
|
||||
show: false,
|
||||
},
|
||||
billboardCollection: { show: false, remove() {} },
|
||||
viewer,
|
||||
history,
|
||||
@@ -235,21 +270,48 @@ test('cached military snapshots retain their observation time and expose stale f
|
||||
});
|
||||
let cache = 'MISS';
|
||||
t.mock.method(Date, 'now', () => now);
|
||||
t.mock.method(globalThis, 'fetch', async () => Response.json({
|
||||
ac: [{ hex: 'ae01ce', lon: -97, lat: 31, alt_baro: 28000,
|
||||
track: 95, gs: 400, seen: 1, seen_pos: 2, flight: 'RCH451' }],
|
||||
}, { headers: {
|
||||
'X-ADS-B-Cache': cache,
|
||||
...(cache === 'MISS' ? {} : { 'X-ADS-B-Cache-Age-Ms': String(now - receivedAt) }),
|
||||
} }));
|
||||
t.mock.method(globalThis, 'fetch', async () =>
|
||||
Response.json(
|
||||
{
|
||||
ac: [
|
||||
{
|
||||
hex: 'ae01ce',
|
||||
lon: -97,
|
||||
lat: 31,
|
||||
alt_baro: 28000,
|
||||
track: 95,
|
||||
gs: 400,
|
||||
seen: 1,
|
||||
seen_pos: 2,
|
||||
flight: 'RCH451',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'X-Feed-Cache': cache,
|
||||
...(cache === 'MISS'
|
||||
? {}
|
||||
: { 'X-Feed-Age-Ms': String(now - receivedAt) }),
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
await militaryFlightsLayer.update(viewer);
|
||||
assert.equal(history.length, 1);
|
||||
assert.equal(history[0].epochMs, receivedAt - 2000);
|
||||
for (const [status, age] of [['HIT', 5000], ['STALE', 45000]]) {
|
||||
for (const [status, age] of [
|
||||
['HIT', 5000],
|
||||
['STALE', 45000],
|
||||
]) {
|
||||
cache = status;
|
||||
now = receivedAt + age;
|
||||
await militaryFlightsLayer.update(viewer);
|
||||
assert.equal(history.length, 1, 'replayed positions cannot create a new fix');
|
||||
assert.equal(
|
||||
history.length,
|
||||
1,
|
||||
'replayed positions cannot create a new fix',
|
||||
);
|
||||
assert.equal(militaryFlightsLayer.getStats().lastUpdate, receivedAt);
|
||||
assert.equal(militaryFlightsLayer.getStats().stale, status === 'STALE');
|
||||
assert.equal(militaryFlightsLayer.getStats().error, null);
|
||||
@@ -276,14 +338,18 @@ test('real military track path creates no native label and publishes every cache
|
||||
trackedEntityChanged,
|
||||
clock: { currentTime: now },
|
||||
camera: {
|
||||
cancelFlight() { cancelledFlights += 1; },
|
||||
cancelFlight() {
|
||||
cancelledFlights += 1;
|
||||
},
|
||||
position: new Cesium.Cartesian3(0, -10_000, 7_000),
|
||||
positionWC: new Cesium.Cartesian3(0, -10_000, 7_000),
|
||||
direction: Cesium.Cartesian3.UNIT_Y,
|
||||
transform: Cesium.Matrix4.IDENTITY,
|
||||
viewMatrix: Cesium.Matrix4.IDENTITY,
|
||||
frustum: { projectionMatrix: Cesium.Matrix4.IDENTITY },
|
||||
lookAtTransform() { appliedFrames += 1; },
|
||||
lookAtTransform() {
|
||||
appliedFrames += 1;
|
||||
},
|
||||
},
|
||||
scene: {
|
||||
canvas: { clientWidth: 1600, clientHeight: 900 },
|
||||
@@ -322,9 +388,12 @@ test('real military track path creates no native label and publishes every cache
|
||||
const realFetch = globalThis.fetch;
|
||||
globalThis.window = new EventTarget();
|
||||
const selectionEvents = [];
|
||||
globalThis.window.addEventListener('gev:awareness-subject-selected', (event) => {
|
||||
selectionEvents.push(event.detail);
|
||||
});
|
||||
globalThis.window.addEventListener(
|
||||
'gev:awareness-subject-selected',
|
||||
(event) => {
|
||||
selectionEvents.push(event.detail);
|
||||
},
|
||||
);
|
||||
globalThis.fetch = async () => ({ ok: false });
|
||||
_setTrackedOverlayHostForTest(host);
|
||||
try {
|
||||
@@ -336,13 +405,15 @@ test('real military track path creates no native label and publishes every cache
|
||||
billboardCollection: { show: true, remove() {} },
|
||||
viewer,
|
||||
tracked: false,
|
||||
history: [{
|
||||
time: now,
|
||||
epochMs: Date.now(),
|
||||
position,
|
||||
velocity: 231.5,
|
||||
track: 92.1,
|
||||
}],
|
||||
history: [
|
||||
{
|
||||
time: now,
|
||||
epochMs: Date.now(),
|
||||
position,
|
||||
velocity: 231.5,
|
||||
track: 92.1,
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
...FULL_INFO,
|
||||
type: 'C17',
|
||||
@@ -352,34 +423,72 @@ test('real military track path creates no native label and publishes every cache
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(militaryFlightsLayer.trackById(icao24, { origin: 'programmatic' }), true);
|
||||
assert.equal(
|
||||
militaryFlightsLayer.trackById(icao24, { origin: 'programmatic' }),
|
||||
true,
|
||||
);
|
||||
const entity = viewer.trackedEntity;
|
||||
assert.ok(entity instanceof Cesium.Entity, 'trackById must create the real Cesium entity');
|
||||
assert.ok(
|
||||
entity instanceof Cesium.Entity,
|
||||
'trackById must create the real Cesium entity',
|
||||
);
|
||||
assert.equal(entity.label, undefined);
|
||||
assert.ok(entities.values.every((candidate) => candidate.label === undefined));
|
||||
assert.ok(
|
||||
entities.values.every((candidate) => candidate.label === undefined),
|
||||
);
|
||||
assert.deepEqual(entity.gevLabelModel, {
|
||||
title: 'RCH451',
|
||||
details: [
|
||||
'C17 · 05-8152',
|
||||
'United States Air Force · 28000 ft · 450 kt',
|
||||
],
|
||||
details: ['C17 · 05-8152', 'United States Air Force · 28000 ft · 450 kt'],
|
||||
accent: '#ffd166',
|
||||
});
|
||||
viewer.scene.preUpdate.raiseEvent();
|
||||
const initialAppliedFrames = appliedFrames;
|
||||
const initialCancelledFlights = cancelledFlights;
|
||||
assert.equal(militaryFlightsLayer.trackById(icao24, { origin: 'user' }), true);
|
||||
assert.equal(cancelledFlights, initialCancelledFlights, 'ordinary repeated tracking stays camera-idempotent');
|
||||
assert.equal(selectionEvents.at(-1)?.origin, 'user', 'same-target selection upgrades durable authority');
|
||||
assert.equal(
|
||||
militaryFlightsLayer.trackById(icao24, { origin: 'user' }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
cancelledFlights,
|
||||
initialCancelledFlights,
|
||||
'ordinary repeated tracking stays camera-idempotent',
|
||||
);
|
||||
assert.equal(
|
||||
selectionEvents.at(-1)?.origin,
|
||||
'user',
|
||||
'same-target selection upgrades durable authority',
|
||||
);
|
||||
assert.equal(entity.gevSelectionOrigin, 'user');
|
||||
assert.equal(militaryFlightsLayer.refocusTrackedById('different-flight'), false);
|
||||
assert.equal(militaryFlightsLayer.refocusTrackedById(icao24, { origin: 'voice' }), true);
|
||||
assert.equal(selectionEvents.at(-1)?.origin, 'voice', 'same-target refocus forwards explicit authority');
|
||||
assert.equal(
|
||||
militaryFlightsLayer.refocusTrackedById('different-flight'),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
militaryFlightsLayer.refocusTrackedById(icao24, { origin: 'voice' }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
selectionEvents.at(-1)?.origin,
|
||||
'voice',
|
||||
'same-target refocus forwards explicit authority',
|
||||
);
|
||||
assert.equal(militaryFlightsLayer.refocusTrackedById(icao24), true);
|
||||
viewer.scene.preUpdate.raiseEvent();
|
||||
assert.equal(viewer.trackedEntity, entity, 'refocus must retain the exact tracked entity');
|
||||
assert.equal(appliedFrames, initialAppliedFrames + 1, 'repeated refocus keeps one camera-frame owner');
|
||||
assert.equal(cancelledFlights, initialCancelledFlights + 2, 'only explicit refocus requests cancel camera flights');
|
||||
assert.equal(
|
||||
viewer.trackedEntity,
|
||||
entity,
|
||||
'refocus must retain the exact tracked entity',
|
||||
);
|
||||
assert.equal(
|
||||
appliedFrames,
|
||||
initialAppliedFrames + 1,
|
||||
'repeated refocus keeps one camera-frame owner',
|
||||
);
|
||||
assert.equal(
|
||||
cancelledFlights,
|
||||
initialCancelledFlights + 2,
|
||||
'only explicit refocus requests cancel camera flights',
|
||||
);
|
||||
const publication = publications.at(-1);
|
||||
assert.equal(publication.sourceId, 'tracked');
|
||||
const entry = publication.entries[0];
|
||||
@@ -390,23 +499,40 @@ test('real military track path creates no native label and publishes every cache
|
||||
|
||||
const display = entity.position.getValue(now);
|
||||
assert.ok(display, 'tracked position callback must seed its frame cache');
|
||||
assert.equal(entry.position(), display, 'host must reuse the exact cached display Cartesian');
|
||||
assert.equal(
|
||||
entry.position(),
|
||||
display,
|
||||
'host must reuse the exact cached display Cartesian',
|
||||
);
|
||||
|
||||
militaryFlightsLayer.setParams(
|
||||
{ selectedMilitaryTrackingId: 'late001' },
|
||||
{ origin: 'share-restore' },
|
||||
);
|
||||
assert.equal(_pendingMilitaryTrackingRestoreForTest(), 'late001');
|
||||
assert.equal(militaryFlightsLayer.trackById(icao24, { origin: 'user' }), true);
|
||||
assert.equal(_pendingMilitaryTrackingRestoreForTest(), null, 'new user selection cancels stale restore');
|
||||
assert.equal(
|
||||
militaryFlightsLayer.trackById(icao24, { origin: 'user' }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
_pendingMilitaryTrackingRestoreForTest(),
|
||||
null,
|
||||
'new user selection cancels stale restore',
|
||||
);
|
||||
_addMilitaryTrackingCandidateForTest({
|
||||
icao24: 'late001',
|
||||
billboard: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-96.9, 31.1, 8_000) },
|
||||
billboard: {
|
||||
...billboard,
|
||||
position: Cesium.Cartesian3.fromDegrees(-96.9, 31.1, 8_000),
|
||||
},
|
||||
meta: { ...FULL_INFO, callsign: 'LATE1', rawLat: 31.1, rawLon: -96.9 },
|
||||
history: [],
|
||||
});
|
||||
_applyPendingMilitaryTrackingRestoreForTest();
|
||||
assert.equal(militaryFlightsLayer.getParams().selectedMilitaryTrackingId, icao24);
|
||||
assert.equal(
|
||||
militaryFlightsLayer.getParams().selectedMilitaryTrackingId,
|
||||
icao24,
|
||||
);
|
||||
|
||||
militaryFlightsLayer.stopTracking();
|
||||
militaryFlightsLayer.setParams(
|
||||
@@ -415,15 +541,25 @@ test('real military track path creates no native label and publishes every cache
|
||||
);
|
||||
assert.equal(_pendingMilitaryTrackingRestoreForTest(), 'late002');
|
||||
militaryFlightsLayer.stopTracking({ origin: 'user' });
|
||||
assert.equal(_pendingMilitaryTrackingRestoreForTest(), null, 'explicit clear cancels stale restore');
|
||||
assert.equal(
|
||||
_pendingMilitaryTrackingRestoreForTest(),
|
||||
null,
|
||||
'explicit clear cancels stale restore',
|
||||
);
|
||||
_addMilitaryTrackingCandidateForTest({
|
||||
icao24: 'late002',
|
||||
billboard: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-96.8, 31.2, 7_000) },
|
||||
billboard: {
|
||||
...billboard,
|
||||
position: Cesium.Cartesian3.fromDegrees(-96.8, 31.2, 7_000),
|
||||
},
|
||||
meta: { ...FULL_INFO, callsign: 'LATE2', rawLat: 31.2, rawLon: -96.8 },
|
||||
history: [],
|
||||
});
|
||||
_applyPendingMilitaryTrackingRestoreForTest();
|
||||
assert.equal(militaryFlightsLayer.getParams().selectedMilitaryTrackingId, null);
|
||||
assert.equal(
|
||||
militaryFlightsLayer.getParams().selectedMilitaryTrackingId,
|
||||
null,
|
||||
);
|
||||
|
||||
militaryFlightsLayer.setParams(
|
||||
{ selectedMilitaryTrackingId: 'late003' },
|
||||
@@ -438,12 +574,18 @@ test('real military track path creates no native label and publishes every cache
|
||||
);
|
||||
_addMilitaryTrackingCandidateForTest({
|
||||
icao24: 'late003',
|
||||
billboard: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-97.3, 30.6, 7_000) },
|
||||
billboard: {
|
||||
...billboard,
|
||||
position: Cesium.Cartesian3.fromDegrees(-97.3, 30.6, 7_000),
|
||||
},
|
||||
meta: { ...FULL_INFO, callsign: 'LATE3', rawLat: 30.6, rawLon: -97.3 },
|
||||
history: [],
|
||||
});
|
||||
_applyPendingMilitaryTrackingRestoreForTest();
|
||||
assert.equal(militaryFlightsLayer.getParams().selectedMilitaryTrackingId, null);
|
||||
assert.equal(
|
||||
militaryFlightsLayer.getParams().selectedMilitaryTrackingId,
|
||||
null,
|
||||
);
|
||||
|
||||
militaryFlightsLayer.setParams(
|
||||
{ selectedMilitaryTrackingId: 'late004' },
|
||||
@@ -452,18 +594,27 @@ test('real military track path creates no native label and publishes every cache
|
||||
assert.equal(_pendingMilitaryTrackingRestoreForTest(), 'late004');
|
||||
_addMilitaryTrackingCandidateForTest({
|
||||
icao24: 'late004',
|
||||
billboard: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-97.2, 30.7, 6_000) },
|
||||
billboard: {
|
||||
...billboard,
|
||||
position: Cesium.Cartesian3.fromDegrees(-97.2, 30.7, 6_000),
|
||||
},
|
||||
meta: { ...FULL_INFO, callsign: 'LATE4', rawLat: 30.7, rawLon: -97.2 },
|
||||
history: [],
|
||||
});
|
||||
assert.equal(_applyPendingMilitaryTrackingRestoreForTest(), true);
|
||||
assert.equal(militaryFlightsLayer.getParams().selectedMilitaryTrackingId, 'late004');
|
||||
assert.equal(
|
||||
militaryFlightsLayer.getParams().selectedMilitaryTrackingId,
|
||||
'late004',
|
||||
);
|
||||
|
||||
militaryFlightsLayer.setParams(
|
||||
{ selectedMilitaryTrackingId: 'late005' },
|
||||
{ origin: 'local-restore' },
|
||||
);
|
||||
assert.equal(militaryFlightsLayer.trackById(icao24, { origin: 'programmatic' }), true);
|
||||
assert.equal(
|
||||
militaryFlightsLayer.trackById(icao24, { origin: 'programmatic' }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
_pendingMilitaryTrackingRestoreForTest(),
|
||||
'late005',
|
||||
@@ -471,12 +622,18 @@ test('real military track path creates no native label and publishes every cache
|
||||
);
|
||||
_addMilitaryTrackingCandidateForTest({
|
||||
icao24: 'late005',
|
||||
billboard: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-96.6, 31.4, 5_000) },
|
||||
billboard: {
|
||||
...billboard,
|
||||
position: Cesium.Cartesian3.fromDegrees(-96.6, 31.4, 5_000),
|
||||
},
|
||||
meta: { ...FULL_INFO, callsign: 'LATE5', rawLat: 31.4, rawLon: -96.6 },
|
||||
history: [],
|
||||
});
|
||||
assert.equal(_applyPendingMilitaryTrackingRestoreForTest(), true);
|
||||
assert.equal(militaryFlightsLayer.getParams().selectedMilitaryTrackingId, 'late005');
|
||||
assert.equal(
|
||||
militaryFlightsLayer.getParams().selectedMilitaryTrackingId,
|
||||
'late005',
|
||||
);
|
||||
} finally {
|
||||
militaryFlightsLayer.stopTracking();
|
||||
viewer.scene.preUpdate.raiseEvent();
|
||||
@@ -521,21 +678,47 @@ test('a callsign-less contact is findable by the tail number the app displays',
|
||||
|
||||
// The identity analyst_query hands back for this contact.
|
||||
const analystId = mapAnalystRecord(icao24, {
|
||||
callsign: null, registration: '6606', rawLat: 40.71, rawLon: -74.01,
|
||||
callsign: null,
|
||||
registration: '6606',
|
||||
rawLat: 40.71,
|
||||
rawLon: -74.01,
|
||||
}).id;
|
||||
assert.equal(analystId, '6606', 'the analyst names a callsign-less contact by its tail');
|
||||
assert.equal(
|
||||
analystId,
|
||||
'6606',
|
||||
'the analyst names a callsign-less contact by its tail',
|
||||
);
|
||||
|
||||
const found = militaryFlightsLayer.findByQuery(analystId);
|
||||
assert.equal(found?.icao24, icao24, 'and the tracker must resolve that same identity');
|
||||
assert.equal(militaryFlightsLayer.findByQuery('660')?.icao24, icao24, 'prefix match too');
|
||||
assert.equal(militaryFlightsLayer.findByQuery(icao24)?.icao24, icao24, 'hex still wins outright');
|
||||
assert.equal(
|
||||
found?.icao24,
|
||||
icao24,
|
||||
'and the tracker must resolve that same identity',
|
||||
);
|
||||
assert.equal(
|
||||
militaryFlightsLayer.findByQuery('660')?.icao24,
|
||||
icao24,
|
||||
'prefix match too',
|
||||
);
|
||||
assert.equal(
|
||||
militaryFlightsLayer.findByQuery(icao24)?.icao24,
|
||||
icao24,
|
||||
'hex still wins outright',
|
||||
);
|
||||
});
|
||||
|
||||
test('the analyst record carries the hex key the tracker keys on', () => {
|
||||
const record = mapAnalystRecord('ae7f01', {
|
||||
callsign: null, registration: '6606', rawLat: 40.71, rawLon: -74.01,
|
||||
callsign: null,
|
||||
registration: '6606',
|
||||
rawLat: 40.71,
|
||||
rawLon: -74.01,
|
||||
});
|
||||
assert.equal(record.icao24, 'ae7f01', 'a resolvable key must ride along with the display label');
|
||||
assert.equal(
|
||||
record.icao24,
|
||||
'ae7f01',
|
||||
'a resolvable key must ride along with the display label',
|
||||
);
|
||||
});
|
||||
|
||||
test('a registration never outranks another contact’s exact callsign', () => {
|
||||
@@ -548,18 +731,35 @@ test('a registration never outranks another contact’s exact callsign', () => {
|
||||
show: true,
|
||||
});
|
||||
const base = {
|
||||
type: 'C17', operator: 'USAF', altitudeFt: 25000, speedMps: 180,
|
||||
track: 90, klass: 'widebody', onGround: false,
|
||||
type: 'C17',
|
||||
operator: 'USAF',
|
||||
altitudeFt: 25000,
|
||||
speedMps: 180,
|
||||
track: 90,
|
||||
klass: 'widebody',
|
||||
onGround: false,
|
||||
};
|
||||
// Registered FIRST, so insertion order favours it.
|
||||
_addMilitaryTrackingCandidateForTest({
|
||||
icao24: 'ae0aa1',
|
||||
meta: { ...base, callsign: null, registration: 'ZULU777', rawLat: 31.0, rawLon: -97.0 },
|
||||
meta: {
|
||||
...base,
|
||||
callsign: null,
|
||||
registration: 'ZULU777',
|
||||
rawLat: 31.0,
|
||||
rawLon: -97.0,
|
||||
},
|
||||
billboard: billboardAt(-97.0, 31.0),
|
||||
});
|
||||
_addMilitaryTrackingCandidateForTest({
|
||||
icao24: 'ae0bb2',
|
||||
meta: { ...base, callsign: 'ZULU777', registration: '05-9999', rawLat: 31.2, rawLon: -97.2 },
|
||||
meta: {
|
||||
...base,
|
||||
callsign: 'ZULU777',
|
||||
registration: '05-9999',
|
||||
rawLat: 31.2,
|
||||
rawLon: -97.2,
|
||||
},
|
||||
billboard: billboardAt(-97.2, 31.2),
|
||||
});
|
||||
|
||||
@@ -582,24 +782,49 @@ test('two contacts matching at the same strength resolve deterministically', ()
|
||||
show: true,
|
||||
});
|
||||
const base = {
|
||||
type: 'C130', operator: 'USAF', altitudeFt: 21000, speedMps: 150,
|
||||
track: 45, klass: 'turboprop', onGround: false,
|
||||
type: 'C130',
|
||||
operator: 'USAF',
|
||||
altitudeFt: 21000,
|
||||
speedMps: 150,
|
||||
track: 45,
|
||||
klass: 'turboprop',
|
||||
onGround: false,
|
||||
};
|
||||
// Both registrations begin with QQ7 — a same-tier prefix tie. Registered
|
||||
// highest-hex first so insertion order would pick the wrong one.
|
||||
_addMilitaryTrackingCandidateForTest({
|
||||
icao24: 'aef002',
|
||||
meta: { ...base, callsign: null, registration: 'QQ7-222', rawLat: 32.0, rawLon: -98.0 },
|
||||
meta: {
|
||||
...base,
|
||||
callsign: null,
|
||||
registration: 'QQ7-222',
|
||||
rawLat: 32.0,
|
||||
rawLon: -98.0,
|
||||
},
|
||||
billboard: billboardAt(-98.0, 32.0),
|
||||
});
|
||||
_addMilitaryTrackingCandidateForTest({
|
||||
icao24: 'aef001',
|
||||
meta: { ...base, callsign: null, registration: 'QQ7-111', rawLat: 32.1, rawLon: -98.1 },
|
||||
meta: {
|
||||
...base,
|
||||
callsign: null,
|
||||
registration: 'QQ7-111',
|
||||
rawLat: 32.1,
|
||||
rawLon: -98.1,
|
||||
},
|
||||
billboard: billboardAt(-98.1, 32.1),
|
||||
});
|
||||
|
||||
const first = militaryFlightsLayer.findByQuery('QQ7')?.icao24;
|
||||
const second = militaryFlightsLayer.findByQuery('qq7')?.icao24;
|
||||
assert.equal(first, 'aef001', 'the stable key (lowest hex) wins, not the feed order');
|
||||
assert.equal(second, first, 'and the same query resolves the same way every time');
|
||||
assert.equal(
|
||||
first,
|
||||
'aef001',
|
||||
'the stable key (lowest hex) wins, not the feed order',
|
||||
);
|
||||
assert.equal(
|
||||
second,
|
||||
first,
|
||||
'and the same query resolves the same way every time',
|
||||
);
|
||||
});
|
||||
@@ -7,6 +7,7 @@ const sourceSlot = createSourceSlot(
|
||||
createInstallationSource(),
|
||||
['getMappedSites', 'searchNearby'],
|
||||
'Installation source',
|
||||
{ destroy: () => {} },
|
||||
);
|
||||
export const configureInstallationSource = sourceSlot.configure;
|
||||
const layer = createApplicationInstallations({
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
installRenderGovernor,
|
||||
} from '../renderGovernor.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { registerEntityContext, selectEntityContext, getSelectedEntityContext } from './contextStore.js';
|
||||
import { registerEntityContext, selectEntityContext, getSelectedEntityContext, clearSelectedEntityContextForLayer } from './contextStore.js';
|
||||
|
||||
test('clicking a selected installation again or empty map clears it through refresh', async () => {
|
||||
const run = await runInstallationLoad({ elements: [{ type: 'node', id: 42,
|
||||
@@ -42,6 +42,23 @@ test('clicking a selected installation again or empty map clears it through refr
|
||||
} finally { run.restore(); }
|
||||
});
|
||||
|
||||
test('background installation paints neither reopen Contacts nor resurrect a cleared selection', async () => {
|
||||
const run = await runInstallationLoad({ elements: [{ type:'node',id:42,lat:30.2,lon:-97.7,tags:{military:'base',name:'Site'} }] });
|
||||
try {
|
||||
const selected=()=>run.contextLabels().length;
|
||||
run.click('osm:node:42');
|
||||
assert.equal(selected(),1);
|
||||
await militaryInstallationsLayer.update();
|
||||
assert.equal(getSelectedEntityContext()?.id,'osm:node:42');
|
||||
assert.equal(selected(),1,'data and floor refreshes must not publish a fresh selection');
|
||||
clearSelectedEntityContextForLayer('military-installations');
|
||||
await militaryInstallationsLayer.update();
|
||||
assert.equal(getSelectedEntityContext(),null);
|
||||
assert.equal(selected(),1,'an external clear must survive the next paint');
|
||||
assert.equal(run.entities()[0].point.pixelSize.getValue(),9);
|
||||
} finally { run.restore(); }
|
||||
});
|
||||
|
||||
test('clearing a stale installation highlight does not clear or reclaim another layer selection', async () => {
|
||||
const run = await runInstallationLoad({ elements: [{ type: 'node', id: 42,
|
||||
lat: 30.2, lon: -97.7, tags: { military: 'base', name: 'Site' } }] });
|
||||
@@ -798,7 +815,7 @@ test('the unavailable retry backs off 30s to a 240s ceiling and restarts clean',
|
||||
|
||||
test('the retry is wired to every lifecycle edge, not just declared', () => {
|
||||
assert.match(installationsSource,
|
||||
/setInstallationStatus\(\s*'unavailable',[^]*?\);\n\s*parts\.viewport\.scheduleUnavailableRetry\(\);/,
|
||||
/setInstallationStatus\(\s*'unavailable',[^]*?\);\n\s*if \(!isUnavailableCapability\(error\)\)\s*parts\.viewport\.scheduleUnavailableRetry\(error\?\.retryAfterMs\);/,
|
||||
'a failed load schedules the retry immediately after reporting unavailable');
|
||||
assert.match(installationsSource,
|
||||
/clearUnavailableRetry\(\);\n\s*setInstallationStatus\(\n?\s*layerState\.records\.length/,
|
||||
@@ -815,3 +832,60 @@ test('the retry is wired to every lifecycle edge, not just declared', () => {
|
||||
/layerState\.enabled && !layerState\.loading\)\s*parts\.ingestion\.loadInstallations\(\)/,
|
||||
'the fired retry re-checks enablement and never races an in-flight load');
|
||||
});
|
||||
|
||||
|
||||
test('a merged installation stays visible when only its second fragment intersects', () => {
|
||||
const far = [[-96.6,30.4],[-96.4,30.4],[-96.4,30.6],[-96.6,30.6]];
|
||||
const near = [[-97.5,30.4],[-97.4,30.4],[-97.4,30.6],[-97.5,30.6]];
|
||||
assert.equal(installationWithinViewport({latitude:30.5,longitude:-96.5,footprint:far,footprints:[[far],[near]]},VIEWPORT), true);
|
||||
});
|
||||
|
||||
test('a Contacts anchor replaces the viewport and ignores small subject moves', async () => {
|
||||
// Field report: a follow/Cockpit camera never fires moveEnd and often looks at
|
||||
// the horizon, so the Context row read "?" for the whole track.
|
||||
const run = await runInstallationLoad({ elements: [{ type: 'node', id: 7,
|
||||
lat: 30.3125, lon: -97.765, tags: { military: 'base', name: 'Camp Mabry' } }] });
|
||||
try {
|
||||
const box = (href) => {
|
||||
const url = new URL(href, 'https://example.test');
|
||||
return Object.fromEntries(['south', 'west', 'north', 'east']
|
||||
.map((key) => [key, Number(url.searchParams.get(key))]));
|
||||
};
|
||||
const centre = (href) => (box(href).south + box(href).north) / 2;
|
||||
assert.equal(run.stats().coverageLabel, 'CURRENT VIEWPORT ONLY');
|
||||
assert.equal(militaryInstallationsLayer.setContextAnchor({ latitude: 30.3125, longitude: -97.765 }), true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 650));
|
||||
const anchored = box(run.requests.at(-1));
|
||||
assert.ok(Math.abs(centre(run.requests.at(-1)) - 30.3125) < 0.01);
|
||||
assert.ok(anchored.north - anchored.south > 1.5, 'window reaches ~100 km each way');
|
||||
assert.equal(run.stats().coverageLabel, 'WITHIN 100 KM');
|
||||
assert.equal(run.stats().coverage.kind, 'subject');
|
||||
const count = run.requests.length;
|
||||
assert.equal(militaryInstallationsLayer.setContextAnchor({ latitude: 30.35, longitude: -97.765 }), false,
|
||||
'a 4 km move keeps the window');
|
||||
assert.equal(militaryInstallationsLayer.setContextAnchor({ latitude: 30.6, longitude: -97.765 }), true,
|
||||
'a 32 km move shifts the window');
|
||||
await new Promise((resolve) => setTimeout(resolve, 650));
|
||||
assert.equal(run.requests.length, count + 1);
|
||||
assert.ok(Math.abs(centre(run.requests.at(-1)) - 30.6) < 0.01);
|
||||
assert.equal(militaryInstallationsLayer.setContextAnchor(null), true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 650));
|
||||
assert.deepEqual(box(run.requests.at(-1)), { south: 30, west: -98, north: 31, east: -97 },
|
||||
'clearing the subject returns to the viewport');
|
||||
assert.equal(run.stats().coverageLabel, 'CURRENT VIEWPORT ONLY');
|
||||
} finally { run.restore(); }
|
||||
});
|
||||
|
||||
test('unchanged installations keep their entities across reloads', async () => {
|
||||
const run = await runInstallationLoad({ elements: [{ type: 'node', id: 42,
|
||||
lat: 30.2, lon: -97.7, tags: { military: 'base', name: 'Site' } }] });
|
||||
try {
|
||||
const before = run.entities().slice();
|
||||
assert.ok(before.length > 0);
|
||||
await militaryInstallationsLayer.update();
|
||||
const after = run.entities();
|
||||
assert.equal(after.length, before.length);
|
||||
assert.ok(after.every((entity, index) => entity === before[index]),
|
||||
'a reload with the same geometry must not recreate entities');
|
||||
} finally { run.restore(); }
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { militaryOsmKey } from '../sources/militaryTileGeometry.js';
|
||||
export { militaryOsmKey } from '../sources/militaryTileGeometry.js';
|
||||
import { requestWithDeadline } from '../sources/requestDeadline.js';
|
||||
import { loadBundledJson } from './bundledJson.js';
|
||||
import { createRetryableLoader } from './retryableLoad.js';
|
||||
|
||||
export const MILITARY_POINT_CAP = 512;
|
||||
export const MILITARY_LABEL_CAP = 24;
|
||||
|
||||
export const MILITARY_NAMES_TIMEOUT_MS = 10_000;
|
||||
|
||||
/** Share a bounded acquisition; failed assets become retryable after cooldown. */
|
||||
export function createMilitaryNamesLoader({
|
||||
loadPack = (signal) =>
|
||||
loadBundledJson(
|
||||
new URL('./local_data/osm_military_names/names.json', import.meta.url),
|
||||
{ signal },
|
||||
),
|
||||
timeoutMs = MILITARY_NAMES_TIMEOUT_MS,
|
||||
...retryOptions
|
||||
} = {}) {
|
||||
return createRetryableLoader(async () => {
|
||||
const pack = await requestWithDeadline(loadPack, { timeoutMs });
|
||||
const records = pack.records.map(
|
||||
([
|
||||
osmKey,
|
||||
name,
|
||||
longitude,
|
||||
latitude,
|
||||
west,
|
||||
south,
|
||||
east,
|
||||
north,
|
||||
kind,
|
||||
areaM2,
|
||||
]) => ({
|
||||
id: `osm:military:${osmKey}`,
|
||||
osmKey,
|
||||
name,
|
||||
longitude,
|
||||
latitude,
|
||||
bbox: [west, south, east, north],
|
||||
class: pack.classes[kind],
|
||||
areaM2,
|
||||
kind: 'installation',
|
||||
namedArea: true,
|
||||
pointOnly: true,
|
||||
validation: 'unreviewed',
|
||||
sources: [
|
||||
{ name: 'OpenStreetMap', id: osmKey },
|
||||
{ name: 'Overture Maps Foundation', id: osmKey },
|
||||
],
|
||||
}),
|
||||
);
|
||||
records.sort(
|
||||
(a, b) => b.areaM2 - a.areaM2 || a.osmKey.localeCompare(b.osmKey),
|
||||
);
|
||||
return { records, byId: new Map(records.map((r) => [r.osmKey, r])) };
|
||||
}, retryOptions);
|
||||
}
|
||||
|
||||
/** Load and index the separate data asset only when installation context needs it. */
|
||||
export const loadMilitaryNames = createMilitaryNamesLoader();
|
||||
|
||||
/** Join exact source identity before parcel merging; reject geographically inconsistent ids. */
|
||||
export function nameMilitaryFragment(fragment, names) {
|
||||
const osmKey = militaryOsmKey(fragment.featureKey);
|
||||
const named = names?.byId.get(osmKey);
|
||||
if (!named || !fragment.footprint?.length) return fragment;
|
||||
const [west, south, east, north] = named.bbox;
|
||||
const epsilon = (fragment.tileEpsilon || 0) * 2 + 0.0001;
|
||||
const xs = fragment.footprint.map((p) => p[0]);
|
||||
const ys = fragment.footprint.map((p) => p[1]);
|
||||
if (
|
||||
Math.max(...xs) < west - epsilon ||
|
||||
Math.min(...xs) > east + epsilon ||
|
||||
Math.max(...ys) < south - epsilon ||
|
||||
Math.min(...ys) > north + epsilon
|
||||
)
|
||||
return fragment;
|
||||
return {
|
||||
...fragment,
|
||||
name: named.name,
|
||||
class: named.class,
|
||||
osmKey,
|
||||
namedArea: true,
|
||||
areaM2: named.areaM2,
|
||||
nameRecord: named,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Area-ranked spatial thinning of visible label points, including dateline
|
||||
* views. With `thinned` false every point in the box is kept, up to `cap`.
|
||||
*/
|
||||
export function militaryNamesInView(
|
||||
names,
|
||||
box,
|
||||
cap = MILITARY_POINT_CAP,
|
||||
thinned = true,
|
||||
) {
|
||||
const width =
|
||||
box.east >= box.west ? box.east - box.west : 360 + box.east - box.west;
|
||||
const height = box.north - box.south;
|
||||
const cells = new Set(),
|
||||
records = [];
|
||||
let count = 0;
|
||||
for (const record of names?.records || []) {
|
||||
const x = (record.longitude - box.west + 360) % 360;
|
||||
if (x > width || record.latitude < box.south || record.latitude > box.north)
|
||||
continue;
|
||||
count++;
|
||||
const cell = `${Math.min(31, Math.floor((x / width) * 32))}:${Math.min(15, Math.floor(((record.latitude - box.south) / height) * 16))}`;
|
||||
if ((thinned && cells.has(cell)) || records.length >= cap) continue;
|
||||
cells.add(cell);
|
||||
records.push(record);
|
||||
}
|
||||
return { records, count };
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { gzipSync } from 'node:zlib';
|
||||
import {
|
||||
loadMilitaryNames,
|
||||
militaryOsmKey,
|
||||
nameMilitaryFragment,
|
||||
militaryNamesInView,
|
||||
MILITARY_POINT_CAP,
|
||||
} from './militaryNames.js';
|
||||
import { decodeOpenFreeMapMilitaryTile } from '../sources/openFreeMap.js';
|
||||
import { createControls } from '../layers/installations/controls.js';
|
||||
import {
|
||||
mergeMilitaryFragments,
|
||||
militaryNameInView,
|
||||
} from '../sources/militaryTileGeometry.js';
|
||||
|
||||
const sites = [
|
||||
['w27894251', 'Camp Mabry'],
|
||||
['r317712', 'Marine Corps Base Camp Pendleton'],
|
||||
['r253814', 'Marine Corps Air Station Miramar'],
|
||||
['w60546523', 'Ramstein Air Base'],
|
||||
['w102826331', 'RAF Lakenheath'],
|
||||
['r13529728', 'Fort Hood'],
|
||||
];
|
||||
|
||||
test('wide-view counts report visible points rather than retained off-screen candidates', () => {
|
||||
const state = {
|
||||
wide: true,
|
||||
records: Array.from({ length: 8 }, () => ({ kind: 'installation' })),
|
||||
lastUpdate: 1,
|
||||
status: 'ready',
|
||||
};
|
||||
const { methods } = createControls({
|
||||
state,
|
||||
services: {},
|
||||
source: {},
|
||||
parts: {
|
||||
namedMarkers: {
|
||||
stats: () => ({
|
||||
namedMarkers: 8,
|
||||
pointsOnScreen: 3,
|
||||
labelsOnScreen: 2,
|
||||
labelCap: 24,
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
const stats = methods.getStats();
|
||||
assert.equal(stats.count, 3);
|
||||
assert.equal(stats.namedMarkers, 8);
|
||||
assert.equal(stats.statusMessage, '3 mapped sites in view');
|
||||
});
|
||||
|
||||
test('the separately licensed pack has unique typed identities, areas, names and a pinned lean output', async () => {
|
||||
const raw = readFileSync(
|
||||
new URL('./local_data/osm_military_names/names.json', import.meta.url),
|
||||
);
|
||||
const hash = readFileSync(
|
||||
new URL('./local_data/osm_military_names/names.sha256', import.meta.url),
|
||||
'utf8',
|
||||
).split(' ')[0];
|
||||
assert.equal(createHash('sha256').update(raw).digest('hex'), hash);
|
||||
assert.ok(gzipSync(raw, { level: 9 }).length <= 2_000_000);
|
||||
const names = await loadMilitaryNames();
|
||||
assert.equal(names.records.length, 36466);
|
||||
assert.equal(names.byId.size, names.records.length);
|
||||
assert.ok(
|
||||
names.records.every(
|
||||
(r) => r.areaM2 > 0 && Number.isFinite(r.latitude + r.longitude),
|
||||
),
|
||||
);
|
||||
for (const [id, name] of sites) assert.equal(names.byId.get(id).name, name);
|
||||
});
|
||||
|
||||
test('checked-in OpenFreeMap fixtures retain typed OSM feature ids and geographical identity', async () => {
|
||||
const names = await loadMilitaryNames();
|
||||
const bytes = readFileSync(
|
||||
new URL('./fixtures/ofm-camp-mabry-12-935-1685.pbf', import.meta.url),
|
||||
);
|
||||
const tile = decodeOpenFreeMapMilitaryTile(bytes, 12, 935, 1685);
|
||||
const mabry = tile.military.find((r) => r.featureKey === '278942512');
|
||||
assert.ok(mabry);
|
||||
assert.equal(militaryOsmKey(mabry.featureKey), 'w27894251');
|
||||
assert.equal(nameMilitaryFragment(mabry, names).name, 'Camp Mabry');
|
||||
assert.equal(militaryOsmKey('3177123'), 'r317712');
|
||||
for (const value of [
|
||||
'tile:12/935/1685:0',
|
||||
'278942510',
|
||||
'3177127',
|
||||
'bad',
|
||||
1e20,
|
||||
])
|
||||
assert.equal(militaryOsmKey(value), null);
|
||||
assert.equal(
|
||||
nameMilitaryFragment({ ...mabry, featureKey: '3177123' }, names).name,
|
||||
'Military area',
|
||||
'a valid but distant id fails soft',
|
||||
);
|
||||
assert.equal(
|
||||
nameMilitaryFragment({ ...mabry, featureKey: '278942510' }, names).name,
|
||||
'Military area',
|
||||
);
|
||||
});
|
||||
|
||||
test('a merged title uses the largest named member and exposes all other member names', async () => {
|
||||
const names = await loadMilitaryNames();
|
||||
const bytes = readFileSync(
|
||||
new URL('./fixtures/ofm-camp-mabry-12-935-1685.pbf', import.meta.url),
|
||||
);
|
||||
const fragment = decodeOpenFreeMapMilitaryTile(bytes, 12, 935, 1685)
|
||||
.military[0];
|
||||
const parent = nameMilitaryFragment(fragment, names);
|
||||
const child = {
|
||||
...parent,
|
||||
featureKey: '12',
|
||||
osmKey: 'w1',
|
||||
nameRecord: {
|
||||
...parent.nameRecord,
|
||||
id: 'osm:military:w1',
|
||||
osmKey: 'w1',
|
||||
name: 'Small barracks',
|
||||
areaM2: 1,
|
||||
},
|
||||
};
|
||||
for (const records of [
|
||||
[child, parent],
|
||||
[parent, child],
|
||||
]) {
|
||||
const merged = mergeMilitaryFragments(records)[0];
|
||||
assert.equal(merged.name, 'Camp Mabry');
|
||||
assert.equal(merged.id, 'osm:military:w27894251');
|
||||
assert.deepEqual(
|
||||
[merged.longitude, merged.latitude],
|
||||
[parent.nameRecord.longitude, parent.nameRecord.latitude],
|
||||
);
|
||||
assert.deepEqual(merged.memberNames, ['Small barracks']);
|
||||
}
|
||||
});
|
||||
|
||||
test('wide views rank and thin names within a bounded viewport, including the dateline', async () => {
|
||||
const names = await loadMilitaryNames();
|
||||
for (const box of [
|
||||
{ west: -130, south: 20, east: -60, north: 60 },
|
||||
{ west: -15, south: 35, east: 35, north: 65 },
|
||||
{ west: 170, south: -60, east: -150, north: 70 },
|
||||
]) {
|
||||
const view = militaryNamesInView(names, box);
|
||||
assert.ok(view.records.length > 0);
|
||||
assert.ok(view.records.length <= MILITARY_POINT_CAP);
|
||||
assert.ok(view.count >= view.records.length);
|
||||
assert.equal(
|
||||
new Set(view.records.map((r) => r.id)).size,
|
||||
view.records.length,
|
||||
);
|
||||
for (const r of view.records) {
|
||||
assert.ok(r.latitude >= box.south && r.latitude <= box.north);
|
||||
assert.ok(
|
||||
box.east < box.west
|
||||
? r.longitude >= box.west || r.longitude <= box.east
|
||||
: r.longitude >= box.west && r.longitude <= box.east,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('a close view keeps the parent name inside its polygon when the usual label is off-screen', () => {
|
||||
const record = {
|
||||
id: 'osm:military:w1',
|
||||
namedArea: true,
|
||||
name: 'Parent base',
|
||||
longitude: 1,
|
||||
latitude: 1,
|
||||
footprints: [
|
||||
[
|
||||
[
|
||||
[0, 0],
|
||||
[10, 0],
|
||||
[10, 10],
|
||||
[0, 10],
|
||||
[0, 0],
|
||||
],
|
||||
],
|
||||
],
|
||||
};
|
||||
const visible = militaryNameInView(record, {
|
||||
west: 5,
|
||||
south: 5,
|
||||
east: 6,
|
||||
north: 6,
|
||||
});
|
||||
assert.equal(visible.id, record.id);
|
||||
assert.deepEqual([visible.longitude, visible.latitude], [5.5, 5.5]);
|
||||
assert.equal(
|
||||
militaryNameInView(record, { west: 0, south: 0, east: 2, north: 2 }),
|
||||
record,
|
||||
);
|
||||
const hole = {
|
||||
...record,
|
||||
footprints: [
|
||||
[
|
||||
record.footprints[0][0],
|
||||
[
|
||||
[4, 4],
|
||||
[7, 4],
|
||||
[7, 7],
|
||||
[4, 7],
|
||||
[4, 4],
|
||||
],
|
||||
],
|
||||
],
|
||||
};
|
||||
assert.equal(
|
||||
militaryNameInView(hole, { west: 5, south: 5, east: 6, north: 6 }),
|
||||
hole,
|
||||
);
|
||||
});
|
||||
|
||||
test('queries can take every named site in a box instead of one per display cell', async () => {
|
||||
const names = await loadMilitaryNames();
|
||||
const box = { west: -2, south: 50, east: 2, north: 54 };
|
||||
const thinned = militaryNamesInView(names, box);
|
||||
const all = militaryNamesInView(names, box, Infinity, false);
|
||||
assert.equal(all.count, thinned.count);
|
||||
assert.equal(all.records.length, all.count);
|
||||
assert.ok(all.records.length > thinned.records.length);
|
||||
});
|
||||
Loaded 100 of 632 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user