Merge branch 'main' into fix/unit-runner-discovers-server-tests

This commit is contained in:
Sameh Khamis committed 2026-10-14 11:59:00 -07:00
commit 3bc41cc12a
671 files changed
+107238 -5592

No files matched your search

+236
View File
@@ -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();
});
+32 -4
View File
@@ -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;
+3 -1
View File
@@ -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');
};
+5 -1
View File
@@ -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 });
+77 -10
View File
@@ -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
View File
@@ -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,
);
+28 -4
View File
@@ -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();
+92 -95
View File
@@ -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;
+1
View File
@@ -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',
+18 -2
View File
@@ -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
View File
@@ -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,
+53 -2
View File
@@ -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/,
);
});
+1
View File
@@ -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();
+305
View File
@@ -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);
};
}
+325
View File
@@ -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/,
);
});
+662
View File
@@ -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}` : '.'),
),
);
}
+2
View File
@@ -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,
+2
View File
@@ -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,
+3 -9
View File
@@ -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 });
}
+24 -1
View File
@@ -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,
);
},
},
},
});
}
+2
View File
@@ -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,
});
+8 -8
View File
@@ -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(),
});
}
+50
View File
@@ -0,0 +1,50 @@
import { createStreetLevelLayer } from '../../layers/streetLevel/index.js';
import { createMapillaryProvider } from '../../layers/streetLevel/providers/mapillary/index.js';
import { createGoogleProvider } from '../../layers/streetLevel/providers/google/index.js';
import { createKartaViewProvider } from '../../layers/streetLevel/providers/kartaview/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 scenePick from '../../data/scenePick.js';
import * as render from '../../renderGovernor.js';
/** The browser Maps key the scene was started with (src/app/scene.js). */
const googleApiKey = () => globalThis.window?.__GOOGLE_MAPS_API_KEY__ || null;
/**
* Mapillary, Google Street View and KartaView, once the Mapillary source is
* supplied; else none.
*/
function defaultProviders(sources) {
if (sources.mapillary == null) return [];
return [
createMapillaryProvider({ source: sources.mapillary }),
createGoogleProvider({ getApiKey: googleApiKey }),
createKartaViewProvider(),
];
}
/**
* Inject providers, or use the defaults. An explicit provider list replaces
* them; an unused sources.mapillary is neither constructed nor validated,
* including when the list is empty.
*/
export function createApplicationStreetLevel({
surface,
sources = {},
providers = defaultProviders(sources),
}) {
return createStreetLevelLayer({
providers,
services: {
sprites,
picking,
input,
scenePick,
render,
ground: surface?.groundFloor ?? null,
meshFloor: surface?.meshFloor ?? null,
terrain: surface?.terrain ?? null,
},
});
}
+56
View File
@@ -0,0 +1,56 @@
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/,
);
});
test('the defaults are Mapillary (given its source), Street View and KartaView, which start off and travel as provider switches', async () => {
const { createDefaultLayerState } = await import('../../data/layerState.js');
const mapillary = {
getStatus() {},
getTile() {},
getSequenceImages() {},
nearestImages() {},
};
const layer = createApplicationStreetLevel({ sources: { mapillary } });
assert.deepEqual(layer.providerIds, ['mapillary', 'google', 'kartaview']);
// A fresh page and a restored default agree: Street View is billed, so off.
layer.setParams(createDefaultLayerState().options['street-level']);
assert.equal(layer.getParams().google, false);
assert.equal(layer.getParams().mapillary, true);
// A link that lit Street View carries it in the shared switch field.
layer.setParams({ providerSwitches: 'google-1' });
assert.equal(layer.getParams().google, true);
assert.equal(layer.getParams().providerSwitches, 'google-1*kartaview-0');
// KartaView is keyless and free, but one source shows at a time.
layer.setParams({ providerSwitches: 'kartaview-1' });
assert.equal(layer.getParams().kartaview, true);
assert.equal(layer.getParams().google, false);
assert.equal(layer.getParams().mapillary, false);
// Without the application's Street Level source there are none.
assert.deepEqual(createApplicationStreetLevel({}).providerIds, []);
});
+3 -2
View File
@@ -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 },
});
}
+3
View File
@@ -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,
});
+1
View File
@@ -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
View File
@@ -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;
+113
View File
@@ -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?.();
},
};
}
+156
View File
@@ -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
View File
@@ -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 };
}
+198
View File
@@ -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;
}
+171
View File
@@ -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();
});
}
+33 -9
View File
@@ -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\(\);/);
});
+22 -5
View File
@@ -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;
}
+75 -23
View File
@@ -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,
});
});
+273
View File
@@ -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 });
});
+281
View File
@@ -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,
);
});
+6
View File
@@ -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/);
});
+803
View File
@@ -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)
);
}
+481
View File
@@ -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',
);
});
+1 -1
View File
@@ -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
});
});
+6 -3
View File
@@ -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,
+2 -2
View File
@@ -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());
}
});
+1 -1
View File
@@ -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();
+3 -2
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+374 -7
View File
@@ -174,11 +174,95 @@ test('analyst: nearest sorting attaches distanceKm ascending', async () => {
test('analyst: follow-up re-filters the remembered set without re-snapshot', async () => {
const eng = makeEngine();
await eng.query({ layers: ['flights'], scope: { kind: 'region', name: 'Texland' } });
const r = await eng.query({ followUp: true, filters: [{ field: 'onGround', op: 'eq', value: true }] });
const first = await eng.query({
layers: ['flights'],
scope: { kind: 'region', name: 'Texland' },
});
const r = await eng.query({
followUp: true,
filters: [{ field: 'onGround', op: 'eq', value: true }],
});
assert.equal(r.count, 1);
assert.equal(r.items[0].id, 'GND1');
assert.equal(r.coverage.followUp, true);
assert.equal(r.scopeLabel, first.scopeLabel);
assert.equal(r.display.scope, first.display.scope);
assert.equal(r.coverage.scope, first.coverage.scope);
});
test('analyst: follow-ups preserve partial and unanswered coverage', async () => {
const eng = createAnalystEngine({
getRecords: (key) => (key === 'flights' ? FLIGHTS : []),
getLayerSnapshot: (key) => ({
enabled: key === 'flights',
feedState: key === 'flights' ? 'nominal' : 'off',
}),
getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }),
});
const first = await eng.query({
layers: ['flights', 'military'],
scope: { kind: 'anywhere' },
});
assert.equal(first.partial, true);
assert.deepEqual(first.unanswered, ['military']);
const followUp = await eng.query({ followUp: true, limit: 1 });
assert.equal(followUp.partial, true);
assert.deepEqual(followUp.unanswered, ['military']);
assert.deepEqual(
followUp.coverage.layersQueried.map(({ layerKey, status }) => [
layerKey,
status,
]),
first.coverage.layersQueried.map(({ layerKey, status }) => [
layerKey,
status,
]),
);
});
test('analyst: cancelled pre-commit work cannot replace follow-up memory', async () => {
const eng = makeEngine();
await eng.query({
layers: ['flights'],
scope: { kind: 'anywhere' },
filters: [{ field: 'id', op: 'eq', value: 'SWA1' }],
});
let current = true;
const stale = await eng.query(
{ layers: ['local-firms'], scope: { kind: 'anywhere' } },
{
isCurrent: () => current,
beforeCommit: async () => {
current = false;
},
},
);
assert.equal(stale.code, 'CANCELLED');
const followUp = await eng.query({ followUp: true });
assert.deepEqual(followUp.items.map((item) => item.id), ['SWA1']);
});
test('analyst: failed pre-commit work cannot replace follow-up memory', async () => {
const eng = makeEngine();
await eng.query({
layers: ['flights'],
scope: { kind: 'anywhere' },
filters: [{ field: 'id', op: 'eq', value: 'SWA1' }],
});
await assert.rejects(
eng.query(
{ layers: ['local-firms'], scope: { kind: 'anywhere' } },
{
beforeCommit: async () => {
throw new Error('projection failed');
},
},
),
/projection failed/,
);
const followUp = await eng.query({ followUp: true });
assert.deepEqual(followUp.items.map((item) => item.id), ['SWA1']);
});
test('analyst: unresolved region is an honest failure, not empty success', async () => {
@@ -200,6 +284,39 @@ test('analyst: a region lookup timeout is reported as region-timeout', async ()
assert.equal(r.coverage.scope, 'region:Texas:timeout');
});
test('analyst: region lookup receives and obeys the owning action signal', async () => {
const controller = new AbortController();
let receivedSignal = null;
const eng = createAnalystEngine({
getRecords: () => FLIGHTS,
resolveRegionRing: async (_name, signal) => {
receivedSignal = signal;
await new Promise((resolve) =>
signal.addEventListener('abort', resolve, { once: true }),
);
return null;
},
getViewContext: () => ({
lat: 30.27,
lon: -97.74,
viewRadiusKm: 150,
}),
});
const pending = eng.query(
{ layers: ['flights'], scope: { kind: 'region', name: 'Texas' } },
{
signal: controller.signal,
isCurrent: () => !controller.signal.aborted,
},
);
await Promise.resolve();
controller.abort();
const result = await pending;
assert.equal(receivedSignal, controller.signal);
assert.equal(result.code, 'CANCELLED');
assert.equal(result.cancelled, true);
});
test('analyst: route fields queryable from cached enrichment only', async () => {
const r = await makeEngine().query({
layers: ['flights'], scope: { kind: 'anywhere' },
@@ -262,20 +379,132 @@ test('helpers: haversine sanity + scope radius', () => {
assert.deepEqual(scoped.map((f) => f.id).sort(), ['GND1', 'SWA1']);
});
test('bounded loaded cohorts disclose truncation before filtering and retain it on follow-up', async () => {
test('capped cohorts report returned/total/truncated and keep it on follow-up', async () => {
const engine = createAnalystEngine({
getRecords: () => [{ id: 'sample dam', lat: 0, lon: 0, name: 'sample' }],
getRecordCoverage: () => ({ basis: 'bounded-loaded-records', recordsExamined: 1, loadedCount: 3000, sourceTruncated: true }),
getRecordCoverage: () => ({ total: 3000, truncated: true }),
getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }),
});
const result = await engine.query({ layers: ['local-dams'], scope: { kind: 'anywhere' }, sortBy: 'distance' });
assert.equal(result.count, 1);
assert.equal(result.coverage.layersQueried[0].sourceTruncated, true);
assert.match(result.coverage.note, /omitted records may change the nearest item or count/);
const layer = result.coverage.layersQueried[0];
assert.deepEqual({ returned: layer.returned, total: layer.total, truncated: layer.truncated }, { returned: 1, total: 3000, truncated: true });
assert.deepEqual(result.coverage.records, { returned: 1, total: 3000, truncated: true });
assert.match(result.display.caveat, /counted 1 of 3,000 loaded records/);
const followUp = await engine.query({ followUp: true });
assert.equal(followUp.coverage.layersQueried[0].loadedCount, 3000);
assert.equal(followUp.coverage.layersQueried[0].total, 3000);
});
test('an uncapped cohort is complete and carries no caveat', async () => {
const result = await makeEngine().query({ layers: ['flights'], scope: { kind: 'anywhere' } });
assert.deepEqual(result.coverage.records, { returned: 4, total: 4, truncated: false });
assert.equal(result.display.caveat, undefined);
assert.equal(result.display.scope, 'anywhere in the loaded data');
});
test('unknown fields, operators, scopes and wrong-typed values are refused with the allowed values', async () => {
const engine = makeEngine();
const field = await engine.query({ layers: ['flights'], filters: [{ field: 'time', op: 'gte', value: 5 }] });
assert.equal(field.ok, false);
assert.equal(field.code, 'UNKNOWN_FIELD');
assert.ok(field.allowed.includes('altitudeM'));
const op = await engine.query({ layers: ['flights'], filters: [{ field: 'altitudeM', op: 'wat', value: 5 }] });
assert.equal(op.code, 'BAD_OPERATOR');
assert.deepEqual(op.allowed, ['gt', 'gte', 'lt', 'lte', 'eq', 'neq']);
const flag = await engine.query({ layers: ['flights'], filters: [{ field: 'military', op: 'eq', value: 'false' }] });
assert.equal(flag.code, 'BAD_VALUE', 'the string "false" must not match the TRUE record');
const num = await engine.query({ layers: ['flights'], filters: [{ field: 'altitudeM', op: 'gt', value: 'high' }] });
assert.equal(num.code, 'BAD_VALUE');
const scope = await engine.query({ layers: ['flights'], scope: { kind: 'polygon' } });
assert.equal(scope.code, 'UNKNOWN_SCOPE');
assert.deepEqual(scope.allowed, ['view', 'region', 'radius', 'anywhere']);
const sort = await engine.query({ layers: ['flights'], sortBy: 'loudness' });
assert.equal(sort.code, 'UNKNOWN_FIELD');
assert.equal(engine.hasMemory(), false, 'refusals never become follow-up memory');
});
test('a typed boolean filter still matches exactly', async () => {
const r = await makeEngine().query({ layers: ['flights'], scope: { kind: 'anywhere' }, filters: [{ field: 'military', op: 'eq', value: false }] });
assert.deepEqual(r.items.map((i) => i.id).sort(), ['GND1', 'N123', 'SWA1']);
});
test('distance honours sortDir: desc is farthest first', async () => {
const far = await makeEngine().query({ layers: ['flights'], scope: { kind: 'anywhere' }, sortBy: 'distance', sortDir: 'desc', limit: 1 });
assert.equal(far.items[0].id, 'N123');
const near = await makeEngine().query({ layers: ['flights'], scope: { kind: 'anywhere' }, sortBy: 'distance', limit: 1 });
assert.equal(near.items[0].id, 'SWA1');
});
test('a follow-up without a previous answer is refused, not a fresh flights query', async () => {
const r = await makeEngine().query({ followUp: true, filters: [] });
assert.equal(r.ok, false);
assert.equal(r.code, 'NO_RESULT_CONTEXT');
});
test('follow-ups never rewrite the remembered rows', async () => {
const engine = makeEngine();
await engine.query({ layers: ['flights'], scope: { kind: 'anywhere' } });
await engine.query({ followUp: true, sortBy: 'distance' });
const again = await engine.query({ followUp: true });
assert.equal(again.count, 4, 'a follow-up keeps the remembered set, not the view');
assert.ok(again.items.every((item) => item.distanceKm === undefined));
});
test('a superseded slow region query cannot overwrite the newer follow-up memory', async () => {
let releaseSlow;
const engine = createAnalystEngine({
getRecords: (key) => ({ flights: FLIGHTS, 'ais-live-vessels': SHIPS }[key] || []),
resolveRegionRing: () => new Promise((resolve) => { releaseSlow = () => resolve(TEXLAND); }),
getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }),
});
let generation = 0;
const slowGeneration = ++generation;
const slow = engine.query({ layers: ['flights'], scope: { kind: 'region', name: 'Texland' } }, { isCurrent: () => generation === slowGeneration });
const newGeneration = ++generation;
const fresh = await engine.query({ layers: ['ais-live-vessels'], scope: { kind: 'anywhere' } }, { isCurrent: () => generation === newGeneration });
assert.equal(fresh.ok, true);
releaseSlow();
const stale = await slow;
assert.equal(stale.code, 'CANCELLED');
const followUp = await engine.query({ followUp: true });
assert.deepEqual(followUp.items.map((i) => i.layerKey), ['ais-live-vessels', 'ais-live-vessels']);
});
test('off, loading and unavailable layers are refused distinctly; a real zero stays ok', async () => {
const engineFor = (snapshot, rows = [], warming = false) => createAnalystEngine({
getRecords: () => rows,
getLayerSnapshot: () => snapshot,
isWarming: () => warming,
getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }),
});
assert.equal((await engineFor({ enabled: false, feedState: 'off' }).query({ layers: ['earthquakes'] })).code, 'LAYER_OFF');
assert.equal((await engineFor({ enabled: true, feedState: 'loading' }).query({ layers: ['earthquakes'] })).code, 'NOT_READY');
assert.equal((await engineFor({ enabled: true, feedState: 'nominal' }, [], true).query({ layers: ['earthquakes'] })).code, 'NOT_READY');
assert.equal((await engineFor({ enabled: true, feedState: 'unavailable' }).query({ layers: ['earthquakes'] })).code, 'FEED_UNAVAILABLE');
const zero = await engineFor({ enabled: true, feedState: 'nominal' }, [{ id: 'q', lat: 50, lon: 50, magnitude: 2 }]).query({ layers: ['earthquakes'], filters: [{ field: 'magnitude', op: 'gte', value: 5 }] });
assert.equal(zero.ok, true);
assert.equal(zero.count, 0);
assert.equal(zero.coverage.layersQueried[0].status, 'ok');
});
test('time fields filter by ISO and derive ageHours; the feed window is exposed', async () => {
const now = Date.parse('2026-09-23T12:00:00Z');
const engine = createAnalystEngine({
now: () => now,
getRecords: () => [
{ id: 'recent', lat: 0, lon: 0, magnitude: 4, timeMs: now - 2 * 3_600_000 },
{ id: 'older', lat: 0, lon: 0, magnitude: 5, timeMs: now - 20 * 3_600_000 },
],
getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }),
});
const iso = await engine.query({ layers: ['earthquakes'], scope: { kind: 'anywhere' }, filters: [{ field: 'timeMs', op: 'gte', value: '2026-09-23T06:00:00Z' }] });
assert.deepEqual(iso.items.map((i) => i.id), ['recent']);
const age = await engine.query({ layers: ['earthquakes'], scope: { kind: 'anywhere' }, filters: [{ field: 'ageHours', op: 'lte', value: 6 }] });
assert.deepEqual(age.items.map((i) => i.id), ['recent']);
assert.equal(age.items[0].ageHours, 2);
assert.equal(age.display.window, 'last 24 h');
assert.equal(age.coverage.layersQueried[0].window, 'last 24 h');
});
test('follow-up provenance stays attached to old rows after a feed recovers', async () => {
let state = 'stale';
@@ -293,3 +522,141 @@ test('follow-up provenance stays attached to old rows after a feed recovers', as
assert.equal(engine.hasMemory(), false);
assert.equal((await engine.query()).coverage.feedProvenance.overall, 'nominal');
});
test('a large loaded set sorts and summarizes without overflowing the stack', async () => {
const rows = Array.from({ length: 200_000 }, (_, i) => ({ id: `FIRE-${i}`, lat: 0, lon: 0, frp: i }));
const engine = createAnalystEngine({
getRecords: () => rows,
getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }),
});
const result = await engine.query({ layers: ['local-firms'], scope: { kind: 'anywhere' }, sortBy: 'frp', limit: 1 });
assert.equal(result.count, 200_000);
assert.equal(result.items[0].frp, 199_999);
assert.equal(result.summary.frpMin, 0);
});
test('an enabled but empty layer says why on screen', async () => {
const engine = createAnalystEngine({
getRecords: () => [],
getLayerSnapshot: () => ({ enabled: true, feedState: 'nominal' }),
getEmptyNote: () => 'Fly below 400 km to a covered region',
getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }),
});
const result = await engine.query({ layers: ['transit'] });
assert.equal(result.ok, true);
assert.equal(result.count, 0);
assert.equal(result.coverage.layersQueried[0].status, 'empty');
assert.match(result.display.caveat, /transit: Fly below 400 km/);
});
test('a radius around a named place without a centre is refused, not measured around the camera', async () => {
const r = await makeEngine().query({ layers: ['ais-live-vessels'], scope: { kind: 'radius', name: 'Singapore', km: 200 } });
assert.equal(r.ok, false);
assert.equal(r.code, 'BAD_SCOPE');
const ok = await makeEngine().query({ layers: ['ais-live-vessels'], scope: { kind: 'radius', name: 'Houston', km: 200, center: { lat: 29.6, lon: -95.0 } } });
assert.equal(ok.ok, true);
});
test('a field copied with its hinted unit still resolves', async () => {
const r = await makeEngine().query({ layers: ['local-firms'], scope: { kind: 'anywhere' }, sortBy: 'frp(MW)', filters: [{ field: 'frp (MW)', op: 'gt', value: 100 }] });
assert.equal(r.ok, true);
assert.deepEqual(r.items.map((i) => i.id), ['FIRE-3', 'FIRE-1']);
});
test('a written unit converts to the field unit or is refused', async () => {
const engine = makeEngine();
const feet = await engine.query({ layers: ['flights'], scope: { kind: 'anywhere' }, filters: [{ field: 'altitudeM(ft)', op: 'gt', value: 40000 }] });
assert.equal(feet.ok, true);
assert.deepEqual(feet.items.map((i) => i.id), ['RCH01'], '40,000 ft is 12,192 m, not 40,000 m');
const same = await engine.query({ layers: ['flights'], scope: { kind: 'anywhere' }, filters: [{ field: 'altitudeM (m)', op: 'gt', value: 12192 }] });
assert.deepEqual(same.items.map((i) => i.id), ['RCH01']);
const knots = await engine.query({ layers: ['flights'], scope: { kind: 'anywhere' }, filters: [{ field: 'speedMps(kts)', op: 'gt', value: 470 }] });
assert.deepEqual(knots.items.map((i) => i.id), ['RCH01'], '470 kn is ~242 m/s');
const wrong = await engine.query({ layers: ['flights'], filters: [{ field: 'altitudeM(kts)', op: 'gt', value: 1 }] });
assert.equal(wrong.code, 'BAD_UNIT');
const unitless = await engine.query({ layers: ['flights'], filters: [{ field: 'callsign(ft)', op: 'eq', value: 'X' }] });
assert.equal(unitless.code, 'BAD_UNIT');
const hinted = await engine.query({ layers: ['local-firms'], scope: { kind: 'anywhere' }, sortBy: 'frp(MW)' });
assert.equal(hinted.ok, true);
});
test('unambiguous numeric strings are numbers; blanks, booleans and words are not', async () => {
const engine = makeEngine();
const text = await engine.query({ layers: ['flights'], scope: { kind: 'anywhere' }, filters: [{ field: 'altitudeM', op: 'gt', value: '12192' }] });
assert.equal(text.ok, true);
assert.deepEqual(text.items.map((i) => i.id), ['RCH01']);
for (const value of ['', ' ', true, 'high', '12k'])
assert.equal((await engine.query({ layers: ['flights'], filters: [{ field: 'altitudeM', op: 'gt', value }] })).code, 'BAD_VALUE', JSON.stringify(value));
});
test('any supplied centre must be a real coordinate', async () => {
const engine = makeEngine();
for (const center of [{ lat: 0 }, { lat: 999, lon: 0 }, { lat: 10, lon: 200 }, { lat: '10', lon: 20 }])
assert.equal((await engine.query({ layers: ['flights'], scope: { kind: 'radius', km: 100, center } })).code, 'BAD_SCOPE', JSON.stringify(center));
assert.equal((await engine.query({ layers: ['flights'], scope: { kind: 'radius', km: 100, center: { lat: 30.2, lon: -97.7 } } })).ok, true);
});
test('mixed off/loading/unavailable layers refuse; a partly answerable query says so', async () => {
const snapshots = { earthquakes: { enabled: true, feedState: 'unavailable' }, 'local-firms': { enabled: true, feedState: 'loading' }, flights: { enabled: false, feedState: 'off' } };
const engine = createAnalystEngine({
getRecords: (key) => (key === 'ais-live-vessels' ? SHIPS : []),
getLayerSnapshot: (key) => snapshots[key] || { enabled: true, feedState: 'nominal' },
getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }),
});
const none = await engine.query({ layers: ['earthquakes', 'local-firms', 'flights'] });
assert.equal(none.ok, false);
assert.equal(none.code, 'NOT_READY');
assert.deepEqual(none.layerStatus.map((l) => l.status), ['error', 'loading', 'off']);
assert.equal(engine.hasMemory(), false, 'a refusal never becomes follow-up memory');
const down = await engine.query({ layers: ['earthquakes', 'flights'] });
assert.equal(down.code, 'FEED_UNAVAILABLE');
const partial = await engine.query({ layers: ['ais-live-vessels', 'earthquakes'], scope: { kind: 'anywhere' } });
assert.equal(partial.ok, true);
assert.equal(partial.partial, true);
assert.deepEqual(partial.unanswered, ['earthquakes']);
assert.match(partial.display.caveat, /earthquakes error/);
});
test('returned items are copies: mutating them cannot change the remembered answer', async () => {
const engine = makeEngine();
const first = await engine.query({ layers: ['flights'], scope: { kind: 'anywhere' }, sortBy: 'altitudeM' });
for (const item of first.items) {
item.altitudeM = -1;
item.id = 'MUTATED';
}
const again = await engine.query({ followUp: true, sortBy: 'altitudeM' });
assert.deepEqual(again.items.map((i) => i.id), ['RCH01', 'SWA1', 'N123', 'GND1']);
assert.equal(FLIGHTS[1].altitudeM, 13500, 'the provider rows are untouched');
});
test('ranking 250k rows stays within a budget and yields to the page', async () => {
const rows = Array.from({ length: 250_000 }, (_, i) => ({ id: `F${i}`, lat: (i % 170) - 85, lon: (i % 350) - 175, frp: (i * 7919) % 100_003 }));
const engine = createAnalystEngine({ getRecords: () => rows, getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }) });
let ticks = 0;
const timer = setInterval(() => ticks++, 0);
const started = performance.now();
const result = await engine.query({ layers: ['local-firms'], scope: { kind: 'anywhere' }, sortBy: 'frp', limit: 1 });
const elapsed = performance.now() - started;
clearInterval(timer);
assert.equal(result.count, 250_000);
assert.equal(result.items[0].frp, 100_002);
assert.ok(elapsed < 400, `took ${Math.round(elapsed)} ms`);
assert.ok(ticks > 0, 'the scan yielded between slices');
let current = true;
const pending = engine.query({ layers: ['local-firms'], scope: { kind: 'anywhere' }, sortBy: 'distance' }, { isCurrent: () => current });
current = false;
assert.equal((await pending).code, 'CANCELLED', 'a superseded scan stops at the next slice');
});
test('analyst: a follow-up naming other layers than the last answer is refused', async () => {
const engine = createAnalystEngine({
getRecords: (key) => ({ flights: FLIGHTS, 'local-firms': FIRES }[key] || []),
resolveRegionRing: async () => null,
getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }),
});
await engine.query({ layers: ['flights'], scope: { kind: 'anywhere' } });
const r = await engine.query({ layers: ['local-firms'], followUp: true });
assert.equal(r.ok, false);
assert.equal(r.code, 'FOLLOW_UP_MISMATCH');
assert.equal(engine.hasMemory(), true, 'the earlier answer is still the last answer');
});
+4
View File
@@ -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;
+1 -1
View File
@@ -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
View File
@@ -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}`);
}
+57 -10
View File
@@ -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/,
});
});
+16
View File
@@ -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);
};
}
+25
View File
@@ -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 });
});
+391
View File
@@ -0,0 +1,391 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
loadNycSourcesFromOpenData,
nycCameraIsOnline,
nycCameraName,
nycCameraToSource,
} from '../../server/providers/cctv/sources.js';
import {
DEFAULT_CCTV_MAX_SOURCES,
DEFAULT_NYC_MAX_SOURCES,
NYC_CAMERAS_URL,
NYC_GROUND_ELEVATION_M,
NYC_IMAGE_ORIGIN,
NYC_IMAGE_URL,
NYC_MAX_CATALOG_BYTES,
} from '../../server/providers/cctv/constants.js';
import {
CAMERA_CODE_MAX_CHARS,
isLikelyNycCoordinate,
} from '../../server/providers/cctv/normalize.js';
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
/**
* A response whose body is a live stream, plus a flag that flips when the
* stream is cancelled. A rejection path that returns without cancelling holds
* the transport open, so the flag is what the refusal tests actually assert.
*/
const streamingResponse = (init = {}) => {
const state = { cancelled: false };
const body = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('['));
},
cancel() {
state.cancelled = true;
},
});
return { response: new Response(body, init), state };
};
const MIDTOWN_ID = '8a6bc417-4877-4ebe-8052-88c1b261baf1';
const BROOKLYN_ID = '11111111-2222-3333-4444-555555555555';
/** One TMC camera row, shaped like the live `webcams.nyctmc.org` payload. */
const row = (overrides = {}) => ({
id: MIDTOWN_ID,
name: 'Central Park West @ 86 St',
latitude: 40.785302,
longitude: -73.969353,
area: 'Manhattan',
isOnline: 'true',
imageUrl: NYC_IMAGE_URL(MIDTOWN_ID),
...overrides,
});
test('a NYC row maps to a source on the pinned HTTPS frame host', () => {
const source = nycCameraToSource(row());
assert.equal(source.id, `nyc-${MIDTOWN_ID}`);
assert.equal(source.name, 'Central Park West @ 86 St');
assert.equal(source.city, 'Manhattan');
assert.equal(source.cityId, 'nyc');
assert.equal(source.provider, 'NYC DOT Traffic Management Center');
assert.equal(source.lat, 40.785302);
assert.equal(source.lon, -73.969353);
assert.equal(source.feedType, 'image');
assert.equal(source.sourceKind, 'nyc-dot-tmc');
assert.equal(
source.license,
'Public NYC DOT Traffic Management Center traffic camera',
);
assert.equal(source.groundElevationM, NYC_GROUND_ELEVATION_M);
assert.equal(source.url, NYC_IMAGE_URL(MIDTOWN_ID));
assert.equal(source.url, source.snapshotUrl);
assert.ok(source.url.startsWith(NYC_IMAGE_ORIGIN));
assert.equal(source.headingConfidence, 'low');
assert.ok(Number.isFinite(source.headingDeg));
});
test('frame URLs are synthesized from the UUID; the payload URL is ignored', () => {
const source = nycCameraToSource(
row({ imageUrl: 'https://evil.example/cameras/x/image' }),
);
assert.equal(source.url, NYC_IMAGE_URL(MIDTOWN_ID));
assert.equal(source.snapshotUrl, source.url);
});
test('offline cameras, off-city geometry and unusable ids are dropped', () => {
assert.equal(nycCameraToSource(row({ isOnline: 'false' })), null);
assert.equal(nycCameraToSource(row({ isOnline: false })), null);
assert.equal(
nycCameraToSource(row({ isOnline: 'TRUE' })).id,
`nyc-${MIDTOWN_ID}`,
);
assert.equal(
nycCameraToSource(row({ isOnline: true })).id,
`nyc-${MIDTOWN_ID}`,
);
// Austin: a plausible lat/lon, but not a New York camera.
assert.equal(
nycCameraToSource(row({ latitude: 30.2672, longitude: -97.7431 })),
null,
);
assert.equal(nycCameraToSource(row({ latitude: 0, longitude: 0 })), null);
assert.equal(nycCameraToSource(row({ id: '../etc/passwd' })), null);
assert.equal(nycCameraToSource(row({ id: 'not-a-uuid' })), null);
assert.equal(nycCameraToSource(row({ id: `${MIDTOWN_ID}/../x` })), null);
assert.equal(nycCameraToSource(null), null);
});
test('ids lower-case the UUID and unknown boroughs fall back to New York', () => {
const mixed = nycCameraToSource(
row({ id: MIDTOWN_ID.toUpperCase(), area: 'Long Island' }),
);
assert.equal(mixed.id, `nyc-${MIDTOWN_ID}`);
assert.equal(mixed.city, 'New York');
assert.equal(
mixed.url,
NYC_IMAGE_URL(MIDTOWN_ID.toUpperCase()),
'the registered frame path keeps the catalog UUID spelling',
);
});
test('a nameless row still gets a label', () => {
assert.equal(
nycCameraName({ name: ' FDR Drive @ 23 St ' }, 'nyc-x'),
'FDR Drive @ 23 St',
);
assert.equal(nycCameraName({}, `nyc-${MIDTOWN_ID}`), 'NYC Camera 8a6bc417');
assert.equal(nycCameraIsOnline('true'), true);
assert.equal(nycCameraIsOnline('false'), false);
});
test('the unselected label is the intersection, trimmed to the code width', () => {
assert.equal(nycCameraToSource(row()).code, 'CENTRAL PARK WEST @ 86 ST');
const long = nycCameraToSource(
row({
name: 'Brooklyn Queens Expressway @ Atlantic Avenue / Flatbush Avenue Extension',
}),
);
assert.ok(long.code.length <= CAMERA_CODE_MAX_CHARS);
assert.ok(long.code.endsWith('…'));
assert.ok(long.code.startsWith('BROOKLYN'));
});
test('the loader reads the keyless catalog and collapses duplicate ids', async (t) => {
t.mock.method(console, 'log', () => {});
const requested = [];
t.mock.method(globalThis, 'fetch', async (url, init) => {
requested.push([String(url), init.redirect]);
return Response.json([
row({ latitude: 40.758, longitude: -73.9855 }),
row({
name: 'Central Park West @ 86 St (duplicate)',
latitude: 40.758,
longitude: -73.9855,
}),
row({
id: BROOKLYN_ID,
name: 'Atlantic Ave @ Flatbush Ave',
latitude: 40.65,
longitude: -73.96,
area: 'Brooklyn',
imageUrl: NYC_IMAGE_URL(BROOKLYN_ID),
}),
row({ isOnline: 'false', name: 'Offline camera' }),
row({ id: 'https://evil.example/x' }),
]);
});
const cameras = await loadNycSourcesFromOpenData();
assert.deepEqual(requested, [[NYC_CAMERAS_URL, 'manual']]);
assert.deepEqual(
cameras.map((camera) => camera.id),
// Nearest to a borough anchor first: the Times Square camera before Brooklyn.
[`nyc-${MIDTOWN_ID}`, `nyc-${BROOKLYN_ID}`],
);
assert.equal(cameras[0].name, 'Central Park West @ 86 St');
assert.equal(cameras[1].city, 'Brooklyn');
});
test('an upstream failure yields an empty pack and releases the response', async (t) => {
t.mock.method(console, 'warn', () => {});
const failed = streamingResponse({ status: 503 });
t.mock.method(globalThis, 'fetch', async () => failed.response);
assert.deepEqual(await loadNycSourcesFromOpenData(), []);
assert.equal(failed.state.cancelled, true, 'the failed body is cancelled');
t.mock.restoreAll();
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => {
throw new Error('network down');
});
assert.deepEqual(await loadNycSourcesFromOpenData(), []);
});
test('the catalog fetch refuses redirects and oversized bodies', async (t) => {
t.mock.method(console, 'warn', () => {});
const seen = [];
const redirected = streamingResponse({
status: 302,
headers: { location: 'https://evil.example/cameras.json' },
});
t.mock.method(globalThis, 'fetch', async (url, init) => {
seen.push([String(url), init.redirect]);
return redirected.response;
});
assert.deepEqual(await loadNycSourcesFromOpenData(), []);
assert.deepEqual(seen, [[NYC_CAMERAS_URL, 'manual']]);
assert.equal(
redirected.state.cancelled,
true,
'the redirect body is cancelled',
);
t.mock.restoreAll();
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => {
const body = JSON.stringify([row()]);
return new Response(body, {
headers: {
'Content-Type': 'application/json',
'content-length': String(NYC_MAX_CATALOG_BYTES + 1),
},
});
});
assert.deepEqual(await loadNycSourcesFromOpenData(), []);
t.mock.restoreAll();
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => {
const oversized = 'x'.repeat(NYC_MAX_CATALOG_BYTES + 1024);
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(oversized));
controller.close();
},
}),
{ headers: { 'Content-Type': 'application/json' } },
);
});
assert.deepEqual(await loadNycSourcesFromOpenData(), []);
});
test('a reduced pack cap keeps cameras nearest the borough anchors', async (t) => {
t.mock.method(console, 'log', () => {});
const saved = process.env.CCTV_NYC_MAX_SOURCES;
t.after(() => {
if (saved === undefined) delete process.env.CCTV_NYC_MAX_SOURCES;
else process.env.CCTV_NYC_MAX_SOURCES = saved;
});
process.env.CCTV_NYC_MAX_SOURCES = '8';
const rows = [
// Far southern Staten Island, listed first so only distance can keep them.
...[40.5, 40.505, 40.51, 40.515, 40.52, 40.525].map((lat, i) =>
row({
id: `aaaaaaaa-bbbb-cccc-dddd-${String(i).padStart(12, '0')}`,
latitude: lat,
longitude: -74.24,
area: 'Staten Island',
name: `SI ${i}`,
}),
),
...[40.758, 40.757, 40.756, 40.755, 40.754, 40.753].map((lat, i) =>
row({
id: `bbbbbbbb-cccc-dddd-eeee-${String(i).padStart(12, '0')}`,
latitude: lat,
longitude: -73.9855,
area: 'Manhattan',
name: `Midtown ${i}`,
}),
),
];
t.mock.method(globalThis, 'fetch', async () => Response.json(rows));
const cameras = await loadNycSourcesFromOpenData();
assert.equal(cameras.length, 8);
assert.deepEqual(
cameras.map((camera) => camera.city),
[
'Manhattan',
'Manhattan',
'Manhattan',
'Manhattan',
'Manhattan',
'Manhattan',
'Staten Island',
'Staten Island',
],
'Midtown fills first; the Staten Island anchor still keeps a share',
);
});
/**
* Serve the NYC catalog to the NYC endpoint and an empty payload to every
* other pack, so one catalog refresh exercises the registration without
* reaching the network.
*/
const runCatalogWithMockedUpstreams = 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 === NYC_CAMERAS_URL) {
return Response.json([
row({ latitude: 40.758, longitude: -73.9855 }),
row({
id: BROOKLYN_ID,
name: 'Atlantic Ave @ Flatbush Ave',
latitude: 40.65,
longitude: -73.96,
area: 'Brooklyn',
}),
]);
}
return Response.json([]);
});
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
return { requested, sources };
};
/** Run one catalog refresh with CCTV_NYC_ENABLED set to `flag` (undefined
* leaves it unset), restoring the environment afterwards. */
const runCatalogWithNycFlag = async (t, flag) => {
const saved = { ...process.env };
try {
delete process.env.CCTV_SOURCES_FILE;
delete process.env.CCTV_SOURCES_JSON;
if (flag === undefined) delete process.env.CCTV_NYC_ENABLED;
else process.env.CCTV_NYC_ENABLED = flag;
return await runCatalogWithMockedUpstreams(t);
} finally {
for (const key of Object.keys(process.env)) {
if (!(key in saved)) delete process.env[key];
}
Object.assign(process.env, saved);
}
};
test('CCTV_NYC_ENABLED=1 wires the NYC lane into the catalog', async (t) => {
const { requested, sources } = await runCatalogWithNycFlag(t, '1');
assert.ok(
requested.includes(NYC_CAMERAS_URL),
'the catalog refresh invokes the NYC loader',
);
assert.deepEqual(
sources.filter((s) => s.cityId === 'nyc').map((s) => s.id),
[`nyc-${MIDTOWN_ID}`, `nyc-${BROOKLYN_ID}`],
'NYC cameras reach the served catalog through the registered lane',
);
});
// NYC DOT requires a data-sharing agreement for this feed, so an install that
// has not opted in must never contact it.
for (const [label, flag] of [
['an unset CCTV_NYC_ENABLED', undefined],
['an empty CCTV_NYC_ENABLED', ''],
['CCTV_NYC_ENABLED=0', '0'],
['CCTV_NYC_ENABLED=true', 'true'],
]) {
test(`${label} makes no NYC request`, async (t) => {
const { requested, sources } = await runCatalogWithNycFlag(t, flag);
assert.ok(requested.length > 0, 'the other packs still refreshed');
assert.equal(
requested.some((href) => href.includes('nyctmc.org')),
false,
'the lane never reaches its upstream',
);
assert.deepEqual(
sources.filter((s) => s.cityId === 'nyc'),
[],
);
});
}
test('the shipped catalog ceiling is not raised to make room for this pack', () => {
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000);
assert.equal(DEFAULT_NYC_MAX_SOURCES, 250);
});
test('isLikelyNycCoordinate spans the five boroughs and rejects the rest', () => {
assert.equal(isLikelyNycCoordinate(40.758, -73.9855), true); // Times Square
assert.equal(isLikelyNycCoordinate(40.5795, -74.1502), true); // Staten Island
assert.equal(isLikelyNycCoordinate(40.8448, -73.8648), true); // Bronx
assert.equal(isLikelyNycCoordinate(40.5077, -74.2304), true); // southern SI slack
assert.equal(isLikelyNycCoordinate(30.2672, -97.7431), false); // Austin
assert.equal(isLikelyNycCoordinate(0, 0), false);
assert.equal(isLikelyNycCoordinate(NaN, -73.98), false);
});
+202
View File
@@ -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'),
[],
);
});
});
+107 -54
View File
@@ -25,6 +25,79 @@ 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;
}
/** NYC DOT asks every application that uses its data to carry this
* disclaimer verbatim (nyc.gov/html/dot/html/about/datafeeds.shtml). */
export const NYC_DOT_DISCLAIMER =
'This application may contain and/or utilize information which was originally compiled by the New York City Department of Transportation (NYC DOT) for governmental purposes; the information may subsequently been modified by entity/entities other than NYC DOT. ' +
'NYC DOT and the City of New York make no representation as to the accuracy or usefulness of the information provided by this application or the information&#39;s suitability for any purpose and disclaim any liability for omissions or errors that may be contained therein. ' +
'The public is advised to observe posted signage for compliance with applicable laws and regulations.';
export const DATA_CREDITS = [
// ── Live sources ────────────────────────────────────────────────
{
@@ -94,42 +167,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 +247,20 @@ 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: 'nyc-cctv',
html:
'CCTV cameras &amp; frames (New York City, when enabled): ' +
'<a href="https://webcams.nyctmc.org/" target="_blank" rel="noopener">NYC DOT Traffic Management Center</a>. ' +
NYC_DOT_DISCLAIMER,
},
{
key: 'gbfs',
html: 'Bikeshare availability: GBFS operator feeds (e.g. Austin BCycle)',
@@ -212,7 +272,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 +321,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 +356,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 +389,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 +442,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)',
};
+92 -1
View File
@@ -1,6 +1,6 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { DATA_CREDITS } from './dataCredits.js';
import { DATA_CREDITS, NYC_DOT_DISCLAIMER } from './dataCredits.js';
test('every credit carries a unique key and some markup to render', () => {
const keys = DATA_CREDITS.map((entry) => entry.key);
@@ -37,3 +37,94 @@ 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);
});
test('the NYC camera credit carries NYC DOT’s requested disclaimer', () => {
const credit = DATA_CREDITS.find((entry) => entry.key === 'nyc-cctv');
assert.ok(credit, 'NYC DOT camera frames must be credited');
assert.ok(credit.html.includes(NYC_DOT_DISCLAIMER));
assert.match(credit.html, /make no representation as to the accuracy/);
assert.match(credit.html, /observe posted signage/);
});
+7 -2
View File
@@ -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 {
+1
View File
@@ -25,6 +25,7 @@ test('firms analyst record: full record maps every contract field', () => {
frp: 1520.4,
confidence: 0.9,
satellite: 'N21',
sensor: 'VIIRS',
acqTime: 1_753_600_000_000,
});
});
+21
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+125 -82
View File
@@ -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;
}
+27 -2
View File
@@ -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]);
});
+63
View File
@@ -0,0 +1,63 @@
const MAX_SWITCHES_LENGTH = 256;
const ENTRY = /^([a-z][a-z0-9-]*)-([01])$/;
/** Canonical, bounded boolean switches keyed by stable slugs. */
export function normalizeIdSwitches(value, reservedIds = []) {
if (typeof value !== 'string' || value.length > MAX_SWITCHES_LENGTH)
return null;
if (!value) return '';
const entries = new Map();
for (const field of value.split('*')) {
const match = ENTRY.exec(field);
if (!match || reservedIds.includes(match[1]) || entries.has(match[1]))
return null;
entries.set(match[1], match[2] === '1');
}
return [...entries]
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
.map(([id, on]) => `${id}-${on ? '1' : '0'}`)
.join('*');
}
/** Encode by stable IDs rather than registration order or generated tokens. */
export function encodeIdSwitches(switches) {
const value = [...switches]
.map(([id, on]) => `${id}-${on === true ? '1' : '0'}`)
.join('*');
if (value.length > MAX_SWITCHES_LENGTH)
throw new RangeError(
`Switch IDs exceed the ${MAX_SWITCHES_LENGTH}-character share-link limit`,
);
const normalized = normalizeIdSwitches(value);
if (normalized === null) throw new TypeError('Invalid boolean switch IDs');
return normalized;
}
/** A Map keeps switch IDs separate from object prototype properties. */
export function decodeIdSwitches(value) {
const normalized = normalizeIdSwitches(value);
return new Map(
normalized
? normalized.split('*').map((entry) => {
const [, id, on] = ENTRY.exec(entry);
return [id, on === '1'];
})
: [],
);
}
/** Persist only explicitly requested switch IDs, not unrelated live changes. */
export function patchRequestedIdSwitches(previous, applied, requested) {
const current = decodeIdSwitches(applied);
const next = new Map(
[...decodeIdSwitches(previous)].filter(([id]) => current.has(id)),
);
let changed = false;
for (const [id, on] of current) {
if (Object.hasOwn(requested, id) && typeof requested[id] === 'boolean') {
next.set(id, on);
changed = true;
}
}
return changed ? encodeIdSwitches(next) : undefined;
}
+5 -8
View File
@@ -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,
+10
View File
@@ -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',
]);
+43
View File
@@ -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',
};
}
+13
View File
@@ -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';
}
+13
View File
@@ -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',
);
});
+46 -3
View File
@@ -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;
+44
View File
@@ -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
View File
@@ -1,3 +1,4 @@
import { normalizeIdSwitches, patchRequestedIdSwitches } from './idSwitches.js';
import reservationRows from './layerStateTokenReservations.json' with { type: 'json' };
const VALID_DISPOSITIONS = new Set([
@@ -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;
}
}
+179 -28
View File
@@ -1,3 +1,4 @@
import { encodeParams, decodeParams } from '../layers/streetLevel/params.js';
import { readShellSource } from '../testSupport/readShellSource.mjs';
import { expandApplicationHtml } from '../../build/application-html.js';
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
@@ -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,141 @@ 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 switches keeps Mapillary on and Street View off', () => {
assert.deepEqual(streetLevelOptions(''), {
mapillary: true,
providerSwitches: '',
pano: 'all',
sinceDays: 0,
});
// Street View rides the shared provider-switch field, off unless lit.
assert.equal(streetLevelOptions('0.r.google-1').providerSwitches, 'google-1');
assert.equal(
createDefaultLayerState().options['street-level'].providerSwitches,
'',
);
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();
}
});
+756
View File
@@ -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;
}
}
+35 -29
View File
@@ -98,6 +98,12 @@ function withPublishedBase(
}
function writeCodecFixture(cwd, rows, entries) {
const providerCodec = path.join(cwd, 'src/data/idSwitches.js');
mkdirSync(path.dirname(providerCodec), { recursive: true });
writeFileSync(
providerCodec,
readFileSync(new URL('./idSwitches.js', import.meta.url)),
);
const source = readFileSync(CODEC_SOURCE_PATH, 'utf8');
const registryEnd = ']);\n\nexport const REGISTERED_LAYER_IDS';
assert.equal(source.split(registryEnd).length, 2);
@@ -122,10 +128,10 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
{ ...parseLayerStateTokenReservations(reservationRows) },
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
);
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 28);
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 29);
assert.deepEqual(
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
{ ...LEGACY_LAYER_STATE_TOKENS },
{ ...LEGACY_LAYER_STATE_TOKENS, 'street-level': '0' },
);
assert.throws(
() => parseLayerStateTokenReservations(reservationRows.slice(1)),
@@ -145,9 +151,9 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
/Duplicate layer-state token reservation/,
);
assert.equal(
parseLayerStateTokenReservations([...reservationRows, ['future', '0']])
parseLayerStateTokenReservations([...reservationRows, ['future', '3']])
.future,
'0',
'3',
);
assert.throws(
() =>
@@ -165,14 +171,14 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
test('published retired digits and competing PRs advance the merge-time allocation', () => {
withPublishedBase(
{ rows: [...reservationRows, ['retired-layer', '0']] },
{ rows: [...reservationRows, ['retired-layer', '3']] },
(cwd) => {
const published = readPublishedLayerStateReservations('HEAD', cwd);
assert.equal(nextLayerStateToken(published), '3');
assert.equal(nextLayerStateToken(published), '4');
assert.equal(
validateLayerStateAllocations(published, {
...published,
newcomer: '3',
newcomer: '4',
}),
true,
);
@@ -180,15 +186,15 @@ test('published retired digits and competing PRs advance the merge-time allocati
() =>
validateLayerStateAllocations(published, {
...published,
competing: '0',
competing: '3',
}),
/next free token 3/,
/next free token 4/,
);
assert.throws(
() =>
validateLayerStateAllocations(published, {
...LAYER_STATE_TOKEN_RESERVATIONS,
newcomer: '0',
newcomer: '3',
}),
/changed or removed: retired-layer/,
);
@@ -196,8 +202,8 @@ test('published retired digits and competing PRs advance the merge-time allocati
);
});
test('PR B manually replaces provisional 0 with 3 after PR A publishes 0', () => {
withPublishedBase({ rows: [...reservationRows, ['pr-a', '0']] }, (cwd) => {
test('PR B manually replaces provisional 3 with 4 after PR A publishes 3', () => {
withPublishedBase({ rows: [...reservationRows, ['pr-a', '3']] }, (cwd) => {
mkdirSync(path.join(cwd, 'scripts'));
const fixtureChecker = path.join(
cwd,
@@ -218,8 +224,8 @@ test('PR B manually replaces provisional 0 with 3 after PR A publishes 0', () =>
writeCodecFixture(
cwd,
[...reservationRows, ['pr-b', '0']],
[{ id: 'pr-b', token: '0' }],
[...reservationRows, ['pr-b', '3']],
[{ id: 'pr-b', token: '3' }],
);
const beforeRebase = runCheck();
assert.equal(beforeRebase.status, 1);
@@ -227,10 +233,10 @@ test('PR B manually replaces provisional 0 with 3 after PR A publishes 0', () =>
writeCodecFixture(
cwd,
[...reservationRows, ['pr-a', '0'], ['pr-b', '0']],
[...reservationRows, ['pr-a', '3'], ['pr-b', '3']],
[
{ id: 'pr-a', token: '0' },
{ id: 'pr-b', token: '0' },
{ id: 'pr-a', token: '3' },
{ id: 'pr-b', token: '3' },
],
);
const staleRebase = runCheck();
@@ -238,16 +244,16 @@ test('PR B manually replaces provisional 0 with 3 after PR A publishes 0', () =>
assert.match(staleRebase.stderr, /Duplicate layer-state token reservation/);
const published = readPublishedLayerStateReservations('HEAD', cwd);
assert.equal(nextLayerStateToken(published), '3');
const validBaselineRows = [...reservationRows, ['pr-a', '0']];
writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '0' }]);
assert.equal(nextLayerStateToken(published), '4');
const validBaselineRows = [...reservationRows, ['pr-a', '3']];
writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '3' }]);
const helper = spawnSync(
process.execPath,
[realpathSync(fixtureNext), 'pr-b'],
{ cwd, encoding: 'utf8' },
);
assert.equal(helper.status, 0, helper.stderr);
assert.match(helper.stdout, /pr-b: 3/);
assert.match(helper.stdout, /pr-b: 4/);
assert.deepEqual(
JSON.parse(readFileSync(path.join(cwd, LEDGER_PATH), 'utf8')),
validBaselineRows,
@@ -255,20 +261,20 @@ test('PR B manually replaces provisional 0 with 3 after PR A publishes 0', () =>
);
writeCodecFixture(
cwd,
[...reservationRows, ['pr-a', '0'], ['pr-b', '3']],
[...reservationRows, ['pr-a', '3'], ['pr-b', '4']],
[
{ id: 'pr-a', token: '0' },
{ id: 'pr-b', token: '3' },
{ id: 'pr-a', token: '3' },
{ id: 'pr-b', token: '4' },
],
);
const corrected = runCheck();
assert.equal(corrected.status, 0, corrected.stderr);
assert.match(corrected.stdout, /29 published, 1 new/);
assert.match(corrected.stdout, /30 published, 1 new/);
});
});
test('allocation batches cross the last digit and base-36 pair boundaries in order', () => {
const digitRows = [...'03456789'].map((digit) => [`retired-${digit}`, digit]);
const digitRows = [...'3456789'].map((digit) => [`retired-${digit}`, digit]);
const allDigits = parseLayerStateTokenReservations([
...reservationRows,
...digitRows,
@@ -320,7 +326,7 @@ test('allocation batches cross the last digit and base-36 pair boundaries in ord
test('an isolated valid two-character fixture round-trips an l field beyond the old 64-character cap', async () => {
const cwd = mkdtempSync(path.join(tmpdir(), 'gev-layer-token-width-'));
try {
const priorDigits = [...'03456789'].map((digit) => [
const priorDigits = [...'3456789'].map((digit) => [
`qa-prior-digit-${digit}`,
digit,
]);
@@ -357,7 +363,7 @@ test('an isolated valid two-character fixture round-trips an l field beyond the
const params = new URLSearchParams([['v', '2']]);
codec.encodeLayerStateParams(params, state);
assert.ok(params.get('l').length > 64, 'fixture must cross the old cap');
assert.equal(params.get('l').length, 67);
assert.equal(params.get('l').length, 69);
const restored = codec.decodeLayerStateParams(params);
assert.deepEqual(restored?.enabledLayerIds, expectedLayerIds);
assert.deepEqual(restored?.options, state.options);
@@ -375,7 +381,7 @@ test('checker reads a complete future base ledger and rejects retired-token reus
},
(cwd) => {
const published = readPublishedLayerStateReservations('HEAD', cwd);
assert.equal(Object.keys(published).length, 29);
assert.equal(Object.keys(published).length, 30);
assert.equal(published['retired-layer'], '00');
assert.throws(
() =>
@@ -19,6 +19,7 @@
["recent-imagery", "1"],
["rocket-launches", "x"],
["satellites", "s"],
["street-level", "0"],
["telegeography-submarine-cables", "u"],
["traffic", "t"],
["transit", "j"],
+27 -1
View File
@@ -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
View File
@@ -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 };
+70 -1
View File
@@ -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
+104 -1
View File
@@ -41,6 +41,7 @@ import {
formatAwarenessLabel,
getAwarenessNavigationTargets,
} from './militaryAwarenessEngine.js';
import { formatAwarenessCount } from '../layers/awareness/panel.js';
import { NAVIGATION_AUTHORITY_EVENT } from '../navigationPolicy.js';
const militaryAwarenessSource = readLayerSource(
@@ -55,6 +56,18 @@ const AWARENESS_DEPENDENCIES = [
'military-installations',
];
test('Contacts labels a capped cohort as a lower bound', () => {
assert.equal(
formatAwarenessCount({ count: 20_000, complete: false, truncated: true }),
'At least 20,000',
);
assert.equal(
formatAwarenessCount({ count: 19_999, complete: true, truncated: false }),
'19,999',
);
assert.equal(formatAwarenessCount({ count: null }), '?');
});
function installAwarenessRuntime({
isEnabled = () => true,
isEffectivelyEnabled = isEnabled,
@@ -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', '&lt;Area four&gt;']) assert.ok(html.includes(name));
assert.ok(!html.includes('<Area four>'));
} finally {
restores.reverse().forEach((restore) => restore());
runtime.restore();
}
});
+2 -2
View File
@@ -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
View File
@@ -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',
);
});
+1
View File
@@ -7,6 +7,7 @@ const sourceSlot = createSourceSlot(
createInstallationSource(),
['getMappedSites', 'searchNearby'],
'Installation source',
{ destroy: () => {} },
);
export const configureInstallationSource = sourceSlot.configure;
const layer = createApplicationInstallations({
+76 -2
View File
@@ -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(); }
});
+119
View File
@@ -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 };
}
Loaded 100 of 671 files, more files were not shown because too many files have changed in this diff. Show more