Merge remote-tracking branch 'upstream/main' into fix/363-geocode-proxy

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
Niccolò Saggese committed 2026-09-29 19:16:15 +02:00
commit 5083a74413
542 files changed
+96845 -3034

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 });
+153
View File
@@ -0,0 +1,153 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { FEATURE_SOURCE_METHODS } from '../sources/featureSource.js';
import { createAnnotationResolver } from './resolver.js';
function featureSource(overrides = {}) {
const source = {};
for (const method of FEATURE_SOURCE_METHODS)
source[method] = async () => null;
return Object.assign(source, overrides);
}
// 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: 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(),
});
for (const name of ['Gulf of Mexico', 'the Alps']) {
const region = await resolveRegionRingForQuery(name);
assert.ok(region?.ring?.length >= 3, `${name} resolves to a ring`);
assert.equal(region.error, undefined);
}
});
test('region ring: a slow admin-boundary lookup returns region-timeout within the budget', async () => {
let adminSignal;
let release;
const { resolveRegionRingForQuery } = createAnnotationResolver({
featureSource: featureSource({
getAdministrativeAreas(_point, { signal }) {
adminSignal = signal;
return new Promise((resolve) => {
release = resolve;
});
},
}),
});
const started = Date.now();
const region = await resolveRegionRingForQuery(
'Atlantis',
undefined,
offPackGeocode,
{ budgetMs: 50 },
);
assert.deepEqual(region, {
name: 'Atlantis',
ring: null,
error: 'region-timeout',
});
assert.ok(
Date.now() - started < 1000,
'returns at the budget, not the lookup',
);
// The lookup is left running so it can fill the boundary cache.
assert.equal(adminSignal?.aborted ?? false, false);
release(null);
});
test('region ring: an unbounded budget waits for the lookup', async () => {
const { resolveRegionRingForQuery } = createAnnotationResolver({
featureSource: featureSource({
getAdministrativeAreas: () =>
new Promise((resolve) => setTimeout(() => resolve(null), 30)),
}),
});
const region = await resolveRegionRingForQuery(
'Atlantis',
undefined,
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');
});
+186 -14
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,13 +19,26 @@ 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';
/** 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,
@@ -173,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) {
@@ -471,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);
@@ -498,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;
@@ -512,6 +568,7 @@ export function createAnnotationResolver({
signal,
'strict',
);
if (isUnavailableCapability(footFp)) return footFp;
if (footFp) {
fp = footFp;
} else if (footFp === null) {
@@ -528,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);
@@ -577,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
@@ -616,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);
@@ -643,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;
@@ -677,6 +738,7 @@ export function createAnnotationResolver({
label,
source,
synthesized,
outlineUnavailable,
viewport: placeViewport,
...(footprint && deferFootprint ? { resolveOutline } : {}),
};
@@ -717,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),
@@ -1001,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);
@@ -1064,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;
@@ -1094,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;
@@ -1123,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;
@@ -1146,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;
@@ -1168,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;
@@ -1217,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;
@@ -1250,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) {
@@ -1323,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) {
@@ -1371,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);
@@ -1494,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 = [];
@@ -1877,14 +2011,22 @@ export function createAnnotationResolver({
* resolve to a region-like boundary — the analyst engine reports that
* honestly rather than silently scoping to nothing.
*
* The geocode + admin-boundary rung is capped at `budgetMs`. Past it the
* call returns `{name, ring: null, error: 'region-timeout'}` so a voice
* answer is not held for tens of seconds; the lookup keeps running and
* fills the boundary cache, so asking again shortly is fast.
*
* @param {string} name e.g. "Texas", "the Alps", "France", "Gulf of Mexico"
* @param {AbortSignal} [signal]
* @returns {Promise<{name:string, ring:Array<[number,number]>}|null>}
* @param {object} [placeSearch]
* @param {{budgetMs?: number}} [options] `Infinity` waits for the lookup.
* @returns {Promise<{name:string, ring:Array<[number,number]>}|{name:string, ring:null, error:'region-timeout'}|null>}
*/
async function resolveRegionRingForQuery(
name,
signal,
placeSearch = unavailablePlaceSearch,
{ budgetMs = REGION_FALLBACK_BUDGET_MS } = {},
) {
signal = lifetime
? signal
@@ -1901,12 +2043,42 @@ 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;
const timeout = new Promise((resolve) => {
timer = setTimeout(
() => resolve({ name: q, ring: null, error: 'region-timeout' }),
budgetMs,
);
});
try {
return await Promise.race([lookup, timeout]);
} finally {
clearTimeout(timer);
}
}
async function resolveAdminRegionRing(q, signal, placeSearch) {
const geo = await geocodePlace(q, null, signal, placeSearch).catch(
() => null,
);
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;
+189
View File
@@ -0,0 +1,189 @@
/**
* Workaround for Apple's Metal/ANGLE shader translator rejecting Cesium's
* per-vertex model atmosphere.
*
* Cesium's `AtmosphereStageVS` calls a function whose `out` parameters are
* bound DIRECTLY to varyings:
*
* czm_computeGroundAtmosphereScattering(
* v_positionWC, lightDirection,
* v_atmosphereRayleighColor, v_atmosphereMieColor, v_atmosphereOpacity);
*
* ANGLE's Metal backend wraps each `out` argument in an `ANGLE_out(...)`
* helper that takes a `thread T &`. Vertex-output struct members live in
* `__metal_generic`, which cannot bind to `thread`, so the program fails to
* LINK (not compile) and Cesium tears the render loop down:
*
* RuntimeError: Program failed to link. ... MSL compilation error:
* reference to type 'thread float3' could not bind to an lvalue of type
* '__metal_generic float3'
*
* This is the only shader in Cesium that binds varyings to `out` params --
* SkyAtmosphere and the ground-atmosphere fragment path both route through
* locals first, so they are unaffected and stay on.
*
* Cesium attaches that stage in `ModelSceneGraph.configurePipeline` under a
* single condition:
*
* const fogRenderable = frameState.fog.enabled && frameState.fog.renderable;
* if (fogRenderable) modelPipelineStages.push(AtmospherePipelineStage);
*
* so clearing `scene.fog.renderable` keeps the broken stage out of the
* pipeline entirely -- the program is never built, so it cannot fail to link.
*
* We clear `renderable` rather than `enabled` deliberately:
* - `Fog.update` still computes fog density when only `renderable` is off,
* preserving the distance-based screen-space-error scaling that governs
* 3D Tiles refinement. Clearing `enabled` returns early and forfeits it.
* - `visualSettings.js` saves and restores `scene.fog.enabled` around IR
* boost. Parking our fix on `enabled` would let that restore resurrect
* the broken shader mid-session; `renderable` is orthogonal to it.
*
* @module app/atmosphereCompat
*/
/** Minimal vertex shader reproducing the exact out-param-to-varying bind. */
const PROBE_VERTEX_SHADER = `#version 300 es
precision highp float;
in vec3 a_position;
out vec3 v_probe;
void writeOut(out vec3 value) { value = vec3(1.0); }
void main() {
writeOut(v_probe);
gl_Position = vec4(a_position, 1.0);
}`;
const PROBE_FRAGMENT_SHADER = `#version 300 es
precision highp float;
in vec3 v_probe;
out vec4 fragColor;
void main() { fragColor = vec4(v_probe, 1.0); }`;
/**
* True when the user agent is iOS or iPadOS.
*
* iPadOS 13+ defaults to desktop-mode browsing and reports `MacIntel` with no
* iPad token, so it is identified by touch points instead -- real Macs report
* `maxTouchPoints` of 0, including Apple Silicon ones that also claim
* `MacIntel`. Brave/Chrome/Firefox on iOS are all WebKit underneath and share
* the same Metal backend, so matching the platform rather than the browser is
* what we want.
*/
export function isAppleMobilePlatform(navigatorLike = globalThis.navigator) {
if (!navigatorLike) return false;
const ua = String(navigatorLike.userAgent || '');
if (/\b(iPad|iPhone|iPod)\b/.test(ua)) return true;
// Desktop-mode iPadOS presents as a Mac. What separates it from a real Mac
// is touch: Macs report 0 touch points, including Apple Silicon ones that
// also claim `MacIntel`. `navigator.platform` is deprecated and may be
// empty, so the UA's Macintosh token is accepted as an equal signal rather
// than relying on platform alone.
const platform = String(navigatorLike.platform || '');
const macLike =
platform === 'MacIntel' ||
platform === 'MacARM' ||
/\bMacintosh\b/.test(ua);
const touchPoints = Number(navigatorLike.maxTouchPoints) || 0;
return macLike && touchPoints > 1;
}
/**
* Link the probe program and report whether the driver rejects the pattern.
*
* Returns `false` when WebGL2 is unavailable so that a missing context is
* never mistaken for a broken driver -- Cesium has its own handling for that,
* and the caller still falls back to platform detection.
*/
export function probeOutParamVaryingLinkFailure(gl) {
if (!gl) return false;
const vs = gl.createShader(gl.VERTEX_SHADER);
const fs = gl.createShader(gl.FRAGMENT_SHADER);
const program = gl.createProgram();
if (!vs || !fs || !program) return false;
try {
gl.shaderSource(vs, PROBE_VERTEX_SHADER);
gl.compileShader(vs);
gl.shaderSource(fs, PROBE_FRAGMENT_SHADER);
gl.compileShader(fs);
// A compile failure here is not the bug we are probing for: the driver
// rejects this pattern at LINK time, after both stages compile cleanly.
if (
!gl.getShaderParameter(vs, gl.COMPILE_STATUS) ||
!gl.getShaderParameter(fs, gl.COMPILE_STATUS)
)
return false;
gl.attachShader(program, vs);
gl.attachShader(program, fs);
gl.linkProgram(program);
return !gl.getProgramParameter(program, gl.LINK_STATUS);
} catch {
return false;
} finally {
gl.deleteShader(vs);
gl.deleteShader(fs);
gl.deleteProgram(program);
}
}
/** Build a throwaway WebGL2 context for the probe, or null if unavailable. */
function defaultProbeContext() {
try {
const canvas =
typeof OffscreenCanvas === 'function'
? new OffscreenCanvas(1, 1)
: globalThis.document?.createElement('canvas');
return canvas?.getContext('webgl2') ?? null;
} catch {
return null;
}
}
/**
* Decide whether the per-vertex model atmosphere must be kept out of the
* pipeline on this device.
*
* The probe is authoritative when it fires, so a future driver fix silently
* restores the effect without a code change. Platform detection is the
* backstop for the case where no context can be created -- on Apple mobile a
* link failure is fatal and unrecoverable, so we would rather lose fog than
* gamble the whole render loop on the probe having run.
*/
export function shouldDisableModelAtmosphere({
navigatorLike = globalThis.navigator,
createProbeContext = defaultProbeContext,
} = {}) {
const gl = createProbeContext();
if (gl) {
let linkFails;
try {
linkFails = probeOutParamVaryingLinkFailure(gl);
} finally {
// Browsers cap live WebGL contexts (Safari especially); release the
// throwaway probe context now rather than waiting for GC.
try {
gl.getExtension?.('WEBGL_lose_context')?.loseContext();
} catch {
// A context that cannot be released is left to GC.
}
}
if (linkFails) return true;
// Probe ran and the driver accepted the pattern. Apple mobile is still
// quarantined: the probe program is far simpler than Cesium's, and a
// false negative there costs the entire scene.
return isAppleMobilePlatform(navigatorLike);
}
return isAppleMobilePlatform(navigatorLike);
}
/**
* Keep Cesium's broken model-atmosphere stage out of the pipeline.
*
* Returns true when the workaround was applied. Safe to call on any scene;
* scenes without a fog object are left untouched.
*/
export function applyModelAtmosphereWorkaround(scene, options = {}) {
if (!scene?.fog) return false;
if (!shouldDisableModelAtmosphere(options)) return false;
scene.fog.renderable = false;
return true;
}
+221
View File
@@ -0,0 +1,221 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
isAppleMobilePlatform,
probeOutParamVaryingLinkFailure,
shouldDisableModelAtmosphere,
applyModelAtmosphereWorkaround,
} from './atmosphereCompat.js';
const IPAD_OS12 = {
userAgent:
'Mozilla/5.0 (iPad; CPU OS 12_5 like Mac OS X) AppleWebKit/605.1.15',
platform: 'iPad',
maxTouchPoints: 5,
};
// iPadOS 13+ desktop mode: no iPad token, reports as a Mac.
const IPAD_DESKTOP_MODE = {
userAgent:
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15',
platform: 'MacIntel',
maxTouchPoints: 5,
};
const MAC_DESKTOP = {
userAgent:
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15',
platform: 'MacIntel',
maxTouchPoints: 0,
};
const LINUX_DESKTOP = {
userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/141',
platform: 'Linux x86_64',
maxTouchPoints: 0,
};
/** Minimal GL double. `linkOk: false` mimics the Metal translator's refusal. */
function fakeGl({ linkOk = true, compileOk = true, throwOn = null } = {}) {
const deleted = { shaders: 0, programs: 0 };
return {
deleted,
VERTEX_SHADER: 1,
FRAGMENT_SHADER: 2,
COMPILE_STATUS: 3,
LINK_STATUS: 4,
createShader: () => ({}),
createProgram: () => ({}),
shaderSource() {
if (throwOn === 'shaderSource') throw new Error('context lost');
},
compileShader() {},
attachShader() {},
linkProgram() {},
getShaderParameter: () => compileOk,
getProgramParameter: () => linkOk,
deleteShader: () => (deleted.shaders += 1),
deleteProgram: () => (deleted.programs += 1),
};
}
test('identifies iPadOS in both legacy and desktop-mode guises', () => {
assert.equal(isAppleMobilePlatform(IPAD_OS12), true);
assert.equal(isAppleMobilePlatform(IPAD_DESKTOP_MODE), true);
});
test('does not mistake a real Mac or a Linux desktop for Apple mobile', () => {
// Regression guard: the desktop-mode check keys on touch points, and a Mac
// reports the same MacIntel platform string with zero of them.
assert.equal(isAppleMobilePlatform(MAC_DESKTOP), false);
assert.equal(isAppleMobilePlatform(LINUX_DESKTOP), false);
});
test('treats a missing navigator as not-Apple rather than throwing', () => {
assert.equal(isAppleMobilePlatform(undefined), false);
assert.equal(isAppleMobilePlatform(null), false);
});
test('detects desktop-mode iPadOS without navigator.platform', () => {
// `navigator.platform` is deprecated and can be absent or stripped; the UA's
// Macintosh token plus touch points has to carry the detection on its own.
assert.equal(
isAppleMobilePlatform({
userAgent:
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15',
maxTouchPoints: 5,
}),
true,
);
// Same UA, no touch: a real Mac, which must stay untouched.
assert.equal(
isAppleMobilePlatform({
userAgent:
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15',
maxTouchPoints: 0,
}),
false,
);
});
test('probe reports failure only when the program fails to link', () => {
assert.equal(
probeOutParamVaryingLinkFailure(fakeGl({ linkOk: false })),
true,
);
assert.equal(
probeOutParamVaryingLinkFailure(fakeGl({ linkOk: true })),
false,
);
});
test('a compile failure is not reported as the link bug', () => {
// The Metal defect surfaces at link time; both stages compile cleanly.
// Treating a compile error as the bug would disable fog for the wrong reason.
assert.equal(
probeOutParamVaryingLinkFailure(
fakeGl({ compileOk: false, linkOk: false }),
),
false,
);
});
test('probe releases its GL objects on both paths', () => {
const ok = fakeGl({ linkOk: true });
probeOutParamVaryingLinkFailure(ok);
assert.equal(ok.deleted.shaders, 2);
assert.equal(ok.deleted.programs, 1);
const thrown = fakeGl({ throwOn: 'shaderSource' });
probeOutParamVaryingLinkFailure(thrown);
assert.equal(thrown.deleted.shaders, 2, 'shaders freed even when GL throws');
assert.equal(thrown.deleted.programs, 1);
});
test('probe treats a null context as healthy, not broken', () => {
assert.equal(probeOutParamVaryingLinkFailure(null), false);
});
test('a failing probe disables the stage on any platform', () => {
assert.equal(
shouldDisableModelAtmosphere({
navigatorLike: LINUX_DESKTOP,
createProbeContext: () => fakeGl({ linkOk: false }),
}),
true,
);
});
test('a passing probe still quarantines Apple mobile but frees desktops', () => {
const createProbeContext = () => fakeGl({ linkOk: true });
assert.equal(
shouldDisableModelAtmosphere({
navigatorLike: IPAD_DESKTOP_MODE,
createProbeContext,
}),
true,
);
assert.equal(
shouldDisableModelAtmosphere({
navigatorLike: LINUX_DESKTOP,
createProbeContext,
}),
false,
);
});
test('falls back to platform detection when no context can be made', () => {
const createProbeContext = () => null;
assert.equal(
shouldDisableModelAtmosphere({
navigatorLike: IPAD_OS12,
createProbeContext,
}),
true,
);
assert.equal(
shouldDisableModelAtmosphere({
navigatorLike: MAC_DESKTOP,
createProbeContext,
}),
false,
);
});
test('applies to the scene by clearing renderable, never enabled', () => {
// `enabled` must survive: Fog.update short-circuits when it is false, which
// forfeits the density used for 3D Tiles screen-space-error scaling, and
// visualSettings' IR boost saves/restores that same flag.
const scene = { fog: { enabled: true, renderable: true } };
const applied = applyModelAtmosphereWorkaround(scene, {
navigatorLike: IPAD_DESKTOP_MODE,
createProbeContext: () => null,
});
assert.equal(applied, true);
assert.equal(scene.fog.renderable, false);
assert.equal(scene.fog.enabled, true);
});
test('leaves unaffected devices completely untouched', () => {
const scene = { fog: { enabled: true, renderable: true } };
const applied = applyModelAtmosphereWorkaround(scene, {
navigatorLike: LINUX_DESKTOP,
createProbeContext: () => fakeGl({ linkOk: true }),
});
assert.equal(applied, false);
assert.equal(scene.fog.renderable, true);
});
test('tolerates a scene with no fog object', () => {
assert.equal(applyModelAtmosphereWorkaround({}, {}), false);
assert.equal(applyModelAtmosphereWorkaround(null, {}), false);
});
test('releases the throwaway probe context after probing', () => {
let lost = 0;
const gl = fakeGl({ linkOk: true });
gl.getExtension = (name) =>
name === 'WEBGL_lose_context' ? { loseContext: () => (lost += 1) } : null;
shouldDisableModelAtmosphere({
navigatorLike: LINUX_DESKTOP,
createProbeContext: () => gl,
});
assert.equal(lost, 1, 'probe context must not linger until GC');
});
+1
View File
@@ -11,6 +11,7 @@ const CONTROL_LAYER_IDS = Object.freeze({
militaryAwarenessLayer: 'military-awareness',
militaryInstallationsLayer: 'military-installations',
rocketLaunchesLayer: 'rocket-launches',
localAdsbLayer: 'local-adsb',
});
/** Capture the ordered application instances and their serialization metadata. */
+2
View File
@@ -93,6 +93,7 @@ test('controls bind catalog instances rather than similarly named defaults', ()
'military-awareness',
'military-installations',
'rocket-launches',
'local-adsb',
];
const layers = ids.map((id) => ({ id }));
const catalog = createLayerCatalog(layers, metadata(layers));
@@ -100,6 +101,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(new Set(Object.values(services)).size, layers.length);
assert.throws(
() => catalogControlServices(createLayerCatalog([], [])),
+62 -3
View File
@@ -1,3 +1,7 @@
import { createWeatherClock } from '../layers/weather/clock.js';
import { createWeatherLayer } from '../layers/weather/index.js';
import { createCyclonesLayer } from '../layers/cyclones/index.js';
import { createWindLayer } from '../layers/wind/index.js';
import { createLayerCatalog } from './catalog.js';
import { LAYER_STATE_REGISTRY } from '../data/layerState.js';
import { createMilitaryRegistry } from '../layers/aircraft/classification.js';
@@ -9,14 +13,17 @@ import { createApplicationRadio } from './layers/radio.js';
import { createApplicationTraffic } from './layers/traffic.js';
import { createApplicationBikeshare } from './layers/bikeshare.js';
import { createApplicationDirections } from './layers/directions.js';
import { createApplicationRecentImagery } from './layers/recentImagery.js';
import { createApplicationTransit } from './layers/transit.js';
import { createApplicationInstallations } from './layers/militaryInstallations.js';
import { createApplicationSatellites } from './layers/satellites.js';
import { createApplicationLaunches } from './layers/rocketLaunches.js';
import { createApplicationAlpr } from './layers/alprCameras.js';
import { createApplicationLocalAdsb } from './layers/localAdsb.js';
import { createApplicationAwareness } from './layers/militaryAwareness.js';
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 { createInfrastructureLayers } from '../data/infrastructure.js';
import { localGeoJsonServices } from './localGeojsonServices.js';
@@ -42,10 +49,28 @@ const SOURCE_METHODS = Object.freeze({
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.
*/
export const LOCAL_ONLY_LAYER_METADATA = Object.freeze([
Object.freeze({ id: 'local-adsb', disposition: 'local-only' }),
]);
/** Serialization metadata for every layer the application catalog constructs. */
export const APPLICATION_LAYER_METADATA = Object.freeze([
...LAYER_STATE_REGISTRY,
...LOCAL_ONLY_LAYER_METADATA,
]);
/** Construct the current catalog without choosing any source provider.
* Scene engines remain page-owned; layers and classification have this app's lifetime.
* The manager owns layer destruction, while abort releases classification even if startup fails.
@@ -54,7 +79,7 @@ export function createApplicationCatalog({
surface,
sources,
signal,
metadata = LAYER_STATE_REGISTRY,
metadata = APPLICATION_LAYER_METADATA,
vesselOptions,
resolveAsset,
nepalBoundaryResolver,
@@ -72,9 +97,11 @@ export function createApplicationCatalog({
throw new TypeError(`Invalid catalog source: ${name}`);
}
const militaryRegistry = createMilitaryRegistry();
const weatherClock = createWeatherClock();
const dispose = () => {
signal.removeEventListener('abort', dispose);
militaryRegistry.dispose();
weatherClock.destroy();
};
signal.addEventListener('abort', dispose, { once: true });
try {
@@ -110,16 +137,26 @@ export function createApplicationCatalog({
}),
flights,
military,
createApplicationLocalAdsb({
surface,
enrichment: sources.flights,
displayParams: () => flights.getParams(),
...(resolveAsset ? { resolveAsset } : {}),
}),
createApplicationEarthquakes({ source: sources.earthquakes }),
createApplicationFirePerimeters({
source: sources['fire-perimeters'],
}),
createApplicationAlpr({ surface, source: sources.alpr }),
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(),
vessels,
installations,
createApplicationAwareness({
@@ -128,6 +165,23 @@ export function createApplicationCatalog({
vessels,
installations,
}),
createWindLayer({ feed: sources.wind, clock: weatherClock }),
createWeatherLayer({
feed: sources.weather,
id: 'weather-radar',
clock: weatherClock,
}),
createWeatherLayer({
feed: sources.weather,
id: 'weather-satellite',
clock: weatherClock,
}),
createWeatherLayer({
feed: sources.weather,
id: 'weather-lightning',
clock: weatherClock,
}),
createCyclonesLayer({ feed: sources.cyclones }),
...createInfrastructureLayers(localGeoJsonServices),
createApplicationCables({ source: sources.cables }),
createApplicationFirms({
@@ -141,7 +195,12 @@ export function createApplicationCatalog({
],
metadata,
);
return Object.freeze({ ...catalog, militaryRegistry, surface });
return Object.freeze({
...catalog,
militaryRegistry,
surface,
weatherClock,
});
} catch (error) {
dispose();
throw error;
+24 -1
View File
@@ -39,7 +39,26 @@ test('catalogs construct distinct layers and classification from their supplied
signal: b.signal,
surface: fixtureSurface(b.signal),
});
assert.equal(first.layers.length, 21);
assert.equal(first.layers.length, 29);
assert.ok(first.get('local-adsb'), 'Local ADS-B is registered');
assert.deepEqual(
first.metadata.find(({ id }) => id === 'local-adsb'),
{ id: 'local-adsb', disposition: 'local-only' },
'the hardware-local layer is never serialized into links',
);
assert.notEqual(first.weatherClock, second.weatherClock);
await first.weatherClock.setTarget('2026-09-21T12:00:00.000Z');
assert.match(
first.get('wind').getRowControls().info,
/Forecast · does not follow history/,
);
assert.equal(second.get('wind').getRowControls().summary.status, null);
for (const id of ['weather-radar', 'weather-satellite', 'weather-lightning'])
assert.equal(
first.get(id).getDiagnostics().clock.target,
'2026-09-21T12:00:00.000Z',
);
assert.ok(first.get('fire-perimeters'));
assert.ok(first.get('transit'));
const order = first.layers.map(({ id }) => id);
assert.deepEqual(
@@ -82,6 +101,10 @@ test('catalogs construct distinct layers and classification from their supplied
assert.equal(first.militaryRegistry.isMilitaryIcao('def456'), false);
assert.equal(second.militaryRegistry.isMilitaryIcao('def456'), true);
a.abort();
assert.equal(
await first.weatherClock.setTarget('2026-09-21T13:00:00.000Z'),
false,
);
assert.equal(callsA[0].aborted, true);
assert.equal(first.militaryRegistry.isMilitaryIcao('abc123'), false);
assert.equal(callsB[0].aborted, false);
+5 -1
View File
@@ -1,5 +1,6 @@
import { LayerLifecycle } from '../data/lifecycle.js';
import { LayerPresentation } from './layerPresentation.js';
import { createCyberSonarScene } from '../cyberSonarScene.js';
/** Register the application layer catalog before allowing state restoration. */
export function createApplicationData({
scene: { viewer, mapStackController },
@@ -20,7 +21,9 @@ export function createApplicationData({
`Data layers could not be destroyed: ${[...dataManager.layers.keys()].join(', ')}`,
);
});
const presentation = new LayerPresentation(dataManager);
const presentation = new LayerPresentation(dataManager, {
weatherClock: catalog?.weatherClock,
});
defer(() => presentation.destroy());
onData?.(dataManager);
if (!catalog?.layers || !catalog?.metadata)
@@ -53,6 +56,7 @@ export function createApplicationData({
}
presentation.mount(document.getElementById('data-toggles'));
styleManager.attachDataManager(dataManager);
defer(createCyberSonarScene(viewer, dataManager));
return { dataManager, catalog, presentation };
}
+9
View File
@@ -7,11 +7,13 @@ export class LayerPresentation {
constructor(
manager,
{
weatherClock,
requestRender = governorRequestRender,
invalidateDetection = markDetectionSourcesChanged,
} = {},
) {
this.manager = manager;
this.weatherClock = weatherClock;
this._panel = null;
this.pendingVisible = false;
this._unsubscribe = manager.subscribeActivity((change) => {
@@ -35,6 +37,7 @@ export class LayerPresentation {
get panel() {
if (!this._panel)
this._panel = new LayerPanel({
weatherClock: this.weatherClock,
getLayers: () => this.manager.getAll(),
isEnabled: (id) => this.manager.isEnabled(id),
setEnabled: (id, enabled, options) =>
@@ -67,6 +70,12 @@ export class LayerPresentation {
mount(container) {
this.panel.mount(container);
}
/** Hand the Recent Imagery readout factory to the panel (see LayerPanel). */
attachRecentImagery(factory) {
// Detaching never builds a panel that teardown already released.
if (factory) this.panel.attachRecentImagery(factory);
else this._panel?.attachRecentImagery(null);
}
refresh() {
this._panel?._refreshTogglePanel();
}
+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,
+48
View File
@@ -0,0 +1,48 @@
import { createLocalAdsbLayer } from '../../layers/localAdsb/index.js';
import { createLocalReceiverFeeds } from '../../layers/localAdsb/feeds.js';
import { SdrController } from '../../sdr/controller.js';
import * as render from '../../renderGovernor.js';
import * as context from '../../data/contextStore.js';
import * as picking from '../../data/pickRegistry.js';
import * as trails from '../../data/trailRenderer.js';
import * as geoid from '../../data/geoid.js';
import { markDetectionSourcesChanged } from '../../data/detection.js';
import { refreshTrackedReadout } from '../../data/trackedReadout.js';
/**
* Construct the Local ADS-B layer, the browser RTL-SDR session it shares with
* the Radio panel, and the decoder-feed session. Construction opens no device
* and makes no request; the SDR starts only from an explicit Connect and the
* feeds are polled only while the layer is enabled.
*
* `displayParams` reads the public Flights layer's DISPLAY-rail 3D preference
* (`{ models3d, models3dMode }`) so local aircraft follow the same toggle;
* `enrichment` is the Flights source whose cached adsbdb proxy they share.
*/
export function createApplicationLocalAdsb({
receiver = new SdrController(),
feeds = createLocalReceiverFeeds(),
surface = null,
enrichment = null,
displayParams = null,
resolveAsset = (url) =>
`${import.meta.env?.BASE_URL || '/'}${url.replace(/^\//, '')}`,
} = {}) {
return createLocalAdsbLayer({
receiver,
feeds,
resolveAsset,
services: {
render,
context,
picking,
trails,
geoid,
groundSnap: surface?.groundSnap || null,
enrichment,
display: displayParams ? { getParams: displayParams } : null,
detection: { markSourcesChanged: markDetectionSourcesChanged },
overlays: { refreshReadout: refreshTrackedReadout },
},
});
}
+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,
});
+29
View File
@@ -0,0 +1,29 @@
import * as Cesium from 'cesium';
import {
createFirePerimetersLayer,
createInciwebIndexSource,
createInciwebPublicationSource,
} from '../../layers/perimeters/index.js';
import * as picking from '../../data/pickRegistry.js';
import * as overlays from '../../overlays/worldOverlay.js';
import { isPointerFree } from '../../data/inputOwnership.js';
/** Wire WFIGS fire perimeters into the application catalog. */
export function createApplicationFirePerimeters(options) {
return createFirePerimetersLayer({
overlayHost: {
setEntries: overlays.setOverlayEntries,
setVisible: overlays.setOverlaySourceVisible,
clearSource: overlays.clearOverlaySource,
hitTest: overlays.hitTestWorldOverlay,
},
inciwebSource: createInciwebIndexSource(),
inciwebPublications: createInciwebPublicationSource(),
openExternal: (url) => window.open(url, '_blank', 'noopener,noreferrer'),
screenSpaceEventHandlerFactory: (viewer) =>
new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas),
picking,
pointer: { isPointerFree },
...options,
});
}
+19
View File
@@ -0,0 +1,19 @@
import { createRecentImageryLayer } from '../../layers/recentImagery/index.js';
import { createRecentImageryRenderer } from '../../layers/recentImagery/rendering.js';
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() {
return createRecentImageryLayer({
renderer: createRecentImageryRenderer({
requestRender: governorRequestRender,
}),
thumbnails: createThumbnailLoader(),
});
}
+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])
+2
View File
@@ -10,6 +10,7 @@ import { configureInstallationSource } from '../data/militaryInstallations.js';
import { configureSatelliteSource } from '../data/satellites.js';
import { configureLaunchSource } from '../data/rocketLaunches.js';
import { configureFirmsSource } from '../data/firmsHeatmap.js';
import { configureWindSource } from '../data/wind.js';
import { configureMilitaryRegistrySource } from '../data/militaryRegistry.js';
const configure = {
alpr: configureAlprSource,
@@ -21,6 +22,7 @@ const configure = {
satellites: configureSatelliteSource,
launches: configureLaunchSource,
firms: configureFirmsSource,
wind: configureWindSource,
};
/** Configure sources before any registration or state restoration starts. */
export function configureApplicationSources({
+39
View File
@@ -1,6 +1,8 @@
import { SceneDirector } from '../scenes/director.js';
import { initAnnotations } from '../annotations/index.js';
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 { installScopeMask, destroyScopeMask } from '../scopeMask.js';
import {
@@ -53,6 +55,43 @@ export function createApplicationTools({
// lifetime rather than to whoever last pressed the button.
const drawTool = initDrawTool({ viewer, annotations });
defer(() => drawTool?.destroy());
// DATA ▸ Recent Imagery: the box tool claims the pointer like Draw and the
// panel lives on the right rail, so both belong to the application
// lifetime. The tileset lets the layer drape while the globe is hidden.
const recentImagery = dataManager.layers.get('recent-imagery')?.module;
if (recentImagery) {
recentImagery.attachTileset(tileset);
const imageryBoxTool = initImageryBoxTool({
viewer,
onBox: (box) => recentImagery.setBox(box),
onCancel: (reason, message, box) => {
if (message) recentImagery.reportBoxRefusal(message, box);
},
onActive: (active) => recentImagery.setToolActive(active),
// The tool takes Escape in a capture listener, so the panel's order
// (clear a preview before cancelling the tool) is applied here.
onEscape: () => recentImagery.clearPreview(),
});
// The readout mounts in its rail body through the layer panel, like the
// weather readout.
data.presentation.attachRecentImagery((container) =>
createRecentImageryPanel({
container,
viewer,
layer: recentImagery,
tool: imageryBoxTool,
}),
);
// The live gate's handle (scripts/qa-recent-imagery.mjs).
const recentImageryHandle = { layer: recentImagery, tool: imageryBoxTool };
window.__gevRecentImagery = recentImageryHandle;
defer(() => {
if (window.__gevRecentImagery === recentImageryHandle)
delete window.__gevRecentImagery;
data.presentation.attachRecentImagery(null);
imageryBoxTool?.destroy();
});
}
if (startChrome)
defer(startChrome({ loadingScreen, styleManager, dataManager, signal }));
// Idle render governor: flips the scene into requestRenderMode whenever
+5
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { applyModelAtmosphereWorkaround } from './atmosphereCompat.js';
const PINCH_ZOOM_MULTIPLIER = 8;
const MAX_PINCH_PIXEL_DELTA = 120;
@@ -124,6 +125,10 @@ export function createApplicationViewer({ container, creditContainer }) {
});
try {
viewer.targetFrameRate = 60;
// Before any tile builds a draw command: Cesium's per-vertex model
// atmosphere fails to LINK on Apple's Metal backend and kills the
// render loop. See app/atmosphereCompat.js.
applyModelAtmosphereWorkaround(viewer.scene);
viewer.scene.globe.show = false;
viewer.scene.skyAtmosphere.show = true;
viewer.scene.skyAtmosphere.atmosphereLightIntensity = 18;
+1 -1
View File
@@ -22,7 +22,7 @@ test('no browser-built module imports a Node core module', () => {
// import survives the build and turns into a runtime failure the moment the
// guard around it is wrong. src/data/naturalEarthRegions.js and
// src/data/neighborhoodPolygons.js both carried one to read their bundled
// JSON packs under node:test; an import attribute serves both runtimes.
// JSON packs under node:test; src/data/bundledJson.js now serves both runtimes.
const offenders = [];
for (const file of browserModules()) {
const source = readFileSync(file, 'utf8');
+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();
});
}
+3 -3
View File
@@ -342,11 +342,11 @@ test('Clear Selected Layers uses one adopted batch and discards Context restorat
test('Cockpit Display portal retains both scroll owners across round trips', () => {
const portal = CockpitDisplayPortal.toString();
assert.match(portal, /this\.standardScrollTop = standardPanel\?\.scrollTop \|\| 0/);
assert.match(portal, /this\.standardScrollTop =\s*displayPanelScroller\(standardPanel\)\?\.scrollTop \|\| 0/);
assert.match(portal, /this\.cockpitScrollTop = cockpitPanel\?\.scrollTop \|\| 0/);
assert.match(portal, /if \(!this\.active\)[\s\S]*?this\.standardScrollTop = standardPanel\.scrollTop/);
assert.match(portal, /if \(!this\.active\)[\s\S]*?this\.standardScrollTop =\s*displayPanelScroller\(standardPanel\)\.scrollTop/);
assert.match(portal, /if \(this\.active\)[\s\S]*?this\.cockpitScrollTop = cockpitPanel\.scrollTop/);
assert.match(portal, /this\.cockpitPanel\.scrollTop = this\.cockpitScrollTop[\s\S]*?this\.standardPanel\.scrollTop = this\.standardScrollTop/);
assert.match(portal, /this\.cockpitPanel\.scrollTop = this\.cockpitScrollTop[\s\S]*?displayPanelScroller\(this\.standardPanel\)\.scrollTop =\s*this\.standardScrollTop/);
});
test('Cockpit side surfaces behave as two single-expanded accordions', () => {
+44 -1
View File
@@ -17,6 +17,11 @@
* @param {number} [input.signalGap] Clearance above the briefing card.
* @param {number} [input.minTopFloor] Absolute ceiling for the strip, in px.
* @param {number} [input.minTopRatio] Viewport-relative ceiling, 0..1.
* @param {boolean} [input.signalCollapsed] Whether the briefing card is collapsed.
* @param {boolean} [input.utilityExpanded] Whether Display or Radio is expanded.
* @param {number} [input.bottomInset] Reserved viewport space below an expanded panel.
* @param {number} [input.signalHeight] Collapsed briefing header reserved below the utility.
* @param {boolean} [input.reserveViewportLane] Theme relocates the briefing below the expanded utility.
* @returns {{ top: number, maxHeight: number }}
*/
export function resolveCockpitUtilityAnchor({
@@ -29,13 +34,29 @@ export function resolveCockpitUtilityAnchor({
signalGap = 8,
minTopFloor = 96,
minTopRatio = 0.12,
signalCollapsed = false,
utilityExpanded = false,
bottomInset = 80,
signalHeight = 0,
reserveViewportLane = false,
}) {
const viewport = Math.max(0, Number(viewportHeight) || 0);
const minTop = Math.max(minTopFloor, viewport * minTopRatio);
const anchoredTop = Math.max(minTop, (Number(recBottom) || 0) + recGap);
const strip = Math.max(0, Number(stripHeight) || 0);
const signal = Number(signalTop);
const lowerBound = Number.isFinite(signal) ? signal : viewport;
const expandedLaneBottom = Math.max(
0,
viewport -
Math.max(0, Number(bottomInset) || 0) -
Math.max(0, Number(signalHeight) || 0),
);
const lowerBound =
reserveViewportLane && signalCollapsed && utilityExpanded
? expandedLaneBottom
: Number.isFinite(signal)
? signal
: viewport;
const clearedTop = lowerBound - signalGap - strip;
const top = Math.max(minTop, Math.min(anchoredTop, clearedTop));
const floor = Math.max(0, Number(collapsedHeight) || 0) || 50;
@@ -79,3 +100,25 @@ export function resolveCockpitUtilityLayout({
),
};
}
/** Shared Cyber panel slot, with footer controls above the lower frame (83vh). */
export function resolveCyberCockpitPanelLane({
viewportHeight,
top,
launcherHeight = 60,
signalHeight = 44,
contactHeight = 64,
}) {
const gap = 8;
const footerHeight = Math.max(
contactHeight + gap,
launcherHeight + signalHeight + gap * 2,
);
const bottom = viewportHeight * 0.82;
const panelHeight = Math.max(120, bottom - top - footerHeight);
return {
panelHeight,
utilityHeight: panelHeight + gap + launcherHeight,
signalTop: top + panelHeight + launcherHeight + gap * 2,
};
}
+78 -1
View File
@@ -4,11 +4,58 @@ import assert from 'node:assert/strict';
import {
resolveCockpitUtilityAnchor,
resolveCockpitUtilityLayout,
resolveCyberCockpitPanelLane,
} from './cockpitUtilityLayout.js';
// 1512x790, the height the strip used to collide with the briefing card at.
const desktop = { viewportHeight: 790, stripHeight: 107, collapsedHeight: 50 };
test('Cyber shared panel slot reserves both footer lanes above the decorative frame', () => {
for (const viewportHeight of [600, 673, 713, 986, 1376]) {
for (const signalHeight of [44, 76]) {
for (const contactHeight of [44, 84, 120]) {
const top = Math.max(164, Math.min(204, viewportHeight * 0.19));
const lane = resolveCyberCockpitPanelLane({ viewportHeight, top, signalHeight, contactHeight });
assert.ok(lane.panelHeight >= 120);
assert.ok(top + lane.panelHeight + 8 + contactHeight <= viewportHeight * 0.82 + .01);
assert.ok(lane.signalTop + signalHeight <= viewportHeight * 0.82 + .01);
assert.equal(lane.signalTop, top + lane.utilityHeight + 8);
}
}
}
});
test('Cyber keeps the Data Layers top while reserving briefing clearance', () => {
for (const viewportHeight of [673, 713, 986, 1376]) {
const leftTop = Math.max(164, Math.min(204, viewportHeight * 0.19));
const collapsed = resolveCockpitUtilityAnchor({
viewportHeight, stripHeight: 128, collapsedHeight: 60, recBottom: 0,
signalTop: leftTop + 128 + 28, minTopFloor: leftTop, minTopRatio: 0,
});
assert.equal(collapsed.top, leftTop);
assert.ok(collapsed.maxHeight >= 128);
const expanded = resolveCockpitUtilityAnchor({
viewportHeight, stripHeight: 700, collapsedHeight: 60, recBottom: 0,
signalTop: 255, minTopFloor: leftTop, minTopRatio: 0,
signalCollapsed: true, utilityExpanded: true, reserveViewportLane: true, signalHeight: 44,
});
assert.equal(expanded.top, leftTop);
assert.equal(expanded.top + expanded.maxHeight + 8 + 44 + 80, viewportHeight);
}
});
test('expanded utilities reserve the visible collapsed briefing header at every desktop height', () => {
for (const viewportHeight of [640, 720, 987, 1376]) {
for (const signalHeight of [48, 76, 100]) {
const { top, maxHeight } = resolveCockpitUtilityAnchor({
...desktop, viewportHeight, recBottom: 0, signalTop: 255,
signalCollapsed: true, utilityExpanded: true, signalHeight, reserveViewportLane: true,
});
assert.equal(top + maxHeight + 8, viewportHeight - 80 - signalHeight);
}
}
});
test('the strip hangs 12px under the REC readout when the briefing card leaves room', () => {
const { top } = resolveCockpitUtilityAnchor({ ...desktop, recBottom: 148.1, signalTop: 420 });
assert.equal(Number(top.toFixed(1)), 160.1);
@@ -54,6 +101,37 @@ test('a missing REC readout leaves the strip on the viewport ceiling', () => {
assert.equal(top, 96);
});
test('an expanded utility claims the vertical lane released by a collapsed briefing', () => {
const { top, maxHeight } = resolveCockpitUtilityAnchor({
...desktop,
recBottom: 148.1,
signalTop: 265.4,
signalCollapsed: true,
utilityExpanded: true,
reserveViewportLane: true,
});
assert.equal(Number(top.toFixed(1)), 160.1);
assert.equal(Number(maxHeight.toFixed(1)), 541.9);
});
test('non-relocating themes preserve the visible briefing boundary even when collapsed', () => {
const { top, maxHeight } = resolveCockpitUtilityAnchor({
...desktop, recBottom: 148.1, signalTop: 265.4,
signalCollapsed: true, utilityExpanded: true,
});
assert.equal(Number((top + maxHeight).toFixed(1)), 257.4);
});
test('a collapsed briefing still bounds the utility strip until a panel expands', () => {
const { maxHeight } = resolveCockpitUtilityAnchor({
...desktop,
recBottom: 148.1,
signalTop: 265.4,
signalCollapsed: true,
});
assert.equal(Number(maxHeight.toFixed(1)), 107);
});
test('keeps the collapsed sibling visible when both Cockpit utilities fit', () => {
assert.deepEqual(resolveCockpitUtilityLayout({
availableHeight: 320,
@@ -94,4 +172,3 @@ test('clamps malformed or short corridors to the minimum expanded height', () =>
expandedMaxHeight: 120,
});
});
+28 -4
View File
@@ -205,6 +205,7 @@ const RECOGNIZED = new Set([
// rail
'#right-context-rail',
'#right-context-rail.layout-focus',
'#right-context-rail[data-rail-measuring]',
// tray
'#command-dock .dock-popover-content',
'#command-dock #location-bar .dock-popover-content',
@@ -400,20 +401,26 @@ test('the model refuses every cascade construct it cannot resolve', () => {
complaints.push(`nested media queries on "${part}": ${rule.media.join(' && ')}`);
}
for (const decl of guarded) {
if (decl.important) complaints.push(`!important on ${decl.prop} of "${part}"`);
// Measurement is synchronous and restored before returning or painting.
// Only these two exact declarations earn the exemption tested below.
const railMeasurement = part === '#right-context-rail[data-rail-measuring]'
&& ((decl.prop === 'height' && decl.value === 'auto')
|| (decl.prop === 'max-height' && decl.value === 'none'));
if (decl.important && !railMeasurement) complaints.push(`!important on ${decl.prop} of "${part}"`);
if (decl.prop === 'inset' || decl.prop === 'margin' || decl.prop === 'all'
|| decl.prop.startsWith('inset-') || decl.prop.startsWith('margin-block')) {
complaints.push(`shorthand ${decl.prop} on "${part}" — the model reads longhands only`);
}
if (decl.prop === 'height' || decl.prop === 'max-height') {
// A capped height can override `bottom` and invalidate the measured
// dock/credit constants. Two exemptions, each earned by a test below:
// dock/credit constants. Exemptions are each earned by a test below:
// the rail's own max-height (resolved to `none` across the whole
// modelled band by the rail clearance test) and `.layout-focus`
// (proven inapplicable at <=720px by the mobile-mode test).
// (proven inapplicable at <=720px by the mobile-mode test), plus
// synchronous measurement, which is removed before any paint.
const railOwn = part === '#right-context-rail' && decl.prop === 'max-height';
const railFocus = part === '#right-context-rail.layout-focus';
if (!railOwn && !railFocus) complaints.push(`${decl.prop}: ${decl.value} on "${part}"`);
if (!railOwn && !railFocus && !railMeasurement) complaints.push(`${decl.prop}: ${decl.value} on "${part}"`);
}
if (decl.prop === 'transform' && /translateY|translate3d|matrix|scale\(/.test(decl.value)) {
const identity = decl.value === 'translateY(0) scale(1)';
@@ -490,6 +497,17 @@ test('the full-width rail cannot inherit a height that overrides its floor', ()
assert.match(mobileBranch, /stack\.style\.removeProperty\('--right-stack-max-height'\)/);
});
test('right rail measurement cannot persist into a painted attribution layout', () => {
const rail = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'rightPanelRail.js'), 'utf8');
const start = rail.indexOf("stack.setAttribute('data-rail-measuring', '')");
const end = rail.indexOf("stack.removeAttribute('data-rail-measuring')", start);
assert.ok(start > rail.indexOf("layoutMode = 'mobile'"));
assert.ok(end > start);
const measurement = rail.slice(start, end);
assert.match(measurement, /try \{[\s\S]*\} finally \{/);
assert.doesNotMatch(measurement, /\b(?:await|return|yield)\b|requestAnimationFrame|setTimeout/);
});
// ── Clearance ───────────────────────────────────────────────────────────────
test('every open dock tray clears the required credit at every modelled viewport', () => {
@@ -596,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/);
});
+562
View File
@@ -0,0 +1,562 @@
/**
* Scanner design inspired by kk376's tactical naval sonar visual style:
* https://github.com/bilawalsidhu/gods-eye-view/pull/214
*
* This is a separate HUD/contact treatment, not a port of that PR's shader.
*/
export const CYBER_SONAR_PERIOD_MS = 5_200;
// Parked-scene wake-ups and shared overlays use the fleet cadence. Native
// contacts still compose after layer updates on each actual draw so owner
// color writes cannot flash through during camera/fleet animation.
export const CYBER_SONAR_RENDER_INTERVAL_MS = 80;
export const CYBER_SONAR_SWEEP_DEG = 24;
export const CYBER_SONAR_FEATHER_DEG = 10;
// The sector should still read as a clear acquisition pass, but contacts
// outside it must remain immediately legible against FLIR terrain. Icons and
// dots keep a strong operator-tunable opacity floor; labels retain an even
// brighter floor so typography never disappears between passes.
export const CYBER_SONAR_DIM_FACTOR = 0.84;
export const CYBER_SONAR_LABEL_DIM_FACTOR = 0.92;
export const CYBER_SONAR_DEFAULTS = Object.freeze({
rings: 8,
range: 100,
intensity: 70,
opacity: 84,
sector: 24,
});
const clamp01 = (value) => Math.min(1, Math.max(0, value));
const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
const primitiveColors = new WeakMap();
const sweepAnimations = new WeakMap();
/** Pin the compositor animation to the absolute clock used by contact sampling.
* A negative delay alone is relative to the animation's eventual start, which
* may be much later than theme initialization (or restart after HUD hiding).
* Cache the running animation: no per-frame style writes or animation queries.
*/
export function syncCyberSonarAnimation(
root = globalThis.document?.documentElement,
) {
if (!root) return;
const cached = sweepAnimations.get(root);
if (cached?.element.isConnected && cached.animation.playState !== 'idle')
return;
const element = root.querySelector?.('.hud-sonar');
const animation = element
?.getAnimations?.({ subtree: true })
.find((item) => item.animationName === 'cyber-sonar-sweep');
if (!animation) return;
// The document animation timeline and performance.now share the navigation
// time origin. Zero both offsets so delayed starts cannot shift the sector.
animation.effect.updateTiming({ delay: 0 });
animation.startTime = 0;
sweepAnimations.set(root, { element, animation });
}
const copyColor = (color) => {
if (!color || !Number.isFinite(color.alpha)) return null;
if (typeof color.clone === 'function') return color.clone();
return { ...color };
};
/** Read owner opacity before a hue-only restyle, never our composited opacity.
* An independently written color still wins, including real freshness changes.
*/
export function cyberSonarBaseAlpha(visual, property = 'color') {
const current = visual?.[property];
const state = primitiveColors.get(visual)?.[property];
const applied = state?.lastAppliedColor;
return applied &&
current &&
current.red === applied.red &&
current.green === applied.green &&
current.blue === applied.blue &&
current.alpha === applied.alpha
? state.base.alpha
: (current?.alpha ?? 1);
}
/** Apply the sector factor to a Cesium point/billboard without owning its base style. */
export function applyCyberSonarPrimitive(
visual,
factor,
active = true,
sampler,
property = 'color',
opaque = false,
) {
if (!visual) return;
const current = visual[property];
let slots = primitiveColors.get(visual);
let state = slots?.[property];
const ownsCurrentColor =
state &&
current &&
current.red === state.lastAppliedColor.red &&
current.green === state.lastAppliedColor.green &&
current.blue === state.lastAppliedColor.blue &&
current.alpha === state.lastAppliedColor.alpha;
if (!active) {
// An owner refresh between the last paint and teardown wins over our cache.
if (ownsCurrentColor)
visual[property] = state.undefinedBase
? undefined
: copyColor(state.base);
if (slots) delete slots[property];
return;
}
if (!current && property !== 'color') return;
const nextFactor = clamp01(factor);
// Cesium setters copy into a stable internal Color instead of retaining the
// assigned object. Compare its channels with our snapshot as well, so an
// owner's in-place style refresh is still recognized as a new base.
if (
ownsCurrentColor &&
Math.abs(nextFactor - state.lastAppliedFactor) <= 0.002 &&
state.floor === sampler?.floor
) {
return;
}
if (!ownsCurrentColor) {
if (!state) {
state = {
base: copyColor(current) || { red: 1, green: 1, blue: 1, alpha: 1 },
lastAppliedFactor: Number.NaN,
};
if (!slots) primitiveColors.set(visual, (slots = Object.create(null)));
slots[property] = state;
} else {
state.base.red = current?.red ?? 1;
state.base.green = current?.green ?? 1;
state.base.blue = current?.blue ?? 1;
state.base.alpha = current?.alpha ?? 1;
}
state.undefinedBase = current === undefined;
}
const next = state.lastAppliedColor || copyColor(state.base);
if (!next) return;
const tint =
property === 'outlineColor' || property === 'backgroundColor'
? state.base
: sampler
? sampler.tint(state.base, nextFactor, next)
: cyberSonarTintColor(state.base, nextFactor);
next.red = tint.red * (opaque ? nextFactor : 1);
next.green = tint.green * (opaque ? nextFactor : 1);
next.blue = tint.blue * (opaque ? nextFactor : 1);
// Keep models in their owner's opaque/translucent render pass. Dimming RGB
// avoids rebuilding model draw commands twice on every sector crossing.
next.alpha = state.base.alpha * (opaque ? 1 : nextFactor);
visual[property] = next;
state.appliedColor = visual[property];
state.lastAppliedColor = next;
state.lastAppliedFactor = nextFactor;
state.floor = sampler?.floor;
}
/** One settings read and one clock/viewport calculation per paint, not contact. */
export function createCyberSonarSampler(
width,
height,
now,
root = globalThis.document?.documentElement,
) {
if (now === undefined) now = globalThis.performance?.now?.() ?? 0;
const settings = readCyberSonarSettings(root);
const floor = settings.opacity / 100;
const radius = (Math.min(width * 0.4, height * 0.52) * settings.range) / 100;
const radiusSquared = radius * radius;
const angle = cyberSonarAngleDeg(now);
const feather = Math.min(CYBER_SONAR_FEATHER_DEG, settings.sector * 0.42);
return {
floor,
neutral: floor + (1 - floor) * 0.46,
at(x, y) {
if (
!Number.isFinite(x) ||
!Number.isFinite(y) ||
width <= 0 ||
height <= 0
)
return 1;
const dx = x - width / 2,
dy = y - height / 2;
const distanceSquared = dx * dx + dy * dy;
if (distanceSquared > radiusSquared) return floor;
if (distanceSquared < radiusSquared * 0.000625) return 1;
const delta =
((((Math.atan2(dx, -dy) * 180) / Math.PI - angle) % 360) + 360) % 360;
if (delta > settings.sector) return floor;
return (
floor + (1 - floor) * Math.min(1, (settings.sector - delta) / feather)
);
},
label(factor) {
return (1 + factor) * 0.5;
},
tint(base, factor, out) {
const coverage =
floor >= 0.9999 ? 1 : clamp01((factor - floor) / (1 - floor));
out.red = base.red * 0.28 + (0.62 + 0.38 * coverage) * 0.72;
out.green = base.green * 0.28 + (0.68 + 0.16 * coverage) * 0.72;
out.blue = base.blue * 0.28 + (0.72 + 0.12 * coverage) * 0.72;
return out;
},
};
}
/** Remaining delay before the next contact-projection refresh is due. */
export function cyberSonarFrameDelay(
lastPaintAt,
nowMs,
intervalMs = CYBER_SONAR_RENDER_INTERVAL_MS,
) {
if (
!Number.isFinite(lastPaintAt) ||
!Number.isFinite(nowMs) ||
!Number.isFinite(intervalMs) ||
intervalMs <= 0
) {
return 0;
}
return Math.max(0, intervalMs - (nowMs - lastPaintAt));
}
const datasetNumber = (root, key, fallback) => {
const value = Number(root?.dataset?.[key]);
return Number.isFinite(value) ? value : fallback;
};
export function readCyberSonarSettings(
root = globalThis.document?.documentElement,
) {
return {
rings: Math.round(
clamp(
datasetNumber(root, 'cyberSonarRings', CYBER_SONAR_DEFAULTS.rings),
3,
12,
),
),
range: Math.round(
clamp(
datasetNumber(root, 'cyberSonarRange', CYBER_SONAR_DEFAULTS.range),
60,
120,
),
),
intensity: Math.round(
clamp(
datasetNumber(
root,
'cyberSonarIntensity',
CYBER_SONAR_DEFAULTS.intensity,
),
0,
100,
),
),
opacity: Math.round(
clamp(
datasetNumber(root, 'cyberSonarOpacity', CYBER_SONAR_DEFAULTS.opacity),
35,
100,
),
),
sector: Math.round(
clamp(
datasetNumber(root, 'cyberSonarSector', CYBER_SONAR_DEFAULTS.sector),
8,
60,
),
),
};
}
export function applyCyberSonarSettings(
settings,
root = globalThis.document?.documentElement,
) {
if (!root?.dataset || !root?.style) return readCyberSonarSettings(root);
const next = {
...readCyberSonarSettings(root),
...(settings || {}),
};
// Ignore stale session input from the removed effect selector.
delete next.mode;
delete root.dataset.cyberSonarMode;
next.rings = Math.round(
clamp(Number(next.rings) || CYBER_SONAR_DEFAULTS.rings, 3, 12),
);
next.range = Math.round(
clamp(Number(next.range) || CYBER_SONAR_DEFAULTS.range, 60, 120),
);
next.intensity = Math.round(
clamp(
Number.isFinite(Number(next.intensity))
? Number(next.intensity)
: CYBER_SONAR_DEFAULTS.intensity,
0,
100,
),
);
next.opacity = Math.round(
clamp(
Number.isFinite(Number(next.opacity))
? Number(next.opacity)
: CYBER_SONAR_DEFAULTS.opacity,
35,
100,
),
);
next.sector = Math.round(
clamp(Number(next.sector) || CYBER_SONAR_DEFAULTS.sector, 8, 60),
);
root.dataset.cyberSonarRings = String(next.rings);
root.dataset.cyberSonarRange = String(next.range);
root.dataset.cyberSonarIntensity = String(next.intensity);
root.dataset.cyberSonarOpacity = String(next.opacity);
root.dataset.cyberSonarSector = String(next.sector);
root.style.setProperty('--cyber-sonar-ring-step', `${100 / next.rings}%`);
root.style.setProperty(
'--cyber-sonar-size-vw',
`${(80 * next.range) / 100}vw`,
);
root.style.setProperty(
'--cyber-sonar-size-vh',
`${(104 * next.range) / 100}vh`,
);
root.style.setProperty(
'--cyber-sonar-visual-intensity',
String(0.22 + 0.5 * (next.intensity / 100)),
);
root.style.setProperty(
'--cyber-sonar-sector-quarter',
`${next.sector * 0.25}deg`,
);
root.style.setProperty(
'--cyber-sonar-sector-middle',
`${next.sector * 0.6}deg`,
);
root.style.setProperty('--cyber-sonar-sector-width', `${next.sector}deg`);
return next;
}
const cyberSonarDimFactor = (root = globalThis.document?.documentElement) =>
readCyberSonarSettings(root).opacity / 100;
const cyberSonarLabelDimFactor = (
root = globalThis.document?.documentElement,
) => {
const contactFloor = cyberSonarDimFactor(root);
return Number((contactFloor + (1 - contactFloor) * 0.5).toFixed(4));
};
export function cyberSonarNeutralIntensity(
root = globalThis.document?.documentElement,
) {
const dimFactor = cyberSonarDimFactor(root);
return dimFactor + (1 - dimFactor) * 0.46;
}
export function cyberSonarTintColor(
baseColor,
contactIntensity,
root = globalThis.document?.documentElement,
) {
const dimFactor = cyberSonarDimFactor(root);
const remainingRange = 1 - dimFactor;
const coverage =
remainingRange <= 0.0001
? 1
: clamp01((contactIntensity - dimFactor) / remainingRange);
const target = {
red: 0.62 + 0.38 * coverage,
green: 0.68 + 0.16 * coverage,
blue: 0.72 + 0.12 * coverage,
};
const retain = 0.28;
return {
red: (baseColor?.red ?? 1) * retain + target.red * (1 - retain),
green: (baseColor?.green ?? 1) * retain + target.green * (1 - retain),
blue: (baseColor?.blue ?? 1) * retain + target.blue * (1 - retain),
alpha: baseColor?.alpha ?? 1,
};
}
const normalizedPhaseMs = (nowMs) => {
if (!Number.isFinite(nowMs)) return 0;
return (
((nowMs % CYBER_SONAR_PERIOD_MS) + CYBER_SONAR_PERIOD_MS) %
CYBER_SONAR_PERIOD_MS
);
};
/** Clockwise sweep heading, where 0 degrees is the top of the keyhole. */
export function cyberSonarAngleDeg(nowMs) {
return (normalizedPhaseMs(nowMs) / CYBER_SONAR_PERIOD_MS) * 360;
}
/** Negative CSS animation delay that synchronizes the sector to the shared clock. */
export function cyberSonarAnimationDelay(nowMs) {
return `${-normalizedPhaseMs(nowMs)}ms`;
}
/**
* Brightness/alpha multiplier for one screen-projected contact.
* Invalid projections fail open so a render edge case never hides a contact.
*/
export function cyberSonarIntensityAtScreenPoint(
x,
y,
viewportWidth,
viewportHeight,
nowMs,
root = globalThis.document?.documentElement,
) {
if (
![x, y, viewportWidth, viewportHeight, nowMs].every(Number.isFinite) ||
viewportWidth <= 0 ||
viewportHeight <= 0
) {
return 1;
}
const centerX = viewportWidth / 2;
const centerY = viewportHeight / 2;
const dx = x - centerX;
const dy = y - centerY;
const settings = readCyberSonarSettings(root);
const dimFactor = cyberSonarDimFactor(root);
const radius =
Math.min(viewportWidth * 0.4, viewportHeight * 0.52) *
(settings.range / 100);
const distance = Math.hypot(dx, dy);
if (distance > radius) return dimFactor;
if (distance < radius * 0.025) return 1;
const pointAngle = ((Math.atan2(dx, -dy) * 180) / Math.PI + 360) % 360;
const delta = (pointAngle - cyberSonarAngleDeg(nowMs) + 360) % 360;
const sweepDeg = settings.sector;
const featherDeg = Math.min(CYBER_SONAR_FEATHER_DEG, sweepDeg * 0.42);
if (delta > sweepDeg) return dimFactor;
const featherStart = sweepDeg - featherDeg;
const coverage =
delta <= featherStart ? 1 : clamp01((sweepDeg - delta) / featherDeg);
return dimFactor + (1 - dimFactor) * coverage;
}
/** Preserve label legibility while keeping it synchronized with the stronger contact sweep. */
export function cyberSonarLabelIntensity(
contactIntensity,
root = globalThis.document?.documentElement,
) {
const dimFactor = cyberSonarDimFactor(root);
const labelDimFactor = cyberSonarLabelDimFactor(root);
const remainingRange = 1 - dimFactor;
if (remainingRange <= 0.0001) return 1;
const normalized = clamp01((contactIntensity - dimFactor) / remainingRange);
return labelDimFactor + (1 - labelDimFactor) * normalized;
}
export function isCyberSonarEnabled(
root = globalThis.document?.documentElement,
) {
return root?.dataset?.cyberSonar !== 'off';
}
export function setCyberSonarEnabled(
enabled,
root = globalThis.document?.documentElement,
) {
if (!root?.dataset) return false;
root.dataset.cyberSonar = enabled ? 'on' : 'off';
if (root.dataset.uiTheme === 'cyber') {
applyCyberSonarThemePhase(root, enabled);
}
return enabled;
}
/** Whether the map-mode sweep should alter flight presentation. */
export function isCyberSonarActive({
root = globalThis.document?.documentElement,
body = globalThis.document?.body,
hud,
reducedMotion,
} = {}) {
// Keep optional method calls in the body: production downleveling of nested
// parameter initializers can otherwise lose the method's receiver binding.
if (hud === undefined)
hud = globalThis.document?.getElementById?.('intel-hud');
if (reducedMotion === undefined)
reducedMotion =
globalThis.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ??
false;
return !!(
root?.dataset?.uiTheme === 'cyber' &&
isCyberSonarEnabled(root) &&
hud?.classList?.contains?.('active') &&
!body?.classList?.contains?.('cockpit-mode') &&
!reducedMotion
);
}
export function isCyberMapThemeActive({
root = globalThis.document?.documentElement,
body = globalThis.document?.body,
hud,
} = {}) {
if (hud === undefined)
hud = globalThis.document?.getElementById?.('intel-hud');
return !!(
root?.dataset?.uiTheme === 'cyber' &&
hud?.classList?.contains?.('active') &&
!body?.classList?.contains?.('cockpit-mode')
);
}
// Unsupported engines retain their native contact rendering, without a dimmer
// fallback. Decorative rings remain independent from the contact adapter.
export function isCyberContactThemeActive(options = {}) {
const root = options.root ?? globalThis.document?.documentElement;
return (
isCyberMapThemeActive(options) &&
root?.dataset?.cyberSonarGpu !== 'unsupported'
);
}
export function isCyberContactSonarActive(options = {}) {
return isCyberContactThemeActive(options) && isCyberSonarActive(options);
}
/** Set or clear the shared visual phase used by the sector and contact sweep. */
export function applyCyberSonarThemePhase(root, enabled, nowMs) {
if (!root?.style) return;
if (!enabled || !isCyberSonarEnabled(root)) {
root.style.removeProperty?.('--cyber-sonar-angle');
root.style.removeProperty?.('--cyber-sonar-phase-delay');
return;
}
const clock = Number.isFinite(nowMs)
? nowMs
: (globalThis.performance?.now?.() ?? 0);
// The visual sweep is compositor-animated. Writing a custom property on the
// document root every frame invalidates styles across the entire HUD, so JS
// only seeds the shared clock when the theme or sonar state changes.
root.style.removeProperty?.('--cyber-sonar-angle');
const currentDelay = root.style
.getPropertyValue?.('--cyber-sonar-phase-delay')
?.trim();
// setVariant() may be called repeatedly with the already-active layout.
// Preserve the running animation's original timeline in that case; changing
// its negative delay after it has started would shift the sector off the
// absolute clock used by contact highlighting.
if (currentDelay) return;
root.style.setProperty?.(
'--cyber-sonar-phase-delay',
cyberSonarAnimationDelay(clock),
);
}
+370
View File
@@ -0,0 +1,370 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFile } from 'node:fs/promises';
import { transformWithEsbuild } from 'vite';
import { BillboardCollection, Color, PointPrimitive } from 'cesium';
import {
syncCyberSonarAnimation,
applyCyberSonarPrimitive,
cyberSonarBaseAlpha,
applyCyberSonarThemePhase,
cyberSonarAngleDeg,
cyberSonarAnimationDelay,
cyberSonarFrameDelay,
cyberSonarIntensityAtScreenPoint,
cyberSonarLabelIntensity,
cyberSonarTintColor,
applyCyberSonarSettings,
CYBER_SONAR_DIM_FACTOR,
CYBER_SONAR_LABEL_DIM_FACTOR,
CYBER_SONAR_PERIOD_MS,
CYBER_SONAR_RENDER_INTERVAL_MS,
isCyberSonarEnabled,
isCyberSonarActive,
setCyberSonarEnabled,
} from './cyberSonar.js';
test('production-transformed sonar defaults retain the document method receiver', async () => {
const source = await readFile(
new URL('./cyberSonar.js', import.meta.url),
'utf8',
);
const { code } = await transformWithEsbuild(source, 'cyberSonar.js', {
target: 'es2019',
minify: true,
});
const compiled = await import(
`data:text/javascript;base64,${Buffer.from(code).toString('base64')}`
);
const previous = Object.getOwnPropertyDescriptor(globalThis, 'document');
const hud = { classList: { contains: (name) => name === 'active' } };
const document = {
documentElement: { dataset: { uiTheme: 'cyber' } },
body: { classList: { contains: () => false } },
getElementById(id) {
assert.equal(this, document);
assert.equal(id, 'intel-hud');
return hud;
},
};
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: document,
});
try {
assert.equal(compiled.isCyberSonarActive(), true);
assert.equal(compiled.isCyberMapThemeActive(), true);
assert.ok(
Number.isFinite(compiled.createCyberSonarSampler(800, 600).at(400, 300)),
);
document.documentElement.dataset.uiTheme = 'tactical';
assert.equal(compiled.isCyberSonarActive(), false);
assert.equal(compiled.isCyberMapThemeActive(), false);
} finally {
if (previous) Object.defineProperty(globalThis, 'document', previous);
else delete globalThis.document;
}
});
test('hue-only aircraft restyles retain owner alpha across sonar opacity changes', () => {
const collection = new BillboardCollection();
const visual = collection.add({ color: Color.WHITE.withAlpha(0.8) });
try {
for (const opacity of [0.84, 0.35, 0.81, 1, 0.84]) {
for (let i = 0; i < 8; i++) {
visual.color = Color.CYAN.withAlpha(cyberSonarBaseAlpha(visual));
applyCyberSonarPrimitive(visual, opacity);
assert.equal(visual.color.alpha, 0.8 * opacity);
}
}
// A true owner freshness update must not be mistaken for our old alpha.
visual.color = Color.YELLOW.withAlpha(0.4);
assert.equal(cyberSonarBaseAlpha(visual), 0.4);
applyCyberSonarPrimitive(visual, 0.35);
visual.color = Color.CYAN.withAlpha(cyberSonarBaseAlpha(visual));
applyCyberSonarPrimitive(visual, 0.84);
applyCyberSonarPrimitive(visual, 1, false);
assert.ok(Color.equals(visual.color, Color.CYAN.withAlpha(0.4)));
} finally {
collection.destroy();
}
});
test('the Cyber sonar clock advances clockwise and wraps cleanly', () => {
assert.equal(cyberSonarAngleDeg(0), 0);
assert.equal(cyberSonarAngleDeg(CYBER_SONAR_PERIOD_MS / 4), 90);
assert.equal(cyberSonarAngleDeg(CYBER_SONAR_PERIOD_MS), 0);
assert.equal(cyberSonarAnimationDelay(CYBER_SONAR_PERIOD_MS / 2), '-2600ms');
});
test('contact refresh cadence waits only until the next bounded sonar frame', () => {
assert.equal(CYBER_SONAR_RENDER_INTERVAL_MS, 80);
assert.equal(cyberSonarFrameDelay(1_000, 1_000), 80);
assert.equal(cyberSonarFrameDelay(1_000, 1_035), 45);
assert.equal(cyberSonarFrameDelay(1_000, 1_080), 0);
assert.equal(cyberSonarFrameDelay(Number.NEGATIVE_INFINITY, 1_000), 0);
});
test('the sweep brightens contacts inside its sector and dims the remainder', () => {
const width = 1_000;
const height = 800;
const centerX = width / 2;
const centerY = height / 2;
assert.equal(
cyberSonarIntensityAtScreenPoint(centerX, centerY - 200, width, height, 0),
1,
);
assert.equal(
cyberSonarIntensityAtScreenPoint(centerX + 200, centerY, width, height, 0),
CYBER_SONAR_DIM_FACTOR,
);
assert.equal(
cyberSonarIntensityAtScreenPoint(
centerX + 200,
centerY,
width,
height,
CYBER_SONAR_PERIOD_MS / 4,
),
1,
);
assert.equal(
cyberSonarIntensityAtScreenPoint(centerX, centerY - 401, width, height, 0),
CYBER_SONAR_DIM_FACTOR,
);
assert.equal(
cyberSonarIntensityAtScreenPoint(Number.NaN, 0, width, height, 0),
1,
);
});
test('the sonar activates only for a visible Cyber map HUD with motion enabled', () => {
const root = { dataset: { uiTheme: 'cyber' } };
const classes = (...values) => ({
contains: (value) => values.includes(value),
});
assert.equal(
isCyberSonarActive({
root,
body: { classList: classes() },
hud: { classList: classes('active') },
reducedMotion: false,
}),
true,
);
assert.equal(
isCyberSonarActive({
root,
body: { classList: classes('cockpit-mode') },
hud: { classList: classes('active') },
reducedMotion: false,
}),
false,
);
assert.equal(
isCyberSonarActive({
root,
body: { classList: classes() },
hud: { classList: classes('active') },
reducedMotion: true,
}),
false,
);
root.dataset.cyberSonar = 'off';
assert.equal(
isCyberSonarActive({
root,
body: { classList: classes() },
hud: { classList: classes('active') },
reducedMotion: false,
}),
false,
);
});
test('labels keep a more readable baseline while tracking the same sweep', () => {
assert.equal(CYBER_SONAR_DIM_FACTOR, 0.84);
assert.equal(CYBER_SONAR_LABEL_DIM_FACTOR, 0.92);
assert.equal(
cyberSonarLabelIntensity(CYBER_SONAR_DIM_FACTOR),
CYBER_SONAR_LABEL_DIM_FACTOR,
);
assert.equal(cyberSonarLabelIntensity(1), 1);
assert.ok(
cyberSonarLabelIntensity(CYBER_SONAR_DIM_FACTOR) > CYBER_SONAR_DIM_FACTOR,
);
});
test('the opacity setting controls every contact floor and preserves brighter labels', () => {
const values = new Map();
const root = {
dataset: {},
style: { setProperty: (name, value) => values.set(name, value) },
};
const settings = applyCyberSonarSettings({ opacity: 62 }, root);
assert.equal(settings.opacity, 62);
assert.equal(root.dataset.cyberSonarOpacity, '62');
assert.equal(
cyberSonarIntensityAtScreenPoint(900, 400, 1_000, 800, 0, root),
0.62,
);
assert.equal(cyberSonarLabelIntensity(0.62, root), 0.81);
assert.equal(cyberSonarLabelIntensity(1, root), 1);
applyCyberSonarSettings({ opacity: 100 }, root);
assert.equal(cyberSonarLabelIntensity(1, root), 1);
assert.deepEqual(
cyberSonarTintColor({ red: 1, green: 1, blue: 1, alpha: 1 }, 1, root),
{ red: 1, green: 0.8848, blue: 0.8848, alpha: 1 },
);
});
test('the sonar control defaults on and records explicit operator state', () => {
const root = { dataset: {} };
assert.equal(isCyberSonarEnabled(root), true);
assert.equal(setCyberSonarEnabled(false, root), false);
assert.equal(root.dataset.cyberSonar, 'off');
assert.equal(isCyberSonarEnabled(root), false);
assert.equal(setCyberSonarEnabled(true, root), true);
assert.equal(root.dataset.cyberSonar, 'on');
});
test('real Cesium points and billboards recover after repeated low/high opacity cycles', () => {
const collection = new BillboardCollection();
const base = new Color(0.7, 0.5, 0.25, 0.8);
const visuals = [
new PointPrimitive({ color: base }),
collection.add({ color: base }),
];
try {
for (const visual of visuals) {
for (const factor of [0.35, 1, 0.5, 1, 0.35]) {
for (let frame = 0; frame < 60; frame++) {
applyCyberSonarPrimitive(visual, factor);
}
assert.equal(visual.color.alpha, base.alpha * factor);
}
// Owner setters mutate the same Cesium Color object, even when the new
// alpha equals the last sonar output. Its new RGB must become the base.
const refreshed = new Color(0.2, 0.8, 0.4, visual.color.alpha);
visual.color = refreshed;
applyCyberSonarPrimitive(visual, 0.5);
assert.equal(visual.color.alpha, refreshed.alpha * 0.5);
applyCyberSonarPrimitive(visual, 1, false);
assert.ok(Color.equals(visual.color, refreshed));
}
} finally {
collection.destroy();
}
});
test('generic Cesium primitives dim, accept owner style refreshes, and restore', () => {
const makeColor = (alpha, red = 1) => ({
red,
green: 0.5,
blue: 0.25,
alpha,
clone() {
return makeColor(this.alpha, this.red);
},
});
const visual = { color: makeColor(0.8) };
applyCyberSonarPrimitive(visual, 0.25, true);
assert.equal(visual.color.alpha, 0.2);
const paintedColor = visual.color;
applyCyberSonarPrimitive(visual, 0.25, true);
assert.equal(visual.color, paintedColor);
visual.color = makeColor(0.6, 0.7);
applyCyberSonarPrimitive(visual, 0.5, true);
assert.equal(visual.color.alpha, 0.3);
assert.notEqual(visual.color.red, 0.7);
applyCyberSonarPrimitive(visual, 1, false);
assert.equal(visual.color.alpha, 0.6);
assert.equal(visual.color.red, 0.7);
});
test('Cyber contact tint moves from cool-muted to warm-bright with the sector', () => {
const root = { dataset: { cyberSonarIntensity: '70' } };
const base = { red: 0.2, green: 0.8, blue: 1, alpha: 0.75 };
const dim = cyberSonarTintColor(base, CYBER_SONAR_DIM_FACTOR, root);
const bright = cyberSonarTintColor(base, 1, root);
assert.ok(bright.red > dim.red);
assert.ok(bright.green > dim.green);
assert.equal(bright.alpha, 0.75);
});
test('unswept white map symbols retain a bright readable Cyber tint', () => {
const root = { dataset: { cyberSonarIntensity: '70' } };
const dim = cyberSonarTintColor(
{ red: 1, green: 1, blue: 1, alpha: 1 },
CYBER_SONAR_DIM_FACTOR,
root,
);
assert.ok(dim.red >= 0.72);
assert.ok(dim.green >= 0.76);
assert.ok(dim.blue >= 0.79);
assert.equal(dim.alpha, 1);
});
test('the sweep corrects delayed starts and reacquires restarted animations without per-frame writes', () => {
let queries = 0,
writes = 0;
const makeAnimation = () => ({
animationName: 'cyber-sonar-sweep',
playState: 'running',
startTime: 1800,
effect: {
updateTiming(value) {
assert.deepEqual(value, { delay: 0 });
writes++;
},
},
});
let animation = makeAnimation();
let element = {
isConnected: true,
getAnimations() {
queries++;
return [animation];
},
};
const root = { querySelector: () => element };
syncCyberSonarAnimation(root);
assert.equal(animation.startTime, 0);
for (let i = 0; i < 100; i++) syncCyberSonarAnimation(root);
assert.equal(queries, 1);
assert.equal(writes, 1);
animation.playState = 'idle';
animation = makeAnimation();
syncCyberSonarAnimation(root);
assert.equal(animation.startTime, 0);
assert.equal(writes, 2);
element.isConnected = false;
animation = makeAnimation();
element = { isConnected: true, getAnimations: () => [animation] };
syncCyberSonarAnimation(root);
assert.equal(animation.startTime, 0);
assert.equal(writes, 3);
assert.doesNotThrow(() => syncCyberSonarAnimation({}));
});
test('the selected Cyber theme seeds and releases the compositor sweep phase', () => {
const values = new Map();
const root = {
dataset: {},
style: {
setProperty: (name, value) => values.set(name, value),
removeProperty: (name) => values.delete(name),
getPropertyValue: (name) => values.get(name) || '',
},
};
applyCyberSonarThemePhase(root, true, CYBER_SONAR_PERIOD_MS / 4);
assert.equal(values.has('--cyber-sonar-angle'), false);
assert.equal(values.get('--cyber-sonar-phase-delay'), '-1300ms');
applyCyberSonarThemePhase(root, true, CYBER_SONAR_PERIOD_MS / 2);
assert.equal(values.get('--cyber-sonar-phase-delay'), '-1300ms');
applyCyberSonarThemePhase(root, false, 0);
assert.equal(values.has('--cyber-sonar-angle'), false);
assert.equal(values.has('--cyber-sonar-phase-delay'), false);
});
+295
View File
@@ -0,0 +1,295 @@
import * as Cesium from 'cesium';
// Narrow, version-tested engine adapter. Cesium has no public custom shader
// option for native point/billboard collections. Do not patch its prototypes or
// access EntityCluster's private collections. An engine upgrade must revalidate
// this adapter; unsupported versions keep the original commands unchanged.
export const CYBER_SONAR_CESIUM_VERSION = '1.138.0';
// Follow every public DrawCommand setter, including pick/shadow/culling flags.
// Copying native.dirty would keep derived pipelines dirty forever: the scene
// consumes our command, so it never clears that flag on the native command.
const commandProperties = Object.entries(
Object.getOwnPropertyDescriptors(Cesium.DrawCommand.prototype),
)
.filter(
([key, descriptor]) =>
descriptor.set && key !== 'shaderProgram' && key !== 'uniformMap',
)
.map(([key]) => key);
export function syncCyberSonarCommand(
native,
record,
program,
uniforms,
labelBackground,
) {
if (!record.command) {
record.command = new Cesium.DrawCommand();
record.uniformMap = {
...uniforms,
u_gevSonarLabelBackground: () => record.labelBackground,
};
}
const command = record.command;
record.labelBackground = labelBackground;
// Uniform values (angle/viewport/etc.) change through stable callbacks, not
// new maps/functions. Owner callback replacement and removal still propagate.
for (const key in record.uniformMap) {
if (
!(key in uniforms) &&
key !== 'u_gevSonarLabelBackground' &&
!(key in native.uniformMap)
) {
delete record.uniformMap[key];
command.dirty = true;
}
}
for (const key in native.uniformMap) {
if (record.uniformMap[key] !== native.uniformMap[key]) {
record.uniformMap[key] = native.uniformMap[key];
command.dirty = true;
}
}
for (const key of commandProperties) command[key] = native[key];
command.shaderProgram = program;
command.uniformMap = record.uniformMap;
return command;
}
export function sonarVertexSource(source, kind) {
const text = source.sources.join('\n');
const attribute =
kind === 'point' ? 'positionHighAndSize' : 'positionHighAndScale';
const lowAttribute =
kind === 'point' ? 'positionLowAndOutline' : 'positionLowAndRotation';
if (
!text.includes(attribute) ||
!text.includes(lowAttribute) ||
!text.includes('v_color') ||
!/void\s+main\s*\(\s*\)/.test(text)
)
return null;
const next = source.clone();
next.sources = [
text.replace(/void\s+main\s*\(\s*\)/, 'void gev_sonar_main()'),
`
uniform vec4 u_gevSonar; // angle, radius, opacity floor, sweep enabled
uniform vec2 u_gevSonarViewport;
uniform float u_gevSonarSector;
uniform float u_gevSonarLabelBackground;
void main() {
gev_sonar_main();
// All corners/glyphs share the contact anchor, before native screen offsets.
// Leave gl_Position untouched so appearance never changes geometry or picks.
vec4 anchor = czm_modelViewProjectionRelativeToEye * czm_translateRelativeToEye(${attribute}.xyz, ${lowAttribute}.xyz);
if (anchor.w <= 0.0) return;
vec2 pixel = anchor.xy / anchor.w * u_gevSonarViewport * 0.5;
float floorValue = u_gevSonar.z;
float factor = floorValue + (1.0 - floorValue) * 0.46;
if (u_gevSonar.w > 0.5) {
float distanceSquared = dot(pixel, pixel);
float radiusSquared = u_gevSonar.y * u_gevSonar.y;
float delta = mod(degrees(atan(pixel.x, pixel.y)) - u_gevSonar.x + 360.0, 360.0);
if (delta > 359.9999) delta = 0.0;
factor = floorValue;
if (distanceSquared < radiusSquared * 0.000625) factor = 1.0;
else if (distanceSquared <= radiusSquared && delta <= u_gevSonarSector)
factor += (1.0 - floorValue) * min(1.0, (u_gevSonarSector - delta) / min(10.0, u_gevSonarSector * 0.42));
}
#ifdef SDF
factor = (1.0 + factor) * 0.5;
#else
if (u_gevSonarLabelBackground > 0.5) factor = (1.0 + factor) * 0.5;
#endif
float coverage = floorValue >= 0.9999 ? 1.0 : clamp((factor - floorValue) / (1.0 - floorValue), 0.0, 1.0);
if (u_gevSonarLabelBackground < 0.5)
v_color.rgb = v_color.rgb * 0.28 + (vec3(0.62, 0.68, 0.72) + vec3(0.38, 0.16, 0.12) * coverage) * 0.72;
// Apply before native fragment pass classification, never after main().
v_color.a *= factor;
${kind === 'point' ? 'v_outlineColor.a *= factor;' : '#ifdef SDF\n v_outlineColor.a *= factor;\n #endif'}
}
`,
];
return next;
}
// Use native batch bounds, never individual contacts, for idle render demand.
// Like Cesium's frustum builder, exclude the far plane (it fits multiple frusta).
export function isCyberSonarCommandVisible(command, frameState, culling) {
const bounds = command.boundingVolume;
if (!bounds || !command.cull) return true;
culling ??= new Cesium.CullingVolume(
frameState.cullingVolume.planes.slice(0, 5),
);
if (culling.computeVisibility(bounds) === Cesium.Intersect.OUTSIDE)
return false;
return !(
frameState.mode === Cesium.SceneMode.SCENE3D &&
frameState.occluder &&
command.occlude &&
bounds.isOccluded(frameState.occluder)
);
}
/** Work scales with draw commands, not the number of contacts in a collection.
* Source programs, colors, pick IDs, render states and other scenes stay owned
* by Cesium. Only this scene's rendered command slice receives derived shaders.
*/
export function createCyberSonarGpu(scene, readFrame) {
const root = scene.primitives;
const original = root.update;
const programs = new Map();
let commands = new WeakMap();
let disposed = false;
const stats = {
commands: 0,
visibleCommands: 0,
programs: 0,
supported: Cesium.VERSION === CYBER_SONAR_CESIUM_VERSION,
error: null,
};
const uniforms = new Cesium.Cartesian4();
const viewport = new Cesium.Cartesian2();
let sector = 24;
const sonarUniforms = {
u_gevSonar: () => uniforms,
u_gevSonarViewport: () => viewport,
u_gevSonarSector: () => sector,
};
const culling = new Cesium.CullingVolume();
const used = new Set();
const replaced = [];
const release = () => {
for (const { program } of programs.values())
if (!program.isDestroyed()) program.destroy();
programs.clear();
commands = new WeakMap();
used.clear();
replaced.length = 0;
stats.programs = 0;
};
const wrapped = function (frameState) {
const start = frameState.commandList.length;
original.call(this, frameState);
stats.commands = 0;
stats.visibleCommands = 0;
if (
disposed ||
!stats.supported ||
!frameState.passes.render ||
frameState.passes.pick
)
return;
const frame = readFrame();
if (!frame.enabled) {
if (programs.size) release();
return;
}
viewport.x = scene.drawingBufferWidth;
viewport.y = scene.drawingBufferHeight;
uniforms.x = frame.angle;
uniforms.y =
(Math.min(viewport.x * 0.4, viewport.y * 0.52) * frame.range) / 100;
uniforms.z = frame.opacity / 100;
uniforms.w = frame.active ? 1 : 0;
sector = frame.sector;
used.clear();
replaced.length = 0;
if (frameState.cullingVolume)
for (let i = 0; i < 5; i++)
culling.planes[i] = frameState.cullingVolume.planes[i];
try {
for (let i = start; i < frameState.commandList.length; i++) {
const native = frameState.commandList[i];
const kind =
native.owner instanceof Cesium.PointPrimitiveCollection
? 'point'
: native.owner instanceof Cesium.BillboardCollection
? 'billboard'
: null;
if (!kind || !native.shaderProgram || !native.uniformMap) continue;
const source = native.shaderProgram;
// Label backgrounds are non-SDF billboard batches. The version-tested
// engine pick association distinguishes them without enumerating labels
// or reaching into EntityCluster. One representative per draw batch;
// Cesium's label glyph/background collections are homogeneous.
const labelBackground =
kind === 'billboard' &&
!source.vertexShaderSource.defines.includes('SDF') &&
native.owner.length > 0 &&
native.owner.get(0).pickPrimitive instanceof Cesium.Label
? 1
: 0;
let entry = programs.get(source);
if (!entry) {
const vertex = sonarVertexSource(source.vertexShaderSource, kind);
if (!vertex) throw new Error('Unsupported native contact shader');
const attributes = Object.fromEntries(
Object.entries(source.vertexAttributes).map(([name, value]) => [
name,
value.index,
]),
);
const program = Cesium.ShaderProgram.fromCache({
context: frameState.context,
vertexShaderSource: vertex,
fragmentShaderSource: source.fragmentShaderSource,
attributeLocations: attributes,
});
// Compile while errors can still leave the whole native command slice
// intact, rather than stopping the viewer's render loop at draw time.
try {
void program.allUniforms;
} catch (error) {
program.destroy();
throw error;
}
programs.set(source, (entry = { program }));
}
used.add(source);
let record = commands.get(native);
if (!record) commands.set(native, (record = {}));
const derived = syncCyberSonarCommand(
native,
record,
entry.program,
sonarUniforms,
labelBackground,
);
replaced.push(i, native);
frameState.commandList[i] = derived;
stats.commands++;
if (isCyberSonarCommandVisible(native, frameState, culling))
stats.visibleCommands++;
}
for (const [source, entry] of programs)
if (!used.has(source)) {
if (!entry.program.isDestroyed()) entry.program.destroy();
programs.delete(source);
}
stats.programs = programs.size;
} catch (error) {
// Recover the original commands already replaced in this slice.
for (let i = 0; i < replaced.length; i += 2)
frameState.commandList[replaced[i]] = replaced[i + 1];
stats.supported = false;
stats.commands = 0;
stats.visibleCommands = 0;
stats.error = String(error?.message || error);
release();
}
};
root.update = wrapped;
return {
stats,
destroy() {
if (disposed) return;
disposed = true;
if (root.update === wrapped && !root.isDestroyed?.())
root.update = original;
release();
},
};
}
+354
View File
@@ -0,0 +1,354 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import {
createCyberSonarGpu,
sonarVertexSource,
CYBER_SONAR_CESIUM_VERSION,
isCyberSonarCommandVisible,
syncCyberSonarCommand,
} from './cyberSonarGpu.js';
test('stable commands reuse uniforms and derived pipelines while all native fields remain live', () => {
const native = new Cesium.DrawCommand({
count: 6,
uniformMap: { value: () => 1 },
});
const record = {},
program = {},
uniforms = { u_gevSonar: () => 42 };
const derived = syncCyberSonarCommand(native, record, program, uniforms, 0);
const map = derived.uniformMap;
derived.dirty = false;
derived.lastDirtyTime = 7;
assert.equal(native.dirty, true);
assert.equal(
syncCyberSonarCommand(native, record, program, uniforms, 1),
derived,
);
assert.equal(
derived.dirty,
false,
'stale native dirty flag must not rebuild pipelines',
);
assert.equal(derived.lastDirtyTime, 7);
assert.equal(derived.uniformMap, map);
assert.equal(map.u_gevSonarLabelBackground(), 1);
for (const [key, value] of Object.entries({
count: 12,
offset: 2,
instanceCount: 3,
pickId: 'pick',
pass: Cesium.Pass.TRANSLUCENT,
cull: false,
occlude: false,
castShadows: true,
receiveShadows: true,
pickOnly: true,
renderState: {},
vertexArray: {},
modelMatrix: Cesium.Matrix4.IDENTITY,
framebuffer: {},
})) {
derived.dirty = false;
native[key] = value;
syncCyberSonarCommand(native, record, program, uniforms, 0);
assert.equal(derived[key], value, key);
assert.equal(derived.dirty, true, key);
}
native.uniformMap.value = () => 2;
syncCyberSonarCommand(native, record, program, uniforms, 0);
assert.equal(map.value(), 2);
native.uniformMap = { replacement: () => 3 };
syncCyberSonarCommand(native, record, program, uniforms, 0);
assert.equal(map.value, undefined);
assert.equal(map.replacement(), 3);
assert.equal(map.u_gevSonar(), 42);
const nextProgram = {};
syncCyberSonarCommand(native, record, nextProgram, uniforms, 0);
assert.equal(derived.shaderProgram, nextProgram);
assert.equal(native.shaderProgram, undefined);
});
test('GPU shader transform is isolated, settings-driven, and precedes native fragment classification', () => {
assert.equal(
Cesium.VERSION,
CYBER_SONAR_CESIUM_VERSION,
'engine upgrades require GPU revalidation',
);
for (const [kind, attr, low] of [
['point', 'positionHighAndSize', 'positionLowAndOutline'],
['billboard', 'positionHighAndScale', 'positionLowAndRotation'],
]) {
const source = new Cesium.ShaderSource({
defines: ['SDF'],
sources: [
`in vec4 ${attr}; in vec4 ${low}; out vec4 v_color; void main() { v_color=vec4(1.); }`,
],
});
const original = source.sources[0];
const next = sonarVertexSource(source, kind);
assert.equal(source.sources[0], original);
assert.notEqual(next, source);
assert.deepEqual(next.defines, ['SDF']);
assert.match(next.sources[0], /void gev_sonar_main/);
assert.match(next.sources[1], /v_color.a \*= factor/);
assert.match(next.sources[1], /u_gevSonarSector/);
assert.match(next.sources[1], /floorValue \+ \(1.0 - floorValue\) \* 0.46/);
assert.ok(
next.sources[1].includes(
`czm_translateRelativeToEye(${attr}.xyz, ${low}.xyz)`,
),
);
assert.match(next.sources[1], /anchor.xy \/ anchor.w/);
assert.doesNotMatch(next.sources[1], /gl_Position\s*[=\/]/);
}
assert.equal(
sonarVertexSource(
new Cesium.ShaderSource({ sources: ['void main() {}'] }),
'point',
),
null,
);
});
test('render demand follows batch bounds, including horizon culling, without far-plane rejection', () => {
const planes = Array.from(
{ length: 6 },
(_, i) => new Cesium.Cartesian4(1, 0, 0, i),
);
const frame = {
cullingVolume: { planes },
mode: Cesium.SceneMode.SCENE3D,
occluder: {},
};
let intersections = 0;
let occluded = false;
const command = {
cull: true,
occlude: true,
boundingVolume: {
intersectPlane(plane) {
intersections++;
return plane.distance === 5
? Cesium.Intersect.OUTSIDE
: Cesium.Intersect.INSIDE;
},
isOccluded() {
return occluded;
},
},
};
assert.equal(isCyberSonarCommandVisible(command, frame), true);
assert.equal(intersections, 5);
occluded = true;
assert.equal(isCyberSonarCommandVisible(command, frame), false);
frame.mode = Cesium.SceneMode.SCENE2D;
assert.equal(isCyberSonarCommandVisible(command, frame), true);
command.boundingVolume.intersectPlane = () => Cesium.Intersect.OUTSIDE;
assert.equal(isCyberSonarCommandVisible(command, frame), false);
command.cull = false;
assert.equal(isCyberSonarCommandVisible(command, frame), true);
assert.equal(isCyberSonarCommandVisible({}, frame), true);
});
test('adapter never walks contacts, touches other scenes, or transforms pick-only passes', () => {
let calls = 0;
const original = function (fs) {
assert.equal(this, root);
calls++;
fs.commandList.push({ owner: {}, id: 'terrain' });
};
const root = { update: original };
const other = { update: original };
const adapter = createCyberSonarGpu({ primitives: root }, () => ({
enabled: false,
}));
const frame = { commandList: [], passes: { render: true, pick: false } };
root.update(frame);
assert.equal(calls, 1);
assert.equal(frame.commandList[0].id, 'terrain');
assert.equal(other.update, original);
root.update({ commandList: [], passes: { render: false, pick: true } });
assert.equal(calls, 2);
adapter.destroy();
adapter.destroy();
assert.equal(root.update, original);
});
test('unknown native shaders fail closed without losing native commands', () => {
const points = new Cesium.PointPrimitiveCollection();
const command = {
owner: points,
uniformMap: {},
shaderProgram: {
vertexShaderSource: new Cesium.ShaderSource({
sources: ['void main() {}'],
}),
},
};
const root = {
update(fs) {
fs.commandList.push(command);
},
};
const adapter = createCyberSonarGpu(
{ primitives: root, drawingBufferWidth: 800, drawingBufferHeight: 600 },
() => ({
enabled: true,
active: true,
angle: 0,
range: 100,
opacity: 84,
sector: 24,
}),
);
const frame = { commandList: [], passes: { render: true } };
root.update(frame);
assert.equal(frame.commandList[0], command);
assert.equal(adapter.stats.supported, false);
assert.match(adapter.stats.error, /Unsupported native contact shader/);
assert.equal(adapter.stats.programs, 0);
adapter.destroy();
points.destroy();
});
test('program swaps, layer removal and late shader failure release only adapter resources', () => {
const points = new Cesium.PointPrimitiveCollection();
const source = () => ({
vertexShaderSource: new Cesium.ShaderSource({
sources: [
'in vec4 positionHighAndSize; in vec4 positionLowAndOutline; out vec4 v_color; void main() {}',
],
}),
vertexAttributes: {},
fragmentShaderSource: new Cesium.ShaderSource({
sources: ['void main() {}'],
}),
});
const native = new Cesium.DrawCommand({
owner: points,
uniformMap: {},
shaderProgram: source(),
cull: false,
vertexArray: { generation: 1 },
pickId: 'native-pick',
});
const unknown = new Cesium.DrawCommand({
owner: points,
uniformMap: {},
shaderProgram: {
vertexShaderSource: new Cesium.ShaderSource({
sources: ['void main() {}'],
}),
},
});
let nativeCommands = [native];
let enabled = true;
const root = {
update(frame) {
frame.commandList.push(...nativeCommands);
},
};
const fromCache = Cesium.ShaderProgram.fromCache;
const created = [];
Cesium.ShaderProgram.fromCache = () => {
const program = {
allUniforms: {},
destroyed: false,
isDestroyed() {
return this.destroyed;
},
destroy() {
assert.equal(this.destroyed, false);
this.destroyed = true;
},
};
created.push(program);
return program;
};
const adapter = createCyberSonarGpu(
{ primitives: root, drawingBufferWidth: 800, drawingBufferHeight: 600 },
() => ({
enabled,
active: true,
angle: 0,
range: 100,
opacity: 84,
sector: 24,
}),
);
const draw = () => {
const frame = { commandList: [], passes: { render: true } };
root.update(frame);
return frame.commandList;
};
try {
const first = draw()[0];
assert.notEqual(first, native);
first.dirty = false;
assert.equal(draw()[0], first);
assert.equal(first.dirty, false);
assert.equal(created.length, 1, 'stable draws reuse the program');
const buffer = { generation: 2 };
native.vertexArray = buffer;
native.shaderProgram = source();
const swapped = draw()[0];
assert.equal(swapped, first);
assert.equal(swapped.vertexArray, buffer);
assert.equal(swapped.pickId, 'native-pick');
assert.equal(swapped.shaderProgram, created[1]);
assert.equal(created[0].destroyed, true);
assert.equal(adapter.stats.programs, 1);
nativeCommands = [];
draw();
assert.equal(created[1].destroyed, true);
assert.equal(
adapter.stats.programs,
0,
'disabled layers retain no adapter program',
);
nativeCommands = [native];
draw();
enabled = false;
assert.equal(draw()[0], native);
assert.equal(
created[2].destroyed,
true,
'theme/cockpit exit releases the derived program',
);
enabled = true;
nativeCommands = [native, unknown];
assert.deepEqual(
draw(),
nativeCommands,
'a later failure rolls back earlier replacements in the same slice',
);
assert.equal(created[3].destroyed, true);
assert.equal(adapter.stats.supported, false);
assert.equal(adapter.stats.programs, 0);
assert.equal(adapter.stats.commands, 0);
assert.equal(adapter.stats.visibleCommands, 0);
assert.deepEqual(draw(), nativeCommands);
assert.equal(
created.length,
4,
'unsupported draws do not retry compilation',
);
} finally {
adapter.destroy();
Cesium.ShaderProgram.fromCache = fromCache;
points.destroy();
}
});
test('disposal does not overwrite a successor update owner', () => {
const root = { update() {} };
const adapter = createCyberSonarGpu({ primitives: root }, () => ({
enabled: false,
}));
const successor = () => {};
root.update = successor;
adapter.destroy();
assert.equal(root.update, successor);
});
+211
View File
@@ -0,0 +1,211 @@
import * as Cesium from 'cesium';
import { createCyberSonarGpu } from './cyberSonarGpu.js';
import {
applyCyberSonarPrimitive,
createCyberSonarSampler,
isCyberContactThemeActive,
isCyberContactSonarActive,
isCyberSonarActive,
readCyberSonarSettings,
cyberSonarAngleDeg,
syncCyberSonarAnimation,
CYBER_SONAR_RENDER_INTERVAL_MS,
} from './cyberSonar.js';
const alive = (value) => value && !value.isDestroyed?.();
const diagnostics = new WeakMap();
export const getCyberSonarSceneDiagnostics = (viewer) => ({
...diagnostics.get(viewer),
});
export function visitCyberSonarModels(primitive, visit, visible = true) {
if (!alive(primitive)) return;
visible = visible && primitive.show !== false;
if (primitive instanceof Cesium.PrimitiveCollection) {
for (let i = 0; i < primitive.length; i++)
visitCyberSonarModels(primitive.get(i), visit, visible);
} else if (primitive instanceof Cesium.Model) {
visit(primitive, primitive, 'model', visible && primitive.ready);
}
}
/** Native dots, icons and glyphs use GPU draw-command treatment. Models retain
* their existing reversible CPU color treatment without walking native records.
*/
export function createCyberSonarScene(viewer, manager) {
const scene = viewer?.scene;
if (
!scene?.postUpdate?.addEventListener ||
!scene?.preRender?.addEventListener
)
return () => {};
const tracked = new Map();
const matrix = new Cesium.Matrix4();
const position = new Cesium.Cartesian3();
let epoch = 0,
timer = null,
disposed = false;
let sampler, width, height, active, count;
const stats = { contacts: 0, updateMs: 0, frames: 0 };
const root = globalThis.document?.documentElement;
const gpu = createCyberSonarGpu(scene, () => ({
...readCyberSonarSettings(),
enabled: isCyberContactThemeActive(),
active: isCyberContactSonarActive(),
angle: cyberSonarAngleDeg(performance.now()),
}));
stats.gpu = gpu.stats;
let gpuSupported = gpu.stats.supported;
diagnostics.set(viewer, stats);
const restore = (visual, state) => {
if (!alive(state.owner)) return;
for (const key of state.keys)
applyCyberSonarPrimitive(visual, 1, false, undefined, key);
};
const visit = (visual, owner, kind, visible) => {
let state = tracked.get(visual);
if (!visible) {
if (state) {
restore(visual, state);
tracked.delete(visual);
}
return;
}
let p = visual.position;
if (kind === 'model')
p = Cesium.Matrix4.getTranslation(visual.modelMatrix, position);
else if (
p &&
owner.modelMatrix &&
!Cesium.Matrix4.equals(owner.modelMatrix, Cesium.Matrix4.IDENTITY)
)
p = Cesium.Matrix4.multiplyByPoint(owner.modelMatrix, p, position);
if (!p) return;
const w = matrix[3] * p.x + matrix[7] * p.y + matrix[11] * p.z + matrix[15];
const x =
(matrix[0] * p.x + matrix[4] * p.y + matrix[8] * p.z + matrix[12]) / w;
const y =
(matrix[1] * p.x + matrix[5] * p.y + matrix[9] * p.z + matrix[13]) / w;
if (w <= 0 || Math.abs(x) > 1.1 || Math.abs(y) > 1.1) return;
if (!state) {
state = {
owner,
keys:
kind === 'label'
? ['fillColor', 'outlineColor', 'backgroundColor']
: kind === 'marker' && visual.outlineColor
? ['color', 'outlineColor']
: ['color'],
epoch,
};
tracked.set(visual, state);
}
state.epoch = epoch;
const factor = active
? sampler.at((x + 1) * width * 0.5, (1 - y) * height * 0.5)
: sampler.neutral;
for (const key of state.keys) {
if (key === 'outlineColor' && visual.outlineWidth === 0) continue;
if (key === 'backgroundColor' && !visual.showBackground) continue;
applyCyberSonarPrimitive(
visual,
kind === 'label' ? sampler.label(factor) : factor,
true,
sampler,
key,
kind === 'model',
);
}
count++;
};
const update = () => {
if (disposed) return;
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
if (!isCyberContactThemeActive()) {
for (const [visual, state] of tracked) restore(visual, state);
tracked.clear();
stats.contacts = 0;
stats.updateMs = 0;
return;
}
const started = performance.now();
width = scene.canvas.clientWidth;
height = scene.canvas.clientHeight;
sampler = createCyberSonarSampler(width, height, started);
active = isCyberContactSonarActive();
Cesium.Matrix4.multiply(
scene.camera.frustum.projectionMatrix,
scene.camera.viewMatrix,
matrix,
);
epoch++;
count = 0;
visitCyberSonarModels(scene.primitives, visit);
for (const [visual, state] of tracked)
if (state.epoch !== epoch) {
restore(visual, state);
tracked.delete(visual);
}
stats.contacts = count;
stats.updateMs = performance.now() - started;
stats.frames++;
// No contacts, no scanner-owned render demand. In continuous scenes the
// next rendered frame cancels this timer; parked scenes refresh at fleet
// cadence. Idle update-only ticks must not continually postpone the timer.
};
let removeRender;
// Aircraft fleet writers run in preRender, while tracked models and entity
// visualizers update earlier. Compose AFTER all those owners and BEFORE
// Cesium uploads primitive attributes for this draw. Requeue at postUpdate
// so writers installed later (enable/re-enable/track) cannot overwrite sonar.
// This only reorders one callback; the contact walk runs once per actual draw.
const removeUpdate = scene.postUpdate.addEventListener(() => {
if (isCyberSonarActive()) syncCyberSonarAnimation();
const supportChanged = gpuSupported !== gpu.stats.supported;
gpuSupported = gpu.stats.supported;
const support = gpu.stats.supported ? 'supported' : 'unsupported';
if (root?.dataset && root.dataset.cyberSonarGpu !== support)
root.dataset.cyberSonarGpu = support;
// Shader setup runs after preRender's model tint. If it fails while the
// scene is parked (including Sonar OFF/reduced motion), draw once more so
// the now-unsupported contact path restores models before becoming idle.
if (supportChanged) scene.requestRender();
const status = globalThis.document?.getElementById?.('cyber-sonar-status');
const message = gpu.stats.supported
? ''
: 'Contact GPU unavailable. Native contacts remain visible.';
if (status && status.textContent !== message) status.textContent = message;
removeRender?.();
removeRender = scene.preRender.addEventListener(update);
});
const removePostRender = scene.postRender?.addEventListener(() => {
if (timer !== null) clearTimeout(timer);
timer = null;
if (isCyberContactSonarActive() && (count || gpu.stats.visibleCommands))
timer = setTimeout(() => {
timer = null;
if (!disposed && isCyberContactSonarActive()) scene.requestRender();
}, CYBER_SONAR_RENDER_INTERVAL_MS);
});
const unsubscribe = manager?.subscribe?.((change) => {
if (change.type !== 'visibility') return;
scene.requestRender();
});
return () => {
if (disposed) return;
disposed = true;
removeUpdate();
removeRender?.();
removePostRender?.();
gpu.destroy();
if (root?.dataset) delete root.dataset.cyberSonarGpu;
unsubscribe?.();
if (timer !== null) clearTimeout(timer);
for (const [visual, state] of tracked) restore(visual, state);
tracked.clear();
diagnostics.delete(viewer);
};
}
+314
View File
@@ -0,0 +1,314 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import {
createCyberSonarSampler,
cyberSonarIntensityAtScreenPoint,
applyCyberSonarPrimitive,
applyCyberSonarSettings,
isCyberContactSonarActive,
isCyberSonarActive,
} from './cyberSonar.js';
import {
createCyberSonarScene,
visitCyberSonarModels,
getCyberSonarSceneDiagnostics,
} from './cyberSonarScene.js';
test('frame sampler matches reference without per-contact settings access', () => {
let reads = 0;
const values = {
cyberSonarOpacity: '62',
cyberSonarSector: '40',
cyberSonarRange: '110',
};
const root = {
dataset: new Proxy(values, {
get(o, k) {
reads++;
return o[k];
},
}),
};
const frame = createCyberSonarSampler(1000, 800, 1234, root),
once = reads;
for (let x = 0; x < 1000; x += 33)
for (let y = 0; y < 800; y += 31) frame.at(x, y);
assert.equal(reads, once);
for (let x = 0; x < 1000; x += 33)
for (let y = 0; y < 800; y += 31)
assert.ok(
Math.abs(
frame.at(x, y) -
cyberSonarIntensityAtScreenPoint(x, y, 1000, 800, 1234, {
dataset: values,
}),
) < 1e-10,
);
});
test('model walk never enumerates native contact or EntityCluster records', () => {
const root = new Cesium.PrimitiveCollection();
const points = root.add(new Cesium.PointPrimitiveCollection());
points.add();
points.get = () => {
throw new Error('must not enumerate contacts');
};
const cluster = root.add(new Cesium.EntityCluster());
Object.defineProperty(cluster, '_billboardCollection', {
get() {
throw new Error('must not access cluster internals');
},
configurable: true,
});
const seen = [];
visitCyberSonarModels(root, (...v) => seen.push(v));
assert.equal(seen.length, 0);
delete cluster._billboardCollection;
root.destroy();
});
test('models retain render-pass alpha and owner refresh survives teardown', () => {
const frame = createCyberSonarSampler(1000, 800, 0);
const model = { color: new Cesium.Color(1, 0.5, 0.2, 1) };
applyCyberSonarPrimitive(model, 0.5, true, frame, 'color', true);
assert.equal(model.color.alpha, 1);
assert.ok(model.color.red < 1);
const owner = new Cesium.Color(0.2, 0.3, 0.4, 0.7);
model.color = owner;
applyCyberSonarPrimitive(model, 1, false);
assert.equal(model.color, owner);
const unset = {};
applyCyberSonarPrimitive(unset, 0.5, true, frame, 'color', true);
applyCyberSonarPrimitive(unset, 1, false);
assert.equal(unset.color, undefined);
});
test('removed CSS mode cannot disable GPU contacts; unsupported adapters fail closed', () => {
const root = { dataset: { uiTheme: 'cyber' }, style: { setProperty() {} } };
const options = {
root,
body: { classList: { contains: () => false } },
hud: { classList: { contains: () => true } },
reducedMotion: false,
};
root.dataset.cyberSonarMode = 'css';
assert.equal(applyCyberSonarSettings({ mode: 'css' }, root).mode, undefined);
assert.equal(root.dataset.cyberSonarMode, undefined);
assert.equal(isCyberSonarActive(options), true);
assert.equal(isCyberContactSonarActive(options), true);
assert.equal(
applyCyberSonarSettings({ mode: 'invalid' }, root).mode,
undefined,
);
assert.equal(isCyberContactSonarActive(options), true);
root.dataset.cyberSonarGpu = 'unsupported';
assert.equal(isCyberContactSonarActive(options), false);
});
test('native owner colors remain untouched and scene teardown releases listeners', () => {
const previous = globalThis.document;
const root = { dataset: { uiTheme: 'cyber', cyberSonar: 'on' } };
let cockpit = false;
globalThis.document = {
documentElement: root,
body: { classList: { contains: () => cockpit } },
getElementById: () => ({ classList: { contains: () => true } }),
};
const primitives = new Cesium.PrimitiveCollection();
const points = primitives.add(new Cesium.PointPrimitiveCollection());
const point = points.add({ color: new Cesium.Color(0.3, 0.7, 0.5, 0.8) });
const original = primitives.update;
const scene = {
primitives,
postUpdate: new Cesium.Event(),
preRender: new Cesium.Event(),
postRender: new Cesium.Event(),
canvas: { clientWidth: 1000, clientHeight: 800 },
camera: {
frustum: { projectionMatrix: Cesium.Matrix4.IDENTITY },
viewMatrix: Cesium.Matrix4.IDENTITY,
},
requestRender() {},
};
const viewer = { scene };
const dispose = createCyberSonarScene(viewer);
try {
for (const enabled of ['on', 'off', 'on']) {
root.dataset.cyberSonar = enabled;
for (const opacity of [35, 84, 100]) {
root.dataset.cyberSonarOpacity = String(opacity);
point.color = new Cesium.Color(0.6, 0.4, 0.2, 0.7);
scene.postUpdate.raiseEvent();
scene.preRender.raiseEvent();
assert.equal(point.color.alpha, 0.7);
assert.equal(point.color.red, 0.6);
}
}
const previousTimeout = globalThis.setTimeout;
const previousClear = globalThis.clearTimeout;
let timers = 0;
globalThis.setTimeout = () => {
timers++;
return 1;
};
globalThis.clearTimeout = () => {};
try {
const gpu = getCyberSonarSceneDiagnostics(viewer).gpu;
gpu.commands = 6;
gpu.visibleCommands = 0;
scene.postRender.raiseEvent();
assert.equal(timers, 0, 'culled batches must not schedule a sonar draw');
gpu.visibleCommands = 1;
scene.postRender.raiseEvent();
assert.equal(timers, 1, 'a visible batch keeps the sweep moving');
root.dataset.cyberSonar = 'off';
scene.postRender.raiseEvent();
assert.equal(timers, 1, 'OFF does not schedule contact draws');
} finally {
globalThis.setTimeout = previousTimeout;
globalThis.clearTimeout = previousClear;
}
cockpit = true;
scene.postUpdate.raiseEvent();
scene.preRender.raiseEvent();
root.dataset.uiTheme = 'tactical';
scene.postUpdate.raiseEvent();
scene.preRender.raiseEvent();
dispose();
dispose();
assert.equal(primitives.update, original);
assert.equal(scene.postUpdate.numberOfListeners, 0);
assert.equal(scene.preRender.numberOfListeners, 0);
assert.equal(scene.postRender.numberOfListeners, 0);
assert.equal(root.dataset.cyberSonarGpu, undefined);
} finally {
dispose();
primitives.destroy();
globalThis.document = previous;
}
});
for (const reducedMotion of [false, true]) {
test(`shader failure requests one parked restoration draw with ${reducedMotion ? 'reduced motion' : 'Sonar OFF'}`, () => {
const previousDocument = globalThis.document;
const previousMatchMedia = globalThis.matchMedia;
const previousTimeout = globalThis.setTimeout;
const previousClear = globalThis.clearTimeout;
const root = {
dataset: { uiTheme: 'cyber', cyberSonar: reducedMotion ? 'on' : 'off' },
};
const status = { textContent: '' };
globalThis.document = {
documentElement: root,
body: { classList: { contains: () => false } },
getElementById: (id) =>
id === 'cyber-sonar-status'
? status
: { classList: { contains: () => true } },
};
globalThis.matchMedia = () => ({ matches: reducedMotion });
let scheduled = 0;
globalThis.setTimeout = () => {
scheduled++;
return 1;
};
globalThis.clearTimeout = () => {};
// A model-shaped fixture exercises the actual scene walk without loading a
// glTF or allocating a WebGL context. Rendering remains the browser gate.
const nativeColor = new Cesium.Color(0.2, 0.4, 0.8, 1);
const model = Object.create(Cesium.Model.prototype, {
ready: { value: true },
show: { value: true },
color: { value: nativeColor.clone(), writable: true },
modelMatrix: { value: Cesium.Matrix4.IDENTITY },
});
const primitives = new Cesium.PrimitiveCollection({
destroyPrimitives: false,
});
primitives.add(model);
const points = new Cesium.PointPrimitiveCollection();
const nativeCommand = {
owner: points,
uniformMap: {},
shaderProgram: {
vertexShaderSource: new Cesium.ShaderSource({
sources: ['void main() {}'],
}),
},
};
primitives.update = (frame) => frame.commandList.push(nativeCommand);
let requests = 0;
const scene = {
primitives,
postUpdate: new Cesium.Event(),
preRender: new Cesium.Event(),
postRender: new Cesium.Event(),
canvas: { clientWidth: 1000, clientHeight: 800 },
drawingBufferWidth: 1000,
drawingBufferHeight: 800,
camera: {
frustum: { projectionMatrix: Cesium.Matrix4.IDENTITY },
viewMatrix: Cesium.Matrix4.IDENTITY,
},
requestRender() {
requests++;
},
};
const viewer = { scene };
const dispose = createCyberSonarScene(viewer);
try {
scene.postUpdate.raiseEvent();
scene.preRender.raiseEvent();
assert.notDeepEqual(
model.color,
nativeColor,
'Cyber neutral treatment precedes shader compilation',
);
const frame = { commandList: [], passes: { render: true } };
primitives.update(frame);
assert.equal(getCyberSonarSceneDiagnostics(viewer).gpu.supported, false);
assert.equal(frame.commandList[0], nativeCommand);
scene.postRender.raiseEvent();
assert.equal(
scheduled,
0,
'inactive sweep has no timer to perform recovery',
);
scene.postUpdate.raiseEvent();
assert.equal(root.dataset.cyberSonarGpu, 'unsupported');
assert.match(status.textContent, /Native contacts remain visible/);
assert.equal(
requests,
1,
'support loss explicitly wakes the parked scene',
);
scene.preRender.raiseEvent();
assert.deepEqual(
model.color,
nativeColor,
'the requested draw restores native model color',
);
assert.equal(getCyberSonarSceneDiagnostics(viewer).contacts, 0);
scene.postRender.raiseEvent();
for (let i = 0; i < 3; i++) scene.postUpdate.raiseEvent();
assert.equal(
requests,
1,
'unsupported idle ticks do not create a render loop',
);
assert.equal(scheduled, 0);
dispose();
scene.postUpdate.raiseEvent();
assert.equal(requests, 1, 'disposed callbacks cannot wake the scene');
} finally {
dispose();
points.destroy();
primitives.destroy();
globalThis.document = previousDocument;
globalThis.matchMedia = previousMatchMedia;
globalThis.setTimeout = previousTimeout;
globalThis.clearTimeout = previousClear;
}
});
}
+821
View File
@@ -0,0 +1,821 @@
/**
* 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),
importJson: () =>
import('./local_data/natural_earth/countries.json', {
with: { type: 'json' },
}),
},
admin1: {
url: new URL(
'./local_data/natural_earth/states_provinces.json',
import.meta.url,
),
importJson: () =>
import('./local_data/natural_earth/states_provinces.json', {
with: { type: 'json' },
}),
},
counties: {
url: new URL(
'./local_data/us_census_counties/counties.json',
import.meta.url,
),
importJson: () =>
import('./local_data/us_census_counties/counties.json', {
with: { type: 'json' },
}),
},
};
/** 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, PACKS.admin1.importJson);
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,
PACKS.counties.importJson,
);
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,
PACKS.countries.importJson,
);
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',
);
});
+14
View File
@@ -438,6 +438,20 @@ export function classifyAircraft({ typeCode, category } = {}) {
return 'airliner';
}
/** Readable name of each class, for cards and readouts. */
export const AIRCRAFT_CLASS_LABELS = Object.freeze({
light: 'Light aircraft',
glider: 'Glider',
turboprop: 'Turboprop',
airliner: 'Airliner',
widebody: 'Widebody',
quadjet: 'Four-engine jet',
helicopter: 'Helicopter',
fastjet: 'Fast jet',
bizjet: 'Business jet',
uav: 'Drone',
});
/** Billboard scale multipliers (skylight GLYPH_SCALE, + fastjet). */
export const CLASS_SCALE_2D = {
light: 0.62,
+25
View File
@@ -1600,6 +1600,31 @@ test('vessel interaction wire: a sibling-owned pick wins without selection mutat
}
});
for (const card of ['live', 'stale', 'absent', 'covered']) {
test(`vessel interaction wire: ${card} foreground card over registered cyclone`, () => {
const id = 'cyclone:fixture:cone:0';
registerPickOwner('weather-cyclones', (pickedId) => pickedId === id);
const harness = installWireHarness({ id });
const requests = [];
harness.windowTarget.addEventListener(WORLD_FOCUS_REQUEST_EVENT, (event) => requests.push(event.detail));
const host = hostWithCardHit(card === 'live' ? `vessel:${harness.record.mmsi}` : card === 'stale' ? 'vessel:999999999' : null);
if (card === 'covered') host.hitTest = (_x, _y, options) => {
assert.equal(options?.sourceId, undefined, 'test topmost host hit, not a covered AIS card');
return { sourceId: 'cctv', entryId: 'vessel:999999999' };
};
_setVesselOverlayHostForTest(host);
try {
harness.handler.click({ position: { x: 10, y: 20 } });
assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.mmsi, harness.record.mmsi);
assert.equal(requests.length, card === 'live' ? 1 : 0);
} finally {
_setVesselOverlayHostForTest(null);
harness.cleanup();
unregisterPickOwner('weather-cyclones');
}
});
}
test('vessel card policy: only MMSI-keyed cards publish a hit rect', () => {
const keyed = applyVesselOverlayPolicy(buildVesselCard(makeRecord()));
assert.equal(keyed.interactive, true);
+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();
}
});
+57 -3
View File
@@ -18,12 +18,13 @@
*
* Providers (injected — keeps the engine pure and node-testable):
* getRecords(layerKey) → Array<record> (layer accessor snapshot)
* resolveRegionRing(name) → Promise<{ring, name}|null> (NE pack / admin boundary)
* resolveRegionRing(name) → Promise<{ring, name}|{error:'region-timeout'}|null>
* getViewContext() → {lat, lon, viewRadiusKm, bounds?} (camera-derived)
*
* @module data/analystEngine
*/
import { feedProvenanceEnvelope } from './layerSnapshot.js';
import { pointInRing } from './naturalEarthRegions.js';
/** Layers the engine understands, with the fields queries may reference. */
@@ -61,6 +62,26 @@ export const ANALYST_LAYERS = {
text: ['place'],
flags: [],
},
satellites: {
numeric: ['altitudeM', 'speedMps'],
text: ['name', 'noradId', 'satelliteClass', 'group'],
flags: [],
},
'local-datacenters': {
numeric: [],
text: ['name', 'operator', 'capacity'],
flags: [],
},
'local-dams': {
numeric: [],
text: ['name', 'operator', 'river', 'output'],
flags: [],
},
'fire-perimeters': {
numeric: ['acres', 'containedPct', 'personnel', 'costToDate'],
text: ['name', 'state', 'county', 'cause', 'behavior', 'complexity'],
flags: [],
},
};
const EARTH_R_KM = 6371;
@@ -163,12 +184,15 @@ export function createAnalystEngine(providers) {
// 1) Source records
let records;
let layersQueried;
let queriedSnapshots;
if (layers === null) {
records = lastResult.items.slice();
layersQueried = lastResult.coverage.layersQueried;
queriedSnapshots = lastResult.coverage.feedProvenance?.layers || [];
} else {
records = [];
layersQueried = [];
queriedSnapshots = [];
const unknown = layers.filter((k) => !ANALYST_LAYERS[k]);
if (unknown.length) {
return {
@@ -180,7 +204,22 @@ export function createAnalystEngine(providers) {
for (const key of layers) {
if (!ANALYST_LAYERS[key]) continue;
const rows = providers.getRecords(key) || [];
layersQueried.push({ layerKey: key, records: rows.length });
const snapshot = providers.getLayerSnapshot?.(key);
if (snapshot) queriedSnapshots.push(snapshot);
layersQueried.push({
layerKey: key,
records: rows.length,
...(snapshot
? {
feedState: snapshot.feedState,
source: snapshot.source,
lastUpdate: snapshot.lastUpdate,
enabled: snapshot.enabled,
error: snapshot.error,
}
: {}),
...providers.getRecordCoverage?.(key, rows),
});
for (const row of rows) records.push({ layerKey: key, ...row });
}
}
@@ -195,6 +234,14 @@ export function createAnalystEngine(providers) {
const scope = spec.scope || { kind: 'view' };
if (scope.kind === 'region' && scope.name) {
const region = await providers.resolveRegionRing(scope.name);
if (region?.error === 'region-timeout') {
return {
ok: false,
code: 'region-timeout',
error: `Looking up the boundary for "${scope.name}" is taking too long — ask again in a moment.`,
coverage: { layersQueried, scope: `region:${scope.name}:timeout` },
};
}
if (!region?.ring) {
return {
ok: false,
@@ -292,8 +339,15 @@ export function createAnalystEngine(providers) {
coverage: {
layersQueried,
scope: scopeNote,
...(queriedSnapshots.length
? { feedProvenance: feedProvenanceEnvelope(queriedSnapshots) }
: {}),
followUp: Boolean(spec.followUp && lastResult),
note: 'client-side data only — answers cover what the enabled layers currently hold',
note: layersQueried.some(
(layer) => layer.basis === 'bounded-loaded-records',
)
? 'Counts and ranks cover the bounded examined loaded records only; omitted records may change the nearest item or count. Satellite distance is ground distance, not slant range.'
: 'client-side data only — answers cover what the enabled layers currently hold',
},
// Surfaced so the narration can name the centre it measured from rather
// than implying a view-centred answer.
+92
View File
@@ -187,6 +187,19 @@ test('analyst: unresolved region is an honest failure, not empty success', async
assert.match(r.error, /Atlantis/);
});
test('analyst: a region lookup timeout is reported as region-timeout', async () => {
const eng = createAnalystEngine({
getRecords: () => FLIGHTS,
resolveRegionRing: async (name) => ({ name, ring: null, error: 'region-timeout' }),
getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }),
});
const r = await eng.query({ layers: ['flights'], scope: { kind: 'region', name: 'Texas' } });
assert.equal(r.ok, false);
assert.equal(r.code, 'region-timeout');
assert.match(r.error, /Texas/);
assert.equal(r.coverage.scope, 'region:Texas:timeout');
});
test('analyst: route fields queryable from cached enrichment only', async () => {
const r = await makeEngine().query({
layers: ['flights'], scope: { kind: 'anywhere' },
@@ -195,9 +208,88 @@ test('analyst: route fields queryable from cached enrichment only', async () =>
assert.deepEqual(r.items.map((i) => i.id), ['SWA1'], 'null route fields never match');
});
test('analyst: satellites and local infrastructure are queryable layers', async () => {
const SATS = [
{ id: 'ISS (ZARYA)', noradId: '25544', name: 'ISS (ZARYA)', lat: 30.3, lon: -97.7, altitudeM: 410000, satelliteClass: 'STATION · ISS', group: 'stations' },
{ id: 'GPS BIIR-2', noradId: '24876', name: 'GPS BIIR-2', lat: 51.0, lon: 0.0, altitudeM: 20200000, satelliteClass: 'NAV · GPS', group: 'gps-ops' },
];
const DAMS = [
{ id: 'Austin Dam', name: 'Austin Dam', lat: 30.27, lon: -97.74, operator: 'LCRA', river: 'Colorado', output: '2 MW' },
{ id: 'Far Dam', name: 'Far Dam', lat: 45.0, lon: -122.0, operator: 'USACE', river: 'Columbia', output: '1000 MW' },
];
const DCS = [
{ id: 'AUS-1', name: 'AUS-1', lat: 30.28, lon: -97.75, operator: 'Example Cloud', capacity: '27 MW' },
];
const eng = createAnalystEngine({
getRecords: (key) => ({ satellites: SATS, 'local-dams': DAMS, 'local-datacenters': DCS }[key] || []),
resolveRegionRing: async (name) => (/texland/i.test(name) ? TEXLAND : null),
getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }),
});
const sats = await eng.query({
layers: ['satellites'], scope: { kind: 'view' }, sortBy: 'distance', limit: 2,
});
assert.equal(sats.ok, true);
assert.equal(sats.count, 1, 'GPS sat is out of the Austin view radius');
assert.equal(sats.items[0].noradId, '25544');
assert.ok(Number.isFinite(sats.items[0].distanceKm));
const nav = await eng.query({
layers: ['satellites'], scope: { kind: 'anywhere' },
filters: [{ field: 'satelliteClass', op: 'contains', value: 'NAV' }],
});
assert.deepEqual(nav.items.map((i) => i.id), ['GPS BIIR-2']);
const dams = await eng.query({
layers: ['local-dams'], scope: { kind: 'view' }, sortBy: 'distance', limit: 5,
});
assert.equal(dams.count, 1);
assert.equal(dams.items[0].id, 'Austin Dam');
assert.equal(dams.items[0].river, 'Colorado');
const dcs = await eng.query({
layers: ['local-datacenters'], scope: { kind: 'region', name: 'Texland' },
filters: [{ field: 'operator', op: 'contains', value: 'cloud' }],
});
assert.equal(dcs.count, 1);
assert.equal(dcs.items[0].id, 'AUS-1');
});
test('helpers: haversine sanity + scope radius', () => {
const km = haversineKm(30.2672, -97.7431, 29.7604, -95.3698); // Austin→Houston
assert.ok(km > 200 && km < 280, `Austin-Houston ~235km, got ${km}`);
const scoped = applyScope(FLIGHTS, { kind: 'radius' }, { center: { lat: 30.27, lon: -97.74 }, km: 50 });
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 () => {
const engine = createAnalystEngine({
getRecords: () => [{ id: 'sample dam', lat: 0, lon: 0, name: 'sample' }],
getRecordCoverage: () => ({ basis: 'bounded-loaded-records', recordsExamined: 1, loadedCount: 3000, sourceTruncated: 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 followUp = await engine.query({ followUp: true });
assert.equal(followUp.coverage.layersQueried[0].loadedCount, 3000);
});
test('follow-up provenance stays attached to old rows after a feed recovers', async () => {
let state = 'stale';
const engine = createAnalystEngine({
getRecords: () => [{ id: 'A', lat: 0, lon: 0 }],
getLayerSnapshot: () => ({ id: 'flights', enabled: true, feedState: state }),
getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }),
});
const first = await engine.query();
state = 'nominal';
const followUp = await engine.query({ followUp: true });
assert.equal(first.coverage.feedProvenance.overall, 'stale');
assert.equal(followUp.coverage.feedProvenance.overall, 'stale');
engine.reset();
assert.equal(engine.hasMemory(), false);
assert.equal((await engine.query()).coverage.feedProvenance.overall, 'nominal');
});
+32 -136
View File
@@ -22,6 +22,8 @@ import {
createBhoteKoshiEmbeddedMedia,
resolveEmbeddedMediaSource,
} from './bhoteKoshiEmbeddedMedia.js';
import { acquireImageryComparison } from '../maps/imageryComparison.js';
import { createImagerySplit } from '../ui/imagerySplit.js';
export const BHOTE_KOSHI_LAYER_ID = 'bhote-koshi-2026';
export const BHOTE_KOSHI_OVERLAY_SOURCE_ID = 'bhote-koshi-witnesses';
@@ -964,10 +966,7 @@ export function createBhoteKoshiEventLayer({
let _enabled = false;
let _panel = null;
let _panelRefs = null;
let _splitLine = null;
let _splitHandle = null;
let _splitDragging = false;
let _splitPointerId = null;
let _splitControl = null;
let _beforeLayer = null;
let _afterLayer = null;
let _imageryLayerCollection = null;
@@ -1022,14 +1021,11 @@ export function createBhoteKoshiEventLayer({
let _playStartedAt = 0;
let _playStartProgress = 0;
let _animationFrame = null;
let _previousMapStack = null;
let _eventMapGeneration = null;
let _previousSplitPosition = null;
let _comparisonLease = null;
let _introTimer = null;
let _introGeneration = 0;
let _lastSurgePosition = null;
let _lastSurgeVisible = null;
let _lastSplitCssValue = null;
let _presentation = 'standalone';
let _sceneBeatId = null;
let _sceneBeatReveal = 0.36;
@@ -2556,79 +2552,20 @@ export function createBhoteKoshiEventLayer({
createFloodDataSource();
}
function ensureSplitLine(event) {
_splitLine = document.createElement('div');
_splitLine.id = 'bhote-koshi-split-line';
const beforeLabel = document.createElement('span');
const afterLabel = document.createElement('span');
const splitHandle = document.createElement('button');
beforeLabel.className = 'before';
afterLabel.className = 'after';
beforeLabel.textContent = 'A';
beforeLabel.title = event.imagery.before.label;
afterLabel.textContent = 'B';
afterLabel.title = event.imagery.after.label;
splitHandle.type = 'button';
splitHandle.className = 'bhote-koshi-split-handle';
splitHandle.setAttribute('role', 'slider');
splitHandle.setAttribute(
'aria-label',
'Historical reference and post-event image divider',
);
splitHandle.setAttribute('aria-valuemin', '0');
splitHandle.setAttribute('aria-valuemax', '100');
splitHandle.setAttribute('aria-orientation', 'horizontal');
splitHandle.textContent = '↔';
splitHandle.addEventListener('pointerdown', handleSplitPointerDown);
splitHandle.addEventListener('pointermove', handleSplitPointerMove);
splitHandle.addEventListener('pointerup', finishSplitPointerDrag);
splitHandle.addEventListener('pointercancel', finishSplitPointerDrag);
splitHandle.addEventListener('keydown', handleSplitKeyDown);
_splitHandle = splitHandle;
_splitLine.append(beforeLabel, splitHandle, afterLabel);
document.body.appendChild(_splitLine);
}
function setSplitFromClientX(clientX) {
const viewportWidth =
Number(document.documentElement?.clientWidth) ||
Number(window.innerWidth) ||
0;
if (viewportWidth <= 0 || !Number.isFinite(Number(clientX))) return;
setSplit(Number(clientX) / viewportWidth);
}
function handleSplitPointerDown(event) {
if (event.button != null && event.button !== 0) return;
event.preventDefault?.();
_splitDragging = true;
_splitPointerId = event.pointerId ?? null;
_splitHandle?.setPointerCapture?.(event.pointerId);
setSplitFromClientX(event.clientX);
}
function handleSplitPointerMove(event) {
if (!_splitDragging) return;
if (_splitPointerId != null && event.pointerId !== _splitPointerId) return;
event.preventDefault?.();
setSplitFromClientX(event.clientX);
}
function finishSplitPointerDrag(event) {
if (!_splitDragging) return;
if (_splitPointerId != null && event.pointerId !== _splitPointerId) return;
_splitHandle?.releasePointerCapture?.(event.pointerId);
_splitDragging = false;
_splitPointerId = null;
}
function handleSplitKeyDown(event) {
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
event.preventDefault?.();
const step = event.shiftKey ? 0.05 : 0.01;
if (event.key === 'Home') setSplit(0);
else if (event.key === 'End') setSplit(1);
else setSplit(_split + (event.key === 'ArrowRight' ? step : -step));
function createSplitControl(event) {
_splitControl = createImagerySplit({
scene: _viewer.scene,
initialValue: _split,
id: 'bhote-koshi-split-line',
handleClass: 'bhote-koshi-split-handle',
cssProperty: '--bhote-koshi-split',
beforeTitle: event.imagery.before.label,
afterTitle: event.imagery.after.label,
ariaLabel: 'Historical reference and post-event image divider',
formatValueText: (beforePercent, afterPercent) =>
`A historical reference ${beforePercent} percent, B post-event ${afterPercent} percent`,
onChange: setSplit,
});
}
function applyCinematicCamera() {
@@ -3211,7 +3148,7 @@ export function createBhoteKoshiEventLayer({
_panelRefs.observedStatus.hidden = !imageryAvailable;
if (_panelRefs?.progressInput)
_panelRefs.progressInput.hidden = !scrubberAvailable;
if (_splitLine) _splitLine.hidden = !imageryAvailable;
_splitControl?.setVisible(imageryAvailable);
if (_beforeLayer) _beforeLayer.show = imageryAvailable;
if (_afterLayer) _afterLayer.show = imageryAvailable;
if (_panelRefs?.caveat) {
@@ -3328,24 +3265,7 @@ export function createBhoteKoshiEventLayer({
function setSplit(split) {
_split = clampUnit(split);
if (_viewer && _viewer.scene.splitPosition !== _split)
_viewer.scene.splitPosition = _split;
const cssValue = `${_split * 100}%`;
if (_lastSplitCssValue !== cssValue) {
document.documentElement.style.setProperty(
'--bhote-koshi-split',
cssValue,
);
_lastSplitCssValue = cssValue;
}
if (_splitHandle) {
const beforePercent = Math.round(_split * 100);
_splitHandle.setAttribute('aria-valuenow', String(beforePercent));
_splitHandle.setAttribute(
'aria-valuetext',
`A historical reference ${beforePercent} percent, B post-event ${100 - beforePercent} percent`,
);
}
_splitControl?.setValue(_split);
syncPanel();
renderHost.request('bhote-koshi-split');
}
@@ -3428,7 +3348,6 @@ export function createBhoteKoshiEventLayer({
async function enable(viewer, { origin = 'local-restore', signal } = {}) {
_viewer = viewer;
_enabled = true;
_previousSplitPosition = viewer.scene.splitPosition;
// Public lifecycle passes signal, not intent origin, to enable. Its atomic
// restore has already applied the scene parameters; those own presentation.
// A missing origin is passive, never authority to focus or start playback.
@@ -3437,24 +3356,20 @@ export function createBhoteKoshiEventLayer({
const event = await loadEvent();
throwIfEnableCancelled(signal, _enabled);
registerDynamicCredit(viewer, BHOTE_KOSHI_CREDIT);
_previousMapStack = _mapStackController?.getActiveId?.() || null;
if (
!sceneDirected &&
_mapStackController &&
_previousMapStack !== 'esri-imagery'
) {
await _mapStackController.setStack('esri-imagery');
if (_mapStackController) {
_comparisonLease = acquireImageryComparison(_mapStackController, {
owner: BHOTE_KOSHI_LAYER_ID,
switchPolicy: sceneDirected ? 'preserve' : 'esri',
});
await _comparisonLease.ready;
}
_eventMapGeneration = sceneDirected
? null
: (_mapStackController?.getSwitchGeneration?.() ?? null);
throwIfEnableCancelled(signal, _enabled);
await createImagery(event);
throwIfEnableCancelled(signal, _enabled);
await syncTerrainComparison();
throwIfEnableCancelled(signal, _enabled);
createFloodDataSource();
ensureSplitLine(event);
createSplitControl(event);
const panelView = createPanel(event, {
close: () =>
_dataManager?.setEnabled(BHOTE_KOSHI_LAYER_ID, false, {
@@ -3573,17 +3488,8 @@ export function createBhoteKoshiEventLayer({
?.classList.remove('bhote-event-active');
_panel = null;
_panelRefs = null;
_splitDragging = false;
_splitPointerId = null;
_splitLine?.remove();
_splitLine = null;
_splitHandle = null;
document.documentElement.style.removeProperty('--bhote-koshi-split');
_lastSplitCssValue = null;
if (_viewer?.scene && _previousSplitPosition != null) {
_viewer.scene.splitPosition = _previousSplitPosition;
}
_previousSplitPosition = null;
_splitControl?.destroy();
_splitControl = null;
_progress = 0;
_split = 0.5;
_presentation = 'standalone';
@@ -3601,20 +3507,10 @@ export function createBhoteKoshiEventLayer({
cancel?.(_sceneSeekFrame);
_sceneSeekFrame = null;
}
const previousMapStack = _previousMapStack;
const eventMapGeneration = _eventMapGeneration;
_previousMapStack = null;
_eventMapGeneration = null;
const comparisonLease = _comparisonLease;
_comparisonLease = null;
try {
if (
_mapStackController &&
previousMapStack &&
previousMapStack !== 'esri-imagery' &&
_mapStackController.getActiveId?.() === 'esri-imagery' &&
_mapStackController.getSwitchGeneration?.() === eventMapGeneration
) {
await _mapStackController.setStack(previousMapStack);
}
await comparisonLease?.release();
} finally {
renderHost.request('bhote-koshi-disable');
}
+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); },
+30
View File
@@ -0,0 +1,30 @@
/**
* @file Load a JSON file shipped beside the source modules.
*
* 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.
*
* 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.
*
* @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.
* @returns {Promise<any>} The parsed JSON.
*/
export async function loadBundledJson(url, importJson, { signal } = {}) {
if (url.protocol === 'file:') return (await importJson()).default;
signal?.throwIfAborted();
const response = await fetch(url, { signal });
if (!response.ok) {
throw new Error(`HTTP ${response.status} for ${url.pathname}`);
}
return response.json();
}
+36
View File
@@ -0,0 +1,36 @@
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' } });
test('bundled JSON: a file URL uses the JSON import under Node', async (t) => {
const fetchMock = t.mock.method(globalThis, 'fetch');
const pack = await loadBundledJson(MARINE, importMarine);
assert.ok(pack.features.some((ft) => ft.name === 'Gulf of Mexico'));
assert.equal(fetchMock.mock.callCount(), 0);
});
test('bundled JSON: a served URL is fetched as plain JSON', async (t) => {
const requests = [];
t.mock.method(globalThis, 'fetch', async (url) => {
requests.push(String(url));
return new Response('{"features":[]}', {
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: [] });
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 }));
await assert.rejects(
loadBundledJson(new URL('http://localhost/missing.json'), importMarine),
/HTTP 404/,
);
});
+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);
};
}
+225
View File
@@ -0,0 +1,225 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createHlsPuller,
fetchHlsBytes,
parseHlsMedia,
HLS_LIMITS,
} from '../../server/providers/cctv/stream.js';
const playlist =
'#EXTM3U\n#EXT-X-MEDIA-SEQUENCE:12\n#EXTINF:2,\na.ts\n#EXTINF:2,\nb.ts\n#EXTINF:2,\nc.ts\n';
const base = 'https://camera.example/live/list.m3u8';
test('playlist parser uses sequence, refuses escaping and unsupported references', () => {
assert.deepEqual(
parseHlsMedia(playlist, base).map((s) => s.seq),
[12, 13, 14],
);
for (const bad of [
playlist.replace('a.ts', 'https://evil.example/a.ts'),
playlist.replace('a.ts', '//evil.example/a.ts'),
'#EXTM3U\n#EXT-X-KEY:METHOD=AES-128\n' + playlist,
playlist.replace('12', '-1'),
playlist.replace('2,', 'Infinity,'),
])
assert.throws(() => parseHlsMedia(bad, base));
});
test('downloads reject redirect responses, oversized declared and chunked bodies', async () => {
let init;
await assert.rejects(
fetchHlsBytes(base, {
maxBytes: 5,
fetchImpl: async (_url, options) => {
init = options;
return new Response('', {
status: 302,
headers: { Location: 'https://evil.example' },
});
},
}),
);
assert.equal(init.redirect, 'error');
await assert.rejects(
fetchHlsBytes(base, {
maxBytes: 5,
fetchImpl: async () => new Response('abcdef'),
}),
);
await assert.rejects(
fetchHlsBytes(base, {
maxBytes: 5,
fetchImpl: async () =>
new Response('a', { headers: { 'content-length': '100' } }),
}),
);
});
test('one session is reserved before await; disk-free cache and capacity remain bounded', async () => {
const manager = createHlsPuller({
limits: {
...HLS_LIMITS,
sessions: 1,
segmentBytes: 4,
sessionBytes: 6,
segments: 2,
pollMs: 100000,
},
fetchImpl: async (url) =>
new Response(url.endsWith('.m3u8') ? playlist : 'abc'),
});
const [a, b] = await Promise.all([
manager.ensure('a', base),
manager.ensure('a', base),
]);
assert.equal(a, b);
await assert.rejects(manager.ensure('b', base));
assert.equal(await manager.waitReady(a), true);
assert.deepEqual(manager.stats(), { sessions: 1, bytes: 6 });
const text = await manager.buildPlaylist(a, 'a');
assert.match(text, /seg_0\.ts\?session=/);
assert.equal(manager.getSegment('a', 'stale-token', 0), null);
assert.equal(manager.getSegment('a', a.token, 0).length, 3);
manager.stop('a', 'stale-token');
assert.equal(manager.stats().sessions, 1);
manager.stop('a', a.token);
assert.deepEqual(manager.stats(), { sessions: 0, bytes: 0 });
await manager.shutdown();
});
test('shutdown cancels in-flight downloads and late responses cannot refill cache', async () => {
let observed;
const manager = createHlsPuller({
fetchImpl: async (_url, { signal }) => {
observed = signal;
return new Promise((resolve, reject) =>
signal.addEventListener('abort', () => reject(new Error('cancelled')), {
once: true,
}),
);
},
});
const entry = await manager.ensure('a', base);
await manager.shutdown();
assert.equal(observed.aborted, true);
assert.equal(entry.stopping, true);
assert.deepEqual(manager.stats(), { sessions: 0, bytes: 0 });
await assert.rejects(manager.ensure('a', base));
});
test('idle cleanup stops all polling without a background sweep', async () => {
let calls = 0;
const manager = createHlsPuller({
limits: { ...HLS_LIMITS, idleMs: 15, pollMs: 100000 },
fetchImpl: async (url) => {
calls++;
return new Response(url.endsWith('.m3u8') ? playlist : 'abc');
},
});
await manager.ensure('a', base);
await new Promise((r) => setTimeout(r, 50));
assert.deepEqual(manager.stats(), { sessions: 0, bytes: 0 });
const stoppedCalls = calls;
await new Promise((r) => setTimeout(r, 30));
assert.equal(calls, stoppedCalls);
await manager.shutdown();
});
test('agency sequence rollback creates a monotonic local discontinuity', async () => {
let current = playlist;
const manager = createHlsPuller({
limits: { ...HLS_LIMITS, pollMs: 5 },
fetchImpl: async (url) =>
new Response(url.endsWith('.m3u8') ? current : 'abc'),
});
const entry = await manager.ensure('a', base);
await manager.waitReady(entry);
const before = await manager.buildPlaylist(entry, 'a');
assert.match(before, /seg_0\.ts/);
current = playlist.replace('SEQUENCE:12', 'SEQUENCE:0');
await new Promise((r) => setTimeout(r, 30));
const after = await manager.buildPlaylist(entry, 'a');
assert.match(
after,
/#EXT-X-DISCONTINUITY\n#EXTINF:2\.000,\n\/api\/cctv\/media\/a\/seg_3\.ts/,
);
await manager.shutdown();
});
test('upstream discontinuity tags survive the media parser', () => {
const parsed = parseHlsMedia(
playlist.replace('a.ts', 'a.ts\n#EXT-X-DISCONTINUITY'),
base,
);
assert.equal(parsed[0].discontinuity, false);
assert.equal(parsed[1].discontinuity, true);
});
test('a reused agency sequence with changed segment URI cannot remain stale', async () => {
let current = playlist;
const manager = createHlsPuller({
limits: { ...HLS_LIMITS, pollMs: 5 },
fetchImpl: async (url) =>
new Response(url.endsWith('.m3u8') ? current : 'abc'),
});
const entry = await manager.ensure('a', base);
await manager.waitReady(entry);
current = playlist.replaceAll('.ts', '.ts?generation=2');
await new Promise((r) => setTimeout(r, 30));
assert.match(
await manager.buildPlaylist(entry, 'a'),
/#EXT-X-DISCONTINUITY\n#EXTINF:2\.000,\n\/api\/cctv\/media\/a\/seg_3\.ts/,
);
await manager.shutdown();
});
test('two consumers share downloads but release and abandoned expiry are independent', async () => {
let downloads = 0;
const manager = createHlsPuller({
limits: { ...HLS_LIMITS, pollMs: 100000, leasesPerSession: 2 },
fetchImpl: async (url) => {
downloads++;
return new Response(url.endsWith('.m3u8') ? playlist : 'abc');
},
});
const [a, b] = await Promise.all([
manager.ensure('camera', base, 'viewer-a'),
manager.ensure('camera', base, 'viewer-b'),
]);
assert.equal(a, b);
await manager.waitReady(a);
assert.equal(downloads, 4); // One manifest and three segments, not per consumer.
assert.equal(a.leases.size, 2);
await assert.rejects(manager.ensure('camera', base, 'viewer-c'));
manager.release('camera', 'viewer-a');
assert.equal(manager.stats().sessions, 1);
assert.equal(manager.getSegment('camera', a.token, 0, 'viewer-a'), null);
assert.equal(manager.getSegment('camera', a.token, 0, 'viewer-b').length, 3);
assert.match(
await manager.buildPlaylist(b, 'camera', 'viewer-b'),
/lease=viewer-b/,
);
manager.release('camera', 'viewer-a'); // Duplicate/late release cannot stop B.
assert.equal(b.controller.signal.aborted, false);
manager.release('camera', 'viewer-b');
assert.equal(b.controller.signal.aborted, true);
assert.deepEqual(manager.stats(), { sessions: 0, bytes: 0 });
await manager.shutdown();
});
test('an abandoned consumer expires while a renewed consumer keeps the session', async () => {
const manager = createHlsPuller({
limits: { ...HLS_LIMITS, idleMs: 80, pollMs: 100000 },
fetchImpl: async (url) =>
new Response(url.endsWith('.m3u8') ? playlist : 'abc'),
});
const entry = await manager.ensure('camera', base, 'abandoned');
await manager.ensure('camera', base, 'active');
await new Promise((r) => setTimeout(r, 50));
await manager.ensure('camera', base, 'active');
await new Promise((r) => setTimeout(r, 50));
assert.equal(entry.leases.has('abandoned'), false);
assert.equal(entry.leases.has('active'), true);
assert.equal(entry.controller.signal.aborted, false);
await manager.shutdown();
});
+116 -1
View File
@@ -8,8 +8,9 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import net from 'node:net';
import { Readable } from 'node:stream';
import { Readable, Writable } from 'node:stream';
import { cctvProxy } from '../../server/providers/cctv.js';
import { proxyMediaResponse } from '../../server/providers/cctv/media.js';
import { sanitizeCctvRangeHeader } from '../../server/providers/cctv/range.js';
import { CCTV_MEDIA_MAX_BODY_BYTES as CAP } from '../../server/providers/cctv/constants.js';
@@ -586,3 +587,117 @@ test('two sequential seeks are served as two partial responses', async (t) => {
assert.equal(first.body, 'chunk-0');
assert.equal(second.body, 'chunk-100');
});
// ---------------------------------------------------------------------------
// The body idle deadline. proxyMediaResponse is driven directly here because
// the deadline is only injectable through its options; the route always passes
// the production value.
// ---------------------------------------------------------------------------
/**
* A response to pipe into, built on a real Writable so the backpressure the
* proxy reads is the stream's own rather than a flag the test sets. With
* `drains: false` the bytes are accepted and never flushed, which is what a
* viewer on a slow link looks like from the server side.
*/
function pipeTarget({ drains = true } = {}) {
const res = new Writable({
// One byte is enough to leave a target that never empties its buffer
// permanently in need of a drain.
highWaterMark: drains ? undefined : 1,
write(_chunk, _encoding, callback) {
if (drains) callback();
},
});
res.writeHead = (status, headers) => {
res.statusCode = status;
res.headers = headers || {};
};
return res;
}
/**
* An upstream that answers with headers, sends one chunk, and then either
* keeps producing every `everyMs` or goes silent. `released` reports the body's
* own cleanup: a cancelled fetch body lands in the web stream's cancel hook.
*/
function mediaUpstream({ everyMs = 0 } = {}) {
const state = { produced: 0, released: false };
let ticker = null;
const body = new ReadableStream({
start(controller) {
const send = () => {
state.produced += 1;
controller.enqueue(new Uint8Array([0xff, 0xd8, 0xff, 0xe0]));
};
send();
if (everyMs > 0) {
ticker = setInterval(send, everyMs);
ticker.unref();
}
},
cancel() {
state.released = true;
if (ticker) clearInterval(ticker);
},
});
state.upstream = {
ok: true,
status: 200,
headers: {
get: (name) =>
String(name).toLowerCase() === 'content-type' ? 'video/mp4' : null,
},
body,
arrayBuffer: async () => Buffer.alloc(0),
};
return state;
}
test('an upstream that goes silent after its headers is released at the idle deadline', async (t) => {
const feed = mediaUpstream();
const res = pipeTarget();
t.after(() => res.destroy());
await proxyMediaResponse(res, feed.upstream, { idleTimeoutMs: 40 });
await new Promise((resolve) => setTimeout(resolve, 20));
assert.equal(feed.released, false, 'released before the deadline was due');
await new Promise((resolve) => setTimeout(resolve, 120));
// Without the deadline this stream is held open against the camera host for
// as long as that host will keep the socket, with no bytes ever arriving.
assert.equal(feed.released, true, 'the silent upstream was never released');
assert.equal(res.writableEnded, true, 'the client was left waiting');
});
test('a live feed that keeps producing is not cut off by the idle deadline', async (t) => {
const feed = mediaUpstream({ everyMs: 10 });
const res = pipeTarget();
t.after(() => res.destroy());
await proxyMediaResponse(res, feed.upstream, { idleTimeoutMs: 40 });
await new Promise((resolve) => setTimeout(resolve, 250));
assert.equal(feed.released, false, 'a healthy feed was torn down');
assert.equal(res.writableEnded, false, 'a healthy feed was ended early');
assert.ok(
feed.produced > 5,
`the feed stopped producing (${feed.produced} chunks)`,
);
});
test('a client that cannot keep up is not mistaken for a stalled upstream', async (t) => {
// Nothing arrives from upstream while the pipe is paused, which looks exactly
// like silence unless the response is asked whether it is still draining.
const feed = mediaUpstream({ everyMs: 10 });
const res = pipeTarget({ drains: false });
t.after(() => res.destroy());
await proxyMediaResponse(res, feed.upstream, { idleTimeoutMs: 30 });
await new Promise((resolve) => setTimeout(resolve, 200));
assert.equal(
res.writableNeedDrain,
true,
'the client was not the bottleneck',
);
assert.equal(feed.released, false, 'a slow viewer tore down a healthy feed');
assert.equal(res.writableEnded, false, 'a slow viewer ended the response');
});
+454
View File
@@ -0,0 +1,454 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import {
cycloneProxy,
parseCycloneStatus,
attachCycloneGeometry,
} from '../../server/providers/cyclones.js';
const NOW = Date.UTC(2026, 8, 16, 3, 35);
const TIME = '2026-09-16T03:00:00.000Z';
function status(overrides = {}) {
return {
activeStorms: [
{
id: 'ep152026',
name: 'Fifteen-E',
classification: 'PTC',
intensity: '25',
pressure: '1006',
longitudeNumeric: -125.8,
latitudeNumeric: 15.5,
movementDir: 255,
movementSpeed: 12,
lastUpdate: TIME,
forecastAdvisory: {
advNum: '010',
issuance: TIME,
url: 'https://www.nhc.noaa.gov/text/MIATCMEP5.shtml',
},
...overrides,
},
],
};
}
function collections(adv = '10') {
const geometry = [
{ type: 'Point', coordinates: [-125.8, 15.5] },
{
type: 'LineString',
coordinates: [
[-125.8, 15.5],
[-128, 15],
],
},
{
type: 'Polygon',
coordinates: [
[
[-126, 15],
[-128, 15],
[-127, 17],
[-126, 15],
],
],
},
];
return geometry.map((shape, index) => ({
type: 'FeatureCollection',
features: [
{
type: 'Feature',
properties: {
idp_source: `ep152026-${adv.padStart(3, '0')}_5day_${['pts', 'lin', 'pgn'][index]}`,
advisnum: adv,
tau: 0,
maxwind: 25,
gust: 9999,
},
geometry: shape,
},
],
}));
}
function install(options = {}, preview = false) {
let handler;
const plugin = cycloneProxy({ now: () => NOW, ...options });
plugin[preview ? 'configurePreviewServer' : 'configureServer']({
middlewares: {
use(path, callback) {
assert.equal(path, '/api/cyclones');
handler = callback;
},
},
});
const begin = (url = '/', method = 'GET') => {
const res = new EventEmitter();
res.writeHead = (code, headers) => {
res.statusCode = code;
res.headers = headers;
};
res.end = (body) => {
res.body = body;
};
return { res, done: handler({ url, method }, res).then(() => res) };
};
return { begin, request: (url, method) => begin(url, method).done };
}
const body = (res) => JSON.parse(res.body);
const nextTurn = () => new Promise((resolve) => setImmediate(resolve));
function fetcher(calls = [], data = status(), parts = collections()) {
return async (url, options) => {
calls.push({ url: new URL(url), options });
return Response.json(
url.includes('CurrentStorms')
? data
: parts[Number(url.match(/\/(\d)\/query/)[1]) - 5],
);
};
}
test('status retains official clocks, knots, source links and finite current position', () => {
const storm = parseCycloneStatus(status(), NOW)[0];
assert.equal(storm.advisoryNumber, '10');
assert.equal(storm.issuedAt, TIME);
assert.equal(storm.positionAt, TIME);
assert.deepEqual(storm.position, { longitude: -125.8, latitude: 15.5 });
assert.deepEqual(storm.movement, { directionDegrees: 255, speedKt: 12 });
assert.equal(storm.windKt, 25);
assert.equal(storm.pressureHpa, 1006);
assert.match(storm.advisoryUrl, /^https:\/\/www\.nhc\.noaa\.gov\/text\//);
const missing = parseCycloneStatus(
status({ intensity: '9999', pressure: null, movementSpeed: '' }),
NOW,
)[0];
assert.equal(missing.windKt, null);
assert.equal(missing.pressureHpa, null);
assert.equal(missing.movement.speedKt, null);
assert.equal(
parseCycloneStatus(
status({
forecastAdvisory: {
advNum: '10',
issuance: TIME,
url: 'https://www.nhc.noaa.gov.evil/secret',
},
}),
NOW,
)[0].advisoryUrl,
null,
);
});
test('malformed, duplicate, oversized, future and expired status is rejected rather than reported empty', () => {
for (const payload of [
{},
{ activeStorms: null },
{ activeStorms: Array(33).fill(status().activeStorms[0]) },
{ activeStorms: [...status().activeStorms, ...status().activeStorms] },
status({ id: 'wp012026' }),
status({ name: 'a'.repeat(81) }),
status({ name: '<script>' }),
status({ longitudeNumeric: Infinity }),
status({ latitudeNumeric: 91 }),
status({ lastUpdate: '2026-09-17T00:00:00.000Z' }),
status({ lastUpdate: '2026-02-30T00:00:00.000Z' }),
status({
forecastAdvisory: { advNum: '10', issuance: '2026-09-15T03:00:00.000Z' },
}),
])
assert.throws(() => parseCycloneStatus(payload, NOW));
assert.deepEqual(parseCycloneStatus({ activeStorms: [] }, NOW), []);
});
test('coherent geometry is retained with holes/dateline while older advisory geometry is omitted', () => {
const storms = parseCycloneStatus(status(), NOW);
const parts = collections();
parts[1].features[0].geometry.coordinates = [
[179, 15],
[-179, 16],
];
parts[2].features[0].geometry.coordinates.push([
[-126.5, 15.5],
[-127, 15.5],
[-126.8, 16],
[-126.5, 15.5],
]);
const good = attachCycloneGeometry(storms, parts)[0];
assert.equal(good.geometryStatus, 'current');
assert.equal(good.geometryAdvisoryNumber, '10');
assert.deepEqual(good.track, parts[1].features[0].geometry);
assert.equal(good.cone.coordinates.length, 2);
assert.equal(good.forecastPoints[0].gustKt, null);
assert.equal(good.forecastPoints[0].tauHours, 0);
assert.equal(
'validAt' in good.forecastPoints[0],
false,
'do not fabricate point time from advisory + tau',
);
const old = attachCycloneGeometry(storms, collections('9'))[0];
assert.equal(old.geometryStatus, 'pending');
assert.equal(old.track, null);
assert.equal(old.cone, null);
assert.deepEqual(old.forecastPoints, []);
assert.deepEqual(old.position, storms[0].position);
const mixed = collections();
mixed[2] = collections('9')[2];
const partial = attachCycloneGeometry(storms, mixed)[0];
assert.equal(partial.geometryStatus, 'pending');
assert.equal(
partial.track,
null,
'a new track and old cone cannot form one advisory snapshot',
);
});
test('geometry rejects transfer truncation, duplicate tracks, invalid coordinates and structural budgets', () => {
const storms = parseCycloneStatus(status(), NOW);
const mutations = [
(p) => {
p[0].exceededTransferLimit = true;
},
(p) => {
p[0].features = Array(501).fill(p[0].features[0]);
},
(p) => {
p[1].features.push(p[1].features[0]);
},
(p) => {
p[0].features.push(p[0].features[0]);
},
(p) => {
p[0].features[0].properties.idp_source = 'ep152026-009_5day_pts';
},
(p) => {
p[1].features[0].geometry.coordinates[1] = [181, 1];
},
(p) => {
p[2].features[0].geometry.coordinates[0].pop();
},
(p) => {
p[1].features[0].geometry.coordinates = Array(10001).fill([0, 0]);
},
(p) => {
p[1].features[0].geometry = {
type: 'MultiLineString',
coordinates: Array.from({ length: 3 }, () => Array(9000).fill([0, 0])),
};
},
(p) => {
p[2].features[0].geometry = {
type: 'GeometryCollection',
geometries: [],
};
},
];
for (const mutate of mutations) {
const parts = collections();
mutate(parts);
assert.throws(() => attachCycloneGeometry(storms, parts));
}
});
test('dev and preview serve only fixed bounded NOAA queries, caching a complete snapshot', async () => {
for (const preview of [false, true]) {
const calls = [];
const { request } = install({ fetchImpl: fetcher(calls) }, preview);
const snapshot = body(await request());
assert.equal(snapshot.unavailable, false);
assert.equal(snapshot.stale, false);
assert.match(snapshot.coverage, /not worldwide/);
assert.equal(snapshot.storms[0].geometryStatus, 'current');
assert.equal(snapshot.fetchedAt, NOW);
assert.equal(calls.length, 4);
assert.equal(
calls[0].url.href,
'https://www.nhc.noaa.gov/CurrentStorms.json',
);
for (const call of calls) assert.equal(call.options.redirect, 'error');
for (const call of calls.slice(1)) {
assert.equal(call.url.origin, 'https://mapservices.weather.noaa.gov');
assert.equal(call.url.searchParams.get('outSR'), '4326');
assert.equal(call.url.searchParams.get('f'), 'geojson');
assert.notEqual(call.url.searchParams.get('outFields'), '*');
}
assert.equal(
calls[2].url.searchParams.get('outFields'),
'idp_source,advisnum',
);
assert.deepEqual(body(await request()), snapshot);
assert.equal(calls.length, 4);
}
});
test('query/method rejection cannot redirect or widen provider work', async () => {
let calls = 0;
const { request } = install({
fetchImpl: async () => {
calls++;
throw new Error('unexpected');
},
});
for (const url of [
'/?url=https://evil',
'/?storm=ep152026',
'/other',
'/?bbox=0,0,1,1',
])
assert.equal((await request(url)).statusCode, 400);
assert.equal((await request('/', 'POST')).statusCode, 405);
assert.equal(calls, 0);
});
test('confirmed empty status skips GIS; broken GIS keeps honest current positions', async () => {
const calls = [];
const empty = install({ fetchImpl: fetcher(calls, { activeStorms: [] }) });
assert.deepEqual(body(await empty.request()).storms, []);
assert.equal(body(await empty.request()).unavailable, false);
assert.equal(calls.length, 1);
const broken = install({
fetchImpl: async (url) =>
url.includes('CurrentStorms')
? Response.json(status())
: new Response('secret detail', { status: 503 }),
});
const snapshot = body(await broken.request());
assert.equal(snapshot.unavailable, false);
assert.equal(snapshot.storms[0].geometryStatus, 'unavailable');
assert.equal(snapshot.storms[0].track, null);
assert.doesNotMatch(JSON.stringify(snapshot), /secret/);
});
test('five-minute TTL, failure cooldown and age-limited last-good retain original advisory/fetch clocks', async () => {
let clock = NOW;
let fail = false;
let calls = 0;
const good = fetcher();
const { request } = install({
now: () => clock,
fetchImpl: async (...args) => {
calls++;
if (fail) throw new Error('private');
return good(...args);
},
});
await request();
clock += 299_000;
await request();
assert.equal(calls, 4);
fail = true;
clock += 2000;
const stale = body(await request());
assert.equal(stale.stale, true);
assert.equal(stale.fetchedAt, NOW);
assert.equal(stale.storms[0].issuedAt, TIME);
await request();
assert.equal(calls, 5);
clock += 12 * 3600_000;
const expired = body(await request());
assert.equal(expired.unavailable, true);
assert.deepEqual(expired.storms, []);
});
test('shared refresh survives one disconnect and aborts when its last consumer leaves', async () => {
let release;
let signal;
const good = fetcher();
const { begin } = install({
fetchImpl: async (url, options) => {
if (url.includes('CurrentStorms')) {
signal = options.signal;
await new Promise((resolve, reject) => {
release = resolve;
signal.addEventListener('abort', () => reject(new Error('aborted')), {
once: true,
});
});
}
return good(url, options);
},
});
const a = begin();
const b = begin();
await nextTurn();
a.res.emit('close');
await a.done;
assert.equal(signal.aborted, false);
assert.equal(a.res.body, undefined);
release();
assert.equal(body(await b.done).unavailable, false);
let calls = 0;
const cancel = install({
fetchImpl: async (url, options) => {
calls++;
if (calls > 1) return good(url, options);
signal = options.signal;
return new Promise((_, reject) =>
signal.addEventListener('abort', () => reject(new Error('aborted')), {
once: true,
}),
);
},
});
const c = cancel.begin();
await nextTurn();
c.res.emit('close');
await c.done;
assert.equal(signal.aborted, true);
assert.equal(
body(await cancel.request()).unavailable,
false,
'cancellation does not impose upstream failure cooldown',
);
});
test('stream caps and whole refresh deadline cancel oversized or stalled response bodies', async () => {
for (const stalled of [false, true]) {
let cancelled = false;
const { request } = install({
timeoutMs: 15,
fetchImpl: async () =>
new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new Uint8Array(stalled ? 4 : 128 * 1024 + 1));
},
cancel() {
cancelled = true;
},
}),
),
});
const snapshot = body(await request());
assert.equal(snapshot.unavailable, true);
assert.equal(cancelled, true);
}
});
test('shared refresh caps waiting consumers and rejects redirects without exposing upstream errors', async () => {
let release;
const good = fetcher();
const { request } = install({
fetchImpl: async (url, options) => {
if (url.includes('CurrentStorms'))
await new Promise((resolve) => {
release = resolve;
});
return good(url, options);
},
});
const waiting = Array.from({ length: 32 }, () => request());
await nextTurn();
assert.equal((await request()).statusCode, 429);
release();
assert.ok((await Promise.all(waiting)).every((r) => r.statusCode === 200));
const redirected = install({
fetchImpl: async () =>
new Response('private', {
status: 302,
headers: { Location: 'http://127.0.0.1/' },
}),
});
assert.equal(body(await redirected.request()).unavailable, true);
});
+133 -52
View File
@@ -25,6 +25,72 @@ import * as Cesium from 'cesium';
* follows DATA_SOURCES.md (live sources, then bundled snapshots).
* @type {{ key: string, html: string }[]}
*/
export const OSM_CREDIT = {
key: 'openstreetmap',
html: 'Map and place data <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> (<a href="https://opendatacommons.org/licenses/odbl/1-0/" target="_blank" rel="noopener">ODbL</a>)',
};
export const OPENMAPTILES_CREDIT = {
key: 'openfreemap',
html: 'Vector tiles: <a href="https://openfreemap.org" target="_blank" rel="noopener">OpenFreeMap</a> © <a href="https://openmaptiles.org" target="_blank" rel="noopener">OpenMapTiles</a>',
};
const osmDisplays = new WeakMap();
function updateOsmDisplay(viewer, state) {
const osm = state.owners.size > 0;
let tiles = false;
for (const usesTiles of state.owners.values()) tiles ||= usesTiles;
for (const [key, visible] of [
['osm', osm],
['tiles', tiles],
]) {
if (state[key].visible === visible) continue;
state[key].visible = visible;
viewer.creditDisplay[visible ? 'addStaticCredit' : 'removeStaticCredit'](
state[key].credit,
);
viewer.scene?.requestRender?.();
}
}
/** Keep one short inline credit while an OSM display owner has visible data. */
export function showOsmCredit(viewer, owner, { openMapTiles = false } = {}) {
if (!viewer?.creditDisplay?.addStaticCredit || !owner) return false;
let state = osmDisplays.get(viewer);
if (!state) {
state = {
owners: new Map(),
osm: {
visible: false,
credit: new Cesium.Credit(
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap</a>',
true,
),
},
tiles: {
visible: false,
credit: new Cesium.Credit(
'<a href="https://openmaptiles.org" target="_blank" rel="noopener">© OpenMapTiles</a>',
true,
),
},
};
osmDisplays.set(viewer, state);
}
if (state.owners.get(owner) === openMapTiles) return false;
state.owners.set(owner, openMapTiles);
updateOsmDisplay(viewer, state);
return true;
}
/** Release only this owner's credit; other displayed OSM sources retain theirs. */
export function hideOsmCredit(viewer, owner) {
const state = osmDisplays.get(viewer);
if (!state?.owners.delete(owner)) return false;
updateOsmDisplay(viewer, state);
return true;
}
export const DATA_CREDITS = [
// ── Live sources ────────────────────────────────────────────────
{
@@ -78,41 +144,31 @@ export const DATA_CREDITS = [
html: 'Earthquakes: Data courtesy of the U.S. Geological Survey',
},
{
key: 'overpass',
key: 'nasa-gibs',
html:
'Road geometry (traffic): ' +
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> ' +
'(ODbL 1.0)',
'Recent imagery: We acknowledge the use of imagery provided by services from ' +
"NASA's Global Imagery Browse Services (GIBS), part of NASA's Earth Science " +
'Data and Information System (ESDIS). ' +
'<a href="https://gibs.earthdata.nasa.gov" target="_blank" rel="noopener">gibs.earthdata.nasa.gov</a> · ' +
'<a href="https://lpdaac.usgs.gov/products/hlss30v002/" target="_blank" rel="noopener">HLS product page</a>',
},
{
key: 'wfigs',
html:
'Wildfire perimeters: ' +
'<a href="https://data-nifc.opendata.arcgis.com/" target="_blank" rel="noopener">National Interagency Fire Center (WFIGS)</a>' +
' · Incident information: ' +
'<a href="https://inciweb.wildfire.gov/" target="_blank" rel="noopener">InciWeb</a>',
},
OSM_CREDIT,
OPENMAPTILES_CREDIT,
{
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',
@@ -147,6 +203,10 @@ export const DATA_CREDITS = [
'CCTV cameras &amp; frames (Texas): ' +
'<a href="https://its.txdot.gov/" target="_blank" rel="noopener">Texas Department of Transportation</a> (courtesy)',
},
{
key: 'deldot-cctv',
html: 'CCTV live video (Delaware): <a href="https://deldot.gov/map/" target="_blank" rel="noopener">DelDOT — Delaware Department of Transportation</a>',
},
{
key: 'caltrans-cctv',
html:
@@ -191,7 +251,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>',
},
{
@@ -212,20 +271,38 @@ export const DATA_CREDITS = [
'<a href="https://terrain.reearth.land" target="_blank" rel="noopener">Re:Earth Terrain</a> / ' +
'Mapterhorn (CC BY 4.0) / EGM2008 (NGA)',
},
// ── Bundled snapshots ───────────────────────────────────────────
{
key: 'datacenters',
html:
'Datacenters: ' +
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> ' +
'(ODbL 1.0)',
key: 'weather-noaa',
html: 'Observed weather: <a href="https://nowcoast.noaa.gov/" target="_blank" rel="noopener">NOAA nowCOAST</a> · NWS/OAR MRMS radar; NESDIS GOES and global satellite partners. <a href="https://oceanservice.noaa.gov/disclaimer.html" target="_blank" rel="noopener">Source disclaimer</a>.',
},
{
key: 'dams',
key: 'weather-cyclones',
html: 'Cyclone advisories: <a href="https://www.nhc.noaa.gov/" target="_blank" rel="noopener">NOAA/NWS NHC / CPHC</a> · Atlantic and eastern/central North Pacific. Forecast center uncertainty, not storm size.',
},
{
key: 'weather-lightning',
html: 'Lightning density: NOAA/NWS nowCOAST · derived from Vaisala NLDN/GLD360. <a href="https://ocean.weather.gov/lightning/lightning_pdd.php" target="_blank" rel="noopener">Public derived density product</a>, not raw detections.',
},
{
key: 'wind-gfs',
html:
'Dams: ' +
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> ' +
'(ODbL 1.0) + Open Infrastructure Map',
'Wind: resampled NOAA Global Forecast System (GFS) 10 m wind via ' +
'<a href="https://registry.opendata.aws/noaa-gfs-bdp-pds/" target="_blank" rel="noopener">NOAA Open Data on AWS</a> ' +
'(U.S. public domain; forecast, not observations)',
},
{
key: 'ecmwf-ifs',
html:
'This service is based on data and products of the European Centre for Medium-Range Weather Forecasts (ECMWF). ' +
'<a href="https://www.ecmwf.int/en/forecasts/datasets/open-data" target="_blank" rel="noopener">ECMWF Open Data</a> · ' +
'<a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener">CC BY 4.0</a>. ' +
'Modified: resampled 10 m IFS forecast vectors and animated display. ' +
'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: 'dams',
html: 'Dams: Open Infrastructure Map',
},
{
key: 'firms',
@@ -258,9 +335,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',
@@ -293,15 +368,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).
@@ -337,10 +421,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)',
};
+83
View File
@@ -37,3 +37,86 @@ test('adsbdb is credited and carries its published route-data restriction', () =
assert.match(credit.html, /Guillaume Michel/);
assert.match(credit.html, /href="https:\/\/www\.adsbdb\.com"/);
});
test('OpenStreetMap has one generic data credit with separate tile and names distributors', () => {
const osm = DATA_CREDITS.filter((entry) =>
entry.html.includes('openstreetmap.org/copyright'),
);
assert.equal(osm.length, 1);
assert.equal(osm[0].key, 'openstreetmap');
assert.match(osm[0].html, /Map and place data/);
assert.match(osm[0].html, /© OpenStreetMap contributors/);
assert.match(
DATA_CREDITS.find((entry) => entry.key === 'openfreemap').html,
/Vector tiles:/,
);
assert.match(
DATA_CREDITS.find((entry) => entry.key === 'overture-military-names').html,
/Overture Maps Foundation/,
);
});
test('inline OSM attribution persists until its last display owner leaves', async (t) => {
const { showOsmCredit, hideOsmCredit } = await import('./dataCredits.js');
t.mock.timers.enable({ apis: ['setTimeout'] });
const visible = new Set();
const viewer = {
creditDisplay: {
addStaticCredit: (c) => visible.add(c),
removeStaticCredit: (c) => visible.delete(c),
},
scene: { requestRender() {} },
};
assert.equal(showOsmCredit(viewer, 'alpr'), true);
assert.equal(showOsmCredit(viewer, 'alpr'), false);
assert.equal(visible.size, 1);
assert.match(
[...visible][0].html,
/href="https:\/\/www.openstreetmap.org\/copyright"/,
);
assert.match([...visible][0].html, />© OpenStreetMap</);
assert.ok([...visible][0].showOnScreen);
t.mock.timers.tick(60000);
assert.equal(visible.size, 1);
showOsmCredit(viewer, 'traffic', { openMapTiles: true });
assert.equal(visible.size, 2);
hideOsmCredit(viewer, 'alpr');
assert.equal(visible.size, 2, 'traffic still owns both credits');
showOsmCredit(viewer, 'datacenters');
hideOsmCredit(viewer, 'traffic');
assert.equal(
visible.size,
1,
'bundled data does not retain the tile distributor',
);
hideOsmCredit(viewer, 'datacenters');
assert.equal(visible.size, 0);
assert.equal(hideOsmCredit(viewer, 'datacenters'), false);
showOsmCredit(viewer, 'alpr');
assert.equal(visible.size, 1, 'reenabling restores the short credit');
});
test('tile attribution follows source changes and viewers have independent ownership', async () => {
const { showOsmCredit, hideOsmCredit } = await import('./dataCredits.js');
const create = () => {
const credits = new Set();
return {
credits,
creditDisplay: {
addStaticCredit: (c) => credits.add(c),
removeStaticCredit: (c) => credits.delete(c),
},
};
};
const a = create(),
b = create();
showOsmCredit(a, 'installations', { openMapTiles: true });
showOsmCredit(b, 'installations');
assert.equal(a.credits.size, 2);
assert.equal(b.credits.size, 1);
showOsmCredit(a, 'installations');
assert.equal(a.credits.size, 1, 'wide names no longer use tiles');
hideOsmCredit(a, 'installations');
assert.equal(a.credits.size, 0);
assert.equal(b.credits.size, 1);
});
+63 -4
View File
@@ -48,6 +48,12 @@ import {
viewScaleForAltitude,
} from './detectionPolicy.js';
import { detectionBracketOpacity } from './detectionPresentation.js';
import {
createCyberSonarSampler,
cyberSonarFrameDelay,
isCyberContactSonarActive as isCyberSonarActive,
isCyberContactThemeActive as isCyberMapThemeActive,
} from '../cyberSonar.js';
/**
* @module detection
@@ -206,6 +212,8 @@ let _lastRenderMs = 0;
let _lastPaintMs = 0;
let _lastSolveMs = 0;
let _throttleSkipCount = 0;
let _lastCyberSonarPaintAt = Number.NEGATIVE_INFINITY;
let _cyberSonarRenderTimer = null;
/** @type {Function|null} External callback invoked when the detection mode changes */
let _onModeChange = null;
/** @type {{surface:HTMLCanvasElement|null,setActive:Function,requestPaint:Function,unregister:Function}|null} */
@@ -325,6 +333,7 @@ export function initDetection(viewer, layers, onModeChange) {
/** Release the host lane and all retained detection runtime state. */
export function destroyDetection() {
_clearCyberSonarRenderTimer();
if (_cockpitModeListener && typeof window !== 'undefined') {
window.removeEventListener(
'gev:cockpit-mode-changed',
@@ -362,6 +371,25 @@ export function destroyDetection() {
_lastPaintMs = 0;
_lastSolveMs = 0;
_throttleSkipCount = 0;
_lastCyberSonarPaintAt = Number.NEGATIVE_INFINITY;
}
function _clearCyberSonarRenderTimer() {
if (_cyberSonarRenderTimer != null) {
globalThis.clearTimeout?.(_cyberSonarRenderTimer);
_cyberSonarRenderTimer = null;
}
}
function _scheduleCyberSonarRender(nowMs) {
if (_cyberSonarRenderTimer != null || !globalThis.setTimeout) return;
const delay = cyberSonarFrameDelay(_lastCyberSonarPaintAt, nowMs);
_cyberSonarRenderTimer = globalThis.setTimeout(() => {
_cyberSonarRenderTimer = null;
if (isCyberSonarActive()) {
governorRequestRender('cyber-sonar-cadence');
}
}, delay);
}
/**
@@ -682,7 +710,18 @@ function _applyModeState() {
function _shouldPaintDetectionLane(frame) {
if (_mode === MODE_OFF || _suspended) return false;
const layoutChanged = frame.layoutRevision !== _hostLayoutRevision;
const nowMs = Number.isFinite(frame.timestamp) ? frame.timestamp : _nowMs();
_frameCount++;
if (isCyberSonarActive()) {
const sonarDelay = cyberSonarFrameDelay(_lastCyberSonarPaintAt, nowMs);
if (!layoutChanged && sonarDelay > 0) {
_scheduleCyberSonarRender(nowMs);
return false;
}
} else {
_clearCyberSonarRenderTimer();
_lastCyberSonarPaintAt = Number.NEGATIVE_INFINITY;
}
// The skip and the follow-up request come from ONE decision, so the valve
// cannot drop a frame without handing its request forward — see
// `detectionPaintSkipDecision`. (Skipping is a deferral, never a cancellation:
@@ -729,6 +768,14 @@ function _paintDetectionLane(frame) {
_lastRenderMs = performance.now() - start;
_lastSolveMs = result.solveMs || 0;
_lastPaintMs = Math.max(0, _lastRenderMs - _lastSolveMs);
const nowMs = Number.isFinite(frame.timestamp) ? frame.timestamp : _nowMs();
if (isCyberSonarActive() && _lastDiagnostics?.visibleCount > 0) {
_lastCyberSonarPaintAt = nowMs;
_scheduleCyberSonarRender(nowMs);
} else {
_clearCyberSonarRenderTimer();
_lastCyberSonarPaintAt = Number.NEGATIVE_INFINITY;
}
if (_lastDiagnostics) {
_lastDiagnostics.frameTotalMs = _lastRenderMs;
_lastDiagnostics.paintMs = _lastPaintMs;
@@ -748,7 +795,7 @@ function _paintDetectionLane(frame) {
// The frame's OWN timestamp, not a fresh sample — re-reading the clock here
// is what dropped the terminal frame of a fade (paint at 219 ms, policy at
// 220 ms, and the settled alpha never painted).
nowMs: Number.isFinite(frame.timestamp) ? frame.timestamp : _nowMs(),
nowMs,
enabledAtMs: _enableTime,
fadeMs: FADE_MS,
animatingLabelCount: result.animatingCount || 0,
@@ -996,7 +1043,8 @@ function _stashCallout(entry, acquireFade, keyhole) {
placement.centerY,
keyhole,
);
const alpha = acquireFade * temporalAlpha * radialAlpha;
const alpha =
acquireFade * temporalAlpha * radialAlpha * (candidate.sonarFactor ?? 1);
if (alpha <= 0.001) return;
let row = _calloutPool[_calloutCount];
@@ -1146,6 +1194,7 @@ function _materializeCandidate(
keyholeAlpha,
placements,
color: obj._candidateColor,
sonarFactor: obj._candidateSonarFactor ?? 1,
// Costed here rather than in the object sweep: the sweep walks every
// observation in view, while this runs only for callouts that actually
// placed — a budgeted handful per frame.
@@ -1180,6 +1229,9 @@ function _drawOverlay(frame) {
// with every arbiter call in this function — so the frame that paints an
// animation's final state is the same frame that ends its demand.
const now = Number.isFinite(frame.timestamp) ? frame.timestamp : _nowMs();
const sonarActive = isCyberSonarActive();
const cyberMapActive = isCyberMapThemeActive();
const sonarSampler = createCyberSonarSampler(width, height, now);
const bracketPresentationOpacity = detectionBracketOpacity(_cockpitActive);
const shouldSolve =
_labelSolveDirty || now - _lastLabelSolveAt >= LABEL_SOLVE_INTERVAL_MS;
@@ -1362,11 +1414,16 @@ function _drawOverlay(frame) {
// The bracket follows the same linear radial keyhole fade as its callout.
const color = colorFor(resolveTier(obj));
const keyholeAlpha = keyholeLabelAlphaFromGeometry(sx, sy, keyhole);
const bracketAlpha = detectionBracketAlpha(
const sonarFactor =
sonarActive && !obj.skipLabel ? sonarSampler.at(sx, sy) : 1;
const sonarLabelFactor = sonarSampler.label(sonarFactor);
const admissionAlpha = detectionBracketAlpha(
obj.type,
keyholeAlpha,
keyholeOutsideOpacity,
cyberMapActive,
);
const bracketAlpha = admissionAlpha * sonarFactor;
if (bracketAlpha > 0) {
const transit = obj.tier?.startsWith('transit_');
(transit ? appendTransitBracket : appendCornerBracket)(
@@ -1404,13 +1461,15 @@ function _drawOverlay(frame) {
obj._cohortSourceId = sourceId;
obj._cohortPriority = _semanticPriority(obj);
obj._cohortBand = bracketAlpha >= 0.999 ? 8 : Math.floor(bracketAlpha * 8);
obj._cohortBand =
admissionAlpha >= 0.999 ? 8 : Math.floor(admissionAlpha * 8);
obj._cohortHash = stableIdentityHash(layerId, sourceId);
obj._candidateScreenX = sx;
obj._candidateScreenY = sy;
obj._candidateHalfW = halfW;
obj._candidateHalfH = halfH;
obj._candidateColor = color;
obj._candidateSonarFactor = sonarLabelFactor;
obj._candidatePrimary = primary;
obj._candidateMicro = micro;
+4
View File
@@ -92,14 +92,18 @@ export function aircraftBracketAlphaFloor(outsideOpacity) {
* @param {number} keyholeAlpha - Radial keyhole alpha for this contact.
* @param {number} [outsideOpacity] - Live OUTSIDE slider value. Omitted means
* the default, so a caller that does not know it reproduces the shipped look.
* @param {boolean} [strictKeyholeFade] - When true, do not apply the AIR
* readability floor; the bracket follows the radial keyhole alpha exactly.
* @returns {number} Paint alpha, 0-1.
*/
export function detectionBracketAlpha(
type,
keyholeAlpha,
outsideOpacity = AIRCRAFT_BRACKET_FLOOR_ANCHOR,
strictKeyholeFade = false,
) {
const alpha = Math.max(0, Math.min(1, Number(keyholeAlpha) || 0));
if (strictKeyholeFade) return alpha;
if (String(type || '').toUpperCase() !== 'AIR' || alpha <= 0) return alpha;
return Math.max(aircraftBracketAlphaFloor(outsideOpacity), alpha);
}
+14
View File
@@ -28,6 +28,20 @@ test('side aircraft brackets stay readable without changing zero-opacity intent'
assert.equal(detectionBracketAlpha('SAT', 0.05), 0.05);
});
test('strict keyhole fading bypasses the aircraft floor for themed brackets', () => {
assert.equal(
detectionBracketAlpha(
'AIR',
0.01,
KEYHOLE_OUTSIDE_OPACITY_DEFAULT,
true,
),
0.01,
);
assert.equal(detectionBracketAlpha('AIR', 0.42, 1, true), 0.42);
assert.equal(detectionBracketAlpha('AIR', 1, 0, true), 1);
});
test('the bracket floor anchor mirrors the real keyhole default it is calibrated to', () => {
// detectionPolicy stays Cesium-free, so the anchor is a mirror. If the keyhole
// default ever moves, this fails rather than silently shifting the shipped
+9 -1
View File
@@ -285,7 +285,15 @@ test('detection holds nothing, and asks for its own frames instead', async () =>
assert.ok(paintLane, 'detection.js still has a paint lane');
assert.doesNotMatch(paintLane, /nowMs: Date\.now\(\)/,
'demand must not re-sample the clock — that is the dropped terminal frame');
assert.match(paintLane, /nowMs: Number\.isFinite\(frame\.timestamp\) \? frame\.timestamp : _nowMs\(\)/);
assert.match(
paintLane,
/const nowMs = Number\.isFinite\(frame\.timestamp\) \? frame\.timestamp : _nowMs\(\)/,
);
assert.match(
paintLane,
/detectionNeedsFollowUpFrame\(\{[\s\S]*?\n\s*nowMs,/,
'the sonar cadence and fade demand share the same frame-clock sample',
);
assert.match(paintLane, /animatingLabelCount: result\.animatingCount/,
'demand counts fades in both directions');
// …and `animatingCount` must really be the BOTH-directions count. Feeding the
+47
View File
@@ -0,0 +1,47 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
import { detectionBracketAlpha } from './detectionPolicy.js';
// Exercise the actual renderer's admission and paint expressions. A sweep is
// appearance only: it must not reshuffle candidates at the cohort cutoff.
const source = readFileSync(new URL('./detection.js', import.meta.url), 'utf8');
const alphaBlock = source.match(
/const admissionAlpha = detectionBracketAlpha\([\s\S]*?const bracketAlpha = [^;]+;/,
)?.[0];
const bandAssignment = source.match(/obj\._cohortBand\s*=\s*[^;]+;/)?.[0];
test('Sonar changes paint alpha without changing detection cohort admission', () => {
assert.ok(alphaBlock, 'renderer separates admission from paint');
assert.ok(bandAssignment);
for (const cyberMapActive of [false, true]) {
for (const type of ['AIR', 'SAT', 'SEA']) {
for (const keyholeAlpha of [0, 0.01, 0.125, 0.5, 0.999, 1]) {
const alpha = detectionBracketAlpha(
type,
keyholeAlpha,
0.35,
cyberMapActive,
);
const expectedBand = alpha >= 0.999 ? 8 : Math.floor(alpha * 8);
for (const sonarFactor of [0.01, 0.12, 0.5, 1]) {
const state = {
obj: { type },
keyholeAlpha,
keyholeOutsideOpacity: 0.35,
cyberMapActive,
sonarFactor,
detectionBracketAlpha,
};
vm.runInNewContext(
alphaBlock + bandAssignment + '; result = bracketAlpha;',
state,
);
assert.equal(state.obj._cohortBand, expectedBand);
assert.equal(state.result, alpha * sonarFactor);
}
}
}
}
});
+2
View File
@@ -10,6 +10,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 +24,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,
+410
View File
@@ -0,0 +1,410 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { firePerimetersProxy } from '../../server/providers/firePerimeters.js';
const incidentHtml = `<meta property="og:updated_time" content="2026-09-23T11:32:31-04:00" />
<th>Date of Origin</th>
<td class="text-tabular text-left"><time datetime="2026-07-15T20:20:00Z">Origin</time></td>`;
const incidentTimes = {
createdMs: Date.parse('2026-07-15T20:20:00Z'),
changedMs: Date.parse('2026-09-23T11:32:31-04:00'),
};
const incidentRedirect = () =>
new Response('redirect', {
status: 301,
headers: { location: 'http://inciweb.wildfire.gov/test-fire' },
});
const feature = (id = 'fire') => ({
properties: { attr_UniqueFireIdentifier: id, poly_DateCurrent: 123 },
geometry: {
type: 'Polygon',
coordinates: [
[
[-108, 35],
[-107, 35],
[-107, 36],
[-108, 35],
],
],
},
});
function install(options = {}, hook = 'configureServer') {
let handler;
const plugin = firePerimetersProxy(options);
assert.equal(typeof plugin.configureServer, 'function');
assert.equal(typeof plugin.configurePreviewServer, 'function');
plugin[hook]({
middlewares: {
use(path, callback) {
assert.equal(path, '/api/fire-perimeters');
handler = callback;
},
},
});
return async (url = '/', method = 'GET', peer = 'local') => {
const res = {
writeHead(status, headers) {
this.status = status;
this.headers = headers;
},
end(body) {
this.body = JSON.parse(body);
},
};
await handler({ url, method, socket: { remoteAddress: peer } }, res);
return res;
};
}
for (const hook of ['configureServer', 'configurePreviewServer']) {
test(`${hook}: paging stops at five pages and normalizes the combined snapshot`, async () => {
const calls = [];
const request = install(
{
now: () => 1234,
fetchImpl: async (url, options) => {
calls.push(new URL(url));
assert.ok(options.signal instanceof AbortSignal);
assert.equal(options.redirect, 'error');
return Response.json({
features: [feature(String(calls.length))],
exceededTransferLimit: true,
});
},
},
hook,
);
const res = await request('/?url=https://invalid.example&resultOffset=999');
assert.equal(res.status, 200);
assert.equal(res.body.fetchedAt, 1234);
assert.equal(res.body.rows.length, 5);
assert.deepEqual(
calls.map((url) => url.searchParams.get('resultOffset')),
[null, '1', '2', '3', '4'],
);
assert.ok(calls.every((url) => url.hostname === 'services3.arcgis.com'));
assert.equal(calls[0].searchParams.get('maxAllowableOffset'), '0.001');
});
}
test('an explicit transfer-limit false stops paging; a fresh cache does not fetch', async () => {
let calls = 0;
const request = install({
fetchImpl: async () => {
calls++;
return Response.json({
features: [feature()],
exceededTransferLimit: false,
});
},
});
const first = await request();
assert.deepEqual((await request()).body, first.body);
assert.equal(calls, 1);
});
test('a failed refresh serves the last good perimeter snapshot as stale', async () => {
let clock = 0,
calls = 0;
const request = install({
now: () => clock,
fetchImpl: async () => {
if (++calls > 1) throw new Error('private upstream details');
return Response.json({ features: [feature()] });
},
});
await request();
clock = 300_000;
const res = await request();
assert.equal(res.status, 200);
assert.equal(res.body.stale, true);
assert.equal(res.body.fetchedAt, 0);
assert.equal(res.body.rows[0].stableId, 'fire');
});
test('oversized streamed and declared bodies produce sanitized 502 responses', async () => {
for (const declared of [true, false]) {
let cancelled = false;
const request = install({
fetchImpl: async () =>
new Response(
new ReadableStream({
pull(controller) {
controller.enqueue(new Uint8Array(1024 * 1024));
},
cancel() {
cancelled = true;
},
}),
{
headers: declared
? { 'Content-Length': String(17 * 1024 * 1024) }
: {},
},
),
});
const res = await request();
assert.equal(res.status, 502);
assert.deepEqual(res.body, { error: 'fire_perimeters_unavailable' });
assert.equal(cancelled, true);
}
});
test('malformed WFIGS data does not become a cached empty snapshot', async () => {
let calls = 0;
const request = install({
fetchImpl: async () => {
calls++;
return Response.json({ error: 'upstream detail' });
},
});
assert.equal((await request()).status, 502);
assert.equal((await request()).status, 502);
assert.equal(calls, 2);
});
test('concurrent requests share one upstream operation for every route', async () => {
for (const [path, payload] of [
['/', { features: [] }],
['/inciweb/index', []],
['/inciweb/publication/42', {}],
]) {
let release,
calls = 0;
const pending = new Promise((resolve) => {
release = resolve;
});
const request = install({
fetchImpl: async () => {
calls++;
await pending;
if (path.includes('/publication/'))
return calls === 1 ? incidentRedirect() : new Response(incidentHtml);
return Response.json(payload);
},
});
const responses = [request(path), request(path)];
await new Promise(setImmediate);
assert.equal(calls, 1);
release();
for (const res of await Promise.all(responses))
assert.equal(res.status, 200);
assert.equal(calls, path.includes('/publication/') ? 2 : 1);
}
});
test('bad publication ids and non-GET requests never fetch upstream', async () => {
let calls = 0;
const request = install({
fetchImpl: async () => {
calls++;
throw new Error('unexpected');
},
});
for (const id of ['', 'abc', '-1', '1234567890', '1/2', '%31', '1.0'])
assert.equal((await request(`/inciweb/publication/${id}`)).status, 400);
for (const path of ['/', '/inciweb/index', '/inciweb/publication/123']) {
for (const method of ['POST', 'HEAD', 'PUT', 'DELETE'])
assert.equal((await request(path, method)).status, 405);
}
assert.equal(calls, 0);
});
test('InciWeb index preserves the fixed POST, caches for one hour and serves stale arrays', async () => {
let clock = 0,
calls = 0;
const rows = [{ incident_id: '42', incident_title: 'Fire' }];
const request = install({
now: () => clock,
fetchImpl: async (url, options) => {
calls++;
assert.equal(url, 'https://inciweb.wildfire.gov/api/single-publication/');
assert.equal(options.method, 'POST');
assert.deepEqual(options.headers, { 'Content-Type': 'application/json' });
assert.deepEqual(JSON.parse(options.body), { title: '' });
if (calls > 1) throw new Error('offline');
return Response.json(rows);
},
});
assert.deepEqual((await request('/inciweb/index')).body, rows);
clock = 3_599_999;
assert.deepEqual((await request('/inciweb/index')).body, rows);
assert.equal(calls, 1);
clock++;
const stale = await request('/inciweb/index');
assert.deepEqual(stale.body, rows);
assert.equal(stale.headers['X-Data-Stale'], 'true');
});
test('publication timestamps, thirty-minute TTL and oldest-entry eviction', async () => {
let clock = 0;
const calls = [];
const request = install({
now: () => clock,
fetchImpl: async (url) => {
calls.push(url);
return url.includes('/node/')
? incidentRedirect()
: new Response(incidentHtml);
},
});
const get = (id) =>
request(`/inciweb/publication/${id}`, 'GET', `peer-${id}`);
assert.deepEqual((await get(1)).body, incidentTimes);
clock = 1_799_999;
await get(1);
assert.equal(calls.length, 2);
clock++;
await get(1);
assert.equal(calls.length, 4);
for (let id = 2; id <= 257; id++) assert.equal((await get(id)).status, 200);
await get(2);
assert.equal(calls.length, 516);
await get(1);
assert.equal(calls.length, 518);
assert.equal(calls.at(-2), 'https://inciweb.wildfire.gov/node/1');
});
test('InciWeb index and publication reads enforce their smaller caps', async () => {
for (const [path, cap] of [
['/inciweb/index', 4 * 1024 * 1024],
['/inciweb/publication/1', 2 * 1024 * 1024],
]) {
const request = install({
fetchImpl: async () =>
new Response('{}', { headers: { 'Content-Length': String(cap + 1) } }),
});
assert.equal((await request(path)).status, 502);
}
});
test('the per-client limit stops a loop while another client can read the cache', async () => {
let calls = 0;
const request = install({
fetchImpl: async () => {
calls++;
return Response.json({ features: [] });
},
});
for (let i = 0; i < 60; i++) assert.equal((await request()).status, 200);
assert.equal((await request()).status, 429);
assert.equal((await request('/', 'GET', 'another-client')).status, 200);
assert.equal(calls, 1);
});
test('publication redirects share a signal, cancel the redirect body and force HTTPS', async () => {
for (const status of [301, 302, 303, 307, 308]) {
const calls = [];
const redirect = new Response('redirect', {
status,
headers: {
location: 'http://inciweb.wildfire.gov/incident/test-fire#details',
},
});
const request = install({
fetchImpl: async (url, options) => {
calls.push({ url, options });
return calls.length === 1 ? redirect : new Response(incidentHtml);
},
});
assert.deepEqual(
(await request('/inciweb/publication/42')).body,
incidentTimes,
);
assert.equal(calls.length, 2);
assert.equal(redirect.bodyUsed, true);
assert.equal(calls[0].url, 'https://inciweb.wildfire.gov/node/42');
assert.equal(calls[0].options.redirect, 'manual');
assert.equal(
calls[1].url,
'https://inciweb.wildfire.gov/incident/test-fire',
);
assert.equal(calls[1].options.redirect, 'error');
assert.ok(calls[0].options.signal instanceof AbortSignal);
assert.equal(calls[0].options.signal, calls[1].options.signal);
}
});
test('unsafe publication redirect locations fail before a second fetch', async () => {
for (const location of [
'https://evil.example/x',
'/a/../x',
'/x?q=1',
'/x?',
'/%2e%2e/x',
'/a/b/c',
'',
]) {
let calls = 0;
const request = install({
fetchImpl: async () => {
calls++;
return new Response('redirect', { status: 301, headers: { location } });
},
});
assert.equal((await request('/inciweb/publication/42')).status, 502);
assert.equal(calls, 1);
}
});
test('incident pages without valid timestamps fail closed and are not cached', async () => {
for (const html of [
'<html>No markers</html>',
incidentHtml.replaceAll(/2026-[^"]+/g, 'invalid'),
]) {
let calls = 0;
const request = install({
fetchImpl: async () => {
calls++;
return calls % 2 ? incidentRedirect() : new Response(html);
},
});
assert.equal((await request('/inciweb/publication/42')).status, 502);
assert.equal((await request('/inciweb/publication/42')).status, 502);
assert.equal(calls, 4);
}
});
test('direct 200 incident pages work, including one missing timestamp', async () => {
for (const [html, expected] of [
[incidentHtml, incidentTimes],
[
incidentHtml.replace('og:updated_time', 'unrelated'),
{ ...incidentTimes, changedMs: null },
],
[
incidentHtml.replace('Date of Origin', 'Unrelated'),
{ ...incidentTimes, createdMs: null },
],
]) {
let calls = 0;
const request = install({
fetchImpl: async () => {
calls++;
return new Response(html);
},
});
assert.deepEqual((await request('/inciweb/publication/42')).body, expected);
assert.equal(calls, 1);
}
});
test('publication HTML streams are capped at 2 MiB', async () => {
let cancelled = false;
const request = install({
fetchImpl: async () =>
new Response(
new ReadableStream({
pull(controller) {
controller.enqueue(new Uint8Array(1024 * 1024));
},
cancel() {
cancelled = true;
},
}),
),
});
assert.equal((await request('/inciweb/publication/42')).status, 502);
assert.equal(cancelled, true);
});
+7 -1
View File
@@ -5,9 +5,15 @@
* VIIRS header (confirmed live 2026-07-16):
* latitude,longitude,bright_ti4,scan,track,acq_date,acq_time,satellite,
* instrument,confidence,version,bright_ti5,frp,daynight
* MODIS header:
* latitude,longitude,brightness,scan,track,acq_date,acq_time,satellite,
* instrument,confidence,version,bright_t31,frp,daynight
*
* Quirks this module owns:
* - `confidence` is CATEGORICAL for VIIRS (`l`/`n`/`h`) — passed through raw.
* - `confidence` is CATEGORICAL for VIIRS (`l`/`n`/`h`) and numeric 0-100 for
* MODIS — passed through raw.
* - Brightness maps from `bright_ti4`/`brightness`; the secondary band maps
* from `bright_ti5`/`bright_t31`.
* - `acq_time` is NOT zero-padded ("45" = 00:45 UTC) — kept as-is in records;
* {@link acquisitionMsUtc} does the padding.
* - Upstream errors come back as HTML or plain text ("Invalid MAP_KEY"),
+19
View File
@@ -48,6 +48,25 @@ test('fixture first row maps every field, categorical confidence preserved raw',
assert.equal(first.instrument, 'VIIRS');
});
test('MODIS schema maps fields and preserves numeric confidence raw', () => {
const header =
'latitude,longitude,brightness,scan,track,acq_date,acq_time,satellite,instrument,confidence,version,bright_t31,frp,daynight';
const row =
'34.123,-117.456,321.5,1.2,0.8,2026-07-16,1045,Aqua,MODIS,78,6.1NRT,290.25,12.4,D';
const [record] = parseFirmsCsv(`${header}\n${row}\n`);
assert.equal(record.lat, 34.123);
assert.equal(record.lon, -117.456);
assert.equal(record.frp, 12.4);
assert.equal(record.brightness, 321.5);
assert.equal(record.brightnessTi5, 290.25);
assert.equal(record.daynight, 'D');
assert.equal(record.acqDate, '2026-07-16');
assert.equal(record.acqTime, '1045');
assert.equal(record.satellite, 'Aqua');
assert.equal(record.instrument, 'MODIS');
assert.equal(record.confidence, '78');
});
test('acquisitionMsUtc: "1006" = 10:06 UTC', () => {
assert.equal(
acquisitionMsUtc('2026-07-16', '1006'),
+4 -4
View File
@@ -9,7 +9,7 @@ assert.notEqual(start, -1, 'FIRMS refresh function exists');
const end = config.indexOf('\n }', start);
assert.notEqual(end, -1, 'FIRMS refresh function closes');
const refreshSource = config.slice(start, end + 4);
const SOURCES = ['VIIRS_NOAA20_NRT', 'VIIRS_NOAA21_NRT', 'VIIRS_SNPP_NRT'];
const SOURCES = ['VIIRS_NOAA20_NRT', 'VIIRS_NOAA21_NRT', 'VIIRS_SNPP_NRT', 'MODIS_NRT'];
const NOW = Date.UTC(2026, 8, 11, 12);
const recent = { acqDate: '2026-09-11', acqTime: '1100' };
@@ -42,7 +42,7 @@ test('FIRMS retains large sources in order and filters expired rows', async () =
assert.equal(result.fires[199_999], large.at(-1));
assert.equal(result.fires.at(-1), last);
assert.deepEqual(result.sources, SOURCES.map((source, index) => ({
source, count: [200_000, 1, 0][index], ok: true,
source, count: [200_000, 1, 0, 0][index], ok: true,
})));
});
@@ -51,7 +51,7 @@ test('FIRMS keeps successful sources when another upstream fails', async () => {
if (source === SOURCES[1]) throw new Error('upstream unavailable');
return [recent];
})('fixture');
assert.equal(result.fires.length, 2);
assert.equal(result.fires.length, 3);
assert.deepEqual(result.sources, SOURCES.map((source, index) => ({
source, count: index === 1 ? 0 : 1, ok: index !== 1,
})));
@@ -65,7 +65,7 @@ test('FIRMS reports one failure if consuming a source throws before append', asy
// Fault injection for aggregation; ordinary parsed CSV returns an array.
return { length: 1, [Symbol.iterator]() { throw new Error('aggregation failed'); } };
})('fixture');
assert.equal(result.fires.length, 2);
assert.equal(result.fires.length, 3);
assert.deepEqual(result.sources, SOURCES.map((source, index) => ({
source, count: index === 0 ? 0 : 1, ok: index !== 0,
})));
+34
View File
@@ -7,3 +7,37 @@
(22,980 bytes). Used by offline decode/source tests and the explicit `qa-traffic --fixtures`
browser mode — it is a point-in-time congestion snapshot, not a bundled
data layer, and is never loaded by ordinary application startup. © TomTom.
- `adsb-austin-frames.txt` — 112 raw Mode S frames (`*<hex>;`, one per line,
in receive order) received over Austin, TX, and
`adsb-austin-dump1090-aircraft.json` — dump1090-fa's `aircraft.json` decode
of the same frames, trimmed to the fields the tests assert on. Used by the
Local ADS-B decoder and record-adapter tests; never loaded at runtime.
- `adsb-austin-capture-timed.json` — a second ~90 s receive over Austin, TX:
846 extended squitters from six aircraft (`frames`: `[ms, hex]`), with
dump1090-fa's decoded positions and emitter categories for the same
interval (`dump1090`: per ICAO `flight`, `category` and `fixes` as
`[ms, lat, lon]`). The capture has no per-frame timestamps; each frame's
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.
@@ -0,0 +1 @@
{"receiver":{"latitude":30.27,"longitude":-97.8},"frames":[[10,"8d0d0c07501d0030aa4c0c2140c6"],[80,"8da5b3a758b99408f8dda679bb00"],[150,"8da0b702e1150d0000000004eb56"],[220,"8d0d0c0722594377e37820b79a0d"],[290,"8d0d0c07990c111d483c0c92e5f3"],[360,"8da0b702590d40253c4af26e47d9"],[430,"8da0b70299105d833818018770a8"],[500,"8d0d0c07501bf7da9cd71ab9e124"],[570,"8da5b3a7ea466864013c085e68e2"],[640,"8da3627d58a580665464f23579f2"],[780,"8d0d0c07990c111d483c0c92e5f3"],[850,"8da0b702590d47cf46d60b223463"],[920,"8da0b70299105d83380c015f9ea8"],[990,"8d0d0c07f8200002004ab89cec0b"],[1060,"8da0b702f8122006005ab8b0ce08"],[1130,"8d0d0c07501bf030d84c0b00e5d5"],[1200,"8da5b3a758b9905ff8528e447e65"],[1275,"8d0d0c07990c111d483c0c92e5f3"],[1350,"8da0b702590d5025384afb963e58"],[1425,"8da0b70299105d83300481465660"],[1500,"8da3627d58a5840ef0ef82e7f39f"],[1600,"8d0d0c07501bf030f64c0af8367b"],[1675,"8da5b3a7990da30b30085d4a3fb6"],[1750,"8d0d0c07990c111d483c0b6d35d7"],[1825,"8da5b3a723041333c31c2045cc84"],[1900,"8da0b702590d4025364affe4f100"],[1963,"8da0b70299105c833008010a006e"],[2088,"8dabe7c59915ad0d380456abc760"],[2150,"8dabe7c558af87ce8ccac01d62c7"],[2213,"8d0d0c07501be031124c090e5e08"],[2275,"8d0d0c07e1058a00000000c4d44e"],[2338,"8da3627de106290000000030ef86"],[2400,"8da5b3a758b994091cdd51bae36d"],[2460,"8d0d0c07990c111d483c0c92e5f3"],[2580,"8da0b702590d47cf40d6170e6567"],[2640,"8da0b70299105c83100c01774a47"],[2700,"8d0d0c07501be031244c08456bbe"],[2800,"8da5b3a7990da30b30085d4a3fb6"],[2900,"8da27e81592147de34ccbfe1e71f"],[3000,"8d0d0c07990c111d683c0b2849fe"],[3100,"8da0b702590d57cf3cd61d759697"],[3133,"8da0b70299105c83101081d8ce87"],[3200,"8da5b3a7ea466864013c085e68e2"],[3233,"8d0d0c07501be7db16d7163df6ea"],[3267,"8da3627d991417bb100854ccc1a2"],[3300,"8da27e81592147de3cccc18db35e"],[3333,"8da27e819910390f18080b8ece70"],[3367,"8d0d0c07990c111d683c0cd799da"],[3400,"8da5b3a758b990601e52360713ad"],[3450,"8da0b702f8122006005ab8b0ce08"],[3500,"8d0d0c07f8200002004ab89cec0b"],[3550,"8da0b702590d50252e4b0d6c0eab"],[3600,"8da0b70299105b83101481e9cced"],[3650,"8da5b3a7f82300060049b8e08700"],[3700,"8d0d0c07501be7db32d715b033d5"],[3700,"8da3627d58a580652c64ebca78d9"],[3860,"8da5b3a7990da30b30085eb5d7a4"],[3940,"8d0d0c07990c111d68380b107ffe"],[4020,"8dabe7c59915ad0d380456abc760"],[4100,"8da0b702590d57cf38d625439697"],[4140,"8da0b70299105b83101801a6902d"],[4180,"8d0d0c07501bd031724c05906709"],[4220,"8dabe7c558af87ceb8ca66b4e8a7"],[4260,"8d0d0c07990c111d68380cefafda"],[4300,"8da5b3a758b9906030520dfe8d1c"],[4417,"8d0d0c07501bd031844c05445a49"],[4533,"8da0b702590d57cf36d62b019958"],[4650,"8da0b70299105b83101881a196ed"],[4767,"8da5b3a7990da30b30085d4a3fb6"],[4883,"8d0d0c07990c111d88380b35ff28"],[5000,"8da0b702590d57cf34d62ee5fde5"],[5000,"8da0b70299105b82f01481e7b168"],[5000,"8da0b702e1150d0000000004eb56"],[5000,"8d0d0c07501bd7db74d71347e30c"],[5150,"8da0b70227c74d5462082004597a"],[5300,"8da5b3a758b990604051e7fb7390"],[5333,"8d0d0c0722594377e37820b79a0d"],[5367,"8d0d0c07990c111d88380cca2f0c"],[5400,"8dabe7c558af8024c23ee7c8e962"],[5460,"8dabe7c5f8230006004ab8db4fc2"],[5520,"8da0b702590d5025264b1f025060"],[5580,"8da0b70299105b82f01401e0b7a8"],[5640,"8da5b3a7ea466864013c085e68e2"],[5700,"8d0d0c07501bd031b44c03dc465d"],[5760,"8da5b3a7990da30b30085eb5d7a4"],[5820,"8da0b702f8122006005ab8b0ce08"],[5880,"8d0d0c07990c111d88380b35ff28"],[5940,"8dabe7c59915ad0d300456c56568"],[6000,"8da0b702590d57cf30d6372dc85c"],[6050,"8da0b70299105b82f01401e0b7a8"],[6100,"8d0d0c07501bc031c64c021a765d"],[6250,"8d0d0c07f8200002004ab89cec0b"],[6400,"8dabe7c558af8024d83eba9c7813"],[6500,"8d0d0c07501bc7dbb4d71117af08"],[6600,"8d0d0c07990c111da83c0b48b501"],[6700,"8da5b3a7990da30b30085d4a3fb6"],[6800,"8dabe7c59915ad0d300456c56568"],[6900,"8d0d0c07501bc7dbc8d710a36866"],[7050,"8d0d0c07990c111da83c0cb76525"],[7200,"8da5b3a758b990606451947414a7"],[7300,"8dabe7c558af87cef8c9e42b1cae"],[7360,"8dabe7c5ea428867215c08fe0874"],[7420,"8d0d0c07e1058a00000000c4d44e"],[7540,"8d0d0c07501bc0320a4c005f6240"],[7600,"8d0d0c07990c111da83c0b48b501"],[7660,"8da0b702590d67cf28d645cd6577"],[7720,"8da0b70299105a82f01081dc8da6"],[7780,"8da5b3a7990da30b30085eb5d7a4"],[7840,"8dabe7c59915ad0d300456c56568"],[7900,"8d0d0c07501bb032264bff008fc5"],[7933,"8d0d0c07990c111da83c0cb76525"],[7967,"8da5b3a7ea466864013c085e68e2"],[8000,"8da0b702590d67cf26d6487082aa"],[8150,"8da0b70299105a82d01481a1c78f"],[8300,"8da5b3a758b9906076516b48fcb5"],[8300,"8dabe7c558af8025003e6a60fadf"],[8364,"8da5b3a7f82300060049b8e08700"],[8427,"8da0b702f8122006005ab8b0ce08"],[8491,"8d0d0c07f8200002004ab89cec0b"],[8555,"8d0d0c07990c111dc83c0b87317a"],[8618,"8d0d0c07501bb032444bfe2b5735"],[8682,"8da5b3a7e10d2800000000aefd57"],[8745,"8da0b702590d67cf24d64d94e617"],[8809,"8da0b70299105a82d01481a1c78f"],[8873,"8dabe7c59915ad0d300456c56568"],[8936,"8d0d0c07990c111dc83c0c78e15e"],[9000,"8d0d0c07501bb032564bfeedbba7"],[9200,"8da0b702590d6025164b3fca5cc9"],[9267,"8da0b70299105a82d010019ef74f"],[9333,"8da5b3a758b9940996dc34d66e25"],[9400,"8dabe7c55Line truncated
@@ -0,0 +1,53 @@
{
"now": 1790127097.5,
"messages": 112,
"aircraft": [
{
"hex": "a15c54",
"flight": "SKW3301 ",
"alt_baro": 5050,
"gs": 287.5,
"track": 291.2,
"baro_rate": 3072,
"seen": 31.5,
"messages": 14,
"rssi": -28.2
},
{
"hex": "ae5d8a",
"flight": "SHINR42 ",
"lat": 30.269662,
"lon": -97.793262,
"alt_baro": 1600,
"gs": 129.9,
"track": 330.5,
"baro_rate": 64,
"seen": 30.5,
"seen_pos": 34.6,
"messages": 74,
"rssi": -30.0
},
{
"hex": "a3c775",
"lat": 30.360625,
"lon": -97.919659,
"alt_baro": 34000,
"gs": 434.4,
"track": 283.7,
"baro_rate": 0,
"seen": 38.2,
"seen_pos": 43.4,
"messages": 20,
"rssi": -30.5
},
{
"hex": "a46e6a",
"gs": 319.8,
"track": 351.2,
"baro_rate": 2688,
"seen": 32.0,
"messages": 4,
"rssi": -37.1
}
]
}
+112
View File
@@ -0,0 +1,112 @@
*8da46e6a99143227b0ac21405d5c;
*8da46e6aea428858015c080bb810;
*5da46e6ae86d20;
*8da3c775990da70d180457361e57;
*8dae5d8aea00085ee53e004546c3;
*8dae5d8a580d77d658d63e32c82f;
*5dae5d8a54a93d;
*8da3c77558af803cce430dad950f;
*200015b8f3156e;
*02e615b82df0c7;
*02e615b82df0c7;
*8da3c775990da70d180458c9be1f;
*8dae5d8a99a4400e70088202ff80;
*5dae5d8a54a93d;
*200015b8f3156e;
*200015b8f3156e;
*8dae5d8a580d702c844b24b6523e;
*8dae5d8af80300020049bc1cd8f3;
*200015b8f3156e;
*20000197d80069;
*5dae5d8a54a93d;
*020001970f9c90;
*8dae5d8a580d77d688d63170d088;
*8da3c77558af87e6a0cdf2c8bbf8;
*8dae5d8aea00085ee53e004546c3;
*8dae5d8a274c824e4b4ca06f9608;
*5dae5d8a54a93d;
*8dae5d8a99a4400e700c823ac980;
*020001970f9c90;
*5da15c54111a4b;
*8dae5d8a580d77d6a0d62aa4760f;
*5da3c7758f35d4;
*8dae5d8aea00085ee53e004546c3;
*8dae5d8a99a4410e500c827cbf67;
*8da15c54581b8019e24c96ebec6d;
*8dae5d8a580d77d6ccd61fccec2f;
*5dae5d8a54a93d;
*8dae5d8a99a4410e500c827cbf67;
*8dae5d8a580d77d6d8d61cd91129;
*8dae5d8a99a4410e500c827cbf67;
*8dae5d8a580d77d6e2d619cbefa5;
*8dae5d8af80300020049bc1cd8f3;
*20000197d80069;
*80000197580d77d6e8d6172cc0e3;
*8dae5d8a580d702d044b0189a64f;
*020001970f9c90;
*8dae5d8a99a4410e500882448967;
*8dae5d8a580d77d6fad6128791c3;
*5dae5d8a54a93d;
*8dae5d8a99a4410e500882448967;
*020001970f9c90;
*8da15c54581bc01a424c2f038351;
*8dae5d8ae1080800000000e6168f;
*8da3c77558af87e730ccd3243e2f;
*8dae5d8a580d87d728d6051c99a8;
*5dae5d8a54a93d;
*8dae5d8aea00085ee53e004546c3;
*8dae5d8a99a4410e500882448967;
*8dae5d8a580d802d584aea9aed46;
*5da15c54111a4b;
*8dae5d8a99a4410e500c827cbf67;
*2000019827a021;
*8dae5d8af80300020049bc1cd8f3;
*8dae5d8a580d87d754d5f94050dd;
*8dae5d8a99a4410e500c827cbf67;
*5dae5d8a54a93d;
*8dae5d8a99a4410e500c827cbf67;
*02000198f03cd8;
*8dae5d8a580d87d76ed5f252fa10;
*8dae5d8aea00085ee53e004546c3;
*8da15c5499150a0df0ac0d713d8d;
*200015b8f3156e;
*8da3c775990da70d100458a71c17;
*8dae5d8a580d87d778d5ef5c1b0d;
*8dae5d8a99a4410e500882448967;
*5dae5d8a54a93d;
*02818393027eb0;
*5da3c7758f35d5;
*5da3c7758f35d5;
*5da3c7758f35d4;
*200015b8f3156e;
*8dae5d8a580d87d782d5ec2e3d53;
*8da15c54ea178858015e88a53d08;
*5dae5d8a54a93d;
*8dae5d8a99a4410e500882448967;
*02000198f03cd8;
*8dae5d8ae1080800000000e6168f;
*200015b8f3156e;
*8dae5d8a580d802db04ad2d09e9c;
*02e195b8f5cd24;
*8da15c5499150b0db0b40d687b11;
*8dae5d8aea00085ee53e004546c3;
*8dae5d8a99a4410e500882448967;
*8dae5d8a580d802dc64accee13e8;
*2000019827a021;
*8dae5d8a99a4410e500881bb6175;
*8dae5d8a580d802ddc4ac5b9d917;
*8dae5d8af80300020049bc1cd8f3;
*80000198580d87d7cad5d9283036;
*02000198f03cd8;
*5da15c54111a4b;
*8dae5d8a580d87d7ecd5d0a28b94;
*8dae5d8a99a4410e500881bb6175;
*8da15c54234cb5f3cf0c6055934a;
*5dae5d8a54a93d;
*8da15c5499150d0d50c40e1a396a;
*8da15c5499150d0d30c40ed5bd11;
*5da15c54111a4b;
*5da46e6ae86d29;
*8da15c54581f201b484af27089b0;
*02000198f03cd8;
*5dae5d8a54a93d;
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
+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]);
});
+2
View File
@@ -22,6 +22,7 @@ export function createInfrastructureLayers(services) {
color: '#00ffff', // Cyan
icon: '▣',
source: 'Local',
osmDerived: true,
labels: true,
labelMax: 700,
labelGridPx: 138,
@@ -37,6 +38,7 @@ export function createInfrastructureLayers(services) {
color: '#0088ff', // Blue
icon: '▰',
source: 'USACE',
osmDerived: true,
labels: true,
labelMax: 900,
labelGridPx: 132,
+9
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,7 @@ 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',
'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',
);
});
+30 -69
View File
@@ -1,33 +1,26 @@
// src/data/issPass.js
/**
* Next-ISS-pass prediction from a satrec: coarse 30 s scan to find a window
* where elevation ≥ minElevDeg, refined to ~5 s at the edges, tracking peak
* elevation. ~2880 SGP4 propagations for a 24 h horizon — tens of ms, fine for
* an on-demand voice call. Pattern from skylight (MIT) shared/src/celestial.ts
* nextISSPass, extended with set-time + peak tracking.
* Next-ISS-pass prediction. Kept as a thin wrapper over satellitePass.js so the
* original findNextIssPass signature and defaults still work.
*/
import { propagate, gstime, eciToEcf, ecfToLookAngles } from 'satellite.js';
import { findNextSatellitePass, lookAnglesAt } from './satellitePass.js';
const R2D = 180 / Math.PI;
const D2R = Math.PI / 180;
/** Observer look angles at an instant, or null when propagation fails. */
export function lookAnglesAt(satrec, dateMs, latDeg, lonDeg) {
const date = new Date(dateMs);
const pv = propagate(satrec, date);
const pos = pv && pv.position;
if (!pos || typeof pos === 'boolean') return null;
const ecf = eciToEcf(pos, gstime(date));
const look = ecfToLookAngles(
{ latitude: latDeg * D2R, longitude: lonDeg * D2R, height: 0 },
ecf,
);
return {
elevDeg: look.elevation * R2D,
azDeg: (((look.azimuth * R2D) % 360) + 360) % 360,
};
}
export { lookAnglesAt };
/**
* Predict the next ISS pass over an observer location.
* @param {Object} options
* @param {Object} options.satrec SGP4 satellite record
* @param {number} options.latDeg Observer latitude in degrees [-90, 90]
* @param {number} options.lonDeg Observer longitude in degrees [-180, 180]
* @param {number} options.fromMs UTC start time in milliseconds
* @param {number} [options.minElevDeg=10] Elevation that defines rise and set, in degrees
* @param {number} [options.horizonHours=24] Maximum search window in hours
* @param {number} [options.coarseStepSec=30] Coarse search step in seconds
* @param {number} [options.fineStepSec=5] Fine transit step in seconds for peak tracking and visibility
* @param {boolean} [options.requireVisible=false] Require a naked-eye-visible pass
* @returns {{ riseMs: number, setMs: number, maxElevDeg: number, maxElevMs: number, riseAzDeg: number, visible: boolean, sunlit: boolean, observerDark: boolean } | null}
*/
export function findNextIssPass({
satrec,
latDeg,
@@ -37,49 +30,17 @@ export function findNextIssPass({
horizonHours = 24,
coarseStepSec = 30,
fineStepSec = 5,
requireVisible = false,
}) {
const elev = (t) => lookAnglesAt(satrec, t, latDeg, lonDeg)?.elevDeg ?? -90;
const horizonMs = fromMs + horizonHours * 3600_000;
const coarse = coarseStepSec * 1000;
const fine = fineStepSec * 1000;
// Coarse scan for the first sample above threshold. If we START inside a
// pass, that's still "the next pass" for a voice answer — accept it.
let hit = null;
for (let t = fromMs; t <= horizonMs; t += coarse) {
if (elev(t) >= minElevDeg) {
hit = t;
break;
}
}
if (hit == null) return null;
// Refine rise: walk back in fine steps to the first sample ≥ threshold.
let riseMs = hit;
while (riseMs - fine > fromMs && elev(riseMs - fine) >= minElevDeg)
riseMs -= fine;
// Walk forward through the pass tracking the peak until we drop below.
let maxElevDeg = -90;
let maxElevMs = riseMs;
let t = riseMs;
while (t <= horizonMs) {
const e = elev(t);
if (e < minElevDeg && t > riseMs) break;
if (e > maxElevDeg) {
maxElevDeg = e;
maxElevMs = t;
}
t += fine;
}
const setMs = t;
const rise = lookAnglesAt(satrec, riseMs, latDeg, lonDeg);
return {
riseMs,
setMs,
maxElevDeg,
maxElevMs,
riseAzDeg: rise ? rise.azDeg : 0,
};
return findNextSatellitePass({
satrec,
latDeg,
lonDeg,
fromMs,
minElevDeg,
horizonHours,
coarseStepSec,
fineStepSec,
requireVisible,
});
}
+8
View File
@@ -3,6 +3,7 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import { twoline2satrec } from 'satellite.js';
import { findNextIssPass, lookAnglesAt } from './issPass.js';
import { findNextSatellitePass } from './satellitePass.js';
// Canonical archived ISS TLE (valid checksums; epoch 2008-09-20 ~12:25 UTC).
const L1 = '1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927';
@@ -27,6 +28,13 @@ test('finds a structurally-consistent ISS pass within 24h of the TLE epoch', ()
assert.ok(before && before.elevDeg < 10);
});
test('forwards its original 30 s coarse step and fineStepSec to the pass engine', () => {
const satrec = twoline2satrec(L1, L2);
const opts = { satrec, ...AUSTIN, fromMs: FROM_MS, minElevDeg: 10, fineStepSec: 30 };
assert.deepEqual(findNextIssPass(opts), findNextSatellitePass({ ...opts, coarseStepSec: 30 }));
assert.notDeepEqual(findNextIssPass(opts), findNextIssPass({ ...opts, fineStepSec: 5 }));
});
test('returns null when no pass clears an absurd threshold', () => {
const satrec = twoline2satrec(L1, L2);
const pass = findNextIssPass({ satrec, ...AUSTIN, fromMs: FROM_MS, minElevDeg: 89.9, horizonHours: 2 });
+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);
});
+253
View File
@@ -0,0 +1,253 @@
import { layerFeedState } from './feedState.js';
/**
* Shared layer snapshot envelope for voice and HUD answers.
*
* Data Layers chips, `analyst_query`, `get_current_view_state`, and the HUD
* summary must all name the same feed-state. This module is the one envelope:
* it classifies `getStats()` through `layerFeedState` and stamps the result
* so a stale or degraded feed cannot be narrated as live. Cesium-free so the
* HUD summary proxy can import it from Node.
*
* @module data/layerSnapshot
*/
/** Feed states the Data Layers chips and voice/HUD provenance share. */
export const LAYER_FEED_STATES = Object.freeze([
'nominal',
'loading',
'degraded',
'stale',
'fallback',
'unavailable',
]);
/** Chip labels for enabled layers. Disabled layers read OFF, not a feed-state. */
export const LAYER_FEED_STATE_LABELS = Object.freeze({
nominal: 'ON',
loading: 'LOADING',
degraded: 'DEGRADED',
stale: 'STALE',
fallback: 'FALLBACK',
unavailable: 'UNAVAILABLE',
});
/** Snapshot feed-state including a disabled layer. */
export const SNAPSHOT_FEED_STATES = Object.freeze([
...LAYER_FEED_STATES,
'off',
]);
/**
* Lower rank is more severe. Voice/HUD `overall` is the worst queried state
* so a mixed roster cannot hide an unavailable or stale feed behind a nominal
* neighbour.
*/
export const FEED_STATE_SEVERITY = Object.freeze({
unavailable: 0,
loading: 1,
degraded: 2,
stale: 3,
fallback: 4,
nominal: 5,
off: 6,
});
export { layerFeedState } from './feedState.js';
function snapshotError(stats) {
const err = stats?.error || stats?.lastError || stats?.managerRefreshError;
if (err == null || err === '') return null;
return err instanceof Error ? err.message : String(err);
}
/**
* Age of a lastUpdate timestamp in whole seconds.
* @param {number|null|undefined} lastUpdate Epoch ms.
* @param {number} [now=Date.now()] Clock for tests.
* @returns {number|null} Non-negative seconds, or null when unknown.
*/
export function snapshotAgeSec(lastUpdate, now = Date.now()) {
const stamp = Number(lastUpdate);
if (!Number.isFinite(stamp) || stamp <= 0) return null;
return Math.max(0, Math.floor((now - stamp) / 1000));
}
/**
* Compact age label matching the Data Layers meta line.
* @param {number|null|undefined} lastUpdate Epoch ms.
* @param {number} [now=Date.now()] Clock for tests.
* @returns {string|null} `just now` / `12s ago` / `4m ago` / `2h ago`.
*/
export function snapshotAgeLabel(lastUpdate, now = Date.now()) {
const sec = snapshotAgeSec(lastUpdate, now);
if (sec === null) return null;
if (sec < 5) return 'just now';
if (sec < 60) return `${sec}s ago`;
if (sec < 3600) return `${Math.floor(sec / 60)}m ago`;
return `${Math.floor(sec / 3600)}h ago`;
}
/**
* Pick the most severe feed-state from a list.
* @param {Iterable<string|null|undefined>} states Feed-state strings.
* @returns {'nominal'|'loading'|'degraded'|'stale'|'fallback'|'unavailable'|'off'|null}
*/
export function worstFeedState(states) {
let worst = null;
let worstRank = Number.POSITIVE_INFINITY;
for (const state of states || []) {
if (!Object.hasOwn(FEED_STATE_SEVERITY, state)) continue;
const rank = FEED_STATE_SEVERITY[state];
if (rank < worstRank) {
worst = state;
worstRank = rank;
}
}
return worst;
}
/**
* One honest layer snapshot for voice/HUD narration.
*
* Disabled layers report `feedState: 'off'` — the same reading the chips use —
* even if leftover stats would otherwise classify as stale. Counts and errors
* still travel so a disabled layer with a last-known error is inspectable.
*
* @param {object} [layer] `DataLayerManager.getAll()` projection, or a layer-like
* object with `id`, `enabled`, `stats`, and optional `source`/`name`.
* @param {{ now?: number }} [options]
* @returns {{
* id: string|null,
* name: string|null,
* enabled: boolean,
* feedState: string,
* source: string|null,
* count: number,
* lastUpdate: number|null,
* ageSec: number|null,
* ageLabel: string|null,
* error: string|null,
* }}
*/
export function layerSnapshot(layer = {}, { now = Date.now() } = {}) {
const stats = (layer && typeof layer.stats === 'object' && layer.stats) || {};
const source = String(stats.source || layer.source || '').trim() || null;
const enabled = Boolean(layer.enabled);
const countRaw = Number(stats.count);
const count = Number.isFinite(countRaw) ? countRaw : 0;
const lastUpdate = Number.isFinite(Number(stats.lastUpdate))
? Number(stats.lastUpdate)
: null;
const feedState = enabled ? layerFeedState({ ...stats, source }) : 'off';
return {
id: layer.id || layer.layerKey || null,
name: layer.name || layer.label || layer.id || layer.layerKey || null,
enabled,
feedState,
source,
count,
lastUpdate,
ageSec: snapshotAgeSec(lastUpdate, now),
ageLabel: snapshotAgeLabel(lastUpdate, now),
error: snapshotError(stats),
};
}
/**
* Snapshot every layer in a roster.
* @param {Array<object>} [layers]
* @param {{ now?: number }} [options]
* @returns {Array<object>}
*/
export function layerSnapshots(layers = [], options) {
if (!Array.isArray(layers)) return [];
return layers.map((layer) => layerSnapshot(layer, options));
}
function asSnapshot(entry, now) {
if (!entry || typeof entry !== 'object') return layerSnapshot({}, { now });
if (typeof entry.feedState === 'string') return entry;
return layerSnapshot(entry, { now });
}
/**
* Mechanical narration note so the model does not have to infer honesty.
* @param {Array<object>} layers Snapshots.
* @param {string|null} overall Worst considered feed-state.
* @returns {string}
*/
export function feedProvenanceNote(layers = [], overall = null) {
if (!overall) {
return 'No layer snapshot is attached. Do not invent a feed-state or a count.';
}
if (overall === 'nominal') {
return 'Queried enabled layers are nominal. Still name the count scope; do not claim whole-world coverage.';
}
if (overall === 'off') {
const names =
layers
.map((s) => s.name || s.id)
.filter(Boolean)
.join(', ') || 'the requested layer';
return `${names} is off. Do not invent a count; say the layer is off and offer to enable it.`;
}
const flagged = layers.filter(
(s) => s.feedState && s.feedState !== 'nominal' && s.feedState !== 'off',
);
const bits = flagged.map((s) => {
const extras = [
s.source,
s.ageLabel || snapshotAgeLabel(s.lastUpdate),
s.error,
]
.filter(Boolean)
.join(', ');
return `${s.name || s.id} is ${String(s.feedState).toUpperCase()}${extras ? ` (${extras})` : ''}`;
});
const body = bits.join('; ') || `${String(overall).toUpperCase()} feed`;
return `${body}. Narrate that provenance with any count; do not describe this as live.`;
}
/**
* Shared envelope stamped onto analyst, view-state, and HUD payloads.
* @param {Array<object>} [snapshots] Snapshots or raw `getAll()` rows.
* @param {{ now?: number }} [options]
* @returns {{ overall: string|null, layers: Array<object>, note: string }}
*/
export function feedProvenanceEnvelope(
snapshots = [],
{ now = Date.now() } = {},
) {
const layers = (Array.isArray(snapshots) ? snapshots : []).map((entry) =>
asSnapshot(entry, now),
);
const active = layers.filter((s) => s.feedState && s.feedState !== 'off');
const overall =
worstFeedState(active.map((s) => s.feedState)) ||
(layers.length ? 'off' : null);
return {
overall,
layers,
note: feedProvenanceNote(layers, overall),
};
}
/**
* Compact voice-tool layer row: identity + the fields a model must read.
* @param {object} layer Raw `getAll()` row or snapshot.
* @param {{ now?: number }} [options]
* @returns {object}
*/
export function viewStateLayerRow(layer, options) {
const snap = layerSnapshot(layer, options);
return {
id: snap.id,
name: snap.name,
enabled: snap.enabled,
count: snap.count,
error: snap.error,
feedState: snap.feedState,
source: snap.source,
lastUpdate: snap.lastUpdate,
};
}
+260
View File
@@ -0,0 +1,260 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import {
FEED_STATE_SEVERITY,
LAYER_FEED_STATE_LABELS,
LAYER_FEED_STATES,
SNAPSHOT_FEED_STATES,
feedProvenanceEnvelope,
feedProvenanceNote,
layerFeedState,
layerSnapshot,
layerSnapshots,
snapshotAgeLabel,
snapshotAgeSec,
viewStateLayerRow,
worstFeedState,
} from './layerSnapshot.js';
const NOW = 1_700_000_000_000;
test('layerSnapshot stays Cesium-free so the HUD proxy can import it', () => {
const src = readFileSync(
new URL('./layerSnapshot.js', import.meta.url),
'utf8',
);
assert.equal(src.includes("from './manager.js'"), false);
assert.doesNotMatch(src, /from ['"]cesium['"]/i);
});
test('layerFeedState matches the chip classifier for every honesty class', () => {
assert.equal(
layerFeedState({ error: 'feed down', count: 0, lastUpdate: null }),
'unavailable',
);
assert.equal(
layerFeedState({
status: 'unavailable',
error: 'feed down',
count: 50,
lastUpdate: 1,
}),
'unavailable',
);
assert.equal(
layerFeedState({ mode: 'sim', count: 100, lastUpdate: 1 }),
'fallback',
);
assert.equal(
layerFeedState({ source: 'adsb.lol', count: 10, lastUpdate: 1 }),
'fallback',
);
assert.equal(
layerFeedState({
source: 'adsb.lol',
fallback: false,
count: 10,
lastUpdate: 1,
}),
'nominal',
);
assert.equal(
layerFeedState({ stale: true, count: 0, lastUpdate: 1 }),
'stale',
);
assert.equal(
layerFeedState({
error: 'partial group failure',
count: 50,
lastUpdate: 1,
}),
'degraded',
);
assert.equal(layerFeedState({ loading: true }), 'loading');
assert.equal(layerFeedState({ count: 5, lastUpdate: 1 }), 'nominal');
assert.equal(
layerFeedState({
status: 'zoom-in',
error: 'zoom in to search',
count: 12,
}),
'nominal',
);
assert.equal(
layerFeedState({ status: 'zoom-in', stale: true, count: 12 }),
'stale',
);
});
test('chip labels and severity cover every feed-state the envelope can emit', () => {
assert.deepEqual(
[...LAYER_FEED_STATES],
['nominal', 'loading', 'degraded', 'stale', 'fallback', 'unavailable'],
);
assert.deepEqual([...SNAPSHOT_FEED_STATES], [...LAYER_FEED_STATES, 'off']);
for (const state of LAYER_FEED_STATES) {
assert.equal(typeof LAYER_FEED_STATE_LABELS[state], 'string');
assert.equal(typeof FEED_STATE_SEVERITY[state], 'number');
}
assert.equal(
FEED_STATE_SEVERITY.unavailable < FEED_STATE_SEVERITY.stale,
true,
);
assert.equal(FEED_STATE_SEVERITY.stale < FEED_STATE_SEVERITY.fallback, true);
assert.equal(worstFeedState(['nominal', 'stale', 'fallback']), 'stale');
assert.equal(worstFeedState(['degraded', 'unavailable']), 'unavailable');
assert.equal(worstFeedState([]), null);
});
test('a disabled layer is off even when leftover stats would read stale', () => {
const snap = layerSnapshot(
{
id: 'flights',
name: 'Live Flights',
enabled: false,
source: 'OpenSky Network',
stats: {
stale: true,
count: 12,
lastUpdate: NOW - 240_000,
source: 'OpenSky Network',
},
},
{ now: NOW },
);
assert.equal(snap.feedState, 'off');
assert.equal(snap.enabled, false);
assert.equal(snap.count, 12);
assert.equal(snap.ageLabel, '4m ago');
});
test('an enabled stale layer carries age, source, and a mechanical note', () => {
const snap = layerSnapshot(
{
id: 'flights',
name: 'Live Flights',
enabled: true,
stats: {
stale: true,
count: 12,
lastUpdate: NOW - 240_000,
source: 'OpenSky Network',
},
},
{ now: NOW },
);
assert.equal(snap.feedState, 'stale');
assert.equal(snap.source, 'OpenSky Network');
assert.equal(snap.ageSec, 240);
assert.equal(snap.ageLabel, '4m ago');
const envelope = feedProvenanceEnvelope([snap], { now: NOW });
assert.equal(envelope.overall, 'stale');
assert.match(envelope.note, /Live Flights is STALE/);
assert.match(envelope.note, /OpenSky Network/);
assert.match(envelope.note, /4m ago/);
assert.match(envelope.note, /do not describe this as live/i);
});
test('degraded and unavailable envelopes outrank leftover nominal neighbours', () => {
const layers = layerSnapshots(
[
{
id: 'earthquakes',
name: 'Earthquakes',
enabled: true,
stats: { count: 3, lastUpdate: NOW - 10_000 },
},
{
id: 'local-firms',
name: 'Active Fires',
enabled: true,
stats: {
error: 'FIRMS key missing',
count: 8,
lastUpdate: NOW - 30_000,
},
},
],
{ now: NOW },
);
const envelope = feedProvenanceEnvelope(layers, { now: NOW });
assert.equal(envelope.overall, 'degraded');
assert.match(envelope.note, /Active Fires is DEGRADED/);
assert.match(envelope.note, /FIRMS key missing/);
});
test('an unavailable feed with no prior data does not become a confident zero', () => {
const snap = layerSnapshot(
{
id: 'ais-live-vessels',
name: 'Live Vessels',
enabled: true,
stats: { error: 'AISStream down', count: 0, lastUpdate: null },
},
{ now: NOW },
);
assert.equal(snap.feedState, 'unavailable');
const envelope = feedProvenanceEnvelope([snap]);
assert.equal(envelope.overall, 'unavailable');
assert.match(envelope.note, /UNAVAILABLE/);
});
test('fallback provenance names the fallback source instead of calling it live', () => {
const snap = layerSnapshot(
{
id: 'flights',
name: 'Live Flights',
enabled: true,
stats: {
source: 'adsb.lol fallback',
count: 40,
lastUpdate: NOW - 8_000,
},
},
{ now: NOW },
);
assert.equal(snap.feedState, 'fallback');
assert.match(feedProvenanceNote([snap], 'fallback'), /adsb\.lol fallback/);
});
test('view-state rows keep the existing identity fields and add feedState', () => {
const row = viewStateLayerRow(
{
id: 'flights',
name: 'Live Flights',
enabled: true,
stats: {
count: 9,
lastUpdate: 42,
error: null,
source: 'OpenSky Network',
},
},
{ now: NOW },
);
assert.deepEqual(row, {
id: 'flights',
name: 'Live Flights',
enabled: true,
count: 9,
error: null,
feedState: 'nominal',
source: 'OpenSky Network',
lastUpdate: 42,
});
});
test('empty roster envelope does not pretend to be nominal', () => {
const envelope = feedProvenanceEnvelope([]);
assert.equal(envelope.overall, null);
assert.match(envelope.note, /Do not invent/);
});
test('age labels match the Data Layers meta clock', () => {
assert.equal(snapshotAgeSec(null, NOW), null);
assert.equal(snapshotAgeLabel(NOW - 3_000, NOW), 'just now');
assert.equal(snapshotAgeLabel(NOW - 12_000, NOW), '12s ago');
assert.equal(snapshotAgeLabel(NOW - 240_000, NOW), '4m ago');
assert.equal(snapshotAgeLabel(NOW - 7_200_000, NOW), '2h ago');
});
+427 -19
View File
@@ -1,3 +1,5 @@
import reservationRows from './layerStateTokenReservations.json' with { type: 'json' };
const VALID_DISPOSITIONS = new Set([
'enabled-only',
'enabled+options',
@@ -5,6 +7,10 @@ const VALID_DISPOSITIONS = new Set([
]);
export const LAYER_STATE_VERSION = 2;
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;
/**
@@ -17,12 +23,13 @@ const PENDING_TRACKING_POLL_MS = 1_000;
*/
const TRACKING_ID_GRAMMAR = /^[0-9a-z~_-]{1,16}$/;
/**
* Ceilings for the untrusted v2 layer fields. Both are far above any legitimate
* payload (16 one-character tokens; a dozen short option assignments), so a
* value past them is malformed or hostile. Reject the WHOLE payload, matching
* the unknown-token rule — never salvage a prefix.
* Ceilings for the untrusted v2 layer fields. The layer ceiling covers the
* complete reserved one-character space plus every two-character base-36
* allocation, while the option ceiling covers a dozen short assignments.
* Reject the WHOLE payload, matching the unknown-token rule — never salvage a
* prefix.
*/
const MAX_ENABLED_LAYERS_CHARS = 64;
const MAX_ENABLED_LAYERS_CHARS = 4_096;
const MAX_LAYER_OPTIONS_CHARS = 512;
export const LAYER_STATE_STORAGE_KEY = 'gev:layer-state:v2';
export const LAYER_RESTORE_ORIGINS = Object.freeze({
@@ -168,7 +175,14 @@ function stringOption(key, token, defaultValue) {
});
}
function enumOption(key, token, defaultValue, values, codes) {
function enumOption(
key,
token,
defaultValue,
values,
codes,
{ absentValue = defaultValue } = {},
) {
const reverse = Object.fromEntries(
Object.entries(codes).map(([name, code]) => [code, name]),
);
@@ -176,6 +190,7 @@ function enumOption(key, token, defaultValue, values, codes) {
key,
token,
defaultValue,
absentValue,
normalize: (value) => normalizeEnum(values, value),
encode: (value) => codes[value],
decode: (value) => reverse[value] || null,
@@ -204,7 +219,138 @@ function integerOption(key, token, defaultValue) {
});
}
/**
* A share-link-only option: the whole-state codec carries it, but the stored
* local blob always holds its default.
*/
function shareOnlyOption(spec) {
return Object.freeze({ ...spec, shareOnly: true });
}
/**
* A signed integer in `[min, max]`, rejected (never clamped) outside it: a
* clamped box edge would be a different box, not a shorter spelling.
*/
function boundedIntegerOption(key, token, defaultValue, { min, max }) {
const parse = (value) => {
const number =
typeof value === 'number'
? value
: /^-?\d{1,9}$/.test(String(value).trim())
? Number(String(value).trim())
: NaN;
return Number.isInteger(number) && number >= min && number <= max
? number
: null;
};
return Object.freeze({
key,
token,
defaultValue,
normalize: parse,
encode: (value) => String(value),
decode: parse,
});
}
/**
* Recent Imagery day: runtime key `S30:2026-09-18`, URL form `S20260918`.
* The calendar is checked both ways, and "today" is never consulted, so a
* link decodes the same whenever it is opened.
*/
const IMAGERY_LETTERS = Object.freeze({ S30: 'S', L30: 'L', VIIRS: 'V' });
const IMAGERY_PRODUCTS = Object.freeze({ S: 'S30', L: 'L30', V: 'VIIRS' });
function imageryKey(product, year, month, day) {
const time = Date.UTC(Number(year), Number(month) - 1, Number(day));
const iso = Number.isFinite(time)
? new Date(time).toISOString().slice(0, 10)
: '';
return product && iso === `${year}-${month}-${day}`
? `${product}:${iso}`
: null;
}
function imageryPinOption(key, token) {
return Object.freeze({
key,
token,
defaultValue: null,
normalize: (value) => {
const match = /^(S30|L30|VIIRS):(\d{4})-(\d{2})-(\d{2})$/.exec(
typeof value === 'string' ? value.trim() : '',
);
return match ? imageryKey(match[1], match[2], match[3], match[4]) : null;
},
encode: (value) => {
const [product, day] = value.split(':');
return `${IMAGERY_LETTERS[product]}${day.replaceAll('-', '')}`;
},
decode: (value) => {
const match = /^([SLV])(\d{4})(\d{2})(\d{2})$/.exec(
typeof value === 'string' ? value : '',
);
return match
? imageryKey(IMAGERY_PRODUCTS[match[1]], match[2], match[3], match[4])
: null;
},
});
}
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',
strong: 's',
}),
]),
'weather-radar': Object.freeze([
enumOption('opacity', 'o', 'strong', ['light', 'strong'], {
light: 'l',
strong: 's',
}),
]),
'weather-satellite': Object.freeze([
enumOption('infrared', 'i', 'filtered', ['filtered', 'full'], {
filtered: 'f',
full: 'a',
}),
enumOption('opacity', 'o', 'strong', ['light', 'strong'], {
light: 'l',
strong: 's',
}),
enumOption(
'product',
'p',
'clouds-regional',
['clouds', 'clouds-regional'],
{ clouds: 'g', 'clouds-regional': 'r' },
),
]),
wind: Object.freeze([
enumOption('model', 'm', 'gfs', ['gfs', 'ifs'], { gfs: 'g', ifs: 'i' }),
enumOption(
'overlay',
'o',
'none',
['none', 'speed', 'temperature', 'pressure'],
{ none: 'n', speed: 's', temperature: 't', pressure: 'p' },
{ absentValue: 'speed' },
),
enumOption('units', 'u', 'km/h', ['km/h', 'm/s', 'mph'], {
'km/h': 'k',
'm/s': 'm',
mph: 'i',
}),
booleanOption('paused', 'p', false),
]),
flights: Object.freeze([
// Owner directive 2026-08-22: the fleet's 3D models are DEFAULT-ON in
// PROXIMITY mode. Proximity is itself the altitude/count gate — models only
@@ -249,6 +395,23 @@ const OPTION_GROUPS = Object.freeze({
booleanOption('showProjection', 'p', true),
booleanOption('autoHop', 'a', false),
]),
'recent-imagery': Object.freeze([
// Box edges in degrees × 100000; latitudes stop at the Web-Mercator limit.
boundedIntegerOption('west', 'w', null, { min: -18000000, max: 18000000 }),
boundedIntegerOption('south', 's', null, { min: -8505110, max: 8505110 }),
boundedIntegerOption('east', 'e', null, { min: -18000000, max: 18000000 }),
boundedIntegerOption('north', 'n', null, { min: -8505110, max: 8505110 }),
imageryPinOption('a', 'a'),
imageryPinOption('b', 'b'),
// 0 one image, 1 image against the basemap, 2 two images A / B.
boundedIntegerOption('mode', 'm', 0, { min: 0, max: 2 }),
// The swipe position is one comparison's framing, not a preference: a
// stored split resurfaced in the next session's first comparison.
shareOnlyOption(
boundedIntegerOption('split', 'p', 50, { min: 0, max: 100 }),
),
booleanOption('viirs', 'v', false),
]),
radio: Object.freeze([
Object.freeze({
key: 'filter',
@@ -297,6 +460,94 @@ export const SHARE_TRACKING_RESTORE_POLICIES = Object.freeze({
}),
});
/**
* The original single-character assignments are a closed compatibility set.
* Keep their exact mapping pinned by the independent snapshot in the tests.
*/
export const LEGACY_LAYER_STATE_TOKENS = Object.freeze({
'ais-live-vessels': 'a',
'alpr-cameras': 'p',
'bhote-koshi-2026': 'h',
'bhote-koshi-locator': 'z',
bikeshare: 'b',
cctv: 'c',
directions: 'n',
earthquakes: 'e',
'fire-perimeters': '2',
flights: 'f',
'local-dams': 'q',
'local-datacenters': 'd',
'local-firms': 'w',
military: 'm',
'military-awareness': 'g',
'military-installations': 'i',
radio: 'r',
'recent-imagery': '1',
'rocket-launches': 'x',
satellites: 's',
'telegeography-submarine-cables': 'u',
traffic: 't',
transit: 'j',
'weather-cyclones': 'y',
'weather-lightning': 'l',
'weather-radar': 'v',
'weather-satellite': 'o',
wind: 'k',
});
/**
* Permanent token ownership. Existing share links are public authored state,
* so an allocation stays here even if its layer is later removed. The JSON
* ledger is also read directly from the published Git base by the checker.
*/
export function parseLayerStateTokenReservations(rows) {
if (!Array.isArray(rows)) {
throw new Error('Layer-state token ledger must be an array');
}
const reservations = Object.create(null);
const reservedIdsByToken = new Map();
for (const row of rows) {
if (
!Array.isArray(row) ||
row.length !== 2 ||
typeof row[0] !== 'string' ||
typeof row[1] !== 'string'
) {
throw new Error('Invalid layer-state token ledger row');
}
const [id, token] = row;
if (!/^[a-z0-9-]+$/.test(id) || !LAYER_STATE_TOKEN_PATTERN.test(token)) {
throw new Error(`Invalid layer-state token reservation: ${id}`);
}
if (Object.hasOwn(reservations, id)) {
throw new Error(`Duplicate layer-state token reservation id: ${id}`);
}
if (reservedIdsByToken.has(token)) {
throw new Error(`Duplicate layer-state token reservation: ${token}`);
}
if (
(Object.hasOwn(LEGACY_LAYER_STATE_TOKENS, id) &&
token !== LEGACY_LAYER_STATE_TOKENS[id]) ||
(token.length === 1 &&
!NEW_SINGLE_CHARACTER_TOKEN_PATTERN.test(token) &&
LEGACY_LAYER_STATE_TOKENS[id] !== token)
) {
throw new Error(`Legacy layer-state token is immutable: ${id}`);
}
reservations[id] = token;
reservedIdsByToken.set(token, id);
}
for (const [id, token] of Object.entries(LEGACY_LAYER_STATE_TOKENS)) {
if (reservations[id] !== token) {
throw new Error(`Missing or changed legacy layer-state token: ${id}`);
}
}
return Object.freeze(reservations);
}
export const LAYER_STATE_TOKEN_RESERVATIONS =
parseLayerStateTokenReservations(reservationRows);
/**
* Canonical serialization registry. Its order, not runtime registration order,
* owns stable URL ordering.
@@ -331,6 +582,11 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
}),
Object.freeze({ id: 'directions', token: 'n', disposition: 'enabled-only' }),
Object.freeze({ id: 'earthquakes', token: 'e', disposition: 'enabled-only' }),
Object.freeze({
id: 'fire-perimeters',
token: '2',
disposition: 'enabled-only',
}),
Object.freeze({
id: 'flights',
token: 'f',
@@ -366,6 +622,12 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
disposition: 'enabled+options',
optionOwner: 'radio',
}),
Object.freeze({
id: 'recent-imagery',
token: '1',
disposition: 'enabled+options',
optionOwner: 'recent-imagery',
}),
Object.freeze({
id: 'rocket-launches',
token: 'x',
@@ -382,14 +644,102 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
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',
token: 'y',
disposition: 'enabled-only',
}),
Object.freeze({
id: 'weather-lightning',
token: 'l',
disposition: 'enabled+options',
optionOwner: 'weather-lightning',
}),
Object.freeze({
id: 'weather-radar',
token: 'v',
disposition: 'enabled+options',
optionOwner: 'weather-radar',
}),
Object.freeze({
id: 'weather-satellite',
token: 'o',
disposition: 'enabled+options',
optionOwner: 'weather-satellite',
}),
Object.freeze({
id: 'wind',
token: 'k',
disposition: 'enabled+options',
optionOwner: 'wind',
}),
]);
export const REGISTERED_LAYER_IDS = Object.freeze(
LAYER_STATE_REGISTRY.map((entry) => entry.id),
);
/** Consume unreserved digits before the two-character base-36 namespace. */
export function nextLayerStateToken(
reservations = LAYER_STATE_TOKEN_RESERVATIONS,
) {
const occupied = new Set(Object.values(reservations || {}));
for (const digit of '0123456789') {
if (!occupied.has(digit)) return digit;
}
for (const first of LAYER_STATE_TOKEN_ALPHABET) {
for (const second of LAYER_STATE_TOKEN_ALPHABET) {
const candidate = `${first}${second}`;
if (!occupied.has(candidate)) return candidate;
}
}
throw new Error('Layer-state token namespace exhausted');
}
function allocationRank(token) {
if (NEW_SINGLE_CHARACTER_TOKEN_PATTERN.test(token)) return Number(token);
if (typeof token !== 'string' || token.length !== 2) return Infinity;
const first = LAYER_STATE_TOKEN_ALPHABET.indexOf(token[0]);
const second = LAYER_STATE_TOKEN_ALPHABET.indexOf(token[1]);
return first < 0 || second < 0 ? Infinity : 10 + first * 36 + second;
}
/** Check a proposed ledger against the published merge-time base. */
export function validateLayerStateAllocations(
baseReservations,
reservations = LAYER_STATE_TOKEN_RESERVATIONS,
) {
if (!baseReservations || typeof baseReservations !== 'object') {
throw new Error('Base layer-state token reservations are required');
}
for (const [id, token] of Object.entries(baseReservations)) {
if (reservations[id] !== token) {
throw new Error(`Published layer-state token changed or removed: ${id}`);
}
}
const newlyReserved = Object.entries(reservations)
.filter(([id]) => !Object.hasOwn(baseReservations, id))
.sort((left, right) => allocationRank(left[1]) - allocationRank(right[1]));
const occupied = { ...baseReservations };
for (const [id, token] of newlyReserved) {
const expected = nextLayerStateToken(occupied);
if (token !== expected) {
throw new Error(
`Layer-state token for ${id} must be the next free token ${expected}`,
);
}
occupied[id] = token;
}
return true;
}
const REGISTRY_BY_ID = new Map(
LAYER_STATE_REGISTRY.map((entry) => [entry.id, entry]),
);
@@ -430,10 +780,39 @@ export function isExplicitLayerStateOrigin(origin) {
}
/** Validate the static registry itself before it is used to seal a manager. */
export function validateLayerStateRegistry(registry = LAYER_STATE_REGISTRY) {
export function validateLayerStateRegistry(
registry = LAYER_STATE_REGISTRY,
reservations = LAYER_STATE_TOKEN_RESERVATIONS,
) {
if (!Array.isArray(registry) || registry.length === 0) {
throw new Error('Layer-state registry must be a non-empty array');
}
if (
!reservations ||
typeof reservations !== 'object' ||
Array.isArray(reservations)
) {
throw new Error('Layer-state token reservations must be an object');
}
const reservedIdsByToken = new Map();
for (const [id, token] of Object.entries(reservations)) {
if (!/^[a-z0-9-]+$/.test(id) || !LAYER_STATE_TOKEN_PATTERN.test(token)) {
throw new Error(`Invalid layer-state token reservation: ${id}`);
}
if (
(Object.hasOwn(LEGACY_LAYER_STATE_TOKENS, id) &&
token !== LEGACY_LAYER_STATE_TOKENS[id]) ||
(token.length === 1 &&
!NEW_SINGLE_CHARACTER_TOKEN_PATTERN.test(token) &&
LEGACY_LAYER_STATE_TOKENS[id] !== token)
) {
throw new Error(`Legacy layer-state token is immutable: ${id}`);
}
if (reservedIdsByToken.has(token)) {
throw new Error(`Duplicate layer-state token reservation: ${token}`);
}
reservedIdsByToken.set(token, id);
}
const ids = new Set();
const tokens = new Set();
for (const entry of registry) {
@@ -444,8 +823,14 @@ export function validateLayerStateRegistry(registry = LAYER_STATE_REGISTRY) {
if (ids.has(entry.id))
throw new Error(`Duplicate layer-state id: ${entry.id}`);
ids.add(entry.id);
if (!/^[a-z0-9]$/.test(entry.token || ''))
if (!LAYER_STATE_TOKEN_PATTERN.test(entry.token || ''))
throw new Error(`Invalid layer-state token: ${entry.id}`);
if (reservations[entry.id] !== entry.token) {
throw new Error(`Unreserved layer-state token: ${entry.id}`);
}
if (reservedIdsByToken.get(entry.token) !== entry.id) {
throw new Error(`Layer-state token reservation mismatch: ${entry.token}`);
}
if (tokens.has(entry.token))
throw new Error(`Duplicate layer-state token: ${entry.token}`);
tokens.add(entry.token);
@@ -567,18 +952,26 @@ export function encodeLayerStateParams(params, state) {
/** Decode v2 fields. Null means that the layer payload is absent. */
export function decodeLayerStateParams(params) {
if (params.get('v') !== String(LAYER_STATE_VERSION) || !params.has('l'))
const layerFields = params.getAll('l');
if (
params.get('v') !== String(LAYER_STATE_VERSION) ||
layerFields.length !== 1
)
return null;
const rawLayers = String(params.get('l') || '');
const rawLayers = layerFields[0];
const rawOptionsField = String(params.get('lo') || '');
// Fail closed on an oversized payload rather than decoding a truncated one.
if (rawLayers.length > MAX_ENABLED_LAYERS_CHARS) return null;
if (rawOptionsField.length > MAX_LAYER_OPTIONS_CHARS) return null;
const layerTokens = rawLayers.split('.').filter(Boolean);
// `l=` is the one valid explicit-empty representation. Any non-empty token
// set containing an unknown member rejects the complete layer payload so a
// typo or future token cannot silently become an authoritative empty set.
if (layerTokens.some((token) => !REGISTRY_BY_TOKEN.has(token))) return null;
const layerTokens = rawLayers ? rawLayers.split('.') : [];
// `l=` is the one valid explicit-empty representation. Reject repeated
// fields, empty members, duplicate members, or unknown members; silently
// removing one would turn a malformed share into a different state.
if (
layerTokens.some((token) => !token || !REGISTRY_BY_TOKEN.has(token)) ||
new Set(layerTokens).size !== layerTokens.length
)
return null;
const enabledLayerIds = layerTokens.map(
(token) => REGISTRY_BY_TOKEN.get(token).id,
);
@@ -616,13 +1009,28 @@ export function decodeLayerStateParams(params) {
return normalizeLayerState({ enabledLayerIds, options: rawOptions });
}
/** Stable local-storage representation (full IDs for debuggability). */
/** Options with share-link-only values reset to their defaults. */
function withoutShareOnlyOptions(options) {
const out = {};
for (const ownerId of OPTION_OWNER_IDS) {
out[ownerId] = { ...(options?.[ownerId] || {}) };
for (const spec of optionSpecs(ownerId)) {
if (spec.shareOnly) out[ownerId][spec.key] = spec.defaultValue;
}
}
return out;
}
/**
* Stable local-storage representation (full IDs for debuggability), with
* share-link-only options at their defaults.
*/
export function serializeStoredLayerState(state) {
const normalized = normalizeLayerState(state);
return JSON.stringify({
v: LAYER_STATE_VERSION,
l: normalized.enabledLayerIds,
o: normalized.options,
o: withoutShareOnlyOptions(normalized.options),
});
}
@@ -634,7 +1042,7 @@ export function parseStoredLayerState(raw) {
return null;
return normalizeLayerState({
enabledLayerIds: parsed.l,
options: parsed.o,
options: withoutShareOnlyOptions(parsed.o),
});
} catch {
return null;
+1312 -274
View File
File diff suppressed because it is too large. Load diff
+419
View File
@@ -0,0 +1,419 @@
import assert from 'node:assert/strict';
import { execFileSync, spawnSync } from 'node:child_process';
import {
mkdtempSync,
mkdirSync,
readFileSync,
realpathSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import test from 'node:test';
import {
LEGACY_LAYER_STATE_TOKENS,
LAYER_STATE_TOKEN_ALPHABET,
LAYER_STATE_TOKEN_RESERVATIONS,
nextLayerStateToken,
parseLayerStateTokenReservations,
validateLayerStateAllocations,
} from './layerState.js';
import {
PRE_LEDGER_LAYER_STATE_TOKENS,
checkLayerStateTokens,
readPublishedLayerStateReservations,
} from '../../scripts/check-layer-state-tokens.mjs';
const LEDGER_PATH = 'src/data/layerStateTokenReservations.json';
const SOURCE_PATH = 'src/data/layerState.js';
const CODEC_SOURCE_PATH = fileURLToPath(
new URL('./layerState.js', import.meta.url),
);
const CHECKER_PATH = fileURLToPath(
new URL('../../scripts/check-layer-state-tokens.mjs', import.meta.url),
);
const NEXT_PATH = fileURLToPath(
new URL('../../scripts/next-layer-state-token.mjs', import.meta.url),
);
const reservationRows = JSON.parse(
readFileSync(
new URL('./layerStateTokenReservations.json', import.meta.url),
'utf8',
),
);
test('pre-ledger published ownership is independent of the candidate mapping', () => {
assert.notStrictEqual(
PRE_LEDGER_LAYER_STATE_TOKENS,
LEGACY_LAYER_STATE_TOKENS,
);
assert.deepEqual(PRE_LEDGER_LAYER_STATE_TOKENS, LEGACY_LAYER_STATE_TOKENS);
assert.equal(Object.keys(PRE_LEDGER_LAYER_STATE_TOKENS).length, 28);
assert.equal(PRE_LEDGER_LAYER_STATE_TOKENS['recent-imagery'], '1');
assert.equal(PRE_LEDGER_LAYER_STATE_TOKENS['fire-perimeters'], '2');
assert.throws(
() =>
validateLayerStateAllocations(PRE_LEDGER_LAYER_STATE_TOKENS, {
...LAYER_STATE_TOKEN_RESERVATIONS,
flights: '00',
}),
/Published layer-state token changed or removed: flights/,
);
});
function git(cwd, ...args) {
return execFileSync('git', args, { cwd, encoding: 'utf8' }).trim();
}
function withPublishedBase(
{ rows, source = 'export const changed = true;\n' },
check,
) {
const cwd = mkdtempSync(path.join(tmpdir(), 'gev-layer-token-ledger-'));
try {
git(cwd, 'init', '-q');
mkdirSync(path.join(cwd, 'src/data'), { recursive: true });
writeFileSync(path.join(cwd, SOURCE_PATH), source);
if (rows !== undefined) {
writeFileSync(path.join(cwd, LEDGER_PATH), JSON.stringify(rows));
}
git(cwd, 'add', 'src/data');
git(
cwd,
'-c',
'user.name=Layer Token Test',
'-c',
'[email protected]',
'commit',
'-q',
'-m',
'published base',
);
check(cwd);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
}
function writeCodecFixture(cwd, rows, entries) {
const source = readFileSync(CODEC_SOURCE_PATH, 'utf8');
const registryEnd = ']);\n\nexport const REGISTERED_LAYER_IDS';
assert.equal(source.split(registryEnd).length, 2);
const additions = entries
.map(
({ id, token }) =>
` Object.freeze({ id: '${id}', token: '${token}', disposition: 'enabled-only' }),`,
)
.join('\n');
mkdirSync(path.join(cwd, 'src/data'), { recursive: true });
writeFileSync(path.join(cwd, 'package.json'), '{"type":"module"}');
writeFileSync(
path.join(cwd, SOURCE_PATH),
source.replace(registryEnd, `${additions}\n${registryEnd}`),
);
writeFileSync(path.join(cwd, LEDGER_PATH), JSON.stringify(rows));
return path.join(cwd, SOURCE_PATH);
}
test('reservation ledger is complete, pinned, and rejects duplicate or malformed rows', () => {
assert.deepEqual(
{ ...parseLayerStateTokenReservations(reservationRows) },
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
);
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 28);
assert.deepEqual(
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
{ ...LEGACY_LAYER_STATE_TOKENS },
);
assert.throws(
() => parseLayerStateTokenReservations(reservationRows.slice(1)),
/Missing or changed legacy/,
);
assert.throws(
() =>
parseLayerStateTokenReservations([
...reservationRows,
reservationRows[0],
]),
/Duplicate layer-state token reservation id/,
);
assert.throws(
() =>
parseLayerStateTokenReservations([...reservationRows, ['future', 'a']]),
/Duplicate layer-state token reservation/,
);
assert.equal(
parseLayerStateTokenReservations([...reservationRows, ['future', '0']])
.future,
'0',
);
assert.throws(
() =>
parseLayerStateTokenReservations([
...reservationRows.filter(([id]) => id !== 'transit'),
['future', 'j'],
]),
/Legacy layer-state token is immutable/,
);
assert.throws(
() => parseLayerStateTokenReservations([...reservationRows, ['future']]),
/Invalid layer-state token ledger row/,
);
});
test('published retired digits and competing PRs advance the merge-time allocation', () => {
withPublishedBase(
{ rows: [...reservationRows, ['retired-layer', '0']] },
(cwd) => {
const published = readPublishedLayerStateReservations('HEAD', cwd);
assert.equal(nextLayerStateToken(published), '3');
assert.equal(
validateLayerStateAllocations(published, {
...published,
newcomer: '3',
}),
true,
);
assert.throws(
() =>
validateLayerStateAllocations(published, {
...published,
competing: '0',
}),
/next free token 3/,
);
assert.throws(
() =>
validateLayerStateAllocations(published, {
...LAYER_STATE_TOKEN_RESERVATIONS,
newcomer: '0',
}),
/changed or removed: retired-layer/,
);
},
);
});
test('PR B manually replaces provisional 0 with 3 after PR A publishes 0', () => {
withPublishedBase({ rows: [...reservationRows, ['pr-a', '0']] }, (cwd) => {
mkdirSync(path.join(cwd, 'scripts'));
const fixtureChecker = path.join(
cwd,
'scripts/check-layer-state-tokens.mjs',
);
writeFileSync(fixtureChecker, readFileSync(CHECKER_PATH, 'utf8'));
const fixtureNext = path.join(cwd, 'scripts/next-layer-state-token.mjs');
writeFileSync(fixtureNext, readFileSync(NEXT_PATH, 'utf8'));
const runCheck = () =>
spawnSync(
process.execPath,
[realpathSync(fixtureChecker), '--base-ref', 'HEAD'],
{
cwd,
encoding: 'utf8',
},
);
writeCodecFixture(
cwd,
[...reservationRows, ['pr-b', '0']],
[{ id: 'pr-b', token: '0' }],
);
const beforeRebase = runCheck();
assert.equal(beforeRebase.status, 1);
assert.match(beforeRebase.stderr, /changed or removed: pr-a/);
writeCodecFixture(
cwd,
[...reservationRows, ['pr-a', '0'], ['pr-b', '0']],
[
{ id: 'pr-a', token: '0' },
{ id: 'pr-b', token: '0' },
],
);
const staleRebase = runCheck();
assert.equal(staleRebase.status, 1);
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' }]);
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.deepEqual(
JSON.parse(readFileSync(path.join(cwd, LEDGER_PATH), 'utf8')),
validBaselineRows,
'the helper reports a token without rewriting the ledger',
);
writeCodecFixture(
cwd,
[...reservationRows, ['pr-a', '0'], ['pr-b', '3']],
[
{ id: 'pr-a', token: '0' },
{ id: 'pr-b', token: '3' },
],
);
const corrected = runCheck();
assert.equal(corrected.status, 0, corrected.stderr);
assert.match(corrected.stdout, /29 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 allDigits = parseLayerStateTokenReservations([
...reservationRows,
...digitRows,
]);
const beforeNine = { ...allDigits };
delete beforeNine['retired-9'];
assert.equal(
validateLayerStateAllocations(beforeNine, {
...beforeNine,
pair: '00',
lastDigit: '9',
}),
true,
);
const baseBefore0a = {
...allDigits,
...Object.fromEntries(
[...'012345678'].map((second) => [`retired-0${second}`, `0${second}`]),
),
};
assert.equal(nextLayerStateToken(baseBefore0a), '09');
assert.equal(
validateLayerStateAllocations(baseBefore0a, {
...baseBefore0a,
after: '0a',
before: '09',
}),
true,
);
const baseBefore10 = {
...allDigits,
...Object.fromEntries(
[...LAYER_STATE_TOKEN_ALPHABET]
.slice(0, -1)
.map((second) => [`retired-0${second}`, `0${second}`]),
),
};
assert.equal(nextLayerStateToken(baseBefore10), '0z');
assert.equal(
validateLayerStateAllocations(baseBefore10, {
...baseBefore10,
after: '10',
before: '0z',
}),
true,
);
});
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) => [
`qa-prior-digit-${digit}`,
digit,
]);
const fixtureLayers = ['00', '01', '02', '03'].map((token) => ({
id: `qa-layer-${token}`,
token,
}));
const fixtureRows = [
...reservationRows,
...priorDigits,
...fixtureLayers.map(({ id, token }) => [id, token]),
];
const fixtureModule = writeCodecFixture(cwd, fixtureRows, fixtureLayers);
const codec = await import(pathToFileURL(fixtureModule));
assert.equal(codec.validateLayerStateRegistry(), true);
assert.equal(
nextLayerStateToken(
parseLayerStateTokenReservations([...reservationRows, ...priorDigits]),
),
'00',
);
assert.equal(
validateLayerStateAllocations(
LAYER_STATE_TOKEN_RESERVATIONS,
codec.LAYER_STATE_TOKEN_RESERVATIONS,
),
true,
);
const expectedLayerIds = codec.REGISTERED_LAYER_IDS;
const state = codec.normalizeLayerState({
enabledLayerIds: expectedLayerIds,
});
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);
const restored = codec.decodeLayerStateParams(params);
assert.deepEqual(restored?.enabledLayerIds, expectedLayerIds);
assert.deepEqual(restored?.options, state.options);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
test('checker reads a complete future base ledger and rejects retired-token reuse', () => {
withPublishedBase(
{
rows: [...reservationRows, ['retired-layer', '00']],
source:
'export const LAYER_STATE_TOKEN_RESERVATIONS = Object.freeze({ ["retired-layer"]: "00" });\n',
},
(cwd) => {
const published = readPublishedLayerStateReservations('HEAD', cwd);
assert.equal(Object.keys(published).length, 29);
assert.equal(published['retired-layer'], '00');
assert.throws(
() =>
validateLayerStateAllocations(published, {
...LAYER_STATE_TOKEN_RESERVATIONS,
newcomer: '00',
}),
/changed or removed/,
);
assert.throws(
() => checkLayerStateTokens('HEAD', cwd),
/changed or removed/,
);
const cli = spawnSync(
process.execPath,
[CHECKER_PATH, '--base-ref', 'HEAD'],
{
cwd,
encoding: 'utf8',
},
);
assert.equal(cli.status, 1);
assert.match(cli.stderr, /changed or removed/);
},
);
});
test('checker fails closed on unknown or malformed published ledgers', () => {
withPublishedBase({}, (cwd) => {
assert.throws(
() => readPublishedLayerStateReservations('HEAD', cwd),
/no recognizable layer-state token ledger/,
);
});
withPublishedBase({ rows: [...reservationRows, ['future', 'a']] }, (cwd) => {
assert.throws(
() => readPublishedLayerStateReservations('HEAD', cwd),
/Duplicate layer-state token reservation/,
);
});
});
+30
View File
@@ -0,0 +1,30 @@
[
["ais-live-vessels", "a"],
["alpr-cameras", "p"],
["bhote-koshi-2026", "h"],
["bhote-koshi-locator", "z"],
["bikeshare", "b"],
["cctv", "c"],
["directions", "n"],
["earthquakes", "e"],
["fire-perimeters", "2"],
["flights", "f"],
["local-dams", "q"],
["local-datacenters", "d"],
["local-firms", "w"],
["military", "m"],
["military-awareness", "g"],
["military-installations", "i"],
["radio", "r"],
["recent-imagery", "1"],
["rocket-launches", "x"],
["satellites", "s"],
["telegeography-submarine-cables", "u"],
["traffic", "t"],
["transit", "j"],
["weather-cyclones", "y"],
["weather-lightning", "l"],
["weather-radar", "v"],
["weather-satellite", "o"],
["wind", "k"]
]
+14
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') {
@@ -16,6 +18,8 @@ const VALID_LAYER_SERIALIZATION_DISPOSITIONS = new Set([
'enabled-only',
'enabled+options',
'enabled+mirrored-options',
// Registered but never serialized (for example hardware-local layers).
'local-only',
]);
function isAbortError(error) {
@@ -1072,6 +1076,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,
@@ -2093,6 +2098,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
@@ -2302,6 +2308,14 @@ export class LayerLifecycle {
return () => this._activityListeners.delete(callback);
}
/**
* Ask presentation to repaint layer rows now, for data that lands outside
* the manager tick (Transit's proximity polls, Directions' route steps).
*/
refreshLayerStats() {
this._publishActivity({ type: 'status' });
}
_publishActivity(change) {
for (const callback of this._activityListeners) {
try {
+222
View File
@@ -0,0 +1,222 @@
// Focused tests for the local-infrastructure analyst-record mapper and snapshot seam.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createLocalGeoJsonLayer, mapAnalystRecord } from './localGeojson.js';
const DC_RAW = {
id: '1176042553',
lat: -52.942,
lon: -70.85,
properties: {
tags: {
name: 'AWS',
operator: 'Amazon Web Services',
'operator:short': 'AWS',
'capacity:it_load': '27 MW',
},
},
};
const DAM_RAW = {
id: '-19685440',
lat: -25.41,
lon: -54.59,
properties: {
name: 'Usina Hidrelétrica de Itaipu',
output: '14000 MW',
tags: {
name: 'Usina Hidrelétrica de Itaipu',
'name:en': 'Itaipu Dam',
operator: 'Itaipú Binacional',
associated_river: 'Paraná',
'plant:output:electricity': '14000 MW',
},
},
};
test('infra analyst record: datacenter maps name, operator, capacity', () => {
const r = mapAnalystRecord(DC_RAW, 'local-datacenters');
assert.deepEqual(r, {
id: 'AWS',
name: 'AWS',
lat: -52.942,
lon: -70.85,
operator: 'Amazon Web Services',
capacity: '27 MW',
river: null,
output: null,
});
});
test('infra analyst record: dam maps name, operator, river, output; names stay unclamped', () => {
const r = mapAnalystRecord(DAM_RAW, 'local-dams');
assert.equal(r.id, 'Usina Hidrelétrica de Itaipu');
assert.equal(r.name, 'Usina Hidrelétrica de Itaipu');
assert.equal(r.operator, 'Itaipú Binacional');
assert.equal(r.river, 'Paraná');
assert.equal(r.output, '14000 MW');
assert.equal(r.capacity, null);
});
test('infra analyst record: unnamed feature falls back to source id', () => {
const r = mapAnalystRecord(
{
id: 'dc-42',
lat: 30.2,
lon: -97.7,
properties: { tags: { operator: 'Example Cloud' } },
},
'local-datacenters',
);
assert.equal(r.id, 'dc-42');
assert.equal(r.name, null);
assert.equal(r.operator, 'Example Cloud');
});
test('infra analyst record: empty record yields nulls, never NaN/undefined', () => {
const r = mapAnalystRecord(undefined, 'local-dams');
assert.equal(r.id, 'Dam');
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`);
}
});
test('infra analyst record: output is JSON-safe (no Cesium types leak)', () => {
const r = mapAnalystRecord(
{
...DAM_RAW,
entity: {},
position: { x: 1 },
},
'local-dams',
);
assert.deepEqual(JSON.parse(JSON.stringify(r)), r);
assert.equal('entity' in r, false);
assert.equal('position' in r, false);
});
class MockLayerEvent {
constructor() {
this.listeners = new Set();
}
addEventListener(listener) {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
}
test('infra getAnalystRecords: enabled layer snapshots loaded stems; disable returns []', async () => {
const originalFetch = globalThis.fetch;
const originalWindow = globalThis.window;
globalThis.window = { dispatchEvent() {} };
globalThis.fetch = async () => ({
ok: true,
status: 200,
text: async () =>
JSON.stringify({
type: 'Feature',
id: 'runtime-dam',
properties: {
name: 'Runtime Dam',
output: '12 MW',
tags: { associated_river: 'Test River', operator: 'Test Hydro' },
},
geometry: {
type: 'Polygon',
coordinates: [
[
[-97.7, 30.2],
[-97.69, 30.2],
[-97.69, 30.21],
[-97.7, 30.2],
],
],
},
}),
});
const viewer = {
selectedEntity: undefined,
dataSources: {
add(dataSource) {
return dataSource;
},
remove() {
return true;
},
},
camera: {
positionWC: Cesium.Cartesian3.fromDegrees(-97.695, 30.205, 100_000),
frustum: { fov: Math.PI / 3 },
moveEnd: new MockLayerEvent(),
flyTo() {},
},
scene: {
canvas: { clientWidth: 800, clientHeight: 600 },
preRender: new MockLayerEvent(),
sampleHeightSupported: false,
sampleHeight() {
return undefined;
},
screenSpaceCameraController: { enableInputs: true },
pick() {
return null;
},
requestRender() {},
},
};
const layer = createLocalGeoJsonLayer({
id: 'local-dams',
url: '/runtime-dam.geojsonl',
name: 'Runtime Dams',
color: '#0088ff',
overlayHost: { setVisible() {}, setEntries() {}, clearSource() {} },
projectToWindow: () => ({ x: 400, y: 300 }),
screenSpaceEventHandlerFactory: () => ({
setInputAction() {},
destroy() {},
}),
});
try {
assert.deepEqual(
layer.getAnalystRecords(),
[],
'unenabled layer has no analyst records',
);
await layer.enable(viewer);
const rows = layer.getAnalystRecords();
assert.equal(rows.length, 1);
assert.equal(rows[0].id, 'Runtime Dam');
assert.equal(rows[0].name, 'Runtime Dam');
assert.equal(rows[0].operator, 'Test Hydro');
assert.equal(rows[0].river, 'Test River');
assert.equal(rows[0].output, '12 MW');
assert.ok(Number.isFinite(rows[0].lat) && Number.isFinite(rows[0].lon));
assert.deepEqual(JSON.parse(JSON.stringify(rows[0])), rows[0]);
assert.equal(
layer.getAnalystRecords(0).length,
1,
'non-finite/zero cap still returns at least one',
);
layer.disable(viewer);
assert.deepEqual(
layer.getAnalystRecords(),
[],
'disable keeps stems but analyst snapshot is empty',
);
} finally {
layer.destroy(viewer);
assert.deepEqual(
layer.getAnalystRecords(),
[],
'destroy releases analyst records',
);
globalThis.fetch = originalFetch;
if (originalWindow === undefined) delete globalThis.window;
else globalThis.window = originalWindow;
}
});
+116 -26
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { createInfrastructureOverlayEntry } from './infrastructureOverlayEntry.js';
import { isPointerFree } from './inputOwnership.js';
import {
selectInfraLod,
@@ -14,10 +15,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,6 +86,78 @@ 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.
@@ -110,33 +179,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',
};
});
}
/**
@@ -310,7 +361,7 @@ export function localDatasetError(error) {
* standard scene.pick natively clicks them.
* @param {object} options Dataset URL, identity, appearance and optional Cesium adapters.
* @param {object} services Caller-owned operations; see docs/INFRASTRUCTURE-LAYERS.md.
* @returns {object} A fresh layer implementing init/enable/disable/update/destroy/getStats.
* @returns {object} A fresh layer implementing init/enable/disable/update/destroy/getStats/getAnalystRecords.
* One live instance per layer id is allowed in a given viewer/context/overlay host.
* Destroy the previous instance before replacing it. Importing creates no layers.
*/
@@ -322,6 +373,7 @@ export function createLocalGeoJsonLayer(
color,
icon = '📍',
source = 'Local JSONL',
osmDerived = false,
labels = true,
labelMax = DEFAULT_LABEL_MAX,
labelGridPx = DEFAULT_LABEL_GRID_PX,
@@ -337,6 +389,8 @@ export function createLocalGeoJsonLayer(
clearSelectedEntityContextForLayer,
removeEntityContextsForLayer,
governorRequestRender,
showOsmCredit,
hideOsmCredit,
},
) {
let _dataSource = null;
@@ -466,6 +520,7 @@ export function createLocalGeoJsonLayer(
};
const disableLayer = (viewer) => {
hideOsmCredit?.(viewer, id);
_enabled = false;
clearGroundRetryRender();
_activeLodIds = new Set();
@@ -530,6 +585,40 @@ export function createLocalGeoJsonLayer(
computed: _lodComputed,
}),
/**
* Snapshot in-memory infrastructure features as plain JSON-safe objects
* for the analyst query engine. On-demand only (called at most once per
* spoken query) — zero per-frame cost, no listeners, no caching. Returns
* [] while the layer is disabled or empty. Disable keeps the loaded
* stems for reuse; this method still returns [] until the next enable.
* @param {number} [maxCount=2000] Maximum records to return (truncation).
* @returns {Array<Object>} See mapAnalystRecord for the record shape.
*/
getAnalystRecords(maxCount = 2000) {
if (!_enabled || !_stemRecords.length) return [];
const limit = Number.isFinite(maxCount)
? Math.max(1, Math.floor(maxCount))
: 2000;
const result = [];
for (let i = 0; i < _stemRecords.length; i++) {
if (result.length >= limit) break;
const record = _stemRecords[i];
const carto = record.carto;
result.push(
mapAnalystRecord(
{
id: record.id,
lat: carto ? Cesium.Math.toDegrees(carto.latitude) : null,
lon: carto ? Cesium.Math.toDegrees(carto.longitude) : null,
properties: propertyObject(record.entity),
},
id,
),
);
}
return result;
},
enable: async (viewer) => {
if (_destroyed) return;
_enabled = true;
@@ -836,6 +925,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(() => {
Loaded 100 of 542 files, more files were not shown because too many files have changed in this diff. Show more