mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Merge branch 'main' into feat/lithuania-cctv
This commit is contained in:
commit
b4d852587a
1150 files changed
+212712
-7996
No files matched your search
@@ -0,0 +1,236 @@
|
||||
// Area annotations draw bundled state/province/county outlines with no
|
||||
// network lookup, and multi-part outlines reach the renderer intact.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { FEATURE_SOURCE_METHODS } from '../sources/featureSource.js';
|
||||
import { createAnnotationResolver } from './resolver.js';
|
||||
import { createAnnotationEngine } from './annotationEngine.js';
|
||||
import { _resetRenderGovernorForTest } from '../renderGovernor.js';
|
||||
|
||||
function viewerAt(lat, lon, height = 20_000) {
|
||||
return {
|
||||
camera: {
|
||||
positionCartographic: {
|
||||
latitude: (lat * Math.PI) / 180,
|
||||
longitude: (lon * Math.PI) / 180,
|
||||
height,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A resolver whose boundary lookups and geocoder record every call. */
|
||||
function harness(place = null) {
|
||||
const lookups = [];
|
||||
const featureSource = {};
|
||||
for (const method of FEATURE_SOURCE_METHODS)
|
||||
featureSource[method] = async () => {
|
||||
lookups.push(method);
|
||||
return null;
|
||||
};
|
||||
const geocodes = [];
|
||||
const placeSearch = {
|
||||
async geocode(query) {
|
||||
geocodes.push(query);
|
||||
return { place };
|
||||
},
|
||||
async textSearch() {
|
||||
geocodes.push('text-search');
|
||||
return [];
|
||||
},
|
||||
};
|
||||
const { resolveAnnotationTarget } = createAnnotationResolver({
|
||||
featureSource,
|
||||
});
|
||||
const resolve = (target, viewer, extra = {}) =>
|
||||
resolveAnnotationTarget({
|
||||
placeSearch,
|
||||
viewer,
|
||||
target,
|
||||
footprint: true,
|
||||
deferFootprint: true,
|
||||
...extra,
|
||||
});
|
||||
return { resolve, lookups, geocodes };
|
||||
}
|
||||
|
||||
const AUSTIN = viewerAt(30.2672, -97.7431, 3000);
|
||||
|
||||
test('"Texas", "Travis County, Texas" and "Bavaria" outline offline', async () => {
|
||||
const { resolve, lookups, geocodes } = harness();
|
||||
for (const [target, label] of [
|
||||
['Texas', 'Texas'],
|
||||
['Travis County, Texas', 'Travis County'],
|
||||
// Far from the camera, but named outright: no proximity gate applies.
|
||||
['Bavaria', 'Bavaria'],
|
||||
]) {
|
||||
const resolved = await resolve(target, AUSTIN);
|
||||
assert.ok(resolved, target);
|
||||
assert.equal(resolved.source, 'bundled', target);
|
||||
assert.equal(resolved.label, label);
|
||||
assert.equal(resolved.footprintKind, 'area');
|
||||
assert.equal(resolved.synthesized, false);
|
||||
assert.equal(resolved.resolveOutline, undefined, 'drawn at once');
|
||||
assert.ok(resolved.ring.length >= 9, `${target} ring`);
|
||||
assert.deepEqual(resolved.ring[0], resolved.ring.at(-1), 'ring closed');
|
||||
assert.ok(resolved.polygons.length >= 1, `${target} parts`);
|
||||
assert.equal(resolved.polygons[0][0].length + 1, resolved.ring.length);
|
||||
assert.ok(resolved.viewport?.low && resolved.viewport?.high);
|
||||
}
|
||||
assert.deepEqual(geocodes, [], 'no geocoding');
|
||||
assert.deepEqual(lookups, [], 'no boundary lookups');
|
||||
});
|
||||
|
||||
test('duplicate county names go to the one near the camera', async () => {
|
||||
const { resolve } = harness();
|
||||
const nearPortland = await resolve(
|
||||
'Washington County',
|
||||
viewerAt(45.52, -122.68),
|
||||
);
|
||||
assert.ok(nearPortland.lat > 45 && nearPortland.lon < -122, 'Oregon');
|
||||
const nearAustin = await resolve('Washington County', AUSTIN);
|
||||
assert.ok(nearAustin.lat > 29.9 && nearAustin.lat < 30.5, 'Texas');
|
||||
assert.ok(nearAustin.lon > -96.8 && nearAustin.lon < -96, 'Texas');
|
||||
});
|
||||
|
||||
test('multi-part outlines carry every part and hole', async () => {
|
||||
const { resolve } = harness();
|
||||
const hawaii = await resolve('Hawaii', AUSTIN);
|
||||
assert.ok(hawaii.polygons.length >= 7, 'islands');
|
||||
const brandenburg = await resolve('Brandenburg', AUSTIN);
|
||||
assert.ok(
|
||||
brandenburg.polygons.some((part) => part.length === 2),
|
||||
'Berlin hole',
|
||||
);
|
||||
});
|
||||
|
||||
test('Georgia resolves from camera geography without a geocoder', async () => {
|
||||
const { resolve, lookups, geocodes } = harness();
|
||||
for (const [query, viewer, latitude] of [
|
||||
['Georgia', viewerAt(33.75, -84.39, 400_000), 32],
|
||||
['the country of Georgia', AUSTIN, 42],
|
||||
['Georgia', viewerAt(41.7, 44.8), 42],
|
||||
]) {
|
||||
const resolved = await resolve(query, viewer);
|
||||
assert.equal(resolved.source, 'bundled');
|
||||
assert.ok(Math.abs(resolved.lat - latitude) < 2);
|
||||
assert.ok(resolved.ring.length > 20);
|
||||
}
|
||||
assert.deepEqual(lookups, []);
|
||||
assert.deepEqual(geocodes, []);
|
||||
});
|
||||
|
||||
test('country asks and geocoded country scopes use the bundled outline', async () => {
|
||||
const direct = harness();
|
||||
for (const name of ['Switzerland', 'Iran', 'France', 'Japan']) {
|
||||
const resolved = await direct.resolve(name, AUSTIN);
|
||||
assert.equal(resolved.source, 'bundled');
|
||||
assert.ok(resolved.polygons.length);
|
||||
if (name === 'Japan') assert.ok(resolved.polygons.length > 3);
|
||||
}
|
||||
assert.deepEqual(direct.lookups, []);
|
||||
assert.deepEqual(direct.geocodes, []);
|
||||
const geocoded = harness({
|
||||
lat: 46.95,
|
||||
lng: 7.45,
|
||||
label: 'Switzerland',
|
||||
name: 'Switzerland',
|
||||
types: ['country', 'political'],
|
||||
});
|
||||
const resolved = await geocoded.resolve(
|
||||
'Swiss country outline',
|
||||
viewerAt(46.95, 7.45),
|
||||
);
|
||||
assert.equal(resolved.source, 'geocode');
|
||||
const outline = await resolved.resolveOutline();
|
||||
assert.ok(outline.polygons.length);
|
||||
assert.deepEqual(geocoded.lookups, []);
|
||||
});
|
||||
|
||||
test('non-admin asks and entity facts keep the existing path', async () => {
|
||||
const { resolve, geocodes } = harness();
|
||||
await resolve('Texas Capitol', AUSTIN);
|
||||
await resolve('Texas', AUSTIN, { entityKind: 'building' });
|
||||
await resolve('Zilker Park', AUSTIN);
|
||||
assert.equal(
|
||||
geocodes.filter((q) => q !== 'text-search').length,
|
||||
3,
|
||||
'each ask geocoded',
|
||||
);
|
||||
// Points and explicit coordinates never use the pack.
|
||||
const point = await resolve('Texas', AUSTIN, { footprint: false });
|
||||
assert.notEqual(point?.source, 'bundled');
|
||||
});
|
||||
|
||||
test('the engine keeps every part on the mark, drawn at once or upgraded', async (t) => {
|
||||
globalThis.requestAnimationFrame ??= () => 1;
|
||||
globalThis.cancelAnimationFrame ??= () => {};
|
||||
_resetRenderGovernorForTest();
|
||||
t.after(() => _resetRenderGovernorForTest());
|
||||
const polygons = [
|
||||
[
|
||||
[
|
||||
[0, 0],
|
||||
[2, 0],
|
||||
[2, 2],
|
||||
[0, 2],
|
||||
],
|
||||
[
|
||||
[0.5, 0.5],
|
||||
[0.5, 1],
|
||||
[1, 1],
|
||||
[1, 0.5],
|
||||
],
|
||||
],
|
||||
[
|
||||
[
|
||||
[3, 3],
|
||||
[4, 3],
|
||||
[4, 4],
|
||||
],
|
||||
],
|
||||
];
|
||||
const ring = [...polygons[0][0], polygons[0][0][0]];
|
||||
const seen = [];
|
||||
const renderer = {
|
||||
add: (anno) => seen.push(['add', anno.polygons]),
|
||||
update: (anno) => seen.push(['update', anno.polygons]),
|
||||
remove() {},
|
||||
sync() {},
|
||||
destroy() {},
|
||||
};
|
||||
const engine = createAnnotationEngine({
|
||||
viewer: {},
|
||||
renderer,
|
||||
resolveTarget: async ({ target }) =>
|
||||
target === 'now'
|
||||
? { lon: 1, lat: 1, height: 0, ring, polygons, footprintKind: 'area' }
|
||||
: {
|
||||
lon: 1,
|
||||
lat: 1,
|
||||
height: 0,
|
||||
ring: null,
|
||||
resolveOutline: async () => ({
|
||||
ring,
|
||||
polygons,
|
||||
footprintKind: 'area',
|
||||
lat: 1,
|
||||
lon: 1,
|
||||
height: 0,
|
||||
}),
|
||||
},
|
||||
});
|
||||
await engine.annotate([{ type: 'area', target: 'now' }]);
|
||||
await engine.annotate([{ type: 'area', target: 'later', label: 'later' }]);
|
||||
for (let i = 0; i < 20; i += 1) await Promise.resolve();
|
||||
const marks = engine.list();
|
||||
assert.equal(marks[0].polygons, polygons);
|
||||
assert.ok(
|
||||
marks.some((m) => m.label === 'later' && m.polygons === polygons),
|
||||
'upgrade carries the parts',
|
||||
);
|
||||
assert.ok(
|
||||
seen.some(([kind, parts]) => kind === 'update' && parts === polygons),
|
||||
);
|
||||
engine.destroy();
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isUnavailableCapability } from '../sources/capability.js';
|
||||
import { defaultGeospatial } from '../search/defaults.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
@@ -81,6 +82,7 @@ export async function resolveOutlineWithRetry(
|
||||
} catch {
|
||||
fp = null; // hard failure — definitive, keep the honest point
|
||||
}
|
||||
if (isUnavailableCapability(fp)) return fp;
|
||||
if (isRateLimitedOutcome(fp)) {
|
||||
// A throttle gets one deliberately spaced retry. If that retry is throttled
|
||||
// too, stop this mark's outline task instead of replaying the batch storm.
|
||||
@@ -333,6 +335,7 @@ export function createAnnotationEngine({
|
||||
// refreshes it — usually instantly from the footprint cache.
|
||||
if (anno.pendingOutline && !anno.ring && dup.ring) {
|
||||
anno.ring = dup.ring;
|
||||
anno.polygons = dup.polygons || null;
|
||||
anno.footprintKind = dup.footprintKind || null;
|
||||
anno.buildingHeight = dup.buildingHeight || null;
|
||||
anno.synthesized = Boolean(dup.synthesized);
|
||||
@@ -673,8 +676,11 @@ export function createAnnotationEngine({
|
||||
if (myGen !== generation || controller.signal.aborted) return; // board superseded
|
||||
if (!annotations.has(anno.id)) return; // mark replaced/removed while resolving
|
||||
anno.pendingOutline = false;
|
||||
if (fp) {
|
||||
anno.outlineUnavailable = isUnavailableCapability(fp);
|
||||
if (anno.outlineUnavailable) renderer.update(anno);
|
||||
if (fp && !anno.outlineUnavailable) {
|
||||
anno.ring = fp.ring;
|
||||
anno.polygons = fp.polygons || null;
|
||||
anno.footprintKind = fp.footprintKind || null;
|
||||
anno.buildingHeight = fp.buildingHeight || null;
|
||||
anno.synthesized = Boolean(fp.synthesized);
|
||||
@@ -725,7 +731,14 @@ export function createAnnotationEngine({
|
||||
id: anno.id,
|
||||
label: anno.label || null,
|
||||
target: anno.targetKey || null,
|
||||
status: fp ? 'resolved' : 'failed',
|
||||
status: anno.outlineUnavailable
|
||||
? 'unavailable'
|
||||
: fp
|
||||
? 'resolved'
|
||||
: 'failed',
|
||||
...(anno.outlineUnavailable
|
||||
? { message: 'Detailed outline unavailable' }
|
||||
: {}),
|
||||
...(fp && anno.synthesized ? { approximate: true } : {}),
|
||||
});
|
||||
} finally {
|
||||
@@ -753,6 +766,9 @@ export function createAnnotationEngine({
|
||||
// layer narrate without waiting out a slow Overpass — and without calling the
|
||||
// missing outline a failure.
|
||||
...(anno.pendingOutline ? { outlinePending: true } : {}),
|
||||
...(anno.outlineUnavailable
|
||||
? { outlineUnavailable: true, message: 'Detailed outline unavailable' }
|
||||
: {}),
|
||||
// approximate=true means the area was SYNTHESIZED (a buffered blob around a label
|
||||
// point), not a real OSM boundary — so the voice layer can be honest.
|
||||
...(anno.synthesized ? { approximate: true } : {}),
|
||||
@@ -919,6 +935,9 @@ export function createAnnotationEngine({
|
||||
anchor: { lon: resolved.lon, lat: resolved.lat, height: resolved.height },
|
||||
to: null,
|
||||
ring: resolved.ring || null,
|
||||
// Every part with its holes ([outer, ...holes][]), when the outline has
|
||||
// more than the main ring (Hawaii's islands, Berlin inside Brandenburg).
|
||||
polygons: resolved.polygons || null,
|
||||
footprintKind: resolved.footprintKind || null, // 'building' | 'area'
|
||||
buildingHeight: resolved.buildingHeight || null, // meters, for extruded volume
|
||||
synthesized: Boolean(resolved.synthesized), // approximate buffered area → dashed render
|
||||
@@ -926,6 +945,7 @@ export function createAnnotationEngine({
|
||||
// the upgrade task fills ring/kind in place when it lands. Transient render state
|
||||
// (not persisted).
|
||||
pendingOutline: typeof resolved.resolveOutline === 'function',
|
||||
outlineUnavailable: Boolean(resolved.outlineUnavailable),
|
||||
// Which THING + SHAPE was asked for — the dedup identity while geometry is still
|
||||
// pending (see findDuplicate). targetKey is the normalized place name with trailing
|
||||
// locality qualifiers stripped ("California, United States" ≡ "California"; null for
|
||||
@@ -1047,7 +1067,11 @@ export function createAnnotationEngine({
|
||||
// Places viewport box when we have one, so a big compound isn't framed at
|
||||
// building scale while its outline is traced. Never re-fly when the ring lands.
|
||||
const range = anno.ring
|
||||
? ringRange(anno.ring)
|
||||
? ringRange(
|
||||
anno.polygons?.length
|
||||
? anno.polygons.flatMap((poly) => poly[0])
|
||||
: anno.ring,
|
||||
)
|
||||
: viewportRange(anno.viewport) || 600;
|
||||
viewer.camera.flyToBoundingSphere(
|
||||
new Cesium.BoundingSphere(
|
||||
@@ -1388,7 +1412,11 @@ function ringRange(ring) {
|
||||
let maxLat = -Infinity;
|
||||
let minLon = Infinity;
|
||||
let maxLon = -Infinity;
|
||||
for (const [lon, lat] of ring) {
|
||||
// Unwrap around the first vertex so parts cut at the antimeridian (the
|
||||
// Aleutians) measure as neighbours, not as a globe-wide span.
|
||||
const ref = ring[0]?.[0] ?? 0;
|
||||
for (const [rawLon, lat] of ring) {
|
||||
const lon = rawLon - 360 * Math.round((rawLon - ref) / 360);
|
||||
if (lat < minLat) minLat = lat;
|
||||
if (lat > maxLat) maxLat = lat;
|
||||
if (lon < minLon) minLon = lon;
|
||||
|
||||
@@ -254,7 +254,9 @@ test('retry: HTTP 429 Retry-After 5s gets one ladder-spaced retry; a second 429
|
||||
t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now: 10_000 });
|
||||
globalThis.window = { setTimeout: globalThis.setTimeout, clearTimeout: globalThis.clearTimeout };
|
||||
const requestTimes = [];
|
||||
globalThis.fetch = async () => {
|
||||
globalThis.fetch = async (url) => {
|
||||
// The one-time capability probe is not an outline request.
|
||||
if (String(url).endsWith('/status')) return httpFailure(404);
|
||||
requestTimes.push(Date.now());
|
||||
return httpFailure(429, '5');
|
||||
};
|
||||
@@ -311,7 +313,9 @@ test('retry: a plain HTTP 500 still exhausts the existing 8s/25s transient ladde
|
||||
t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now: 100_000 });
|
||||
globalThis.window = { setTimeout: globalThis.setTimeout, clearTimeout: globalThis.clearTimeout };
|
||||
const requestTimes = [];
|
||||
globalThis.fetch = async () => {
|
||||
globalThis.fetch = async (url) => {
|
||||
if (String(url).endsWith('/status'))
|
||||
return Response.json({ configured: true });
|
||||
requestTimes.push(Date.now());
|
||||
return httpFailure(500);
|
||||
};
|
||||
@@ -400,7 +404,7 @@ test('outline upgrade updates the rendered element in place without remove/add',
|
||||
};
|
||||
let overpassCall = 0;
|
||||
globalThis.fetch = async (url) => {
|
||||
if (String(url).startsWith('https://maps.googleapis.com/')) {
|
||||
if (String(url).startsWith('/api/google/geocode')) {
|
||||
return { json: async () => ({
|
||||
status: 'OK',
|
||||
results: [{
|
||||
|
||||
@@ -200,7 +200,7 @@ test('ask-side admin bypass: "the Texas Capitol" recovers near-view despite a fa
|
||||
const calls = [];
|
||||
installGoogleMocks(t, async (url) => {
|
||||
calls.push(String(url));
|
||||
if (String(url).startsWith('https://maps.googleapis.com/')) {
|
||||
if (String(url).startsWith('/api/google/geocode')) {
|
||||
return { json: async () => geocodePayload({
|
||||
lat: 31.0000,
|
||||
lon: -99.0000,
|
||||
@@ -235,7 +235,7 @@ test('ask-side admin bypass: explicit "state of Texas" skips recovery and proxim
|
||||
const calls = [];
|
||||
installGoogleMocks(t, async (url) => {
|
||||
calls.push(String(url));
|
||||
assert.match(String(url), /^https:\/\/maps\.googleapis\.com\/maps\/api\/geocode/);
|
||||
assert.match(String(url), /^\/api\/google\/geocode\?/);
|
||||
return { json: async () => geocodePayload({
|
||||
lat: 31.0000,
|
||||
lon: -99.0000,
|
||||
@@ -273,7 +273,7 @@ for (const fixture of [
|
||||
const calls = [];
|
||||
installGoogleMocks(t, async (url) => {
|
||||
calls.push(String(url));
|
||||
if (String(url).startsWith('https://maps.googleapis.com/')) {
|
||||
if (String(url).startsWith('/api/google/geocode')) {
|
||||
return { json: async () => geocodePayload({
|
||||
lat: fixture.lat,
|
||||
lon: fixture.lon,
|
||||
@@ -299,7 +299,7 @@ test('ask-side admin bypass: bare "Texas" remains on the guarded recovery path',
|
||||
const calls = [];
|
||||
installGoogleMocks(t, async (url) => {
|
||||
calls.push(String(url));
|
||||
if (String(url).startsWith('https://maps.googleapis.com/')) {
|
||||
if (String(url).startsWith('/api/google/geocode')) {
|
||||
return { json: async () => geocodePayload({
|
||||
lat: 31.0000,
|
||||
lon: -99.0000,
|
||||
@@ -328,7 +328,7 @@ test('ask-side admin bypass: admin level 2/3 result types never grant a township
|
||||
const calls = [];
|
||||
installGoogleMocks(t, async (url) => {
|
||||
calls.push(String(url));
|
||||
if (String(url).startsWith('https://maps.googleapis.com/')) {
|
||||
if (String(url).startsWith('/api/google/geocode')) {
|
||||
const query = new URL(String(url)).searchParams.get('address');
|
||||
return { json: async () => geocodePayload({
|
||||
lat: 39.7817,
|
||||
@@ -380,7 +380,7 @@ function installCapitolMocks(t, elements, components = [
|
||||
{ long_name: 'Capitol Hill', types: ['neighborhood', 'political'] },
|
||||
]) {
|
||||
installGoogleMocks(t, async (url) => {
|
||||
if (String(url).startsWith('https://maps.googleapis.com/')) {
|
||||
if (String(url).startsWith('/api/google/geocode')) {
|
||||
return { json: async () => ({ status: 'OK', results: [{
|
||||
formatted_address: 'Washington, DC 20004, USA',
|
||||
types: ['establishment', 'landmark', 'point_of_interest', 'tourist_attraction'],
|
||||
@@ -458,7 +458,7 @@ test('keyless: a key that geocodes to nothing still anchors the annotation', asy
|
||||
const requests = [];
|
||||
installGoogleMocks(t, async (url) => {
|
||||
requests.push(String(url));
|
||||
if (String(url).startsWith('https://maps.googleapis.com/')) return GOOGLE_FOUND_NOTHING;
|
||||
if (String(url).startsWith('/api/google/geocode')) return GOOGLE_FOUND_NOTHING;
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
@@ -495,7 +495,7 @@ test('keyless: a key that geocodes to nothing still anchors the annotation', asy
|
||||
high: { latitude: 30.28, longitude: -97.68 },
|
||||
});
|
||||
// Google is asked first and exactly once; one Photon call answers it.
|
||||
assert.equal(requests.filter((url) => url.includes('maps.googleapis.com')).length, 1);
|
||||
assert.equal(requests.filter((url) => url.includes('/api/google/geocode')).length, 1);
|
||||
assert.equal(requests.filter((url) => url.includes('photon.komoot.io')).length, 1);
|
||||
});
|
||||
|
||||
@@ -512,7 +512,7 @@ test('keyless: OSM matching uses the feature\'s canonical name, not the user\'s
|
||||
|
||||
installGoogleMocks(t, async (url, init) => {
|
||||
const href = String(url);
|
||||
if (href.startsWith('https://maps.googleapis.com/')) return GOOGLE_FOUND_NOTHING;
|
||||
if (href.startsWith('/api/google/geocode')) return GOOGLE_FOUND_NOTHING;
|
||||
if (href.startsWith('/api/google/text-search')) return { ok: true, json: async () => ({ places: [] }) };
|
||||
if (href.startsWith('https://photon.komoot.io/')) {
|
||||
return {
|
||||
@@ -575,7 +575,7 @@ test('keyless: a Photon outage is not remembered as "no such place"', async (t)
|
||||
const requests = [];
|
||||
installGoogleMocks(t, async (url) => {
|
||||
requests.push(String(url));
|
||||
if (String(url).startsWith('https://maps.googleapis.com/')) return GOOGLE_FOUND_NOTHING;
|
||||
if (String(url).startsWith('/api/google/geocode')) return GOOGLE_FOUND_NOTHING;
|
||||
if (!photonReachable) throw new Error('network down');
|
||||
return photonUp;
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
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;
|
||||
let lookupStarted;
|
||||
const { resolveRegionRingForQuery } = createAnnotationResolver({
|
||||
featureSource: featureSource({
|
||||
getAdministrativeAreas(_point, { signal }) {
|
||||
lookupStarted = Date.now();
|
||||
adminSignal = signal;
|
||||
return new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
},
|
||||
}),
|
||||
});
|
||||
const region = await resolveRegionRingForQuery(
|
||||
'Atlantis',
|
||||
undefined,
|
||||
offPackGeocode,
|
||||
{ budgetMs: 50 },
|
||||
);
|
||||
assert.deepEqual(region, {
|
||||
name: 'Atlantis',
|
||||
ring: null,
|
||||
error: 'region-timeout',
|
||||
});
|
||||
// Timed from the boundary lookup, so the bundled pack scans before it do not
|
||||
// count. Under the 3 s default budget, so an ignored budgetMs still fails.
|
||||
assert.ok(
|
||||
Date.now() - lookupStarted < 2_000,
|
||||
'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
@@ -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,
|
||||
);
|
||||
|
||||
@@ -161,7 +161,12 @@ export function createScreenAnnotationRenderer(
|
||||
filter: 'url(#gev-sketch)',
|
||||
});
|
||||
group.appendChild(parts.poly);
|
||||
parts.label = makeCallout(anno.label, c);
|
||||
parts.label = makeCallout(
|
||||
anno.outlineUnavailable
|
||||
? `${anno.label || ''} · Detailed outline unavailable`
|
||||
: anno.label,
|
||||
c,
|
||||
);
|
||||
if (parts.label) group.appendChild(parts.label.node);
|
||||
} else if (anno.type === 'arrow' && anno.to) {
|
||||
parts.path = svgEl('path', {
|
||||
@@ -174,7 +179,12 @@ export function createScreenAnnotationRenderer(
|
||||
});
|
||||
ensureArrowMarker(defs, anno.color || 'primary', c);
|
||||
group.appendChild(parts.path);
|
||||
parts.label = makeCallout(anno.label, c);
|
||||
parts.label = makeCallout(
|
||||
anno.outlineUnavailable
|
||||
? `${anno.label || ''} · Detailed outline unavailable`
|
||||
: anno.label,
|
||||
c,
|
||||
);
|
||||
if (parts.label) group.appendChild(parts.label.node);
|
||||
} else if (
|
||||
anno.type === 'route' &&
|
||||
@@ -203,7 +213,12 @@ export function createScreenAnnotationRenderer(
|
||||
group.appendChild(dot);
|
||||
return dot;
|
||||
});
|
||||
parts.label = makeCallout(anno.label, c);
|
||||
parts.label = makeCallout(
|
||||
anno.outlineUnavailable
|
||||
? `${anno.label || ''} · Detailed outline unavailable`
|
||||
: anno.label,
|
||||
c,
|
||||
);
|
||||
if (parts.label) group.appendChild(parts.label.node);
|
||||
} else {
|
||||
// pin / highlight / label — pulsing target rings + a marker dot + callout
|
||||
@@ -242,7 +257,12 @@ export function createScreenAnnotationRenderer(
|
||||
'stroke-opacity': '0.7',
|
||||
});
|
||||
group.appendChild(parts.leader);
|
||||
parts.label = makeCallout(anno.label, c);
|
||||
parts.label = makeCallout(
|
||||
anno.outlineUnavailable
|
||||
? `${anno.label || ''} · Detailed outline unavailable`
|
||||
: anno.label,
|
||||
c,
|
||||
);
|
||||
if (parts.label) group.appendChild(parts.label.node);
|
||||
}
|
||||
|
||||
@@ -301,6 +321,10 @@ export function createScreenAnnotationRenderer(
|
||||
return;
|
||||
}
|
||||
rec.anno = anno;
|
||||
if (anno.outlineUnavailable && rec.parts.label) {
|
||||
rec.parts.label.text.textContent = `${anno.label || ''} · Detailed outline unavailable`;
|
||||
sizeCallout(rec.parts.label);
|
||||
}
|
||||
|
||||
if (empty) {
|
||||
for (const child of Array.from(rec.group.children)) child.remove();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { showOsmCredit, hideOsmCredit } from '../data/dataCredits.js';
|
||||
|
||||
/**
|
||||
* World-space annotation renderer (Direction A).
|
||||
@@ -10,7 +11,7 @@ import * as Cesium from 'cesium';
|
||||
* IMPORTANT — drawing on Google Photorealistic 3D Tiles:
|
||||
* The Cesium globe is hidden, so there is no terrain to clamp to. Ground
|
||||
* geometry (areas, rings, connectors) is draped onto the photoreal tiles with
|
||||
* `classificationType: CESIUM_3D_TILE`; points and labels clamp to the tile
|
||||
* `classificationType: CESIUM_3D_TILE`; points clamp to the tile
|
||||
* surface with `heightReference: CLAMP_TO_GROUND` (which requires the tileset
|
||||
* to have `enableCollision = true`, set in initAnnotations). This keeps marks
|
||||
* sitting ON the world instead of buried at sea level.
|
||||
@@ -49,6 +50,7 @@ const CLAMP = Cesium.HeightReference.CLAMP_TO_GROUND;
|
||||
export function createWorldAnnotationRenderer(viewer) {
|
||||
const dataSource = new Cesium.CustomDataSource('gev-annotations');
|
||||
viewer.dataSources.add(dataSource);
|
||||
const osmAnnotations = new Set();
|
||||
|
||||
// Register the GevRouteFlow fabric once so Cesium's Material.fromType() can build the
|
||||
// material the route pipeline renders. The animated `time` uniform is read straight
|
||||
@@ -159,51 +161,74 @@ export function createWorldAnnotationRenderer(viewer) {
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (anno.label) entities.push(labelMarker(anno, base, { point: false }));
|
||||
} else if (anno.ring && anno.ring.length >= 3) {
|
||||
// Larger area (district / compound / park) → flat fill draped on the tiles
|
||||
// + a glowing outline. Draping is right here: you can't extrude a whole
|
||||
// neighbourhood, and the GIS overlay shows the boundary clearly.
|
||||
const fillPositions = Cesium.Cartesian3.fromDegreesArray(
|
||||
anno.ring.flat(),
|
||||
);
|
||||
entities.push(
|
||||
dataSource.entities.add({
|
||||
polygon: {
|
||||
hierarchy: new Cesium.PolygonHierarchy(fillPositions),
|
||||
// Synthesized (approximate) areas get a fainter fill so they don't read as solid.
|
||||
material: new Cesium.ColorMaterialProperty(
|
||||
liveColor(anno, base, {
|
||||
alpha: anno.synthesized ? 0.1 : 0.2,
|
||||
pulse: true,
|
||||
}),
|
||||
),
|
||||
classificationType: CLASSIFY,
|
||||
},
|
||||
// Larger area (district / compound / park / state) → flat fill draped on
|
||||
// the tiles + a glowing outline. Draping is right here: you can't extrude
|
||||
// a whole neighbourhood, and the GIS overlay shows the boundary clearly.
|
||||
// A multi-part outline (`anno.polygons`: Hawaii's islands, Berlin as a
|
||||
// hole in Brandenburg) draws every part with its holes. All parts share
|
||||
// ONE fill and ONE line material, so Cesium batches the whole mark into a
|
||||
// ground primitive per kind instead of one per island.
|
||||
const parts =
|
||||
Array.isArray(anno.polygons) && anno.polygons.length
|
||||
? anno.polygons
|
||||
: [[anno.ring]];
|
||||
const fillMaterial = new Cesium.ColorMaterialProperty(
|
||||
// Synthesized (approximate) areas get a fainter fill so they don't read as solid.
|
||||
liveColor(anno, base, {
|
||||
alpha: anno.synthesized ? 0.1 : 0.2,
|
||||
pulse: true,
|
||||
}),
|
||||
);
|
||||
entities.push(
|
||||
dataSource.entities.add({
|
||||
polyline: {
|
||||
positions: fillPositions,
|
||||
width: 6,
|
||||
// Synthesized → DASHED outline (signals "approximate, not an authoritative
|
||||
// boundary", research §8.4/§8.6); real footprints → solid glow.
|
||||
material: anno.synthesized
|
||||
? new Cesium.PolylineDashMaterialProperty({
|
||||
color: liveColor(anno, base, { alpha: 0.95 }),
|
||||
dashLength: 24,
|
||||
})
|
||||
: new Cesium.PolylineGlowMaterialProperty({
|
||||
glowPower: 0.35,
|
||||
color: liveColor(anno, base, { alpha: 1 }),
|
||||
}),
|
||||
clampToGround: true,
|
||||
classificationType: CLASSIFY,
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (anno.label) entities.push(labelMarker(anno, base, { point: false }));
|
||||
// Synthesized → DASHED outline (signals "approximate, not an authoritative
|
||||
// boundary", research §8.4/§8.6); real footprints → solid glow.
|
||||
const lineMaterial = anno.synthesized
|
||||
? new Cesium.PolylineDashMaterialProperty({
|
||||
color: liveColor(anno, base, { alpha: 0.95 }),
|
||||
dashLength: 24,
|
||||
})
|
||||
: new Cesium.PolylineGlowMaterialProperty({
|
||||
glowPower: 0.35,
|
||||
color: liveColor(anno, base, { alpha: 1 }),
|
||||
});
|
||||
for (const [outer, ...holes] of parts) {
|
||||
if (!Array.isArray(outer) || outer.length < 3) continue;
|
||||
const rings = [outer, ...holes.filter((h) => h?.length >= 3)];
|
||||
entities.push(
|
||||
dataSource.entities.add({
|
||||
polygon: {
|
||||
hierarchy: new Cesium.PolygonHierarchy(
|
||||
Cesium.Cartesian3.fromDegreesArray(outer.flat()),
|
||||
rings
|
||||
.slice(1)
|
||||
.map(
|
||||
(hole) =>
|
||||
new Cesium.PolygonHierarchy(
|
||||
Cesium.Cartesian3.fromDegreesArray(hole.flat()),
|
||||
),
|
||||
),
|
||||
),
|
||||
material: fillMaterial,
|
||||
classificationType: CLASSIFY,
|
||||
},
|
||||
}),
|
||||
);
|
||||
for (const ring of rings) {
|
||||
entities.push(
|
||||
dataSource.entities.add({
|
||||
polyline: {
|
||||
positions: Cesium.Cartesian3.fromDegreesArray(
|
||||
closedRing(ring).flat(),
|
||||
),
|
||||
width: 6,
|
||||
material: lineMaterial,
|
||||
clampToGround: true,
|
||||
classificationType: CLASSIFY,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
anno.type === 'route' &&
|
||||
Array.isArray(anno.path) &&
|
||||
@@ -228,7 +253,8 @@ export function createWorldAnnotationRenderer(viewer) {
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (anno.label) entities.push(labelMarker(anno, base, { point: false }));
|
||||
osmAnnotations.add(anno);
|
||||
showOsmCredit(viewer, anno);
|
||||
} else if (anno.type === 'arrow' && anno.to) {
|
||||
// Connector draped across the ground from origin to destination.
|
||||
const positions = [
|
||||
@@ -248,18 +274,6 @@ export function createWorldAnnotationRenderer(viewer) {
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (anno.label) {
|
||||
const mid = {
|
||||
lon: (anno.anchor.lon + anno.to.lon) / 2,
|
||||
lat: (anno.anchor.lat + anno.to.lat) / 2,
|
||||
};
|
||||
entities.push(
|
||||
dataSource.entities.add({
|
||||
position: Cesium.Cartesian3.fromDegrees(mid.lon, mid.lat),
|
||||
label: labelGraphic(anno, base),
|
||||
}),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// pin / highlight / label — a camera-proportional target ring + a marker.
|
||||
if (anno.type !== 'label') {
|
||||
@@ -284,54 +298,28 @@ export function createWorldAnnotationRenderer(viewer) {
|
||||
}),
|
||||
);
|
||||
}
|
||||
entities.push(labelMarker(anno, base, { point: true }));
|
||||
entities.push(pointMarker(anno, base));
|
||||
}
|
||||
}
|
||||
|
||||
// A clamped point + (optional) label that sits on the tile surface.
|
||||
function labelMarker(anno, base, { point }) {
|
||||
// Captions belong to the hybrid renderer's screen-space callouts.
|
||||
function pointMarker(anno, base) {
|
||||
return dataSource.entities.add({
|
||||
position: Cesium.Cartesian3.fromDegrees(anno.anchor.lon, anno.anchor.lat),
|
||||
point: point
|
||||
? {
|
||||
pixelSize: anno.type === 'label' ? 8 : 14,
|
||||
color: liveColor(anno, base, { alpha: 1 }),
|
||||
outlineColor: liveColor(anno, Cesium.Color.WHITE, { alpha: 0.95 }),
|
||||
outlineWidth: 3,
|
||||
heightReference: CLAMP,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
}
|
||||
: undefined,
|
||||
label: anno.label ? labelGraphic(anno, base) : undefined,
|
||||
point: {
|
||||
pixelSize: anno.type === 'label' ? 8 : 14,
|
||||
color: liveColor(anno, base, { alpha: 1 }),
|
||||
outlineColor: liveColor(anno, Cesium.Color.WHITE, { alpha: 0.95 }),
|
||||
outlineWidth: 3,
|
||||
heightReference: CLAMP,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function labelGraphic(anno, base) {
|
||||
return {
|
||||
text: anno.label,
|
||||
font: '600 14px "Inter", system-ui, sans-serif',
|
||||
fillColor: liveColor(anno, Cesium.Color.WHITE, { alpha: 1 }),
|
||||
outlineColor: liveColor(anno, Cesium.Color.BLACK, { alpha: 0.85 }),
|
||||
outlineWidth: 3,
|
||||
style: Cesium.LabelStyle.FILL_AND_OUTLINE,
|
||||
verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
|
||||
pixelOffset: new Cesium.Cartesian2(0, -16),
|
||||
showBackground: true,
|
||||
backgroundColor: new Cesium.CallbackProperty(
|
||||
() =>
|
||||
Cesium.Color.fromCssColorString('#0b1622').withAlpha(
|
||||
0.72 * (anno.alpha ?? 1),
|
||||
),
|
||||
false,
|
||||
),
|
||||
backgroundPadding: new Cesium.Cartesian2(8, 5),
|
||||
heightReference: CLAMP,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
scaleByDistance: new Cesium.NearFarScalar(500, 1.05, 14000, 0.55),
|
||||
};
|
||||
}
|
||||
|
||||
function remove(anno) {
|
||||
hideOsmCredit(viewer, anno);
|
||||
osmAnnotations.delete(anno);
|
||||
if (!anno?._entities) return;
|
||||
for (const entity of anno._entities) {
|
||||
try {
|
||||
@@ -348,6 +336,8 @@ export function createWorldAnnotationRenderer(viewer) {
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
for (const anno of osmAnnotations) hideOsmCredit(viewer, anno);
|
||||
osmAnnotations.clear();
|
||||
try {
|
||||
viewer.dataSources.remove(dataSource, true);
|
||||
} catch {
|
||||
@@ -452,6 +442,13 @@ FlowMaterialProperty.prototype.equals = function equals(other) {
|
||||
return this === other;
|
||||
};
|
||||
|
||||
/** The ring with its first vertex repeated at the end, so an outline closes. */
|
||||
function closedRing(ring) {
|
||||
const first = ring[0];
|
||||
const last = ring[ring.length - 1];
|
||||
return first[0] === last[0] && first[1] === last[1] ? ring : [...ring, first];
|
||||
}
|
||||
|
||||
/** Evenly down-sample a [[lon,lat],...] ring to at most n points (keeps shape). */
|
||||
function decimateRing(ring, n) {
|
||||
if (ring.length <= n) return ring;
|
||||
|
||||
@@ -2,6 +2,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createApplication } from './application.js';
|
||||
import { installTrackpadPinchZoom } from './viewer.js';
|
||||
|
||||
const phases = ['Scene', 'Controls', 'Data', 'Tools'];
|
||||
function fixture(overrides = {}) {
|
||||
@@ -217,3 +218,115 @@ test('the separate viewer export imports without constructing a browser viewer',
|
||||
assert.equal(typeof createApplicationViewer, 'function');
|
||||
assert.throws(() => createApplicationViewer({}), /containers are required/);
|
||||
});
|
||||
|
||||
function pinchFixture({ zoomEventTypes } = {}) {
|
||||
const handlers = new Set();
|
||||
const relayed = [];
|
||||
const container = {
|
||||
addEventListener(type, handler, options) {
|
||||
assert.equal(type, 'wheel');
|
||||
assert.deepEqual(options, { capture: true, passive: false });
|
||||
handlers.add(handler);
|
||||
},
|
||||
removeEventListener(type, handler, capture) {
|
||||
assert.equal(type, 'wheel');
|
||||
assert.equal(capture, true);
|
||||
handlers.delete(handler);
|
||||
},
|
||||
emit(event) {
|
||||
for (const handler of handlers) handler(event);
|
||||
},
|
||||
};
|
||||
const canvas = {
|
||||
dispatchEvent(event) {
|
||||
// A real canvas dispatch crosses the container's capture listener again.
|
||||
container.emit(event);
|
||||
relayed.push(event);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
const controller = { zoomEventTypes };
|
||||
const viewer = {
|
||||
container,
|
||||
canvas,
|
||||
scene: { screenSpaceCameraController: controller },
|
||||
};
|
||||
const createEvent = (init = {}) => ({
|
||||
deltaX: 0,
|
||||
deltaY: 0,
|
||||
deltaZ: 0,
|
||||
deltaMode: 0,
|
||||
screenX: 0,
|
||||
screenY: 0,
|
||||
clientX: 0,
|
||||
clientY: 0,
|
||||
ctrlKey: false,
|
||||
defaultPrevented: false,
|
||||
propagationStopped: false,
|
||||
preventDefault() {
|
||||
this.defaultPrevented = true;
|
||||
},
|
||||
stopPropagation() {
|
||||
this.propagationStopped = true;
|
||||
},
|
||||
...init,
|
||||
});
|
||||
return { viewer, controller, container, handlers, relayed, createEvent };
|
||||
}
|
||||
|
||||
test('trackpad pinch adds the Ctrl+wheel binding without replacing Cesium inputs', () => {
|
||||
const original = [1, 2, 3];
|
||||
const f = pinchFixture({ zoomEventTypes: original });
|
||||
const dispose = installTrackpadPinchZoom(f.viewer, {
|
||||
createWheelEvent: (_type, init) => f.createEvent(init),
|
||||
});
|
||||
assert.deepEqual(f.controller.zoomEventTypes.slice(0, 3), original);
|
||||
assert.equal(f.controller.zoomEventTypes.length, 4);
|
||||
|
||||
const plain = f.createEvent({ deltaY: 2 });
|
||||
f.container.emit(plain);
|
||||
assert.equal(plain.defaultPrevented, false);
|
||||
const lineMode = f.createEvent({ ctrlKey: true, deltaY: 2, deltaMode: 1 });
|
||||
f.container.emit(lineMode);
|
||||
assert.equal(lineMode.defaultPrevented, false);
|
||||
assert.equal(f.relayed.length, 0);
|
||||
|
||||
dispose();
|
||||
dispose();
|
||||
assert.equal(f.controller.zoomEventTypes, original);
|
||||
assert.equal(f.handlers.size, 0);
|
||||
});
|
||||
|
||||
test('trackpad pinch relays pixel deltas once with bounded amplification', () => {
|
||||
const f = pinchFixture({ zoomEventTypes: [1, 2, 3] });
|
||||
installTrackpadPinchZoom(f.viewer, {
|
||||
createWheelEvent: (_type, init) => f.createEvent(init),
|
||||
});
|
||||
|
||||
const small = f.createEvent({ ctrlKey: true, deltaY: 2 });
|
||||
f.container.emit(small);
|
||||
assert.equal(small.defaultPrevented, true);
|
||||
assert.equal(small.propagationStopped, true);
|
||||
assert.equal(f.relayed.length, 1);
|
||||
assert.equal(f.relayed[0].deltaY, 16);
|
||||
assert.equal(f.relayed[0].ctrlKey, true);
|
||||
|
||||
const large = f.createEvent({ ctrlKey: true, deltaY: -100 });
|
||||
f.container.emit(large);
|
||||
assert.equal(f.relayed.length, 2);
|
||||
assert.equal(f.relayed[1].deltaY, -120);
|
||||
});
|
||||
|
||||
test('trackpad pinch falls back to the original event when relay construction fails', () => {
|
||||
const f = pinchFixture({ zoomEventTypes: [1, 2, 3] });
|
||||
installTrackpadPinchZoom(f.viewer, {
|
||||
createWheelEvent() {
|
||||
throw new Error('unsupported');
|
||||
},
|
||||
});
|
||||
const event = f.createEvent({ ctrlKey: true, deltaY: 2 });
|
||||
f.container.emit(event);
|
||||
assert.equal(event.defaultPrevented, false);
|
||||
assert.equal(event.propagationStopped, false);
|
||||
assert.equal(f.relayed.length, 0);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -4,6 +4,7 @@ const CONTROL_LAYER_IDS = Object.freeze({
|
||||
militaryFlightsLayer: 'military',
|
||||
satellitesLayer: 'satellites',
|
||||
cctvLayer: 'cctv',
|
||||
streetLevelLayer: 'street-level',
|
||||
radioLayer: 'radio',
|
||||
bikeshareLayer: 'bikeshare',
|
||||
transitLayer: 'transit',
|
||||
@@ -11,6 +12,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. */
|
||||
|
||||
@@ -65,13 +65,28 @@ test('data setup seals the caller catalog before controls can restore and drains
|
||||
},
|
||||
},
|
||||
},
|
||||
catalog: createLayerCatalog(layers, metadata(layers)),
|
||||
catalog: {
|
||||
...createLayerCatalog(layers, metadata(layers)),
|
||||
getSourceAvailability: (id) =>
|
||||
id === 'one'
|
||||
? {
|
||||
available: false,
|
||||
reason: 'One: data source not configured',
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
defer: (release) => releases.push(release),
|
||||
onData: (manager) => {
|
||||
observed = manager;
|
||||
},
|
||||
});
|
||||
assert.equal(observed, dataManager);
|
||||
assert.equal(await dataManager.setEnabled('one', true), false);
|
||||
assert.equal(dataManager.layers.get('one').initialized, false);
|
||||
assert.equal(
|
||||
dataManager.getAll().find(({ id }) => id === 'one').stats.error,
|
||||
'One: data source not configured',
|
||||
);
|
||||
assert.deepEqual(attached, [['one', 'two']]);
|
||||
assert.deepEqual([...dataManager.layers.keys()], ['one', 'two']);
|
||||
assert.throws(() => dataManager.register({ id: 'late' }), /finalized/);
|
||||
@@ -93,6 +108,8 @@ test('controls bind catalog instances rather than similarly named defaults', ()
|
||||
'military-awareness',
|
||||
'military-installations',
|
||||
'rocket-launches',
|
||||
'street-level',
|
||||
'local-adsb',
|
||||
];
|
||||
const layers = ids.map((id) => ({ id }));
|
||||
const catalog = createLayerCatalog(layers, metadata(layers));
|
||||
@@ -100,6 +117,7 @@ test('controls bind catalog instances rather than similarly named defaults', ()
|
||||
assert.equal(services.flightsLayer, layers[1]);
|
||||
assert.equal(services.transitLayer, layers[7]);
|
||||
assert.equal(services.aisLiveVesselsLayer, layers[8]);
|
||||
assert.equal(services.localAdsbLayer, layers[13]);
|
||||
assert.equal(new Set(Object.values(services)).size, layers.length);
|
||||
assert.throws(
|
||||
() => catalogControlServices(createLayerCatalog([], [])),
|
||||
|
||||
+79
-33
@@ -1,3 +1,8 @@
|
||||
import { resolveCatalogSources } from './sourceComposition.js';
|
||||
import { createWeatherClock } from '../layers/weather/clock.js';
|
||||
import { createWeatherLayer } from '../layers/weather/index.js';
|
||||
import { createCyclonesLayer } from '../layers/cyclones/index.js';
|
||||
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,42 +14,39 @@ 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 { createApplicationAirQuality } from './layers/airQuality.js';
|
||||
import { createApplicationFirePerimeters } from './layers/perimeters.js';
|
||||
import { createApplicationGnssIntegrity } from './layers/gnss.js';
|
||||
import { createApplicationCables } from './layers/submarineCables.js';
|
||||
import { createApplicationStreetLevel } from './layers/streetLevel.js';
|
||||
import { createInfrastructureLayers } from '../data/infrastructure.js';
|
||||
import { localGeoJsonServices } from './localGeojsonServices.js';
|
||||
import { createBhoteKoshiEventLayer } from '../data/bhoteKoshiEvent.js';
|
||||
import { createBhoteKoshiLocatorLayer } from '../data/bhoteKoshiLocator.js';
|
||||
|
||||
const SOURCE_METHODS = Object.freeze({
|
||||
flights: ['getSnapshot'],
|
||||
military: ['getSnapshot'],
|
||||
vessels: ['getSnapshot'],
|
||||
cctv: ['getCatalog', 'getHealth', 'getFrameUrl', 'getMediaUrl'],
|
||||
radio: ['getDirectory', 'recordClick'],
|
||||
traffic: [
|
||||
'requestRoads',
|
||||
'getStatus',
|
||||
'fetchFlowForBounds',
|
||||
'getFlowSessionStats',
|
||||
'resetFlowTileCache',
|
||||
],
|
||||
bikeshare: ['getStations'],
|
||||
installations: ['getMappedSites', 'searchNearby'],
|
||||
satellites: ['readGroup'],
|
||||
launches: ['getLaunches', 'getActiveTle'],
|
||||
alpr: ['fetch'],
|
||||
firms: ['getSnapshot'],
|
||||
earthquakes: ['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.
|
||||
@@ -54,10 +56,11 @@ export function createApplicationCatalog({
|
||||
surface,
|
||||
sources,
|
||||
signal,
|
||||
metadata = LAYER_STATE_REGISTRY,
|
||||
metadata = APPLICATION_LAYER_METADATA,
|
||||
vesselOptions,
|
||||
resolveAsset,
|
||||
nepalBoundaryResolver,
|
||||
streetLevelProviders,
|
||||
}) {
|
||||
if (!signal?.addEventListener)
|
||||
throw new TypeError('An application lifetime signal is required');
|
||||
@@ -65,20 +68,19 @@ export function createApplicationCatalog({
|
||||
if (!surface?.groundFloor || !surface?.terrain)
|
||||
throw new TypeError('Application surface services are required');
|
||||
|
||||
for (const [name, methods] of Object.entries(SOURCE_METHODS)) {
|
||||
if (
|
||||
methods.some((method) => typeof sources?.[name]?.[method] !== 'function')
|
||||
)
|
||||
throw new TypeError(`Invalid catalog source: ${name}`);
|
||||
}
|
||||
const sourceComposition = resolveCatalogSources(sources);
|
||||
sources = sourceComposition.sources;
|
||||
const militaryRegistry = createMilitaryRegistry();
|
||||
const weatherClock = createWeatherClock();
|
||||
const dispose = () => {
|
||||
signal.removeEventListener('abort', dispose);
|
||||
militaryRegistry.dispose();
|
||||
weatherClock.destroy();
|
||||
};
|
||||
signal.addEventListener('abort', dispose, { once: true });
|
||||
try {
|
||||
militaryRegistry.configureSource(sources.military, { signal });
|
||||
if (sourceComposition.isConfigured('military'))
|
||||
militaryRegistry.configureSource(sources.military, { signal });
|
||||
const flights = createApplicationFlights({
|
||||
surface,
|
||||
source: sources.flights,
|
||||
@@ -110,16 +112,35 @@ export function createApplicationCatalog({
|
||||
}),
|
||||
flights,
|
||||
military,
|
||||
createApplicationLocalAdsb({
|
||||
surface,
|
||||
enrichment: sources.flights,
|
||||
displayParams: () => flights.getParams(),
|
||||
...(resolveAsset ? { resolveAsset } : {}),
|
||||
}),
|
||||
createApplicationEarthquakes({ source: sources.earthquakes }),
|
||||
createApplicationAirQuality({ source: sources['air-quality'] }),
|
||||
createApplicationFirePerimeters({
|
||||
source: sources['fire-perimeters'],
|
||||
}),
|
||||
createApplicationGnssIntegrity({
|
||||
source: sources['gnss-integrity'],
|
||||
}),
|
||||
createApplicationAlpr({ surface, source: sources.alpr }),
|
||||
createApplicationStreetLevel({
|
||||
surface,
|
||||
sources: { mapillary: sources.mapillary },
|
||||
providers: streetLevelProviders,
|
||||
}),
|
||||
satellites,
|
||||
createApplicationLaunches({ source: sources.launches, satellites }),
|
||||
createApplicationTraffic({ source: sources.traffic }),
|
||||
createApplicationTraffic({ source: sources.traffic, surface }),
|
||||
createApplicationCctv({ surface, source: sources.cctv }),
|
||||
createApplicationRadio({ surface, source: sources.radio }),
|
||||
createApplicationTransit({ surface, source: sources.transit }),
|
||||
createApplicationBikeshare({ source: sources.bikeshare }),
|
||||
createApplicationDirections(),
|
||||
createApplicationDirections({ source: sources.directions }),
|
||||
createApplicationRecentImagery({ source: sources['recent-imagery'] }),
|
||||
vessels,
|
||||
installations,
|
||||
createApplicationAwareness({
|
||||
@@ -128,6 +149,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 +179,15 @@ export function createApplicationCatalog({
|
||||
],
|
||||
metadata,
|
||||
);
|
||||
return Object.freeze({ ...catalog, militaryRegistry, surface });
|
||||
return Object.freeze({
|
||||
...catalog,
|
||||
getSourceAvailability: sourceComposition.createAvailabilityLookup(
|
||||
catalog.layers,
|
||||
),
|
||||
militaryRegistry,
|
||||
surface,
|
||||
weatherClock,
|
||||
});
|
||||
} catch (error) {
|
||||
dispose();
|
||||
throw error;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createApplicationCatalog } from './constructCatalog.js';
|
||||
import { createStandaloneLayerSources } from '../standalone/layerSources.js';
|
||||
import { catalogControlServices } from './catalog.js';
|
||||
import { LayerLifecycle } from '../data/lifecycle.js';
|
||||
import { fakeStreetLevelProvider } from '../testSupport/streetLevelFakes.mjs';
|
||||
|
||||
function fixtureSources(ids, calls) {
|
||||
const sources = createStandaloneLayerSources();
|
||||
@@ -39,7 +40,27 @@ 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, 32);
|
||||
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('gnss-integrity'));
|
||||
assert.ok(first.get('transit'));
|
||||
const order = first.layers.map(({ id }) => id);
|
||||
assert.deepEqual(
|
||||
@@ -82,6 +103,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);
|
||||
@@ -93,7 +118,7 @@ test('invalid or already cancelled construction fails before classification can
|
||||
assert.throws(
|
||||
() =>
|
||||
createApplicationCatalog({
|
||||
sources: {},
|
||||
sources: { flights: {} },
|
||||
signal: lifetime.signal,
|
||||
surface: fixtureSurface(lifetime.signal),
|
||||
}),
|
||||
@@ -117,3 +142,53 @@ function fixtureSurface(signal) {
|
||||
eventTarget: null,
|
||||
});
|
||||
}
|
||||
|
||||
test('Mapillary can be omitted or replaced without changing catalog membership', (t) => {
|
||||
const lifetime = new AbortController();
|
||||
t.after(() => lifetime.abort());
|
||||
const sources = fixtureSources([], []);
|
||||
delete sources.mapillary;
|
||||
const options = {
|
||||
sources,
|
||||
signal: lifetime.signal,
|
||||
surface: fixtureSurface(lifetime.signal),
|
||||
};
|
||||
const catalog = createApplicationCatalog(options);
|
||||
assert.equal(catalog.layers.length, 32);
|
||||
assert.deepEqual(catalog.get('street-level').providerIds, []);
|
||||
assert.equal(
|
||||
catalogControlServices(catalog).streetLevelLayer,
|
||||
catalog.get('street-level'),
|
||||
);
|
||||
assert.match(
|
||||
catalog.get('street-level').getUIState().coverage.hint,
|
||||
/No street-level imagery providers/,
|
||||
);
|
||||
const provider = fakeStreetLevelProvider({
|
||||
id: 'example',
|
||||
pickPrefix: 'example:',
|
||||
});
|
||||
const replacement = createApplicationCatalog({
|
||||
...options,
|
||||
streetLevelProviders: [provider],
|
||||
});
|
||||
assert.deepEqual(replacement.get('street-level').providerIds, ['example']);
|
||||
assert.deepEqual(replacement.metadata, catalog.metadata);
|
||||
assert.equal(provider.calls.activate, 0, 'construction must not acquire');
|
||||
assert.deepEqual(
|
||||
createApplicationCatalog({
|
||||
...options,
|
||||
sources: fixtureSources([], []),
|
||||
streetLevelProviders: [],
|
||||
}).get('street-level').providerIds,
|
||||
[],
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
createApplicationCatalog({
|
||||
...options,
|
||||
sources: { ...sources, mapillary: {} },
|
||||
}),
|
||||
/Mapillary source/,
|
||||
);
|
||||
});
|
||||
+6
-1
@@ -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 },
|
||||
@@ -12,6 +13,7 @@ export function createApplicationData({
|
||||
// Initialize data layer manager
|
||||
const dataManager = new LayerLifecycle(viewer, {
|
||||
allowQaRegistration,
|
||||
getSourceAvailability: catalog?.getSourceAvailability,
|
||||
});
|
||||
defer(async () => {
|
||||
await dataManager.destroyAll();
|
||||
@@ -20,7 +22,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 +57,7 @@ export function createApplicationData({
|
||||
}
|
||||
presentation.mount(document.getElementById('data-toggles'));
|
||||
styleManager.attachDataManager(dataManager);
|
||||
defer(createCyberSonarScene(viewer, dataManager));
|
||||
|
||||
return { dataManager, catalog, presentation };
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* Embed mode and views in the running app.
|
||||
*
|
||||
* With `?embed=1` the app shows only the globe: clean view, with the HUD,
|
||||
* welcome and setup prompts hidden. A page that frames it changes what it
|
||||
* shows by posting `{ type: 'gev:view', id, view }`; the app applies the view
|
||||
* through its own actions and answers `{ type: 'gev:view-applied', id, ok,
|
||||
* steps }`. It announces `{ type: 'gev:ready' }` once it can take views.
|
||||
*
|
||||
* Annotations in a share link are drawn once the link has been restored,
|
||||
* embedded or not.
|
||||
*/
|
||||
|
||||
import * as Cesium from 'cesium';
|
||||
import { announceNavigationAuthority } from '../navigationPolicy.js';
|
||||
import { annotationsFromParams, createView } from '../view/index.js';
|
||||
|
||||
export const EMBED_VIEW_MESSAGE = 'gev:view';
|
||||
export const EMBED_APPLIED_MESSAGE = 'gev:view-applied';
|
||||
export const EMBED_READY_MESSAGE = 'gev:ready';
|
||||
|
||||
const FOLLOW_LAYERS = {
|
||||
aircraft: 'flights',
|
||||
military_aircraft: 'military',
|
||||
satellite: 'satellites',
|
||||
};
|
||||
const FOLLOW_ATTEMPTS = 20;
|
||||
const FOLLOW_RETRY_MS = 1000;
|
||||
const FLIGHT_SECONDS = 2;
|
||||
|
||||
/**
|
||||
* Whether this page shows the app embedded: `?embed=1` when another page
|
||||
* frames it, or inline when a panel page loads the app into itself and sets
|
||||
* `globalThis.GEV_EMBED_INLINE` first.
|
||||
*/
|
||||
export function isEmbedded(location = globalThis.location) {
|
||||
return (
|
||||
isEmbeddedInline() ||
|
||||
new URLSearchParams(location?.search || '').get('embed') === '1'
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether a panel page loaded the app into itself. */
|
||||
export function isEmbeddedInline() {
|
||||
return globalThis.GEV_EMBED_INLINE === true;
|
||||
}
|
||||
|
||||
// A panel keeps drawing at about this rate when its host stops animation
|
||||
// frames; see keepPanelRendering.
|
||||
const PANEL_FRAME_MS = 33;
|
||||
const MISSED_FRAMES_MS = 250;
|
||||
|
||||
/** A render error's details, including the plain objects workers report. */
|
||||
function describeError(error) {
|
||||
if (error instanceof Error)
|
||||
return `${error.name}: ${error.message}\n${error.stack}`;
|
||||
try {
|
||||
return JSON.stringify(error, Object.getOwnPropertyNames(error ?? {}));
|
||||
} catch {
|
||||
return String(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the globe drawing in an inline panel. Some hosts report a panel on
|
||||
* screen as hidden, which stops the browser's animation frames and with them
|
||||
* Cesium's render loop; while frames stop arriving, draw from a timer.
|
||||
* Returns a function that stops it.
|
||||
*/
|
||||
export function keepPanelRendering(
|
||||
viewer,
|
||||
{ windowRef = globalThis.window, now = () => performance.now() } = {},
|
||||
) {
|
||||
let lastFrame = now();
|
||||
let frameRequest = null;
|
||||
const onFrame = () => {
|
||||
lastFrame = now();
|
||||
frameRequest = windowRef.requestAnimationFrame(onFrame);
|
||||
};
|
||||
frameRequest = windowRef.requestAnimationFrame(onFrame);
|
||||
// Cesium stops drawing after a render error, and so does the timer.
|
||||
// Report the error in full: workers report plain objects, which Cesium
|
||||
// prints as [object Object].
|
||||
let failed = false;
|
||||
const removeErrorListener = viewer.scene?.renderError?.addEventListener(
|
||||
(_scene, error) => {
|
||||
failed = true;
|
||||
console.error(
|
||||
"[God's Eye View panel] render error:",
|
||||
describeError(error),
|
||||
);
|
||||
},
|
||||
);
|
||||
const timer = windowRef.setInterval(() => {
|
||||
if (
|
||||
failed ||
|
||||
now() - lastFrame < MISSED_FRAMES_MS ||
|
||||
viewer.isDestroyed?.()
|
||||
)
|
||||
return;
|
||||
viewer.resize();
|
||||
viewer.render();
|
||||
}, PANEL_FRAME_MS);
|
||||
return () => {
|
||||
windowRef.clearInterval(timer);
|
||||
windowRef.cancelAnimationFrame(frameRequest);
|
||||
removeErrorListener?.();
|
||||
};
|
||||
}
|
||||
|
||||
const delay = (ms, signal) =>
|
||||
new Promise((resolve) => {
|
||||
const timer = setTimeout(resolve, ms);
|
||||
signal?.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
|
||||
/** Fly the camera to a view's camera and resolve when the flight ends. */
|
||||
function flyTo(viewer, camera) {
|
||||
announceNavigationAuthority('embed-view');
|
||||
return new Promise((resolve) => {
|
||||
viewer.camera.flyTo({
|
||||
destination: Cesium.Cartesian3.fromDegrees(
|
||||
camera.lon,
|
||||
camera.lat,
|
||||
camera.altitude_m,
|
||||
),
|
||||
orientation: {
|
||||
heading: Cesium.Math.toRadians(camera.heading_deg),
|
||||
pitch: Cesium.Math.toRadians(camera.pitch_deg),
|
||||
roll: 0,
|
||||
},
|
||||
duration: FLIGHT_SECONDS,
|
||||
complete: () => resolve(true),
|
||||
cancel: () => resolve(false),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Make the app show a view: style and map, exactly the view's layers,
|
||||
* annotations, the camera, and the followed entity. Each step runs through the
|
||||
* app's own actions (`run(name, args)`); a failed step is reported and the
|
||||
* rest still run. Resolves to the steps and whether each succeeded.
|
||||
*/
|
||||
export async function applyView(
|
||||
view,
|
||||
{ viewer, dataManager, run, signal, retryMs = FOLLOW_RETRY_MS },
|
||||
) {
|
||||
const steps = [];
|
||||
const act = async (name, args) => {
|
||||
let result = null;
|
||||
try {
|
||||
result = await run(name, args);
|
||||
} catch (error) {
|
||||
result = { ok: false, error: error?.message || String(error) };
|
||||
}
|
||||
steps.push({
|
||||
step: name,
|
||||
ok: result?.ok !== false,
|
||||
...(result?.error ? { error: result.error } : {}),
|
||||
});
|
||||
return result;
|
||||
};
|
||||
if (view.style) await act('set_visual_style', { style: view.style });
|
||||
if (view.map) await act('set_map_stack', { stack: view.map });
|
||||
const wanted = new Set(view.layers);
|
||||
for (const layer of dataManager.getAll()) {
|
||||
if (layer.enabled && !wanted.has(layer.id))
|
||||
await act('set_layer_visibility', { layerId: layer.id, enabled: false });
|
||||
}
|
||||
for (const layerId of view.layers) {
|
||||
if (!dataManager.isEnabled(layerId))
|
||||
await act('set_layer_visibility', { layerId, enabled: true });
|
||||
}
|
||||
if (!view.follow) await act('stop_tracking', {});
|
||||
// Annotations go first: drawing them may frame the marks, and the view's
|
||||
// own camera should have the last word.
|
||||
await act('clear_annotations', {});
|
||||
if (view.annotations.length)
|
||||
await act('annotate_map', { annotations: [...view.annotations] });
|
||||
if (!view.follow) {
|
||||
steps.push({ step: 'camera', ok: await flyTo(viewer, view.camera) });
|
||||
return steps;
|
||||
}
|
||||
// A followed entity owns the camera, and a camera flight would end the
|
||||
// follow, so fly to the view only while the entity is not there yet. It
|
||||
// appears once its layer has data; retry until then.
|
||||
const layerId = FOLLOW_LAYERS[view.follow.kind];
|
||||
const follow = async () => {
|
||||
try {
|
||||
return (
|
||||
(await run('track_entity', { query: view.follow.id, layerId }))?.ok ===
|
||||
true
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let followed = await follow();
|
||||
if (!followed) {
|
||||
steps.push({ step: 'camera', ok: await flyTo(viewer, view.camera) });
|
||||
for (let attempt = 1; attempt < FOLLOW_ATTEMPTS && !followed; attempt++) {
|
||||
if (signal?.aborted) break;
|
||||
await delay(retryMs, signal);
|
||||
followed = await follow();
|
||||
}
|
||||
}
|
||||
steps.push({ step: 'follow', ok: followed });
|
||||
if (followed && view.follow.cockpit)
|
||||
await act('control_cockpit', { action: 'enter', targetLayer: layerId });
|
||||
return steps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Install embed mode when the page asks for it, and draw any annotations the
|
||||
* opening link carries. Returns a function that removes the message handler.
|
||||
*/
|
||||
export function installViews({
|
||||
shell,
|
||||
viewer,
|
||||
dataManager,
|
||||
run,
|
||||
signal,
|
||||
location = globalThis.location,
|
||||
windowRef = globalThis.window,
|
||||
}) {
|
||||
const ready = Promise.resolve(shell.initialRestorePromise).catch(() => {});
|
||||
const linked = annotationsFromParams(
|
||||
new URLSearchParams(String(location?.hash || '').replace(/^#/, '')),
|
||||
);
|
||||
if (linked.length)
|
||||
void ready.then(() => {
|
||||
if (!signal?.aborted) void run('annotate_map', { annotations: linked });
|
||||
});
|
||||
if (!isEmbedded(location)) return () => {};
|
||||
|
||||
windowRef.document.body.classList.add('ui-embed');
|
||||
shell.setCleanView?.(true);
|
||||
const stopRendering = isEmbeddedInline()
|
||||
? keepPanelRendering(viewer, { windowRef })
|
||||
: () => {};
|
||||
// A framing page talks to the app across frames; an inline panel shares
|
||||
// the page with it and talks through the page's own window.
|
||||
const peer = isEmbeddedInline() ? windowRef : windowRef.parent;
|
||||
// Answers go back only to the origin that asked. An inline panel posts to
|
||||
// its own window, and a sandboxed framing page has no origin to name.
|
||||
const post = (message, origin = '*') => {
|
||||
if (peer && (peer !== windowRef || isEmbeddedInline()))
|
||||
peer.postMessage(message, origin);
|
||||
};
|
||||
// Views apply one at a time, in the order they arrive.
|
||||
let queue = ready;
|
||||
const onMessage = (event) => {
|
||||
if (event.source !== peer || event.data?.type !== EMBED_VIEW_MESSAGE)
|
||||
return;
|
||||
const { id = null } = event.data;
|
||||
const replyOrigin =
|
||||
isEmbeddedInline() || !event.origin || event.origin === 'null'
|
||||
? '*'
|
||||
: event.origin;
|
||||
let view;
|
||||
try {
|
||||
view = createView(event.data.view);
|
||||
} catch (error) {
|
||||
post(
|
||||
{
|
||||
type: EMBED_APPLIED_MESSAGE,
|
||||
id,
|
||||
ok: false,
|
||||
error: error.message,
|
||||
},
|
||||
replyOrigin,
|
||||
);
|
||||
return;
|
||||
}
|
||||
queue = queue.then(async () => {
|
||||
if (signal?.aborted) return;
|
||||
const steps = await applyView(view, { viewer, dataManager, run, signal });
|
||||
post(
|
||||
{
|
||||
type: EMBED_APPLIED_MESSAGE,
|
||||
id,
|
||||
ok: steps.every((step) => step.ok),
|
||||
steps,
|
||||
},
|
||||
replyOrigin,
|
||||
);
|
||||
});
|
||||
};
|
||||
windowRef.addEventListener('message', onMessage);
|
||||
void ready.then(() => {
|
||||
if (!signal?.aborted) post({ type: EMBED_READY_MESSAGE });
|
||||
});
|
||||
return () => {
|
||||
stopRendering();
|
||||
windowRef.removeEventListener('message', onMessage);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
applyView,
|
||||
installViews,
|
||||
isEmbedded,
|
||||
keepPanelRendering,
|
||||
} from './embed.js';
|
||||
import { createView } from '../view/index.js';
|
||||
|
||||
const fakeViewer = () => {
|
||||
const flights = [];
|
||||
return {
|
||||
flights,
|
||||
camera: {
|
||||
flyTo(options) {
|
||||
flights.push(options);
|
||||
options.complete();
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
const fakeLayers = (enabled) => ({
|
||||
getAll: () =>
|
||||
['flights', 'earthquakes', 'ais-live-vessels'].map((id) => ({
|
||||
id,
|
||||
enabled: enabled.has(id),
|
||||
})),
|
||||
isEnabled: (id) => enabled.has(id),
|
||||
});
|
||||
const recorder = (answers = {}) => {
|
||||
const calls = [];
|
||||
const run = async (name, args) => {
|
||||
calls.push([name, args]);
|
||||
const answer = answers[name];
|
||||
return typeof answer === 'function'
|
||||
? answer(calls)
|
||||
: (answer ?? { ok: true });
|
||||
};
|
||||
return { calls, run };
|
||||
};
|
||||
|
||||
test('embed mode is opt-in by query parameter', () => {
|
||||
assert.equal(isEmbedded({ search: '?embed=1' }), true);
|
||||
assert.equal(isEmbedded({ search: '?welcome=0' }), false);
|
||||
});
|
||||
|
||||
test('a view is applied through the app actions in order', async () => {
|
||||
const viewer = fakeViewer();
|
||||
const { calls, run } = recorder();
|
||||
const view = createView({
|
||||
camera: { lat: 25, lon: 121, altitude_m: 300000, pitch_deg: -40 },
|
||||
layers: ['ais-live-vessels'],
|
||||
style: 'thermal',
|
||||
map: 'osm',
|
||||
annotations: [{ type: 'pin', target: 'Taipei 101' }],
|
||||
});
|
||||
const steps = await applyView(view, {
|
||||
viewer,
|
||||
dataManager: fakeLayers(new Set(['earthquakes'])),
|
||||
run,
|
||||
});
|
||||
assert.deepEqual(
|
||||
calls.map(([name, args]) => [
|
||||
name,
|
||||
args.layerId ?? args.style ?? args.stack ?? null,
|
||||
args.enabled ?? null,
|
||||
]),
|
||||
[
|
||||
['set_visual_style', 'thermal', null],
|
||||
['set_map_stack', 'osm', null],
|
||||
['set_layer_visibility', 'earthquakes', false],
|
||||
['set_layer_visibility', 'ais-live-vessels', true],
|
||||
['stop_tracking', null, null],
|
||||
['clear_annotations', null, null],
|
||||
['annotate_map', null, null],
|
||||
],
|
||||
);
|
||||
assert.equal(viewer.flights.length, 1);
|
||||
assert.ok(steps.every((step) => step.ok));
|
||||
assert.ok(steps.some((step) => step.step === 'camera'));
|
||||
});
|
||||
|
||||
test('a followed entity is retried until its layer has it', async () => {
|
||||
let tries = 0;
|
||||
const { run } = recorder({
|
||||
track_entity: () => ({ ok: ++tries >= 3 }),
|
||||
});
|
||||
const view = createView({
|
||||
camera: { lat: 0, lon: 0 },
|
||||
follow: { kind: 'aircraft', id: 'abc123' },
|
||||
});
|
||||
const steps = await applyView(view, {
|
||||
viewer: fakeViewer(),
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run,
|
||||
retryMs: 1,
|
||||
});
|
||||
assert.equal(tries, 3);
|
||||
assert.deepEqual(steps.at(-1), { step: 'follow', ok: true });
|
||||
});
|
||||
|
||||
test('an embedded page takes views only from its parent and answers it', async () => {
|
||||
const posted = [];
|
||||
const targets = [];
|
||||
const parent = {
|
||||
postMessage: (message, target) => {
|
||||
posted.push(message);
|
||||
targets.push(target);
|
||||
},
|
||||
};
|
||||
const windowRef = new EventTarget();
|
||||
windowRef.parent = parent;
|
||||
windowRef.document = { body: { classList: new Set() } };
|
||||
windowRef.document.body.classList.add = Set.prototype.add;
|
||||
const shell = {
|
||||
initialRestorePromise: Promise.resolve(),
|
||||
clean: null,
|
||||
setCleanView(value) {
|
||||
this.clean = value;
|
||||
},
|
||||
};
|
||||
const { calls, run } = recorder();
|
||||
const remove = installViews({
|
||||
shell,
|
||||
viewer: fakeViewer(),
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run,
|
||||
location: { search: '?embed=1', hash: '' },
|
||||
windowRef,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(shell.clean, true);
|
||||
assert.deepEqual(posted, [{ type: 'gev:ready' }]);
|
||||
const send = (source, data, origin = 'https://host.example') => {
|
||||
const event = new Event('message');
|
||||
Object.assign(event, { source, data, origin });
|
||||
windowRef.dispatchEvent(event);
|
||||
};
|
||||
send({}, { type: 'gev:view', id: 1, view: { camera: { lat: 1, lon: 2 } } });
|
||||
send(parent, { type: 'gev:view', id: 2, view: { camera: {} } });
|
||||
send(parent, {
|
||||
type: 'gev:view',
|
||||
id: 3,
|
||||
view: { camera: { lat: 1, lon: 2 }, layers: ['flights'] },
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
assert.deepEqual(posted[1], {
|
||||
type: 'gev:view-applied',
|
||||
id: 2,
|
||||
ok: false,
|
||||
error: 'A view needs a camera lat and lon',
|
||||
});
|
||||
assert.equal(posted[2].id, 3);
|
||||
assert.equal(posted[2].ok, true);
|
||||
assert.ok(
|
||||
calls.some(
|
||||
([name, args]) =>
|
||||
name === 'set_layer_visibility' && args.layerId === 'flights',
|
||||
),
|
||||
);
|
||||
assert.equal(posted.filter((message) => message.id === 1).length, 0);
|
||||
// Ready names no data and goes to any parent; answers go only to the
|
||||
// origin that asked.
|
||||
assert.deepEqual(targets, [
|
||||
'*',
|
||||
'https://host.example',
|
||||
'https://host.example',
|
||||
]);
|
||||
remove();
|
||||
});
|
||||
|
||||
test('an app loaded inline into a panel page talks through its own window', async () => {
|
||||
const posted = [];
|
||||
const windowRef = new EventTarget();
|
||||
windowRef.parent = windowRef;
|
||||
windowRef.postMessage = (message) => posted.push(message);
|
||||
windowRef.document = { body: { classList: new Set() } };
|
||||
windowRef.document.body.classList.add = Set.prototype.add;
|
||||
Object.assign(windowRef, {
|
||||
requestAnimationFrame: () => 1,
|
||||
cancelAnimationFrame() {},
|
||||
setInterval: () => 1,
|
||||
clearInterval() {},
|
||||
});
|
||||
globalThis.GEV_EMBED_INLINE = true;
|
||||
try {
|
||||
assert.equal(isEmbedded({ search: '' }), true);
|
||||
const remove = installViews({
|
||||
shell: { initialRestorePromise: Promise.resolve() },
|
||||
viewer: fakeViewer(),
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run: recorder().run,
|
||||
location: { search: '', hash: '' },
|
||||
windowRef,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(posted, [{ type: 'gev:ready' }]);
|
||||
const event = new Event('message');
|
||||
Object.assign(event, {
|
||||
source: windowRef,
|
||||
data: { type: 'gev:view', id: 7, view: { camera: { lat: 1, lon: 2 } } },
|
||||
});
|
||||
windowRef.dispatchEvent(event);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
assert.equal(posted[1].id, 7);
|
||||
assert.equal(posted[1].ok, true);
|
||||
remove();
|
||||
} finally {
|
||||
delete globalThis.GEV_EMBED_INLINE;
|
||||
}
|
||||
});
|
||||
|
||||
test('annotations in a link are drawn after it restores, embedded or not', async () => {
|
||||
const { calls, run } = recorder();
|
||||
const an = encodeURIComponent(
|
||||
JSON.stringify([{ type: 'pin', target: 'Austin' }]),
|
||||
);
|
||||
installViews({
|
||||
shell: { initialRestorePromise: Promise.resolve() },
|
||||
viewer: fakeViewer(),
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run,
|
||||
location: { search: '', hash: `#v=2&lat=1&lon=2&an=${an}` },
|
||||
windowRef: new EventTarget(),
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(calls, [
|
||||
['annotate_map', { annotations: [{ type: 'pin', target: 'Austin' }] }],
|
||||
]);
|
||||
});
|
||||
|
||||
test('a panel keeps drawing from a timer while animation frames stop', () => {
|
||||
let clock = 0;
|
||||
let tick = null;
|
||||
let frame = null;
|
||||
const windowRef = {
|
||||
requestAnimationFrame: (fn) => ((frame = fn), 1),
|
||||
cancelAnimationFrame: () => (frame = null),
|
||||
setInterval: (fn) => ((tick = fn), 1),
|
||||
clearInterval: () => (tick = null),
|
||||
};
|
||||
const drawn = [];
|
||||
const viewer = {
|
||||
resize: () => drawn.push('resize'),
|
||||
render: () => drawn.push('render'),
|
||||
};
|
||||
const stop = keepPanelRendering(viewer, { windowRef, now: () => clock });
|
||||
// Frames arrive: the render loop draws, so the timer does nothing.
|
||||
clock = 100;
|
||||
frame();
|
||||
tick();
|
||||
assert.deepEqual(drawn, []);
|
||||
// Frames stop, as in a panel its host reports hidden: the timer draws.
|
||||
clock = 400;
|
||||
tick();
|
||||
assert.deepEqual(drawn, ['resize', 'render']);
|
||||
stop();
|
||||
assert.equal(tick, null);
|
||||
assert.equal(frame, null);
|
||||
});
|
||||
|
||||
test('a panel reports a render error in full and stops drawing', () => {
|
||||
let listener = null;
|
||||
let tick = null;
|
||||
const windowRef = {
|
||||
requestAnimationFrame: () => 1,
|
||||
cancelAnimationFrame() {},
|
||||
setInterval: (fn) => ((tick = fn), 1),
|
||||
clearInterval() {},
|
||||
};
|
||||
const drawn = [];
|
||||
const viewer = {
|
||||
resize() {},
|
||||
render: () => drawn.push('render'),
|
||||
scene: {
|
||||
renderError: {
|
||||
addEventListener: (fn) => ((listener = fn), () => (listener = null)),
|
||||
},
|
||||
},
|
||||
};
|
||||
const logged = [];
|
||||
const original = console.error;
|
||||
console.error = (...args) => logged.push(args.join(' '));
|
||||
try {
|
||||
let clock = 0;
|
||||
const stop = keepPanelRendering(viewer, { windowRef, now: () => clock });
|
||||
listener(viewer.scene, { name: 'RuntimeError', message: 'worker failed' });
|
||||
assert.match(logged[0], /render error: .*"message":"worker failed"/);
|
||||
clock = 1000;
|
||||
tick();
|
||||
assert.deepEqual(drawn, []);
|
||||
stop();
|
||||
assert.equal(listener, null);
|
||||
} finally {
|
||||
console.error = original;
|
||||
}
|
||||
});
|
||||
|
||||
test('a followed aircraft owns the camera, and cockpit view is entered after it', async () => {
|
||||
const viewer = fakeViewer();
|
||||
const { calls, run } = recorder();
|
||||
const steps = await applyView(
|
||||
createView({
|
||||
camera: { lat: 32.7, lon: -117.2 },
|
||||
follow: { kind: 'military_aircraft', id: 'AE1234', cockpit: true },
|
||||
}),
|
||||
{ viewer, dataManager: fakeLayers(new Set()), run, retryMs: 0 },
|
||||
);
|
||||
// Following worked at once, so no camera flight ended it.
|
||||
assert.equal(viewer.flights.length, 0);
|
||||
assert.deepEqual(calls.slice(-2), [
|
||||
['track_entity', { query: 'ae1234', layerId: 'military' }],
|
||||
['control_cockpit', { action: 'enter', targetLayer: 'military' }],
|
||||
]);
|
||||
assert.ok(steps.every((step) => step.ok));
|
||||
assert.throws(
|
||||
() =>
|
||||
createView({
|
||||
camera: { lat: 0, lon: 0 },
|
||||
follow: { kind: 'satellite', id: '25544', cockpit: true },
|
||||
}),
|
||||
/no cockpit view/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,662 @@
|
||||
/**
|
||||
* The God's Eye View panel's script, run inside the panel page a host shows.
|
||||
* It is sent as source text (see src/tools/globePanel.js), so it must not use
|
||||
* imports or anything outside the function.
|
||||
*
|
||||
* The page lives on the host's site, and hosts may refuse other addresses,
|
||||
* such as a server on the user's machine. So the panel never requests the
|
||||
* app's server itself: every request for the app's own paths goes to the
|
||||
* panel's MCP server as a call to `config.toolName`, which requests the path
|
||||
* from the app's server. The app's built page, scripts, styles, images and
|
||||
* data all arrive that way; map imagery from providers loads directly.
|
||||
*/
|
||||
export function panelRuntime(config) {
|
||||
const status = document.getElementById('status');
|
||||
const open = document.getElementById('open');
|
||||
const expand = document.getElementById('expand');
|
||||
const say = (text) => {
|
||||
if (status.isConnected) status.textContent = text;
|
||||
};
|
||||
let blocked = null;
|
||||
document.addEventListener('securitypolicyviolation', (event) => {
|
||||
blocked = `${event.effectiveDirective} blocked ${event.blockedURI || 'a resource'}`;
|
||||
});
|
||||
|
||||
// JSON-RPC with the host.
|
||||
let nextId = 1;
|
||||
const pending = new Map();
|
||||
const send = (message) =>
|
||||
window.parent.postMessage({ jsonrpc: '2.0', ...message }, '*');
|
||||
const request = (method, params) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const id = nextId++;
|
||||
pending.set(id, { resolve, reject });
|
||||
send({ id, method, params });
|
||||
});
|
||||
const notify = (method, params) => send({ method, params });
|
||||
|
||||
// Requests to the app's server, through the MCP server.
|
||||
const MAX_CALLS = 6;
|
||||
let calls = 0;
|
||||
const waiting = [];
|
||||
async function callTool(args) {
|
||||
if (calls >= MAX_CALLS) await new Promise((go) => waiting.push(go));
|
||||
calls += 1;
|
||||
try {
|
||||
const result = await request('tools/call', {
|
||||
name: config.toolName,
|
||||
// The server refuses the panel's requests without its key.
|
||||
arguments: { ...args, key: config.panelKey },
|
||||
});
|
||||
if (result?.isError || !result?.structuredContent)
|
||||
throw new Error(result?.content?.[0]?.text || 'The request failed');
|
||||
return result.structuredContent;
|
||||
} finally {
|
||||
calls -= 1;
|
||||
waiting.shift()?.();
|
||||
}
|
||||
}
|
||||
const toBase64 = (bytes) => {
|
||||
let binary = '';
|
||||
for (let start = 0; start < bytes.length; start += 0x8000)
|
||||
binary += String.fromCharCode(...bytes.subarray(start, start + 0x8000));
|
||||
return btoa(binary);
|
||||
};
|
||||
const fromBase64 = (text) =>
|
||||
Uint8Array.from(atob(text), (char) => char.charCodeAt(0));
|
||||
async function bodyBytes(body) {
|
||||
if (body == null) return null;
|
||||
if (typeof body === 'string') return new TextEncoder().encode(body);
|
||||
if (body instanceof ArrayBuffer) return new Uint8Array(body);
|
||||
if (ArrayBuffer.isView(body))
|
||||
return new Uint8Array(body.buffer, body.byteOffset, body.byteLength);
|
||||
return new Uint8Array(await new Response(body).arrayBuffer());
|
||||
}
|
||||
const NULL_BODY = new Set([101, 204, 205, 304]);
|
||||
const held = new Map();
|
||||
|
||||
/** Request a path on the app's server; resolves to a Response. */
|
||||
function appFetch(path, init = {}) {
|
||||
const method = (init.method || 'GET').toUpperCase();
|
||||
// Built files never change while the panel runs, so each loads once.
|
||||
const shared = method === 'GET' && path.startsWith(config.panelBase);
|
||||
if (shared && held.has(path)) return held.get(path).then(respond);
|
||||
const loading = (async () => {
|
||||
const headers = {};
|
||||
new Headers(init.headers || {}).forEach((value, name) => {
|
||||
headers[name] = value;
|
||||
});
|
||||
const bytes = await bodyBytes(init.body);
|
||||
let part = await callTool({
|
||||
path,
|
||||
method,
|
||||
headers,
|
||||
...(bytes ? { body: toBase64(bytes) } : {}),
|
||||
});
|
||||
const parts = [fromBase64(part.body)];
|
||||
while (part.nextOffset !== undefined) {
|
||||
if (init.signal?.aborted) break;
|
||||
part = await callTool({ id: part.id, offset: part.nextOffset });
|
||||
parts.push(fromBase64(part.body));
|
||||
}
|
||||
let body = new Blob(parts);
|
||||
if (part.encoding === 'gzip')
|
||||
body = await new Response(
|
||||
body.stream().pipeThrough(new DecompressionStream('gzip')),
|
||||
).blob();
|
||||
return {
|
||||
body: await body.arrayBuffer(),
|
||||
status: part.status,
|
||||
statusText: part.statusText,
|
||||
headers: part.headers,
|
||||
};
|
||||
})();
|
||||
if (shared) {
|
||||
held.set(path, loading);
|
||||
loading.catch(() => held.delete(path));
|
||||
}
|
||||
const result = loading.then(respond);
|
||||
if (!init.signal) return result;
|
||||
return Promise.race([
|
||||
result,
|
||||
new Promise((_, reject) => {
|
||||
const abort = () =>
|
||||
reject(new DOMException('The request was aborted', 'AbortError'));
|
||||
if (init.signal.aborted) abort();
|
||||
init.signal.addEventListener('abort', abort, { once: true });
|
||||
}),
|
||||
]);
|
||||
}
|
||||
function respond({ body, status, statusText, headers }) {
|
||||
return new Response(NULL_BODY.has(status) ? null : body.slice(0), {
|
||||
status,
|
||||
statusText,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
// The app's own paths: relative ones, absolute ones on this page's site
|
||||
// (matched by scheme and host, since a host may use its own scheme), and
|
||||
// ones under the app's address in the page, an https address for code
|
||||
// that needs one (`config.appBaseUrl`).
|
||||
const page = new URL(document.baseURI);
|
||||
const appOrigin = new URL(config.appBaseUrl).origin;
|
||||
function appPath(value) {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(String(value), document.baseURI);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const own =
|
||||
url.origin === appOrigin ||
|
||||
(url.protocol === page.protocol && url.host === page.host);
|
||||
return own ? url.pathname + url.search : null;
|
||||
}
|
||||
const fileUrls = new Map();
|
||||
/**
|
||||
* A data: URL holding an app file, for elements that load by URL. Hosts
|
||||
* allow data: images where some refuse blob: ones. Files of the panel
|
||||
* build never change and are kept; others, such as live camera frames
|
||||
* that change address on every refresh, are not.
|
||||
*/
|
||||
function appFileUrl(path) {
|
||||
if (fileUrls.has(path)) return fileUrls.get(path);
|
||||
const loading = appFetch(path)
|
||||
.then((response) => response.blob())
|
||||
.then(
|
||||
(blob) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result);
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsDataURL(blob);
|
||||
}),
|
||||
);
|
||||
if (path.startsWith(config.panelBase)) {
|
||||
fileUrls.set(path, loading);
|
||||
loading.catch(() => fileUrls.delete(path));
|
||||
}
|
||||
return loading;
|
||||
}
|
||||
|
||||
function installRequests() {
|
||||
const nativeFetch = window.fetch.bind(window);
|
||||
window.fetch = async (input, init = {}) => {
|
||||
const isRequest = input instanceof Request;
|
||||
const path = appPath(isRequest ? input.url : input);
|
||||
if (path === null) return nativeFetch(input, init);
|
||||
if (!isRequest) return appFetch(path, init);
|
||||
return appFetch(path, {
|
||||
method: init.method || input.method,
|
||||
headers: init.headers || input.headers,
|
||||
body:
|
||||
init.body ??
|
||||
(['GET', 'HEAD'].includes(input.method)
|
||||
? undefined
|
||||
: await input.arrayBuffer()),
|
||||
signal: init.signal || input.signal,
|
||||
});
|
||||
};
|
||||
|
||||
// XMLHttpRequest for app paths: answered from appFetch, with the
|
||||
// instance's state replaced, and the usual events dispatched.
|
||||
const xhr = XMLHttpRequest.prototype;
|
||||
const nativeOpen = xhr.open;
|
||||
const nativeSend = xhr.send;
|
||||
const nativeHeader = xhr.setRequestHeader;
|
||||
const nativeAbort = xhr.abort;
|
||||
xhr.open = function (method, url, ...rest) {
|
||||
const path = appPath(url);
|
||||
if (path === null) {
|
||||
this.__gev = null;
|
||||
return nativeOpen.call(this, method, url, ...rest);
|
||||
}
|
||||
this.__gev = { method, path, headers: {}, controller: null };
|
||||
setState(this, { readyState: 1 });
|
||||
this.dispatchEvent(new Event('readystatechange'));
|
||||
};
|
||||
xhr.setRequestHeader = function (name, value) {
|
||||
if (!this.__gev) return nativeHeader.call(this, name, value);
|
||||
this.__gev.headers[name] = value;
|
||||
};
|
||||
xhr.abort = function () {
|
||||
if (!this.__gev) return nativeAbort.call(this);
|
||||
this.__gev.controller?.abort();
|
||||
};
|
||||
xhr.send = function (body) {
|
||||
const state = this.__gev;
|
||||
if (!state) return nativeSend.call(this, body);
|
||||
state.controller = new AbortController();
|
||||
appFetch(state.path, {
|
||||
method: state.method,
|
||||
headers: state.headers,
|
||||
body,
|
||||
signal: state.controller.signal,
|
||||
})
|
||||
.then(async (response) => {
|
||||
const type = this.responseType;
|
||||
const value =
|
||||
type === 'arraybuffer'
|
||||
? await response.arrayBuffer()
|
||||
: type === 'blob'
|
||||
? await response.blob()
|
||||
: await response.text();
|
||||
const text = typeof value === 'string' ? value : null;
|
||||
let parsed = value;
|
||||
if (type === 'json') {
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
parsed = null;
|
||||
}
|
||||
} else if (type === 'document')
|
||||
parsed = new DOMParser().parseFromString(text, 'text/html');
|
||||
const headers = [...response.headers]
|
||||
.map(([name, val]) => `${name}: ${val}`)
|
||||
.join('\r\n');
|
||||
setState(this, {
|
||||
readyState: 4,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
response: parsed,
|
||||
responseText: type === '' || type === 'text' ? text : '',
|
||||
responseURL: new URL(state.path, document.baseURI).href,
|
||||
getAllResponseHeaders: () => headers,
|
||||
getResponseHeader: (name) => response.headers.get(name),
|
||||
});
|
||||
for (const name of ['readystatechange', 'load', 'loadend'])
|
||||
this.dispatchEvent(
|
||||
name === 'readystatechange'
|
||||
? new Event(name)
|
||||
: new ProgressEvent(name),
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
setState(this, { readyState: 4, status: 0 });
|
||||
const name = error?.name === 'AbortError' ? 'abort' : 'error';
|
||||
for (const event of ['readystatechange', name, 'loadend'])
|
||||
this.dispatchEvent(
|
||||
event === 'readystatechange'
|
||||
? new Event(event)
|
||||
: new ProgressEvent(event),
|
||||
);
|
||||
});
|
||||
};
|
||||
function setState(target, values) {
|
||||
for (const [name, value] of Object.entries(values))
|
||||
Object.defineProperty(target, name, {
|
||||
configurable: true,
|
||||
...(typeof value === 'function' ? { value } : { get: () => value }),
|
||||
});
|
||||
}
|
||||
|
||||
const nativeBeacon = navigator.sendBeacon?.bind(navigator);
|
||||
if (nativeBeacon)
|
||||
navigator.sendBeacon = (url, data) => {
|
||||
const path = appPath(url);
|
||||
if (path === null) return nativeBeacon(url, data);
|
||||
appFetch(path, { method: 'POST', body: data }).catch(() => {});
|
||||
return true;
|
||||
};
|
||||
|
||||
// Images name app files by URL; give them the file as a data: URL.
|
||||
const image = Object.getOwnPropertyDescriptor(
|
||||
HTMLImageElement.prototype,
|
||||
'src',
|
||||
);
|
||||
Object.defineProperty(HTMLImageElement.prototype, 'src', {
|
||||
...image,
|
||||
set(value) {
|
||||
const path = appPath(value);
|
||||
if (path === null) return image.set.call(this, value);
|
||||
appFileUrl(path).then(
|
||||
(url) => image.set.call(this, url),
|
||||
() => this.dispatchEvent(new Event('error')),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// Workers on the app's server start from a small worker here that is
|
||||
// handed the script once it has loaded; messages wait until then.
|
||||
const NativeWorker = window.Worker;
|
||||
const BOOT =
|
||||
'const early=[];let ready=false;' +
|
||||
"addEventListener('message',e=>{if(e.data&&e.data.__gevScript){" +
|
||||
'const go=()=>{ready=true;for(const d of early)dispatchEvent(new MessageEvent("message",{data:d}))};' +
|
||||
'if(e.data.module)import(e.data.__gevScript).then(go);else{importScripts(e.data.__gevScript);go()}' +
|
||||
'e.stopImmediatePropagation();return}' +
|
||||
'if(!ready){early.push(e.data);e.stopImmediatePropagation()}});';
|
||||
window.Worker = function Worker(url, options) {
|
||||
const path = appPath(url);
|
||||
if (path === null || String(url).startsWith('blob:'))
|
||||
return new NativeWorker(url, options);
|
||||
const worker = new NativeWorker(
|
||||
URL.createObjectURL(new Blob([BOOT], { type: 'text/javascript' })),
|
||||
options,
|
||||
);
|
||||
appFetch(path)
|
||||
.then((response) => response.blob())
|
||||
.then((blob) => URL.createObjectURL(blob))
|
||||
.then((script) =>
|
||||
worker.postMessage({
|
||||
__gevScript: script,
|
||||
module: options?.type === 'module',
|
||||
}),
|
||||
);
|
||||
return worker;
|
||||
};
|
||||
window.Worker.prototype = NativeWorker.prototype;
|
||||
|
||||
// Some hosts drop the GPU contents of a panel's 2D canvases while they
|
||||
// consider the panel off screen, and the overlays then cover the globe
|
||||
// in black. Keep 2D canvases in memory instead, which hosts do not drop.
|
||||
const getContext = HTMLCanvasElement.prototype.getContext;
|
||||
HTMLCanvasElement.prototype.getContext = function (type, options) {
|
||||
return type === '2d'
|
||||
? getContext.call(this, type, { ...options, willReadFrequently: true })
|
||||
: getContext.call(this, type, options);
|
||||
};
|
||||
|
||||
// Markup the app adds as HTML text would request its images from this
|
||||
// page's site at once; hold their addresses until the files are here.
|
||||
const htmlWithHeldImages = (html) =>
|
||||
String(html).replace(
|
||||
/(<(?:img|source)\b[^>]*?\s)src=(["'])([^"']*)\2/gi,
|
||||
(tag, before, quote, value) =>
|
||||
appPath(value) === null
|
||||
? tag
|
||||
: `${before}data-gev-src=${quote}${value}${quote}`,
|
||||
);
|
||||
const fillHeldImages = (root) => {
|
||||
for (const element of root?.querySelectorAll?.('[data-gev-src]') ?? []) {
|
||||
const path = appPath(element.getAttribute('data-gev-src'));
|
||||
element.removeAttribute('data-gev-src');
|
||||
appFileUrl(path).then((url) => element.setAttribute('src', url));
|
||||
}
|
||||
};
|
||||
const innerHtml = Object.getOwnPropertyDescriptor(
|
||||
Element.prototype,
|
||||
'innerHTML',
|
||||
);
|
||||
Object.defineProperty(Element.prototype, 'innerHTML', {
|
||||
...innerHtml,
|
||||
set(value) {
|
||||
innerHtml.set.call(this, htmlWithHeldImages(value));
|
||||
fillHeldImages(this);
|
||||
},
|
||||
});
|
||||
const insertHtml = Element.prototype.insertAdjacentHTML;
|
||||
Element.prototype.insertAdjacentHTML = function (position, html) {
|
||||
insertHtml.call(this, position, htmlWithHeldImages(html));
|
||||
fillHeldImages(this.parentNode ?? this);
|
||||
};
|
||||
|
||||
// Markup and styles the app adds later name app files too.
|
||||
new MutationObserver((records) => {
|
||||
for (const record of records)
|
||||
for (const node of record.addedNodes) {
|
||||
if (node.nodeType !== 1) continue;
|
||||
if (node.nodeName === 'STYLE') void inlineStyleUrls(node);
|
||||
for (const element of [node, ...node.querySelectorAll('img[src]')]) {
|
||||
if (element.nodeName !== 'IMG') continue;
|
||||
const path = appPath(element.getAttribute('src'));
|
||||
if (path !== null)
|
||||
appFileUrl(path).then((url) => element.setAttribute('src', url));
|
||||
}
|
||||
}
|
||||
}).observe(document.documentElement, { childList: true, subtree: true });
|
||||
}
|
||||
|
||||
/** Replace url() references to app files in CSS with data: URLs. */
|
||||
async function cssWithFiles(text, baseUrl) {
|
||||
const found = new Map();
|
||||
for (const match of text.matchAll(/url\(\s*(['"]?)([^'")]+)\1\s*\)/g)) {
|
||||
const reference = match[2];
|
||||
if (/^(data|blob):/.test(reference)) continue;
|
||||
const path = appPath(new URL(reference, baseUrl).href);
|
||||
if (path !== null && !found.has(reference))
|
||||
found.set(
|
||||
reference,
|
||||
appFileUrl(path).catch(() => null),
|
||||
);
|
||||
}
|
||||
let result = text;
|
||||
for (const [reference, loading] of found) {
|
||||
const url = await loading;
|
||||
if (url) result = result.split(reference).join(url);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
async function inlineStyleUrls(style) {
|
||||
if (!/url\(/.test(style.textContent)) return;
|
||||
style.textContent = await cssWithFiles(style.textContent, document.baseURI);
|
||||
}
|
||||
|
||||
async function addStylesheet(href) {
|
||||
const path = appPath(href);
|
||||
const style = document.createElement('style');
|
||||
document.head.appendChild(style);
|
||||
if (path === null) {
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = href;
|
||||
style.replaceWith(link);
|
||||
return;
|
||||
}
|
||||
const text = await (await appFetch(path)).text();
|
||||
style.textContent = await cssWithFiles(
|
||||
text,
|
||||
new URL(path, document.baseURI).href,
|
||||
);
|
||||
}
|
||||
|
||||
function runScript(text, type) {
|
||||
const script = document.createElement('script');
|
||||
if (type) script.type = type;
|
||||
script.textContent = text;
|
||||
document.body.appendChild(script);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load God's Eye View into this page from the app's panel build, in
|
||||
* inline embed mode, starting at the first view as its link would.
|
||||
*/
|
||||
async function startApp(url, view) {
|
||||
window.GEV_EMBED_INLINE = true;
|
||||
window.GEV_APP_BASE_URL = config.appBaseUrl;
|
||||
try {
|
||||
history.replaceState(
|
||||
null,
|
||||
'',
|
||||
location.href.split('#')[0] + new URL(url).hash,
|
||||
);
|
||||
// The app restores this view from its link, following included, so
|
||||
// it needs applying again only for what links cannot carry: cockpit.
|
||||
if (queued === view && !view.follow?.cockpit) queued = null;
|
||||
} catch {
|
||||
// The view still arrives once the app is ready.
|
||||
}
|
||||
installRequests();
|
||||
const response = await appFetch(config.panelBase);
|
||||
if (!response.ok)
|
||||
throw new Error(
|
||||
response.status === 404
|
||||
? 'the app has no panel build (npm run build:panel)'
|
||||
: `the app answered ${response.status}`,
|
||||
);
|
||||
const built = new DOMParser().parseFromString(
|
||||
await response.text(),
|
||||
'text/html',
|
||||
);
|
||||
window.CESIUM_BASE_URL = `${config.panelBase}cesium/`;
|
||||
const [prelude] = await Promise.all([
|
||||
appFetch(config.panelBase + config.workerPreludePath).then((r) =>
|
||||
r.text(),
|
||||
),
|
||||
...[...built.head.querySelectorAll('link[rel="stylesheet"]')].map(
|
||||
(link) => addStylesheet(link.getAttribute('href')),
|
||||
),
|
||||
]);
|
||||
// Cesium's script sets CESIUM_WORKERS, its workers as one script it
|
||||
// starts from memory. Workers request files themselves, which this page
|
||||
// cannot answer, so the prelude, holding those files, runs ahead of it.
|
||||
let workers;
|
||||
Object.defineProperty(window, 'CESIUM_WORKERS', {
|
||||
configurable: true,
|
||||
get: () => workers,
|
||||
set: (script) => {
|
||||
workers = prelude + script;
|
||||
},
|
||||
});
|
||||
// Images in the markup load their files once added; give them the
|
||||
// files instead of letting them request this page's site.
|
||||
const images = [];
|
||||
for (const image of built.body.querySelectorAll('img[src]')) {
|
||||
const path = appPath(image.getAttribute('src'));
|
||||
if (path === null) continue;
|
||||
image.removeAttribute('src');
|
||||
images.push([image, path]);
|
||||
}
|
||||
for (const node of [...built.body.childNodes]) {
|
||||
if (node.nodeName !== 'SCRIPT')
|
||||
document.body.insertBefore(document.adoptNode(node), status);
|
||||
}
|
||||
for (const [image, path] of images)
|
||||
appFileUrl(path).then((url) => image.setAttribute('src', url));
|
||||
const scripts = await Promise.all(
|
||||
[...built.querySelectorAll('script')].map(async (script) => ({
|
||||
type: script.type,
|
||||
text: script.getAttribute('src')
|
||||
? await (await appFetch(appPath(script.getAttribute('src')))).text()
|
||||
: script.textContent,
|
||||
})),
|
||||
);
|
||||
for (const { type, text } of scripts) runScript(text, type);
|
||||
// Browsers cap live 3D contexts across pages and take one back when
|
||||
// there are too many; say so rather than show no map.
|
||||
document.addEventListener(
|
||||
'webglcontextlost',
|
||||
() => {
|
||||
const notice = document.createElement('div');
|
||||
notice.id = 'status';
|
||||
notice.textContent =
|
||||
"This panel lost its 3D graphics, likely because other God's Eye " +
|
||||
"View panels in this conversation hold them. Use Open in God's " +
|
||||
'Eye View above, or show it in a new conversation.';
|
||||
document.body.appendChild(notice);
|
||||
},
|
||||
{ capture: true, once: true },
|
||||
);
|
||||
}
|
||||
|
||||
// Tool results: the first view starts the app, later ones move it.
|
||||
let started = false;
|
||||
let ready = false;
|
||||
let queued = null;
|
||||
let currentUrl = null;
|
||||
const postView = (view) =>
|
||||
window.postMessage({ type: 'gev:view', id: nextId++, view }, '*');
|
||||
function show(result) {
|
||||
const data = result?.structuredContent;
|
||||
const view = data?.view;
|
||||
const url = data?.url || view?.url;
|
||||
if (!view || !url) return;
|
||||
currentUrl = url;
|
||||
open.hidden = false;
|
||||
if (ready) return postView(view);
|
||||
queued = view;
|
||||
if (started) return;
|
||||
started = true;
|
||||
say("Loading God's Eye View…");
|
||||
startApp(url, view).catch((error) =>
|
||||
say(`God's Eye View could not load here: ${error?.message || error}.`),
|
||||
);
|
||||
setTimeout(() => {
|
||||
if (!ready)
|
||||
say(
|
||||
"God's Eye View did not load here" +
|
||||
(blocked ? ` (${blocked})` : '') +
|
||||
". Use Open in God's Eye View above.",
|
||||
);
|
||||
}, config.loadTimeoutMs);
|
||||
}
|
||||
|
||||
window.addEventListener('message', (event) => {
|
||||
const message = event.data;
|
||||
if (event.source === window) {
|
||||
if (message?.type === 'gev:ready') {
|
||||
ready = true;
|
||||
status.remove();
|
||||
if (queued) postView(queued);
|
||||
queued = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.source !== window.parent || message?.jsonrpc !== '2.0') return;
|
||||
if (
|
||||
message.id !== undefined &&
|
||||
pending.has(message.id) &&
|
||||
!message.method
|
||||
) {
|
||||
const { resolve, reject } = pending.get(message.id);
|
||||
pending.delete(message.id);
|
||||
if (message.error) reject(message.error);
|
||||
else resolve(message.result);
|
||||
return;
|
||||
}
|
||||
if (message.method === 'ui/notifications/tool-result') show(message.params);
|
||||
if (message.method === 'ui/notifications/host-context-changed')
|
||||
showDisplayMode(message.params);
|
||||
});
|
||||
|
||||
// Fullscreen, where the host offers it, gives the globe the room.
|
||||
let displayMode = 'inline';
|
||||
const showDisplayMode = (context) => {
|
||||
if (context?.displayMode) displayMode = context.displayMode;
|
||||
if (context?.availableDisplayModes)
|
||||
expand.hidden = !context.availableDisplayModes.includes('fullscreen');
|
||||
expand.textContent = displayMode === 'fullscreen' ? 'Collapse' : 'Expand';
|
||||
};
|
||||
expand.addEventListener('click', () => {
|
||||
request('ui/request-display-mode', {
|
||||
mode: displayMode === 'fullscreen' ? 'inline' : 'fullscreen',
|
||||
}).then(
|
||||
(result) => showDisplayMode({ displayMode: result?.mode }),
|
||||
() => {},
|
||||
);
|
||||
});
|
||||
|
||||
open.addEventListener('click', () => {
|
||||
if (!currentUrl) return;
|
||||
request('ui/open-link', { url: currentUrl }).catch(() =>
|
||||
window.open(currentUrl, '_blank', 'noopener'),
|
||||
);
|
||||
});
|
||||
|
||||
// The handshake the MCP Apps SDK's App.connect performs: hosts validate
|
||||
// these parameters, and keep a view hidden until it is initialized.
|
||||
request('ui/initialize', {
|
||||
appCapabilities: { availableDisplayModes: ['inline', 'fullscreen'] },
|
||||
appInfo: {
|
||||
name: 'gods-eye-view',
|
||||
title: "God's Eye View",
|
||||
version: '1.0.0',
|
||||
},
|
||||
protocolVersion: config.protocolVersion,
|
||||
}).then(
|
||||
(result) => {
|
||||
showDisplayMode(result?.hostContext);
|
||||
send({ method: 'ui/notifications/initialized' });
|
||||
notify('ui/notifications/size-changed', {
|
||||
width: document.body.clientWidth,
|
||||
height: config.panelHeight,
|
||||
});
|
||||
},
|
||||
(error) =>
|
||||
say(
|
||||
"This client did not accept the God's Eye View panel" +
|
||||
(error?.message ? `: ${error.message}` : '.'),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createAirQualityLayer } from '../../layers/airQuality/index.js';
|
||||
import { overlayHost } from './overlayHost.js';
|
||||
/** Wire air-quality readings to the application overlay host. */
|
||||
export function createApplicationAirQuality(options) {
|
||||
return createAirQualityLayer({ overlayHost, ...options });
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import * as credits from '../../data/dataCredits.js';
|
||||
import { createAlprCamerasLayer } from '../../layers/alpr/index.js';
|
||||
import * as render from '../../renderGovernor.js';
|
||||
import * as context from '../../data/contextStore.js';
|
||||
@@ -12,6 +13,7 @@ export function createApplicationAlpr({ surface, source }) {
|
||||
return createAlprCamerasLayer({
|
||||
source,
|
||||
services: {
|
||||
credits,
|
||||
render,
|
||||
context,
|
||||
picking,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createCctvLayer } from '../../layers/cctv/index.js';
|
||||
import * as credits from '../../data/dataCredits.js';
|
||||
import * as sprites from '../../data/spriteOrder.js';
|
||||
import * as activation from '../../cctvFocusRequest.js';
|
||||
import * as overlays from '../../overlays/worldOverlay.js';
|
||||
@@ -13,6 +14,7 @@ export function createApplicationCctv({ surface, source }) {
|
||||
return createCctvLayer({
|
||||
source,
|
||||
services: {
|
||||
credits,
|
||||
sprites,
|
||||
activation,
|
||||
overlays,
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import { createApplicationDirectionsLayer } from '../../data/directions.js';
|
||||
|
||||
/**
|
||||
* Construct one Directions layer using the application scene owners.
|
||||
*
|
||||
* It takes no data source: a route is fetched from `/api/route` when the
|
||||
* operator places both endpoints, so there is nothing to supply up front.
|
||||
* @returns {object} A fresh layer instance for this catalog.
|
||||
*/
|
||||
export function createApplicationDirections() {
|
||||
return createApplicationDirectionsLayer();
|
||||
/** Construct Directions with application scene owners and an explicit route source. */
|
||||
export function createApplicationDirections({ source }) {
|
||||
return createApplicationDirectionsLayer({ source });
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createGnssIntegrityLayer } from '../../layers/gnss/index.js';
|
||||
|
||||
/** Wire the adsb.lol GNSS navigation-integrity layer into the application catalog. */
|
||||
export function createApplicationGnssIntegrity(options) {
|
||||
return createGnssIntegrityLayer(options);
|
||||
}
|
||||
@@ -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 },
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { overlayHost } from './overlayHost.js';
|
||||
import * as credits from '../../data/dataCredits.js';
|
||||
import { createInstallationsLayer } from '../../layers/installations/index.js';
|
||||
import * as render from '../../renderGovernor.js';
|
||||
import * as context from '../../data/contextStore.js';
|
||||
@@ -8,6 +10,27 @@ export function createApplicationInstallations({ surface, source }) {
|
||||
const { groundFloor: ground, anchors } = surface;
|
||||
return createInstallationsLayer({
|
||||
source,
|
||||
services: { render, context, ground, anchors, picking },
|
||||
overlayHost,
|
||||
services: {
|
||||
credits,
|
||||
render,
|
||||
context,
|
||||
ground,
|
||||
anchors,
|
||||
picking,
|
||||
maps: {
|
||||
subscribeMapStack(callback) {
|
||||
globalThis.window?.addEventListener?.(
|
||||
'gev:map-stack-changed',
|
||||
callback,
|
||||
);
|
||||
return () =>
|
||||
globalThis.window?.removeEventListener?.(
|
||||
'gev:map-stack-changed',
|
||||
callback,
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import {
|
||||
clearOverlaySource,
|
||||
getWorldOverlayDiagnostics,
|
||||
setOverlayEntries,
|
||||
setOverlaySourceVisible,
|
||||
} from '../../overlays/worldOverlay.js';
|
||||
export const overlayHost = Object.freeze({
|
||||
clearSource: clearOverlaySource,
|
||||
getDiagnostics: getWorldOverlayDiagnostics,
|
||||
setEntries: setOverlayEntries,
|
||||
setVisible: setOverlaySourceVisible,
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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 imagery presentation over one explicitly supplied acquisition source. */
|
||||
export function createApplicationRecentImagery({ source }) {
|
||||
return createRecentImageryLayer({
|
||||
catalog: { searchHls: (...args) => source.searchHls(...args) },
|
||||
renderer: createRecentImageryRenderer({
|
||||
requestRender: governorRequestRender,
|
||||
tileTemplate: (...args) => source.getTileTemplate(...args),
|
||||
credit: source.credit || source.label || 'Imagery',
|
||||
}),
|
||||
thumbnails: createThumbnailLoader({
|
||||
requestThumbnail: (...args) => source.getThumbnail(...args),
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { createStreetLevelLayer } from '../../layers/streetLevel/index.js';
|
||||
import { createMapillaryProvider } from '../../layers/streetLevel/providers/mapillary/index.js';
|
||||
import { createGoogleProvider } from '../../layers/streetLevel/providers/google/index.js';
|
||||
import { createKartaViewProvider } from '../../layers/streetLevel/providers/kartaview/index.js';
|
||||
import { createPanoramaxProvider } from '../../layers/streetLevel/providers/panoramax/index.js';
|
||||
import * as sprites from '../../data/spriteOrder.js';
|
||||
import * as picking from '../../data/pickRegistry.js';
|
||||
import * as input from '../../data/inputOwnership.js';
|
||||
import * as scenePick from '../../data/scenePick.js';
|
||||
import * as render from '../../renderGovernor.js';
|
||||
|
||||
/** The browser Maps key the scene was started with (src/app/scene.js). */
|
||||
const googleApiKey = () => globalThis.window?.__GOOGLE_MAPS_API_KEY__ || null;
|
||||
|
||||
/**
|
||||
* Mapillary, Google Street View, KartaView and Panoramax, once the Mapillary
|
||||
* source is supplied; else none.
|
||||
*/
|
||||
function defaultProviders(sources) {
|
||||
if (sources.mapillary == null) return [];
|
||||
return [
|
||||
createMapillaryProvider({ source: sources.mapillary }),
|
||||
createGoogleProvider({ getApiKey: googleApiKey }),
|
||||
createKartaViewProvider(),
|
||||
createPanoramaxProvider(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject providers, or use the defaults. An explicit provider list replaces
|
||||
* them; an unused sources.mapillary is neither constructed nor validated,
|
||||
* including when the list is empty.
|
||||
*/
|
||||
export function createApplicationStreetLevel({
|
||||
surface,
|
||||
sources = {},
|
||||
providers = defaultProviders(sources),
|
||||
}) {
|
||||
return createStreetLevelLayer({
|
||||
providers,
|
||||
services: {
|
||||
sprites,
|
||||
picking,
|
||||
input,
|
||||
scenePick,
|
||||
render,
|
||||
ground: surface?.groundFloor ?? null,
|
||||
meshFloor: surface?.meshFloor ?? null,
|
||||
terrain: surface?.terrain ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createApplicationStreetLevel } from './streetLevel.js';
|
||||
import { fakeStreetLevelProvider } from '../../testSupport/streetLevelFakes.mjs';
|
||||
|
||||
test('explicit providers replace the default source and an empty list removes it', () => {
|
||||
const sources = { mapillary: {} };
|
||||
const provider = fakeStreetLevelProvider({
|
||||
id: 'example',
|
||||
pickPrefix: 'example:',
|
||||
});
|
||||
const layer = createApplicationStreetLevel({
|
||||
sources,
|
||||
providers: [provider],
|
||||
});
|
||||
assert.deepEqual(layer.providerIds, ['example']);
|
||||
assert.deepEqual(
|
||||
createApplicationStreetLevel({ sources, providers: [] }).providerIds,
|
||||
[],
|
||||
);
|
||||
assert.throws(
|
||||
() => createApplicationStreetLevel({ sources }),
|
||||
/Mapillary source/,
|
||||
);
|
||||
assert.throws(
|
||||
() => createApplicationStreetLevel({ providers: null }),
|
||||
/array/,
|
||||
);
|
||||
});
|
||||
|
||||
test('the defaults are Mapillary (given its source), Street View, KartaView and Panoramax, which start off and travel as provider switches', async () => {
|
||||
const { createDefaultLayerState } = await import('../../data/layerState.js');
|
||||
const mapillary = {
|
||||
getStatus() {},
|
||||
getTile() {},
|
||||
getSequenceImages() {},
|
||||
nearestImages() {},
|
||||
};
|
||||
const layer = createApplicationStreetLevel({ sources: { mapillary } });
|
||||
assert.deepEqual(layer.providerIds, [
|
||||
'mapillary',
|
||||
'google',
|
||||
'kartaview',
|
||||
'panoramax',
|
||||
]);
|
||||
// A fresh page and a restored default agree: Street View is billed, so off.
|
||||
layer.setParams(createDefaultLayerState().options['street-level']);
|
||||
assert.equal(layer.getParams().google, false);
|
||||
assert.equal(layer.getParams().mapillary, true);
|
||||
// A link that lit Street View carries it in the shared switch field.
|
||||
layer.setParams({ providerSwitches: 'google-1' });
|
||||
assert.equal(layer.getParams().google, true);
|
||||
assert.equal(
|
||||
layer.getParams().providerSwitches,
|
||||
'google-1*kartaview-0*panoramax-0',
|
||||
);
|
||||
// KartaView is keyless and free, but one source shows at a time.
|
||||
layer.setParams({ providerSwitches: 'kartaview-1' });
|
||||
assert.equal(layer.getParams().kartaview, true);
|
||||
assert.equal(layer.getParams().google, false);
|
||||
assert.equal(layer.getParams().mapillary, false);
|
||||
// As is Panoramax: lighting it puts KartaView out.
|
||||
layer.setParams({ providerSwitches: 'panoramax-1' });
|
||||
assert.equal(layer.getParams().panoramax, true);
|
||||
assert.equal(layer.getParams().kartaview, false);
|
||||
assert.equal(layer.getParams().mapillary, false);
|
||||
// The form the app writes (the README and CHANGELOG quote it).
|
||||
assert.equal(
|
||||
layer.getParams().providerSwitches,
|
||||
'google-0*kartaview-0*panoramax-1',
|
||||
);
|
||||
// Without the application's Street Level source there are none.
|
||||
assert.deepEqual(createApplicationStreetLevel({}).providerIds, []);
|
||||
});
|
||||
@@ -1,11 +1,12 @@
|
||||
import { createTrafficLayer } from '../../layers/traffic/index.js';
|
||||
import * as credits from '../../data/dataCredits.js';
|
||||
import * as ground from '../../data/groundFloor.js';
|
||||
import * as render from '../../renderGovernor.js';
|
||||
|
||||
/** Construct one layer using the application scene owners and a supplied source. */
|
||||
export function createApplicationTraffic({ source }) {
|
||||
export function createApplicationTraffic({ source, surface }) {
|
||||
return createTrafficLayer({
|
||||
source,
|
||||
services: { credits, render },
|
||||
services: { credits, render, ground: surface?.groundFloor || ground },
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { showOsmCredit, hideOsmCredit } from '../data/dataCredits.js';
|
||||
import { governorRequestRender } from '../renderGovernor.js';
|
||||
import {
|
||||
clearSelectedEntityContextForLayer,
|
||||
@@ -22,4 +23,6 @@ export const localGeoJsonServices = Object.freeze({
|
||||
removeEntityContextsForLayer,
|
||||
selectEntityContext,
|
||||
governorRequestRender,
|
||||
showOsmCredit,
|
||||
hideOsmCredit,
|
||||
});
|
||||
@@ -39,6 +39,7 @@ export function createApplicationOperations({ requests, signal, eventTarget }) {
|
||||
searchAndFlyTo(viewer, query, {
|
||||
...options,
|
||||
features,
|
||||
ground: surface.groundFloor,
|
||||
signal:
|
||||
signal && options.signal
|
||||
? AbortSignal.any([signal, options.signal])
|
||||
|
||||
+8
-2
@@ -1,6 +1,9 @@
|
||||
import { createApplicationOperations } from './operations.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createApplicationViewer } from '../app/viewer.js';
|
||||
import {
|
||||
createApplicationViewer,
|
||||
installTrackpadPinchZoom,
|
||||
} from '../app/viewer.js';
|
||||
import { registerDataCredits } from '../data/dataCredits.js';
|
||||
import { configureCreditKeyboardAccess } from '../creditKeyboard.js';
|
||||
import { MapStackController } from '../mapStackController.js';
|
||||
@@ -16,6 +19,7 @@ import { describeError } from './errors.js';
|
||||
export async function createApplicationScene({
|
||||
requestServices,
|
||||
googleApiKey,
|
||||
googleTokens = null,
|
||||
cesiumToken,
|
||||
credits,
|
||||
MapController = MapStackController,
|
||||
@@ -52,14 +56,16 @@ export async function createApplicationScene({
|
||||
uninstallRenderGovernor(viewer);
|
||||
if (!viewer.isDestroyed()) viewer.destroy();
|
||||
});
|
||||
defer(installTrackpadPinchZoom(viewer));
|
||||
registerDataCredits(viewer, credits);
|
||||
configureCreditKeyboardAccess(document);
|
||||
loaderStatus.textContent =
|
||||
googleApiKey || cesiumToken
|
||||
googleApiKey || googleTokens || cesiumToken
|
||||
? 'Loading Google 3D Tiles...'
|
||||
: 'Loading the keyless globe...';
|
||||
const photoreal = await loadPhotorealisticTileset(Cesium, {
|
||||
googleApiKey,
|
||||
googleTokens,
|
||||
cesiumToken,
|
||||
});
|
||||
const tileset = photoreal.tileset;
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
const contract = (layers, methods) =>
|
||||
Object.freeze({
|
||||
layers: Object.freeze(layers),
|
||||
methods: Object.freeze(methods),
|
||||
});
|
||||
|
||||
/** Acquisition contracts and their consumers; records remain family-specific. */
|
||||
export const CATALOG_SOURCE_CONTRACTS = Object.freeze({
|
||||
directions: contract(['directions'], ['getRoute']),
|
||||
'recent-imagery': contract(
|
||||
['recent-imagery'],
|
||||
['searchHls', 'getThumbnail', 'getTileTemplate'],
|
||||
),
|
||||
flights: contract(['flights'], ['getSnapshot']),
|
||||
military: contract(['military'], ['getSnapshot']),
|
||||
vessels: contract(['ais-live-vessels'], ['getSnapshot']),
|
||||
cctv: contract(
|
||||
['cctv'],
|
||||
['getCatalog', 'getHealth', 'getFrameUrl', 'getMediaUrl'],
|
||||
),
|
||||
radio: contract(['radio'], ['getDirectory', 'recordClick']),
|
||||
traffic: contract(
|
||||
['traffic'],
|
||||
[
|
||||
'requestRoads',
|
||||
'getStatus',
|
||||
'fetchFlowForBounds',
|
||||
'getFlowSessionStats',
|
||||
'resetFlowTileCache',
|
||||
],
|
||||
),
|
||||
bikeshare: contract(['bikeshare'], ['getStations']),
|
||||
installations: contract(
|
||||
['military-installations'],
|
||||
['getMappedSites', 'searchNearby'],
|
||||
),
|
||||
satellites: contract(['satellites'], ['readGroup']),
|
||||
launches: contract(['rocket-launches'], ['getLaunches', 'getActiveTle']),
|
||||
alpr: contract(['alpr-cameras'], ['fetch']),
|
||||
firms: contract(['local-firms'], ['getSnapshot']),
|
||||
wind: contract(['wind'], ['getSnapshot']),
|
||||
weather: contract(
|
||||
['weather-radar', 'weather-satellite', 'weather-lightning'],
|
||||
['getSnapshot'],
|
||||
),
|
||||
cyclones: contract(['weather-cyclones'], ['getSnapshot']),
|
||||
earthquakes: contract(['earthquakes'], ['getSnapshot']),
|
||||
'fire-perimeters': contract(['fire-perimeters'], ['getSnapshot']),
|
||||
'air-quality': contract(['air-quality'], ['getSnapshot']),
|
||||
'gnss-integrity': contract(['gnss-integrity'], ['getSnapshot']),
|
||||
cables: contract(['telegeography-submarine-cables'], ['fetch']),
|
||||
transit: contract(['transit'], ['requestSnapshot', 'getHistory']),
|
||||
});
|
||||
|
||||
/** An omitted provider is unavailable, never an authoritative empty dataset. */
|
||||
export class SourceUnavailableError extends Error {
|
||||
constructor(name) {
|
||||
super(`Data source not configured: ${name}`);
|
||||
this.name = 'SourceUnavailableError';
|
||||
this.code = 'unavailable';
|
||||
this.unavailable = true;
|
||||
this.retryable = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate supplied contracts and keep omitted sources inert during construction. */
|
||||
export function resolveCatalogSources(supplied = {}) {
|
||||
if (!supplied || typeof supplied !== 'object' || Array.isArray(supplied))
|
||||
throw new TypeError('Catalog sources must be an object');
|
||||
const sources = { ...supplied };
|
||||
const missing = new Set();
|
||||
for (const [name, { methods }] of Object.entries(CATALOG_SOURCE_CONTRACTS)) {
|
||||
const source = sources[name];
|
||||
if (source == null) {
|
||||
missing.add(name);
|
||||
const unavailable = () => {
|
||||
throw new SourceUnavailableError(name);
|
||||
};
|
||||
sources[name] = Object.freeze({
|
||||
label: name,
|
||||
...Object.fromEntries(methods.map((method) => [method, unavailable])),
|
||||
});
|
||||
} else if (methods.some((method) => typeof source[method] !== 'function')) {
|
||||
throw new TypeError(`Invalid catalog source: ${name}`);
|
||||
}
|
||||
}
|
||||
return {
|
||||
sources,
|
||||
isConfigured: (name) => !missing.has(name),
|
||||
createAvailabilityLookup(layers) {
|
||||
const byId = new Map(layers.map((layer) => [layer.id, layer]));
|
||||
const availability = new Map();
|
||||
for (const [name, { layers: ids }] of Object.entries(
|
||||
CATALOG_SOURCE_CONTRACTS,
|
||||
)) {
|
||||
for (const id of ids) {
|
||||
const layer = byId.get(id);
|
||||
if (!layer) throw new Error(`Missing source consumer: ${id}`);
|
||||
availability.set(
|
||||
id,
|
||||
Object.freeze({
|
||||
available: !missing.has(name),
|
||||
source: name,
|
||||
reason: missing.has(name)
|
||||
? `${layer.name || id}: data source not configured`
|
||||
: null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
return (id) =>
|
||||
availability.get(id) ?? byId.get(id)?.getSourceAvailability?.();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
resolveCatalogSources,
|
||||
CATALOG_SOURCE_CONTRACTS,
|
||||
} from './sourceComposition.js';
|
||||
import { createApplicationCatalog } from './constructCatalog.js';
|
||||
import { createSurfaceServices } from './surfaceServices.js';
|
||||
import { createStandaloneLayerSources } from '../standalone/layerSources.js';
|
||||
import { LayerLifecycle } from '../data/lifecycle.js';
|
||||
|
||||
function catalog(sources, t) {
|
||||
const lifetime = new AbortController();
|
||||
t.after(() => lifetime.abort());
|
||||
return createApplicationCatalog({
|
||||
sources,
|
||||
signal: lifetime.signal,
|
||||
surface: createSurfaceServices({
|
||||
terrainSource: { getHeights: async () => [] },
|
||||
signal: lifetime.signal,
|
||||
eventTarget: null,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
test('every acquisition source can be omitted without losing catalog membership', (t) => {
|
||||
const baseline = catalog(createStandaloneLayerSources(), t);
|
||||
for (const name of Object.keys(CATALOG_SOURCE_CONTRACTS)) {
|
||||
const sources = createStandaloneLayerSources({ [name]: null });
|
||||
const actual = catalog(sources, t);
|
||||
assert.deepEqual(actual.metadata, baseline.metadata, name);
|
||||
for (const id of CATALOG_SOURCE_CONTRACTS[name].layers) {
|
||||
assert.equal(actual.getSourceAvailability(id).available, false, id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('all acquisition sources may be absent, and enable fails before initializing or fetching', async (t) => {
|
||||
const actual = catalog({}, t);
|
||||
assert.equal(actual.layers.length, 32);
|
||||
const manager = new LayerLifecycle(
|
||||
{},
|
||||
{ getSourceAvailability: actual.getSourceAvailability },
|
||||
);
|
||||
for (const layer of actual.layers) manager.register(layer);
|
||||
for (const { layers: ids } of Object.values(CATALOG_SOURCE_CONTRACTS)) {
|
||||
for (const id of ids) {
|
||||
assert.equal(await manager.setEnabled(id, true), false, id);
|
||||
assert.equal(manager.layers.get(id).initialized, false, id);
|
||||
const row = manager.getAll().find((entry) => entry.id === id);
|
||||
assert.equal(row.enabled, false);
|
||||
assert.equal(row.stats.status, 'unavailable');
|
||||
assert.match(row.stats.error, /data source not configured/);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('configured sources retain identity; invalid implementations never become unavailable silently', () => {
|
||||
const source = { getSnapshot: async () => [] };
|
||||
assert.equal(
|
||||
resolveCatalogSources({ earthquakes: source }).sources.earthquakes,
|
||||
source,
|
||||
);
|
||||
for (const name of Object.keys(CATALOG_SOURCE_CONTRACTS)) {
|
||||
assert.throws(
|
||||
() => resolveCatalogSources({ [name]: {} }),
|
||||
/Invalid catalog source/,
|
||||
);
|
||||
}
|
||||
const missing = resolveCatalogSources({}).sources.earthquakes;
|
||||
assert.throws(() => missing.getSnapshot(), { code: 'unavailable' });
|
||||
});
|
||||
|
||||
test('standalone defaults allow exact replacements and explicit removal, and reject typos', () => {
|
||||
const source = { getSnapshot: async () => [] };
|
||||
const sources = createStandaloneLayerSources({
|
||||
flights: source,
|
||||
military: null,
|
||||
});
|
||||
assert.equal(sources.flights, source);
|
||||
assert.equal(sources.military, null);
|
||||
assert.equal(typeof sources.cctv.getCatalog, 'function');
|
||||
assert.throws(
|
||||
() => createStandaloneLayerSources({ fligts: source }),
|
||||
/Unknown source/,
|
||||
);
|
||||
});
|
||||
|
||||
test('empty Street Level uses the shared unavailable lifecycle without initialization', async (t) => {
|
||||
const actual = catalog({}, t);
|
||||
const manager = new LayerLifecycle(
|
||||
{},
|
||||
{ getSourceAvailability: actual.getSourceAvailability },
|
||||
);
|
||||
manager.register(actual.get('street-level'));
|
||||
assert.equal(await manager.setEnabled('street-level', true), false);
|
||||
assert.equal(manager.layers.get('street-level').initialized, false);
|
||||
const row = manager.getAll()[0];
|
||||
assert.equal(row.stats.status, 'unavailable');
|
||||
assert.equal(row.stats.error, 'No street-level imagery providers configured');
|
||||
assert.equal(await manager.setEnabled('street-level', false), true);
|
||||
const configured = catalog(createStandaloneLayerSources(), t);
|
||||
assert.equal(
|
||||
configured.get('street-level').getSourceAvailability().available,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('availability lookup accepts frozen layers and uses their display names', () => {
|
||||
const composition = resolveCatalogSources({});
|
||||
const layers = Object.values(CATALOG_SOURCE_CONTRACTS)
|
||||
.flatMap(({ layers }) => layers)
|
||||
.map((id) =>
|
||||
Object.freeze({
|
||||
id,
|
||||
name: id === 'fire-perimeters' ? 'Fire perimeters' : id,
|
||||
}),
|
||||
);
|
||||
const lookup = composition.createAvailabilityLookup(layers);
|
||||
assert.equal(
|
||||
lookup('fire-perimeters').reason,
|
||||
'Fire perimeters: data source not configured',
|
||||
);
|
||||
assert.equal(Object.hasOwn(layers[0], 'getSourceAvailability'), false);
|
||||
const manager = new LayerLifecycle({}, { getSourceAvailability: lookup });
|
||||
manager.register(layers.find(({ id }) => id === 'fire-perimeters'));
|
||||
assert.equal(
|
||||
manager.getAll()[0].stats.error,
|
||||
'Fire perimeters: data source not configured',
|
||||
);
|
||||
});
|
||||
|
||||
test('absent military acquisition does not poll, suppress flights, or erase supplied classification', async (t) => {
|
||||
let fetches = 0;
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
fetches++;
|
||||
throw new Error('Unexpected fallback acquisition');
|
||||
});
|
||||
const actual = catalog(createStandaloneLayerSources({ military: null }), t);
|
||||
const registry = actual.militaryRegistry;
|
||||
assert.equal(actual.getSourceAvailability('flights').available, true);
|
||||
assert.equal(registry.isMilitaryLayerActive(), false);
|
||||
assert.equal(registry.isMilitaryIcao('abc123'), false);
|
||||
registry.registerMilitaryIcaos([' ABC123 ']);
|
||||
await registry.refreshMilitaryRegistryIfStale();
|
||||
assert.equal(registry.isMilitaryIcao('abc123'), true);
|
||||
assert.equal(registry.isMilitaryIcao('def456'), false);
|
||||
const manager = new LayerLifecycle(
|
||||
{},
|
||||
{ getSourceAvailability: actual.getSourceAvailability },
|
||||
);
|
||||
manager.register(actual.get('military'));
|
||||
assert.equal(await manager.setEnabled('military', true), false);
|
||||
assert.equal(registry.isMilitaryLayerActive(), false);
|
||||
assert.equal(fetches, 0);
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
+65
-1
@@ -1,7 +1,11 @@
|
||||
import { SceneDirector } from '../scenes/director.js';
|
||||
import { initAnnotations } from '../annotations/index.js';
|
||||
import { initDrawTool } from '../annotations/drawTool.js';
|
||||
import { initGeoFileImport } from '../ui/geoFileImport.js';
|
||||
import { initImageryBoxTool } from '../ui/imageryBoxTool.js';
|
||||
import { createRecentImageryPanel } from '../ui/recentImagery.js';
|
||||
import { initGevVoiceCommands } from '../voice/gevRealtime.js';
|
||||
import { installViews, isEmbeddedInline } from './embed.js';
|
||||
import { installScopeMask, destroyScopeMask } from '../scopeMask.js';
|
||||
import {
|
||||
installRenderGovernor,
|
||||
@@ -21,6 +25,7 @@ export function createApplicationTools({
|
||||
voice = {},
|
||||
startChrome,
|
||||
onSceneDirector,
|
||||
sceneDataPacks,
|
||||
signal,
|
||||
defer,
|
||||
}) {
|
||||
@@ -28,6 +33,7 @@ export function createApplicationTools({
|
||||
const { styleManager, weatherEffects, cockpitCloudEffects } = controls;
|
||||
const { dataManager } = data;
|
||||
const sceneDirector = new SceneDirector(viewer, styleManager, dataManager, {
|
||||
dataPacks: sceneDataPacks,
|
||||
isMapStackAvailable: (id) =>
|
||||
mapStackController?.isStackAvailable(id) === true,
|
||||
});
|
||||
@@ -51,6 +57,53 @@ export function createApplicationTools({
|
||||
// lifetime rather than to whoever last pressed the button.
|
||||
const drawTool = initDrawTool({ viewer, annotations });
|
||||
defer(() => drawTool?.destroy());
|
||||
// DISPLAY ▸ Import: the person's own GeoJSON/KML/KMZ/GPX files, each its
|
||||
// own data source. They live for the page session, so the application
|
||||
// lifetime removes them and the drop listeners on the viewer container.
|
||||
const geoFileImport = initGeoFileImport({
|
||||
viewer,
|
||||
navigation: styleManager,
|
||||
ground: operations.surface?.groundFloor,
|
||||
subscribeMapSource: (listener) => mapStackController?.subscribe?.(listener),
|
||||
});
|
||||
defer(() => geoFileImport?.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
|
||||
@@ -77,7 +130,9 @@ export function createApplicationTools({
|
||||
// GPU. Holder/data state is untouched, so return is seamless: restore
|
||||
// the loop, refresh the one DOM surface we gated, render a frame.
|
||||
const syncVisibilitySuspension = () => {
|
||||
const hidden = document.hidden;
|
||||
// A panel's host may report it hidden while it is on screen; the panel
|
||||
// keeps drawing itself (see keepPanelRendering in embed.js).
|
||||
const hidden = document.hidden && !isEmbeddedInline();
|
||||
viewer.useDefaultRenderLoop = !hidden;
|
||||
cockpitCloudEffects?.setSuspended?.(hidden);
|
||||
if (!hidden) {
|
||||
@@ -135,5 +190,14 @@ export function createApplicationTools({
|
||||
delete window.__gevVoiceCommands;
|
||||
});
|
||||
debug.voiceCommands = voiceCommands;
|
||||
defer(
|
||||
installViews({
|
||||
shell: styleManager,
|
||||
viewer,
|
||||
dataManager,
|
||||
run: (name, args) => voiceCommands.runner(name, args, { signal }),
|
||||
signal,
|
||||
}),
|
||||
);
|
||||
return { sceneDirector, annotations, voiceCommands };
|
||||
}
|
||||
@@ -1,4 +1,106 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { applyModelAtmosphereWorkaround } from './atmosphereCompat.js';
|
||||
|
||||
const PINCH_ZOOM_MULTIPLIER = 8;
|
||||
const MAX_PINCH_PIXEL_DELTA = 120;
|
||||
|
||||
function boundedPinchDelta(delta) {
|
||||
if (!Number.isFinite(delta) || delta === 0) return delta;
|
||||
return (
|
||||
Math.sign(delta) *
|
||||
Math.min(Math.abs(delta) * PINCH_ZOOM_MULTIPLIER, MAX_PINCH_PIXEL_DELTA)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add browser trackpad pinch to Cesium's zoom inputs and return its disposer.
|
||||
* Browsers expose this gesture as a small pixel-mode Ctrl+wheel event.
|
||||
*/
|
||||
export function installTrackpadPinchZoom(
|
||||
viewer,
|
||||
{ createWheelEvent = (type, init) => new WheelEvent(type, init) } = {},
|
||||
) {
|
||||
const controller = viewer?.scene?.screenSpaceCameraController;
|
||||
const container = viewer?.container;
|
||||
const canvas = viewer?.canvas;
|
||||
if (!controller || !container || !canvas)
|
||||
throw new TypeError('A complete Cesium viewer is required');
|
||||
|
||||
const originalZoomEventTypes = controller.zoomEventTypes;
|
||||
const zoomEventTypes = Array.isArray(originalZoomEventTypes)
|
||||
? originalZoomEventTypes
|
||||
: originalZoomEventTypes === undefined
|
||||
? []
|
||||
: [originalZoomEventTypes];
|
||||
const alreadyHandlesControlWheel = zoomEventTypes.some(
|
||||
(binding) =>
|
||||
binding?.eventType === Cesium.CameraEventType.WHEEL &&
|
||||
binding?.modifier === Cesium.KeyboardEventModifier.CTRL,
|
||||
);
|
||||
const configuredZoomEventTypes = alreadyHandlesControlWheel
|
||||
? originalZoomEventTypes
|
||||
: [
|
||||
...zoomEventTypes,
|
||||
{
|
||||
eventType: Cesium.CameraEventType.WHEEL,
|
||||
modifier: Cesium.KeyboardEventModifier.CTRL,
|
||||
},
|
||||
];
|
||||
if (!alreadyHandlesControlWheel)
|
||||
controller.zoomEventTypes = configuredZoomEventTypes;
|
||||
|
||||
const relayedEvents = new WeakSet();
|
||||
const relayPinch = (event) => {
|
||||
if (
|
||||
!event.ctrlKey ||
|
||||
relayedEvents.has(event) ||
|
||||
event.deltaMode !== 0 ||
|
||||
!Number.isFinite(event.deltaY) ||
|
||||
event.deltaY === 0
|
||||
)
|
||||
return;
|
||||
let relayed;
|
||||
try {
|
||||
relayed = createWheelEvent('wheel', {
|
||||
deltaX: event.deltaX,
|
||||
deltaY: boundedPinchDelta(event.deltaY),
|
||||
deltaZ: event.deltaZ,
|
||||
deltaMode: event.deltaMode,
|
||||
screenX: event.screenX,
|
||||
screenY: event.screenY,
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
ctrlKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
view: globalThis.window,
|
||||
});
|
||||
} catch {
|
||||
// The registered Ctrl+wheel binding can still consume the original.
|
||||
return;
|
||||
}
|
||||
relayedEvents.add(relayed);
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
canvas.dispatchEvent(relayed);
|
||||
};
|
||||
container.addEventListener('wheel', relayPinch, {
|
||||
capture: true,
|
||||
passive: false,
|
||||
});
|
||||
|
||||
let active = true;
|
||||
return () => {
|
||||
if (!active) return;
|
||||
active = false;
|
||||
container.removeEventListener('wheel', relayPinch, true);
|
||||
if (
|
||||
!alreadyHandlesControlWheel &&
|
||||
controller.zoomEventTypes === configuredZoomEventTypes
|
||||
)
|
||||
controller.zoomEventTypes = originalZoomEventTypes;
|
||||
};
|
||||
}
|
||||
|
||||
/** Create the standard globe viewer in caller-owned, visible containers. */
|
||||
export function createApplicationViewer({ container, creditContainer }) {
|
||||
@@ -23,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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Camera ground guard: never leave an arrival buried in, or pressed against,
|
||||
* the rendered surface.
|
||||
*
|
||||
* Arrival framing is computed before the destination's tiles exist, so it has
|
||||
* to predict the ground height. `globe.getHeight()` answers nothing while the
|
||||
* globe is hidden under photoreal tiles, curated `groundElevation` presets
|
||||
* cover only a few cities, and the elevation service resolves asynchronously.
|
||||
* A missing prediction used to fall back to sea level, which buried the eye by
|
||||
* roughly the local elevation (Denver ~1,600 m; Austin ~170 m).
|
||||
*
|
||||
* After the flight lands and tiles have streamed in, this module measures the
|
||||
* rendered surface under the framed target and under the camera itself, and
|
||||
* lifts the eye if it sits below a usable clearance. Heading and pitch are
|
||||
* held, so the correction reads as the shot settling.
|
||||
*/
|
||||
import * as Cesium from 'cesium';
|
||||
import { ownCameraArrival } from './data/cameraArrival.js';
|
||||
|
||||
/** Minimum eye height above the rendered surface for a usable view. */
|
||||
export const MIN_EYE_CLEARANCE_M = 120;
|
||||
/** Retries while tiles stream in; sampling fails until the mesh exists. */
|
||||
export const GUARD_ATTEMPTS = 8;
|
||||
export const GUARD_INTERVAL_MS = 600;
|
||||
/** Ignore sub-metre noise rather than nudging the camera forever. */
|
||||
const GUARD_EPSILON_M = 2;
|
||||
|
||||
const probeScratch = new Cesium.Cartographic();
|
||||
|
||||
/**
|
||||
* Highest rendered surface across a few cells, with how many cells answered.
|
||||
* A cell whose tiles are not streamed yet contributes nothing.
|
||||
* @param {Cesium.Scene} scene Live scene.
|
||||
* @param {Array<{lat: number, lon: number}>} cells Cells to probe.
|
||||
* @returns {{heightM: number, sampled: number}} Reading.
|
||||
*/
|
||||
function sampleSurfaceM(scene, cells) {
|
||||
let heightM = Number.NaN;
|
||||
let sampled = 0;
|
||||
if (typeof scene?.sampleHeight !== 'function') return { heightM, sampled };
|
||||
for (const cell of cells) {
|
||||
try {
|
||||
const height = scene.sampleHeight(
|
||||
Cesium.Cartographic.fromDegrees(cell.lon, cell.lat, 0, probeScratch),
|
||||
);
|
||||
if (!Number.isFinite(height)) continue;
|
||||
sampled += 1;
|
||||
heightM = Number.isFinite(heightM) ? Math.max(heightM, height) : height;
|
||||
} catch {
|
||||
/* tiles not ready for this cell */
|
||||
}
|
||||
}
|
||||
return { heightM, sampled };
|
||||
}
|
||||
|
||||
/**
|
||||
* How far the camera must rise to clear the ground, given a measured surface.
|
||||
* Pure so the policy is testable without a scene.
|
||||
* @param {number} cameraHeightM Eye height above the ellipsoid.
|
||||
* @param {number} groundHeightM Measured surface height above the ellipsoid.
|
||||
* @param {number} [clearanceM] Desired eye height above that surface.
|
||||
* @returns {number} Metres to lift; 0 when the view is already clear.
|
||||
*/
|
||||
export function groundClearanceDeficitM(
|
||||
cameraHeightM,
|
||||
groundHeightM,
|
||||
clearanceM = MIN_EYE_CLEARANCE_M,
|
||||
) {
|
||||
if (!Number.isFinite(cameraHeightM) || !Number.isFinite(groundHeightM))
|
||||
return 0;
|
||||
const deficit = groundHeightM + clearanceM - cameraHeightM;
|
||||
return deficit > GUARD_EPSILON_M ? deficit : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch an arrival and lift the camera once the real surface can be measured.
|
||||
*
|
||||
* Stops quietly on a clear view, exhausted attempts, the user taking the
|
||||
* controls (pointer or wheel on the canvas), or `isStale()` reporting that a
|
||||
* newer arrival owns the camera.
|
||||
* @param {Cesium.Viewer} viewer Live viewer.
|
||||
* @param {{lat: number, lon: number}} at Destination being framed.
|
||||
* @param {{clearanceM?: number, attempts?: number, intervalMs?: number,
|
||||
* isStale?: () => boolean, onLift?: (liftM: number) => void}} [options]
|
||||
* @returns {() => void} Cancel function.
|
||||
*/
|
||||
export function guardCameraAboveGround(viewer, at, options = {}) {
|
||||
const {
|
||||
clearanceM = MIN_EYE_CLEARANCE_M,
|
||||
attempts = GUARD_ATTEMPTS,
|
||||
intervalMs = GUARD_INTERVAL_MS,
|
||||
isStale = () => false,
|
||||
onLift = null,
|
||||
} = options;
|
||||
if (!viewer?.scene || !Number.isFinite(at?.lat) || !Number.isFinite(at?.lon))
|
||||
return () => {};
|
||||
|
||||
let timer = null;
|
||||
let done = false;
|
||||
const canvas = viewer.scene.canvas;
|
||||
const documentRef = canvas?.ownerDocument;
|
||||
const removers = [];
|
||||
let release = () => {};
|
||||
const stop = () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
clearTimeout(timer);
|
||||
for (const type of ['pointerdown', 'wheel'])
|
||||
canvas?.removeEventListener?.(type, stop);
|
||||
documentRef?.removeEventListener?.('keydown', stop, true);
|
||||
for (const remove of removers) remove();
|
||||
release();
|
||||
};
|
||||
release = ownCameraArrival(viewer, stop);
|
||||
// Direct camera API callers (including duration-zero flights) must yield
|
||||
// before moving the eye. Restore the exact method ownership on completion.
|
||||
for (const name of [
|
||||
'flyTo',
|
||||
'flyToBoundingSphere',
|
||||
'setView',
|
||||
'lookAt',
|
||||
'cancelFlight',
|
||||
]) {
|
||||
const camera = viewer.camera;
|
||||
const original = camera[name];
|
||||
if (typeof original !== 'function') continue;
|
||||
const descriptor = Object.getOwnPropertyDescriptor(camera, name);
|
||||
const wrapped = function (...args) {
|
||||
stop();
|
||||
return original.apply(this, args);
|
||||
};
|
||||
camera[name] = wrapped;
|
||||
removers.push(() => {
|
||||
if (camera[name] !== wrapped) return;
|
||||
if (descriptor) Object.defineProperty(camera, name, descriptor);
|
||||
else delete camera[name];
|
||||
});
|
||||
}
|
||||
const removeTracking = viewer.trackedEntityChanged?.addEventListener(stop);
|
||||
if (removeTracking) removers.push(removeTracking);
|
||||
documentRef?.addEventListener?.('keydown', stop, true);
|
||||
// A manual gesture ends the guard, as it interrupts camera motion elsewhere.
|
||||
for (const type of ['pointerdown', 'wheel'])
|
||||
canvas?.addEventListener?.(type, stop, { once: true, passive: true });
|
||||
|
||||
let remaining = attempts;
|
||||
const attempt = () => {
|
||||
if (done) return;
|
||||
if (isStale() || viewer.isDestroyed?.()) return stop();
|
||||
remaining -= 1;
|
||||
const camera = viewer.camera;
|
||||
const carto = camera.positionCartographic;
|
||||
// The eye must clear both the framed target and the ground beneath it.
|
||||
const below = {
|
||||
lat: Cesium.Math.toDegrees(carto.latitude),
|
||||
lon: Cesium.Math.toDegrees(carto.longitude),
|
||||
};
|
||||
const { heightM, sampled } = sampleSurfaceM(viewer.scene, [
|
||||
{ lat: at.lat, lon: at.lon },
|
||||
below,
|
||||
]);
|
||||
if (sampled > 0 && Number.isFinite(heightM)) {
|
||||
const deficit = groundClearanceDeficitM(
|
||||
carto.height,
|
||||
heightM,
|
||||
clearanceM,
|
||||
);
|
||||
if (deficit > 0) {
|
||||
stop();
|
||||
camera.flyTo({
|
||||
destination: Cesium.Cartesian3.fromRadians(
|
||||
carto.longitude,
|
||||
carto.latitude,
|
||||
carto.height + deficit,
|
||||
),
|
||||
orientation: {
|
||||
heading: camera.heading,
|
||||
pitch: camera.pitch,
|
||||
roll: camera.roll,
|
||||
},
|
||||
duration: 0.9,
|
||||
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
|
||||
});
|
||||
try {
|
||||
onLift?.(deficit);
|
||||
} catch {
|
||||
/* observer only */
|
||||
}
|
||||
}
|
||||
// Both samples answered: the surface is measured, nothing left to wait for.
|
||||
if (sampled === 2 || deficit > 0) return stop();
|
||||
}
|
||||
if (remaining <= 0) return stop();
|
||||
timer = setTimeout(attempt, intervalMs);
|
||||
};
|
||||
timer = setTimeout(attempt, intervalMs);
|
||||
return stop;
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
// Camera ground-guard policy tests: pure arithmetic plus a fake scene, no network.
|
||||
//
|
||||
// Arrival framing is computed before the destination's tiles exist, so it
|
||||
// works from a predicted ground height. The guard measures the real surface
|
||||
// after arrival and lifts the eye; these tests pin when it acts.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
MIN_EYE_CLEARANCE_M,
|
||||
groundClearanceDeficitM,
|
||||
guardCameraAboveGround,
|
||||
} from './cameraGroundGuard.js';
|
||||
|
||||
test('a buried camera is lifted clear of the surface', () => {
|
||||
// Framed near sea level, arriving over ground at ~1,609 m (Denver).
|
||||
const lift = groundClearanceDeficitM(140, 1609);
|
||||
assert.equal(lift, 1609 + MIN_EYE_CLEARANCE_M - 140);
|
||||
});
|
||||
|
||||
test('a camera resting on the surface is lifted to a usable height', () => {
|
||||
// Camp Mabry field report: eye 1 m above the mesh at 171 m.
|
||||
assert.equal(groundClearanceDeficitM(172, 171), MIN_EYE_CLEARANCE_M - 1);
|
||||
});
|
||||
|
||||
test('a well-framed arrival is left alone', () => {
|
||||
assert.equal(groundClearanceDeficitM(1755, 1609), 0);
|
||||
assert.equal(groundClearanceDeficitM(2960, 1609), 0);
|
||||
});
|
||||
|
||||
test('sub-metre noise never triggers a nudge', () => {
|
||||
assert.equal(groundClearanceDeficitM(1609 + MIN_EYE_CLEARANCE_M, 1609), 0);
|
||||
assert.equal(
|
||||
groundClearanceDeficitM(1609 + MIN_EYE_CLEARANCE_M - 1, 1609),
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('an unmeasurable surface is never acted on', () => {
|
||||
assert.equal(groundClearanceDeficitM(1000, Number.NaN), 0);
|
||||
assert.equal(groundClearanceDeficitM(Number.NaN, 1609), 0);
|
||||
assert.equal(groundClearanceDeficitM(1000, undefined), 0);
|
||||
});
|
||||
|
||||
test('the clearance frames a subject without turning into an overflight', () => {
|
||||
assert.ok(MIN_EYE_CLEARANCE_M >= 60);
|
||||
assert.ok(MIN_EYE_CLEARANCE_M <= 250);
|
||||
});
|
||||
|
||||
function fakeViewer({ cameraHeightM, surfaceM }) {
|
||||
const flights = [];
|
||||
const position = Cesium.Cartographic.fromDegrees(-97.7603, 30.3141, cameraHeightM);
|
||||
return {
|
||||
flights,
|
||||
scene: {
|
||||
canvas: null,
|
||||
sampleHeight: (carto) =>
|
||||
typeof surfaceM === 'function' ? surfaceM(carto) : surfaceM,
|
||||
},
|
||||
camera: {
|
||||
positionCartographic: position,
|
||||
heading: 0.5,
|
||||
pitch: -0.55,
|
||||
roll: 0,
|
||||
flyTo: (options) => flights.push(options),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('the guard lifts a buried arrival once the surface answers', async () => {
|
||||
const viewer = fakeViewer({ cameraHeightM: 172, surfaceM: 171 });
|
||||
const lifts = [];
|
||||
guardCameraAboveGround(
|
||||
viewer,
|
||||
{ lat: 30.3125, lon: -97.765 },
|
||||
{ intervalMs: 1, onLift: (m) => lifts.push(m) },
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
assert.equal(viewer.flights.length, 1);
|
||||
const lifted = Cesium.Cartographic.fromCartesian(
|
||||
viewer.flights[0].destination,
|
||||
);
|
||||
assert.ok(Math.abs(lifted.height - (171 + MIN_EYE_CLEARANCE_M)) < 0.5);
|
||||
assert.equal(viewer.flights[0].orientation.heading, 0.5);
|
||||
assert.equal(lifts.length, 1);
|
||||
});
|
||||
|
||||
test('the guard waits for streaming tiles and yields to a newer arrival', async () => {
|
||||
let answer = Number.NaN;
|
||||
let stale = false;
|
||||
const viewer = fakeViewer({ cameraHeightM: 172, surfaceM: () => answer });
|
||||
guardCameraAboveGround(
|
||||
viewer,
|
||||
{ lat: 30.3125, lon: -97.765 },
|
||||
{ intervalMs: 2, attempts: 20, isStale: () => stale },
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 12));
|
||||
assert.equal(viewer.flights.length, 0, 'no guess while tiles stream in');
|
||||
stale = true;
|
||||
answer = 171;
|
||||
await new Promise((resolve) => setTimeout(resolve, 12));
|
||||
assert.equal(viewer.flights.length, 0, 'a newer arrival owns the camera');
|
||||
});
|
||||
|
||||
test('the guard leaves a clear view untouched', async () => {
|
||||
const viewer = fakeViewer({ cameraHeightM: 400, surfaceM: 171 });
|
||||
guardCameraAboveGround(viewer, { lat: 30.3125, lon: -97.765 }, { intervalMs: 1 });
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
assert.equal(viewer.flights.length, 0);
|
||||
});
|
||||
|
||||
// Handoffs act before the delayed probe, even when the next owner stays low.
|
||||
import { NavigationController } from './ui/navigationController.js';
|
||||
import { SceneDirector } from './scenes/director.js';
|
||||
import { LayerLifecycle } from './data/lifecycle.js';
|
||||
|
||||
function handoffFixture(t) {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const viewer = fakeViewer({ cameraHeightM: 172, surfaceM: 171 });
|
||||
viewer.scene.canvas = new EventTarget();
|
||||
viewer.scene.canvas.ownerDocument = new EventTarget();
|
||||
viewer.trackedEntityChanged = new Cesium.Event();
|
||||
viewer.camera.cancelFlight = () => {};
|
||||
viewer.camera.lookAtTransform = () => {};
|
||||
viewer.camera.flyToBoundingSphere = () => {};
|
||||
viewer.camera.setView = () => {};
|
||||
const originalFly = viewer.camera.flyTo;
|
||||
const navigation = new NavigationController({
|
||||
viewer,
|
||||
tracking: Object.fromEntries(['flightsLayer', 'militaryFlightsLayer',
|
||||
'satellitesLayer', 'aisLiveVesselsLayer', 'militaryAwarenessLayer',
|
||||
'rocketLaunchesLayer'].map((key) => [key, {}])),
|
||||
cancelOrientation() {}, clearLocation() {}, cancelShareSelection() {},
|
||||
getDataManager() {}, interruptCameraMotion() {}, stopOrbit() {},
|
||||
isCockpitActive: () => false, showToast() {},
|
||||
});
|
||||
guardCameraAboveGround(viewer, { lat: 30.3, lon: -97.7 });
|
||||
return { viewer, navigation, originalFly };
|
||||
}
|
||||
|
||||
for (const handoff of ['Director', 'tracking', 'follow', 'keyboard', 'UI',
|
||||
'flyTo', 'flyToBoundingSphere', 'setView', 'layer-disable', 'layer-destroy', 'app-stop']) {
|
||||
test(`arrival guard relinquishes and removes hooks on ${handoff}`, async (t) => {
|
||||
const { viewer, navigation, originalFly } = handoffFixture(t);
|
||||
if (handoff === 'Director') {
|
||||
SceneDirector.prototype._claimCameraOwnership.call({
|
||||
viewer, styleManager: { runImmediateNavigation: (noun, fn) =>
|
||||
navigation._runExplicitNavigation(noun, fn) },
|
||||
});
|
||||
} else if (handoff === 'tracking') {
|
||||
viewer.trackedEntityChanged.raiseEvent({ id: 'contact' });
|
||||
} else if (handoff === 'follow' || handoff === 'UI') {
|
||||
navigation._stampNavigation({ cancelPendingSelection: false });
|
||||
} else if (handoff === 'keyboard') {
|
||||
viewer.scene.canvas.ownerDocument.dispatchEvent(new Event('keydown'));
|
||||
} else if (handoff === 'app-stop') navigation.stop();
|
||||
else if (handoff.startsWith('layer-')) {
|
||||
const lifecycle = new LayerLifecycle(viewer);
|
||||
lifecycle.register({ id: 'test', init() {}, enable() {}, disable() {}, destroy() {} });
|
||||
await lifecycle.setEnabled('test', true);
|
||||
if (handoff === 'layer-disable') await lifecycle.setEnabled('test', false);
|
||||
else await lifecycle.destroyLayer('test');
|
||||
} else viewer.camera[handoff]({});
|
||||
const priorFlights = viewer.flights.length;
|
||||
t.mock.timers.tick(10000);
|
||||
assert.equal(viewer.flights.length, priorFlights, 'the old probe cannot fly');
|
||||
assert.equal(viewer.camera.flyTo, originalFly, 'direct-call hook is removed');
|
||||
assert.equal(viewer.trackedEntityChanged.numberOfListeners, 0);
|
||||
navigation.destroy();
|
||||
});
|
||||
}
|
||||
+36
-12
@@ -86,23 +86,47 @@ test('Cockpit heading tape leaves the bottom exit row unobstructed', () => {
|
||||
assert.doesNotMatch(css, /cockpit-compass-label/);
|
||||
});
|
||||
|
||||
test('Cockpit vision cycle exposes exactly five real visual styles without NONE', () => {
|
||||
assert.match(cycleVisionMode.toString(), /const modes = COCKPIT_VISION_MODES;/);
|
||||
assert.match(setVisionMode.toString(), /const labels = \{\s*optical: inherited,\s*crt: 'CRT',\s*nvg: 'NVG',\s*thermal: 'FLIR',\s*noir: 'NOIR',?\s*\};/);
|
||||
test('Cockpit vision cycle exposes every map preset exactly once', () => {
|
||||
assert.match(
|
||||
cycleVisionMode.toString(),
|
||||
/const modes = COCKPIT_VISION_MODES;/,
|
||||
);
|
||||
assert.match(
|
||||
setVisionMode.toString(),
|
||||
/const labels = \{\s*optical: 'NORMAL',\s*crt: 'CRT',\s*nvg: 'NVG',\s*thermal: 'FLIR',\s*anime: 'ANIME',\s*noir: 'NOIR',\s*snow: 'SNOW',?\s*\};/,
|
||||
);
|
||||
assert.doesNotMatch(setVisionMode.toString(), /none: 'NONE'/);
|
||||
assert.match(ui, /getInheritedVisionLabel: \(\) =>\s*\(?[\s\S]*?STYLE_STATUS_LABELS\[this\.activeStyle\]/);
|
||||
assert.doesNotMatch(ui, /getInheritedVisionLabel/);
|
||||
assert.match(html, /id="cockpit-vision-current-label"[^>]*>NORMAL<\/strong>/);
|
||||
assert.match(ui, /const target = applyCockpitVisionStageIntensities\(\s*this\.stages,\s*next,\s*this\._cockpitVisionRestore,?\s*\);/);
|
||||
assert.match(ui, /this\._cockpitVisionRestore = captureCockpitVisionBaseline\(\s*this\.stages,\s*this\.transitions,?\s*\);/);
|
||||
assert.match(
|
||||
cockpitEnter.toString(),
|
||||
/this\.setVisionMode\(this\.getInitialVisionMode\(\)\)/,
|
||||
'Cockpit must enter on the mode matching the active map preset',
|
||||
);
|
||||
assert.match(
|
||||
readRadioSource(
|
||||
new URL('./ui/cockpitCoordinator.js', import.meta.url),
|
||||
'utf8',
|
||||
),
|
||||
/getInitialVisionMode:\s*\(\)\s*=>\s*cockpitVisionModeForStyle\(this\.activeStyle\)/,
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
/if \(next === 'optical'\) \{[\s\S]*?applyCockpitVisionStageIntensities\(\s*this\.stages,\s*next,\s*this\._cockpitVisionRestore,?\s*\);[\s\S]*?return;[\s\S]*?const target = applyCockpitVisionStageIntensities/,
|
||||
'the inherited entry must restore the map shader while CRT, NVG, FLIR, and NOIR remain temporary Cockpit overrides',
|
||||
/const target = applyCockpitVisionStageIntensities\(\s*this\.stages,\s*next,?\s*\);/,
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
/this\._cockpitVisionRestore = captureCockpitVisionBaseline\(\s*this\.stages,\s*this\.transitions,?\s*\);/,
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
/if \(next === 'optical'\) \{[\s\S]*?applyCockpitVisionStageIntensities\(\s*this\.stages,\s*next,?\s*\);[\s\S]*?this\._updateSliderPanel\(null,[\s\S]*?return;[\s\S]*?const target = applyCockpitVisionStageIntensities/,
|
||||
'Normal must clear the inherited map shader while the other map presets remain temporary Cockpit overrides',
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
/_syncCockpitInheritedStyle\(\)[\s\S]*?name === this\.activeStyle \? 1 : 0[\s\S]*?this\.transitions\.delete\(name\)[\s\S]*?setVisionMode\(this\.cockpitView\.visionMode\)/,
|
||||
'changing the map preset in Cockpit must refresh both the inherited label and restore baseline',
|
||||
'changing the map preset in Cockpit must refresh the exit restore baseline without replacing Normal',
|
||||
);
|
||||
assert.match(ui, /setStyle\([\s\S]*?this\._syncCockpitInheritedStyle\(\);/);
|
||||
});
|
||||
@@ -342,11 +366,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', () => {
|
||||
|
||||
@@ -83,6 +83,26 @@ export function cockpitAltitudeDisplayFt(altitudeM, onGround) {
|
||||
return Number.isFinite(altitudeM) ? altitudeM * 3.28084 : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* HUD wording for a tracked aircraft's transponder emergency, or null.
|
||||
* States what was broadcast, never a conclusion about the flight: the Mode A
|
||||
* code when it names the status, else the ADS-B field it came from (an
|
||||
* unrelated squawk is dropped).
|
||||
* @param {{label?: string, squawk?: string|null, source?: string,
|
||||
* severity?: string}|null|undefined} emergency
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function formatCockpitEmergency(emergency) {
|
||||
const label = String(emergency?.label || '')
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
if (!label) return null;
|
||||
if (emergency.source === 'squawk' && emergency.squawk)
|
||||
return `SQUAWK ${emergency.squawk} · ${label}`;
|
||||
const field = emergency.severity === 'priority' ? 'PRIORITY' : 'EMERGENCY';
|
||||
return `ADS-B ${field} · ${label}`;
|
||||
}
|
||||
|
||||
/** Format the cockpit Context scope without overstating installation coverage. */
|
||||
export function formatCockpitContextScope(
|
||||
subjectLabel,
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
compassDivisions,
|
||||
formatAltitudeRulerTick,
|
||||
formatCockpitContextScope,
|
||||
formatCockpitEmergency,
|
||||
formatCompassDivision,
|
||||
formatSpeedRulerTick,
|
||||
normalizeHeading,
|
||||
@@ -364,3 +365,21 @@ test('tracked-identity resolution falls back to layer precedence', () => {
|
||||
assert.equal(resolveTrackedAircraftInfo({ trackedId: 'flights:aaa077' }), null);
|
||||
assert.equal(resolveTrackedAircraftInfo(), null);
|
||||
});
|
||||
|
||||
test('cockpit emergency wording states the broadcast code, not a conclusion', () => {
|
||||
assert.equal(
|
||||
formatCockpitEmergency({ label: 'General emergency', squawk: '7700', source: 'squawk', severity: 'emergency' }),
|
||||
'SQUAWK 7700 · GENERAL EMERGENCY',
|
||||
);
|
||||
assert.equal(
|
||||
formatCockpitEmergency({ label: 'Downed aircraft', squawk: '1200', source: 'ads-b', severity: 'emergency' }),
|
||||
'ADS-B EMERGENCY · DOWNED AIRCRAFT',
|
||||
'an unrelated squawk is not quoted beside an ADS-B status',
|
||||
);
|
||||
assert.equal(
|
||||
formatCockpitEmergency({ label: 'Minimum fuel', squawk: null, source: 'ads-b', severity: 'priority' }),
|
||||
'ADS-B PRIORITY · MINIMUM FUEL',
|
||||
);
|
||||
assert.equal(formatCockpitEmergency(null), null);
|
||||
assert.equal(formatCockpitEmergency({ label: '' }), null);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,21 +3,40 @@ export const COCKPIT_VISION_MODES = Object.freeze([
|
||||
'crt',
|
||||
'nvg',
|
||||
'thermal',
|
||||
'anime',
|
||||
'noir',
|
||||
'snow',
|
||||
]);
|
||||
|
||||
const TARGET_STYLE_BY_MODE = Object.freeze({
|
||||
crt: 'retro',
|
||||
nvg: 'surveillance',
|
||||
thermal: 'thermal',
|
||||
anime: 'anime',
|
||||
noir: 'noir',
|
||||
snow: 'snow',
|
||||
});
|
||||
|
||||
/** Normalize a requested Cockpit vision mode to the inherited preset entry. */
|
||||
const COCKPIT_MODE_BY_STYLE = Object.freeze({
|
||||
normal: 'optical',
|
||||
retro: 'crt',
|
||||
surveillance: 'nvg',
|
||||
thermal: 'thermal',
|
||||
anime: 'anime',
|
||||
noir: 'noir',
|
||||
snow: 'snow',
|
||||
});
|
||||
|
||||
/** Normalize a requested Cockpit vision mode to the unfiltered Normal entry. */
|
||||
export function normalizeCockpitVisionMode(mode) {
|
||||
return COCKPIT_VISION_MODES.includes(mode) ? mode : 'optical';
|
||||
}
|
||||
|
||||
/** Start Cockpit on the equivalent supported map preset. */
|
||||
export function cockpitVisionModeForStyle(style) {
|
||||
return COCKPIT_MODE_BY_STYLE[String(style || '').toLowerCase()] || 'optical';
|
||||
}
|
||||
|
||||
/** Settle pending map-style crossfades and return their intended final intensities. */
|
||||
export function captureCockpitVisionBaseline(stages, transitions) {
|
||||
const baseline = {};
|
||||
@@ -34,12 +53,10 @@ export function captureCockpitVisionBaseline(stages, transitions) {
|
||||
* Apply Cockpit-only stage intensities without changing any shader parameters.
|
||||
* Returns the temporary style whose parameters should be shown, or null.
|
||||
*/
|
||||
export function applyCockpitVisionStageIntensities(stages, mode, restore = {}) {
|
||||
export function applyCockpitVisionStageIntensities(stages, mode) {
|
||||
const next = normalizeCockpitVisionMode(mode);
|
||||
if (next === 'optical') {
|
||||
for (const [name, intensity] of Object.entries(restore)) {
|
||||
if (stages[name]) stages[name].uniforms.intensity = intensity;
|
||||
}
|
||||
for (const stage of Object.values(stages)) stage.uniforms.intensity = 0;
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import test from 'node:test';
|
||||
import {
|
||||
applyCockpitVisionStageIntensities,
|
||||
captureCockpitVisionBaseline,
|
||||
cockpitVisionModeForStyle,
|
||||
COCKPIT_VISION_MODES,
|
||||
normalizeCockpitVisionMode,
|
||||
} from './cockpitVisionPolicy.js';
|
||||
@@ -14,12 +15,31 @@ const createStages = () => ({
|
||||
thermal: { uniforms: { intensity: 0, heat: 0.8 } },
|
||||
});
|
||||
|
||||
test('Cockpit vision order exposes inherited, CRT, NVG, FLIR, and NOIR modes', () => {
|
||||
assert.deepEqual(COCKPIT_VISION_MODES, ['optical', 'crt', 'nvg', 'thermal', 'noir']);
|
||||
test('Cockpit vision order mirrors the unique map preset order', () => {
|
||||
assert.deepEqual(COCKPIT_VISION_MODES, [
|
||||
'optical',
|
||||
'crt',
|
||||
'nvg',
|
||||
'thermal',
|
||||
'anime',
|
||||
'noir',
|
||||
'snow',
|
||||
]);
|
||||
assert.equal(normalizeCockpitVisionMode('none'), 'optical');
|
||||
assert.equal(normalizeCockpitVisionMode('unknown'), 'optical');
|
||||
});
|
||||
|
||||
test('Cockpit entry selects the vision mode equivalent to the active map style', () => {
|
||||
assert.equal(cockpitVisionModeForStyle('normal'), 'optical');
|
||||
assert.equal(cockpitVisionModeForStyle('retro'), 'crt');
|
||||
assert.equal(cockpitVisionModeForStyle('surveillance'), 'nvg');
|
||||
assert.equal(cockpitVisionModeForStyle('thermal'), 'thermal');
|
||||
assert.equal(cockpitVisionModeForStyle('anime'), 'anime');
|
||||
assert.equal(cockpitVisionModeForStyle('noir'), 'noir');
|
||||
assert.equal(cockpitVisionModeForStyle('snow'), 'snow');
|
||||
assert.equal(cockpitVisionModeForStyle('unsupported'), 'optical');
|
||||
});
|
||||
|
||||
test('Cockpit settles pending map crossfades before a temporary preset takes ownership', () => {
|
||||
const stages = createStages();
|
||||
const transitions = new Map([
|
||||
@@ -29,49 +49,81 @@ test('Cockpit settles pending map crossfades before a temporary preset takes own
|
||||
const restore = captureCockpitVisionBaseline(stages, transitions);
|
||||
assert.deepEqual(restore, { noir: 1, retro: 0, surveillance: 0, thermal: 0 });
|
||||
assert.equal(transitions.size, 0);
|
||||
applyCockpitVisionStageIntensities(stages, 'nvg', restore);
|
||||
applyCockpitVisionStageIntensities(stages, 'nvg');
|
||||
assert.equal(stages.surveillance.uniforms.intensity, 1);
|
||||
applyCockpitVisionStageIntensities(stages, 'optical', restore);
|
||||
assert.equal(stages.noir.uniforms.intensity, 1);
|
||||
applyCockpitVisionStageIntensities(stages, 'optical');
|
||||
assert.equal(stages.noir.uniforms.intensity, 0);
|
||||
assert.equal(stages.retro.uniforms.intensity, 0);
|
||||
});
|
||||
|
||||
test('returning from a temporary preset restores the exact inherited intensities', () => {
|
||||
test('Normal clears every temporary and inherited visual stage', () => {
|
||||
const stages = createStages();
|
||||
const restore = Object.fromEntries(
|
||||
Object.entries(stages).map(([name, stage]) => [name, stage.uniforms.intensity]),
|
||||
Object.entries(stages).map(([name, stage]) => [
|
||||
name,
|
||||
stage.uniforms.intensity,
|
||||
]),
|
||||
);
|
||||
applyCockpitVisionStageIntensities(stages, 'thermal', restore);
|
||||
applyCockpitVisionStageIntensities(stages, 'optical', restore);
|
||||
applyCockpitVisionStageIntensities(stages, 'thermal');
|
||||
applyCockpitVisionStageIntensities(stages, 'optical');
|
||||
assert.deepEqual(
|
||||
Object.fromEntries(Object.entries(stages).map(([name, stage]) => [name, stage.uniforms.intensity])),
|
||||
restore,
|
||||
Object.fromEntries(
|
||||
Object.entries(stages).map(([name, stage]) => [
|
||||
name,
|
||||
stage.uniforms.intensity,
|
||||
]),
|
||||
),
|
||||
{ noir: 0, retro: 0, surveillance: 0, thermal: 0 },
|
||||
);
|
||||
assert.deepEqual(restore, {
|
||||
noir: 0.72,
|
||||
retro: 0.18,
|
||||
surveillance: 0,
|
||||
thermal: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('temporary styles replace each other without changing the inherited restore snapshot', () => {
|
||||
const stages = createStages();
|
||||
const restore = Object.fromEntries(
|
||||
Object.entries(stages).map(([name, stage]) => [name, stage.uniforms.intensity]),
|
||||
Object.entries(stages).map(([name, stage]) => [
|
||||
name,
|
||||
stage.uniforms.intensity,
|
||||
]),
|
||||
);
|
||||
applyCockpitVisionStageIntensities(stages, 'thermal', restore);
|
||||
assert.equal(applyCockpitVisionStageIntensities(stages, 'crt', restore), 'retro');
|
||||
applyCockpitVisionStageIntensities(stages, 'thermal');
|
||||
assert.equal(applyCockpitVisionStageIntensities(stages, 'crt'), 'retro');
|
||||
assert.equal(stages.retro.uniforms.intensity, 1);
|
||||
assert.equal(stages.noir.uniforms.intensity, 0);
|
||||
applyCockpitVisionStageIntensities(stages, 'optical', restore);
|
||||
assert.equal(stages.noir.uniforms.intensity, 0.72);
|
||||
assert.equal(stages.retro.uniforms.intensity, 0.18);
|
||||
applyCockpitVisionStageIntensities(stages, 'optical');
|
||||
assert.equal(stages.noir.uniforms.intensity, 0);
|
||||
assert.equal(stages.retro.uniforms.intensity, 0);
|
||||
assert.deepEqual(restore, {
|
||||
noir: 0.72,
|
||||
retro: 0.18,
|
||||
surveillance: 0,
|
||||
thermal: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('NOIR is a temporary Cockpit override and inherited restores the captured map style', () => {
|
||||
test('NOIR is a temporary Cockpit override and Normal clears it', () => {
|
||||
const stages = createStages();
|
||||
const restore = Object.fromEntries(
|
||||
Object.entries(stages).map(([name, stage]) => [name, stage.uniforms.intensity]),
|
||||
Object.entries(stages).map(([name, stage]) => [
|
||||
name,
|
||||
stage.uniforms.intensity,
|
||||
]),
|
||||
);
|
||||
assert.equal(applyCockpitVisionStageIntensities(stages, 'noir', restore), 'noir');
|
||||
assert.equal(applyCockpitVisionStageIntensities(stages, 'noir'), 'noir');
|
||||
assert.equal(stages.noir.uniforms.intensity, 1);
|
||||
assert.equal(stages.retro.uniforms.intensity, 0);
|
||||
applyCockpitVisionStageIntensities(stages, 'optical', restore);
|
||||
assert.equal(stages.noir.uniforms.intensity, 0.72);
|
||||
assert.equal(stages.retro.uniforms.intensity, 0.18);
|
||||
applyCockpitVisionStageIntensities(stages, 'optical');
|
||||
assert.equal(stages.noir.uniforms.intensity, 0);
|
||||
assert.equal(stages.retro.uniforms.intensity, 0);
|
||||
assert.deepEqual(restore, {
|
||||
noir: 0.72,
|
||||
retro: 0.18,
|
||||
surveillance: 0,
|
||||
thermal: 0,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,276 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import test from 'node:test';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
codexAuthPath,
|
||||
preferCodexOAuth,
|
||||
readCodexOAuthAccessToken,
|
||||
resolveCodexExecutable,
|
||||
startCodexChatGptLogin,
|
||||
} from '../server/providers/openai/codex-auth.js';
|
||||
|
||||
const nowMs = Date.UTC(2026, 8, 27);
|
||||
const jwt = (payload) =>
|
||||
`${Buffer.from('{}').toString('base64url')}.${Buffer.from(JSON.stringify(payload)).toString('base64url')}.signature`;
|
||||
const auth = (token, extras = {}) =>
|
||||
JSON.stringify({
|
||||
auth_mode: 'chatgpt',
|
||||
tokens: { access_token: token, refresh_token: 'refresh-fixture' },
|
||||
...extras,
|
||||
});
|
||||
|
||||
test('optional Codex OAuth pin fails closed on ambiguous values', () => {
|
||||
assert.equal(preferCodexOAuth({}), false);
|
||||
assert.equal(preferCodexOAuth({ GEV_PREFER_CODEX_OAUTH: 'true' }), true);
|
||||
assert.equal(preferCodexOAuth({ GEV_PREFER_CODEX_OAUTH: 'off' }), false);
|
||||
assert.throws(
|
||||
() => preferCodexOAuth({ GEV_PREFER_CODEX_OAUTH: 'maybe' }),
|
||||
/must be true or false/,
|
||||
);
|
||||
});
|
||||
|
||||
test('Codex auth path follows explicit auth file, CODEX_HOME, then home directory', () => {
|
||||
assert.equal(
|
||||
codexAuthPath({
|
||||
environment: { CODEX_AUTH_JSON: '/tmp/custom-auth.json' },
|
||||
home: '/home/fixture',
|
||||
}),
|
||||
path.resolve('/tmp/custom-auth.json'),
|
||||
);
|
||||
assert.equal(
|
||||
codexAuthPath({
|
||||
environment: { CODEX_HOME: '/tmp/codex-home' },
|
||||
home: '/home/fixture',
|
||||
}),
|
||||
path.join(path.resolve('/tmp/codex-home'), 'auth.json'),
|
||||
);
|
||||
assert.equal(
|
||||
codexAuthPath({ environment: {}, home: '/home/fixture' }),
|
||||
path.join('/home/fixture', '.codex', 'auth.json'),
|
||||
);
|
||||
});
|
||||
|
||||
test('Codex OAuth reader accepts a current ChatGPT token and rejects unusable credentials', () => {
|
||||
const current = jwt({ exp: nowMs / 1000 + 3600 });
|
||||
assert.equal(
|
||||
readCodexOAuthAccessToken({
|
||||
authPath: '/unused',
|
||||
nowMs,
|
||||
readFile: () => auth(current),
|
||||
}),
|
||||
current,
|
||||
);
|
||||
assert.equal(
|
||||
readCodexOAuthAccessToken({
|
||||
authPath: '/unused',
|
||||
nowMs,
|
||||
readFile: () =>
|
||||
JSON.stringify({
|
||||
auth_mode: 'chatgpt',
|
||||
tokens: { access_token: current },
|
||||
}),
|
||||
}),
|
||||
current,
|
||||
'GEV does not require or consume the vendor refresh token',
|
||||
);
|
||||
for (const readFile of [
|
||||
() => '{bad json',
|
||||
() => JSON.stringify({ tokens: {} }),
|
||||
() => auth(current, { auth_mode: 'apikey' }),
|
||||
() =>
|
||||
JSON.stringify({
|
||||
OPENAI_API_KEY: 'sk-fixture',
|
||||
tokens: { access_token: current, refresh_token: 'refresh-fixture' },
|
||||
}),
|
||||
() => auth('opaque-token'),
|
||||
() => auth('header.not-json.signature'),
|
||||
() => auth(jwt({ exp: 'tomorrow' })),
|
||||
() => {
|
||||
throw new Error('missing');
|
||||
},
|
||||
]) {
|
||||
assert.throws(
|
||||
() => readCodexOAuthAccessToken({ authPath: '/unused', readFile, nowMs }),
|
||||
/ChatGPT sign-in is unavailable/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('Codex OAuth reader enforces expiry margin and bounds tokens without exp by file age', () => {
|
||||
for (const exp of [nowMs / 1000 - 1, nowMs / 1000 + 59]) {
|
||||
assert.throws(
|
||||
() => {
|
||||
readCodexOAuthAccessToken({
|
||||
authPath: '/unused',
|
||||
nowMs,
|
||||
readFile: () => auth(jwt({ exp })),
|
||||
});
|
||||
},
|
||||
(error) => {
|
||||
assert.match(error.message, /sign-in has expired/);
|
||||
assert.equal(error.code, 'CODEX_OAUTH_REAUTH_REQUIRED');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
}
|
||||
const noExp = jwt({ sub: 'fixture' });
|
||||
const base = { authPath: '/unused', nowMs, readFile: () => auth(noExp) };
|
||||
assert.equal(
|
||||
readCodexOAuthAccessToken({
|
||||
...base,
|
||||
statFile: () => ({ mtimeMs: nowMs }),
|
||||
}),
|
||||
noExp,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
readCodexOAuthAccessToken({
|
||||
...base,
|
||||
statFile: () => ({ mtimeMs: nowMs - 3600_000 }),
|
||||
}),
|
||||
/sign-in has expired/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
readCodexOAuthAccessToken({
|
||||
...base,
|
||||
statFile: () => {
|
||||
throw new Error('missing');
|
||||
},
|
||||
}),
|
||||
/sign-in is unavailable/,
|
||||
);
|
||||
});
|
||||
|
||||
test('Codex login resolves an installed executable before PATH and starts browser auth detached', async () => {
|
||||
const checked = [];
|
||||
// Built with path.join so the stub matches the resolver on Windows too,
|
||||
// where the separators are backslashes.
|
||||
const installed = path.join('/home/fixture', '.local', 'bin', 'codex');
|
||||
assert.equal(
|
||||
resolveCodexExecutable({
|
||||
environment: {},
|
||||
home: '/home/fixture',
|
||||
access(candidate) {
|
||||
checked.push(candidate);
|
||||
if (candidate === installed) return;
|
||||
throw new Error('missing');
|
||||
},
|
||||
}),
|
||||
installed,
|
||||
);
|
||||
assert.equal(checked.length, 1);
|
||||
|
||||
const child = new EventEmitter();
|
||||
let unref = 0;
|
||||
child.unref = () => {
|
||||
unref += 1;
|
||||
};
|
||||
const calls = [];
|
||||
const pending = startCodexChatGptLogin({
|
||||
executable: '/fixture/codex',
|
||||
home: '/home/fixture',
|
||||
environment: {
|
||||
PATH: '/fixture',
|
||||
https_proxy: 'http://proxy.fixture:8080',
|
||||
OPENAI_API_KEY: 'fixture-api-key',
|
||||
GOOGLE_MAPS_SERVER_API_KEY: 'fixture-google-key',
|
||||
OPENSKY_CLIENT_SECRET: 'fixture-opensky-secret',
|
||||
},
|
||||
spawnImpl(command, args, options) {
|
||||
calls.push({ command, args, options });
|
||||
queueMicrotask(() => child.emit('spawn'));
|
||||
return child;
|
||||
},
|
||||
});
|
||||
const login = await pending;
|
||||
assert.equal(unref, 1);
|
||||
assert.equal(calls[0].command, '/fixture/codex');
|
||||
assert.deepEqual(calls[0].args, ['login']);
|
||||
assert.equal(calls[0].options.detached, true);
|
||||
assert.equal(calls[0].options.stdio, 'ignore');
|
||||
assert.deepEqual(
|
||||
calls[0].options.env,
|
||||
{
|
||||
PATH: '/fixture',
|
||||
https_proxy: 'http://proxy.fixture:8080',
|
||||
CODEX_HOME: path.join('/home/fixture', '.codex'),
|
||||
},
|
||||
'provider secrets stay out of the login process',
|
||||
);
|
||||
child.emit('exit', 1);
|
||||
assert.deepEqual(await login.completion, { exitCode: 1 });
|
||||
});
|
||||
|
||||
test('Codex login refuses a read/write storage mismatch before spawning', async () => {
|
||||
await assert.rejects(
|
||||
startCodexChatGptLogin({
|
||||
environment: { CODEX_AUTH_JSON: '/tmp/another-auth.json' },
|
||||
home: '/home/fixture',
|
||||
executable: 'codex',
|
||||
spawnImpl: () => assert.fail('mismatched storage must not launch login'),
|
||||
}),
|
||||
{ code: 'CODEX_OAUTH_STORAGE_MISMATCH' },
|
||||
);
|
||||
});
|
||||
|
||||
test('Codex login resolves relative CODEX_HOME before changing the child directory', async () => {
|
||||
const child = new EventEmitter();
|
||||
const environment = { CODEX_HOME: './fixture-codex' };
|
||||
const login = await startCodexChatGptLogin({
|
||||
environment,
|
||||
executable: 'codex',
|
||||
spawnImpl(_command, _args, options) {
|
||||
assert.equal(
|
||||
options.env.CODEX_HOME,
|
||||
path.resolve(environment.CODEX_HOME),
|
||||
);
|
||||
queueMicrotask(() => child.emit('spawn'));
|
||||
return child;
|
||||
},
|
||||
});
|
||||
assert.equal(
|
||||
environment.CODEX_HOME,
|
||||
'./fixture-codex',
|
||||
'caller environment is unchanged',
|
||||
);
|
||||
child.emit('exit', 0);
|
||||
assert.deepEqual(await login.completion, { exitCode: 0 });
|
||||
});
|
||||
|
||||
test('Codex login hides spawn errors and can cancel only its owned child', async () => {
|
||||
const child = new EventEmitter();
|
||||
await assert.rejects(
|
||||
startCodexChatGptLogin({
|
||||
executable: 'codex',
|
||||
spawnImpl() {
|
||||
queueMicrotask(() =>
|
||||
child.emit('error', new Error('private-token-fixture')),
|
||||
);
|
||||
return child;
|
||||
},
|
||||
}),
|
||||
(error) =>
|
||||
error.code === 'CODEX_OAUTH_LOGIN_FAILED' &&
|
||||
!error.message.includes('private-token-fixture'),
|
||||
);
|
||||
|
||||
const running = new EventEmitter();
|
||||
let cancelled = 0;
|
||||
running.kill = (signal) => {
|
||||
assert.equal(signal, 'SIGTERM');
|
||||
cancelled += 1;
|
||||
running.emit('exit', null);
|
||||
};
|
||||
const login = await startCodexChatGptLogin({
|
||||
executable: 'codex',
|
||||
spawnImpl() {
|
||||
queueMicrotask(() => running.emit('spawn'));
|
||||
return running;
|
||||
},
|
||||
});
|
||||
login.cancel();
|
||||
assert.equal(cancelled, 1);
|
||||
assert.deepEqual(await login.completion, { exitCode: null });
|
||||
});
|
||||
@@ -0,0 +1,281 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { Readable } from 'node:stream';
|
||||
import { readPinokioEnvironment } from '../scripts/pinokio-environment.mjs';
|
||||
import { openAiRealtimeProxy } from 'gods-eye-view/server/providers/openai';
|
||||
import {
|
||||
makeCostRateLimiter,
|
||||
resolvePerMinuteCap,
|
||||
} from '../server/providers/common/rate-limit.js';
|
||||
import { OPENAI_DEFAULT_PER_MIN } from '../server/providers/openai/rate-limit.js';
|
||||
import { handleHudSummary } from '../server/providers/openai/hud-summary.js';
|
||||
import {
|
||||
googlePlacesContextProxy,
|
||||
GOOGLE_DEFAULT_PER_MIN,
|
||||
} from '../server/providers/places/google.js';
|
||||
|
||||
// Both limiters are cached at module scope on first use, so the env value that
|
||||
// matters is the one in place when a route is first exercised — restoring the
|
||||
// variable afterwards does not rebuild the limiter. Each route is therefore
|
||||
// driven by exactly one test here; a second test wanting a different cap would
|
||||
// need its own process, not just a different env value.
|
||||
|
||||
/** Set an env var for one test and put the old value back afterwards. */
|
||||
function env(t, name, value) {
|
||||
const old = process.env[name];
|
||||
if (value === undefined) delete process.env[name];
|
||||
else process.env[name] = value;
|
||||
t.after(() => {
|
||||
if (old === undefined) delete process.env[name];
|
||||
else process.env[name] = old;
|
||||
});
|
||||
}
|
||||
|
||||
function install(plugin) {
|
||||
const routes = new Map();
|
||||
plugin.configureServer({
|
||||
middlewares: { use: (route, handler) => routes.set(route, handler) },
|
||||
restart: async () => {},
|
||||
});
|
||||
return routes;
|
||||
}
|
||||
|
||||
function request(
|
||||
handler,
|
||||
{ url = '/', method = 'POST', remoteAddress = '127.0.0.1' } = {},
|
||||
) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = Readable.from([]);
|
||||
Object.assign(req, {
|
||||
method,
|
||||
url,
|
||||
headers: { host: 'localhost:4173', 'content-type': 'application/json' },
|
||||
socket: { remoteAddress },
|
||||
});
|
||||
const headers = {};
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
setHeader(name, value) {
|
||||
headers[name.toLowerCase()] = value;
|
||||
},
|
||||
end(body = '') {
|
||||
resolve({ status: this.statusCode, headers, body: String(body) });
|
||||
},
|
||||
};
|
||||
Promise.resolve(handler(req, res)).catch(reject);
|
||||
});
|
||||
}
|
||||
|
||||
test('an unset throttle resolves to the documented default, not to unlimited', () => {
|
||||
// The defect #16 reports: the cap was opt-in, so an unconfigured server let
|
||||
// anyone who could reach it mint tokens on the operator's key without bound.
|
||||
assert.equal(resolvePerMinuteCap(undefined, 30), 30);
|
||||
assert.equal(resolvePerMinuteCap('', 30), 30);
|
||||
assert.equal(resolvePerMinuteCap(' ', 30), 30);
|
||||
assert.ok(
|
||||
typeof makeCostRateLimiter(undefined, 30) === 'function',
|
||||
'an unconfigured cost endpoint must still be throttled',
|
||||
);
|
||||
});
|
||||
|
||||
test('an explicit 0 is the documented way to run unthrottled', () => {
|
||||
assert.equal(resolvePerMinuteCap('0', 30), 0);
|
||||
assert.equal(
|
||||
makeCostRateLimiter('0', 30),
|
||||
null,
|
||||
'operators who mean "no throttle" must still be able to say so',
|
||||
);
|
||||
});
|
||||
|
||||
test('a configured value wins, and a fractional one floors', () => {
|
||||
assert.equal(resolvePerMinuteCap('45', 30), 45);
|
||||
assert.equal(resolvePerMinuteCap(45, 30), 45);
|
||||
assert.equal(resolvePerMinuteCap('30.7', 30), 30);
|
||||
});
|
||||
|
||||
test('a positive fraction keeps the guard rather than flooring to unlimited', () => {
|
||||
for (const fraction of ['0.5', '0.99', 0.01, '1e-9']) {
|
||||
assert.equal(resolvePerMinuteCap(fraction, 30), 1, String(fraction));
|
||||
assert.equal(
|
||||
typeof makeCostRateLimiter(fraction, 30),
|
||||
'function',
|
||||
`${fraction} must still throttle`,
|
||||
);
|
||||
}
|
||||
// Only an explicit zero turns the limiter off.
|
||||
assert.equal(makeCostRateLimiter('0', 30), null);
|
||||
assert.equal(makeCostRateLimiter('0.0', 30), null);
|
||||
});
|
||||
|
||||
test('an unreadable value keeps the guard instead of removing it', () => {
|
||||
// `3O` (letter O) and `60/min` are the shapes a hand-edited .env produces.
|
||||
// Reading either as "unlimited" would disarm the spend guard on a typo —
|
||||
// the opposite of what the operator was reaching for when they typed it.
|
||||
for (const typo of ['3O', '60/min', 'thirty', '-5', 'NaN']) {
|
||||
assert.equal(
|
||||
resolvePerMinuteCap(typo, 30),
|
||||
30,
|
||||
`${typo} must fall back to the default, not to unlimited`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('the default cap clears the app’s own demand with room to spare', () => {
|
||||
// src/hud.js HUD_SUMMARY_INTERVAL_MS = 15000 -> 4 summaries/min per open
|
||||
// tab, plus one token mint per voice session. The cap has to sit well above
|
||||
// that or it would throttle the app rather than an abuser.
|
||||
const hudRequestsPerMinutePerTab = 60_000 / 15_000;
|
||||
assert.ok(
|
||||
OPENAI_DEFAULT_PER_MIN >= hudRequestsPerMinutePerTab * 4,
|
||||
`default ${OPENAI_DEFAULT_PER_MIN}/min leaves too little headroom over ${hudRequestsPerMinutePerTab}/min of ordinary use`,
|
||||
);
|
||||
});
|
||||
|
||||
test('the realtime token route throttles by default, and says so in a 429', async (t) => {
|
||||
env(t, 'GEV_RATELIMIT_OPENAI_PER_MIN', undefined);
|
||||
env(t, 'OPENAI_API_KEY', 'fixture-upstream-secret');
|
||||
let upstreamCalls = 0;
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
upstreamCalls += 1;
|
||||
return Response.json({ value: 'fixture-ephemeral' });
|
||||
});
|
||||
|
||||
const handler = install(openAiRealtimeProxy({})).get('/api/realtime/token');
|
||||
const statuses = [];
|
||||
for (let i = 0; i < OPENAI_DEFAULT_PER_MIN + 1; i += 1) {
|
||||
statuses.push((await request(handler)).status);
|
||||
}
|
||||
|
||||
assert.deepEqual(
|
||||
statuses.slice(0, OPENAI_DEFAULT_PER_MIN),
|
||||
Array(OPENAI_DEFAULT_PER_MIN).fill(200),
|
||||
'the default must not throttle ordinary use',
|
||||
);
|
||||
const overLimit = await request(handler);
|
||||
assert.equal(overLimit.status, 429);
|
||||
assert.equal(overLimit.headers['retry-after'], '5');
|
||||
assert.equal(JSON.parse(overLimit.body).error, 'Rate limit exceeded');
|
||||
assert.equal(
|
||||
overLimit.body.includes('fixture-upstream-secret'),
|
||||
false,
|
||||
'a throttled reply must not leak the server-held key',
|
||||
);
|
||||
assert.ok(
|
||||
upstreamCalls <= OPENAI_DEFAULT_PER_MIN,
|
||||
'a throttled request must be refused before it reaches OpenAI, or it still costs money',
|
||||
);
|
||||
});
|
||||
|
||||
test('the HUD summary route throttles the paid path, never the free one', async (t) => {
|
||||
env(t, 'OPENAI_API_KEY', 'fixture-upstream-secret');
|
||||
let upstreamCalls = 0;
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
upstreamCalls += 1;
|
||||
return Response.json({ output_text: 'five word hud summary here' });
|
||||
});
|
||||
|
||||
// Its own client address: the limiter is cached at module scope and shared
|
||||
// across the OpenAI routes, so the realtime case above has already spent
|
||||
// 127.0.0.1's budget inside this process.
|
||||
const from = '10.9.9.9';
|
||||
const statuses = [];
|
||||
for (let i = 0; i < OPENAI_DEFAULT_PER_MIN; i += 1)
|
||||
statuses.push(
|
||||
(await request(handleHudSummary, { remoteAddress: from })).status,
|
||||
);
|
||||
assert.deepEqual(
|
||||
statuses,
|
||||
Array(OPENAI_DEFAULT_PER_MIN).fill(200),
|
||||
'the default must not throttle ordinary HUD use',
|
||||
);
|
||||
|
||||
const overLimit = await request(handleHudSummary, { remoteAddress: from });
|
||||
assert.equal(overLimit.status, 429);
|
||||
assert.equal(overLimit.headers['retry-after'], '5');
|
||||
assert.equal(JSON.parse(overLimit.body).error, 'Rate limit exceeded');
|
||||
assert.equal(
|
||||
overLimit.body.includes('fixture-upstream-secret'),
|
||||
false,
|
||||
'a throttled reply must not leak the server-held key',
|
||||
);
|
||||
assert.ok(
|
||||
upstreamCalls <= OPENAI_DEFAULT_PER_MIN,
|
||||
'a throttled request must be refused before it reaches OpenAI, or it still costs money',
|
||||
);
|
||||
|
||||
// The keyless HUD answer is deliberately NOT throttled: it is composed
|
||||
// locally, spends nothing upstream, and capping it would only take the free
|
||||
// HUD away from an unconfigured install. Same exhausted client, no key.
|
||||
delete process.env.OPENAI_API_KEY;
|
||||
const free = await request(handleHudSummary, { remoteAddress: from });
|
||||
assert.equal(free.status, 200, 'the free path answers past a spent budget');
|
||||
assert.equal(JSON.parse(free.body).configured, false);
|
||||
assert.equal(
|
||||
upstreamCalls <= OPENAI_DEFAULT_PER_MIN,
|
||||
true,
|
||||
'the free path reaches no provider',
|
||||
);
|
||||
});
|
||||
|
||||
test('the Google places route throttles by default and keeps its places[] contract', async (t) => {
|
||||
env(t, 'GEV_RATELIMIT_GOOGLE_PER_MIN', undefined);
|
||||
let upstreamCalls = 0;
|
||||
const handler = install(
|
||||
googlePlacesContextProxy({
|
||||
resolveApiKey: () => 'fixture-google-server-key',
|
||||
fetchImpl: async () => {
|
||||
upstreamCalls += 1;
|
||||
return Response.json({ places: [] });
|
||||
},
|
||||
}),
|
||||
).get('/api/google/nearby-places');
|
||||
|
||||
const url = '/?lat=37.77493&lon=-122.41942';
|
||||
const statuses = [];
|
||||
for (let i = 0; i < GOOGLE_DEFAULT_PER_MIN; i += 1) {
|
||||
statuses.push((await request(handler, { url, method: 'GET' })).status);
|
||||
}
|
||||
assert.ok(
|
||||
statuses.every((s) => s === 200),
|
||||
'the default must not throttle ordinary use',
|
||||
);
|
||||
|
||||
const overLimit = await request(handler, { url, method: 'GET' });
|
||||
assert.equal(overLimit.status, 429);
|
||||
assert.equal(overLimit.headers['retry-after'], '5');
|
||||
// Every error response on this route carries places[]; the client reads it
|
||||
// unconditionally, so a throttled reply must not be the one shape that omits it.
|
||||
assert.deepEqual(JSON.parse(overLimit.body).places, []);
|
||||
assert.equal(
|
||||
upstreamCalls,
|
||||
GOOGLE_DEFAULT_PER_MIN,
|
||||
'a throttled request must be refused before it reaches Google, or it still costs money',
|
||||
);
|
||||
});
|
||||
|
||||
test('the code defaults match the caps the Pinokio build ships', () => {
|
||||
// These two numbers used to live only in pinokio/_ENVIRONMENT, and the value
|
||||
// this repo suggested elsewhere had already drifted away from them. Now that
|
||||
// they are the server's defaults, the packaged app and an unconfigured
|
||||
// checkout must agree, or a Pinokio user's throttle silently stops matching
|
||||
// the one everybody else gets. If you deliberately want them to differ, this
|
||||
// assertion is the place to say so.
|
||||
const shipped = readPinokioEnvironment(
|
||||
path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'..',
|
||||
'pinokio',
|
||||
'_ENVIRONMENT',
|
||||
),
|
||||
);
|
||||
assert.equal(
|
||||
Number(shipped.GEV_RATELIMIT_OPENAI_PER_MIN),
|
||||
OPENAI_DEFAULT_PER_MIN,
|
||||
);
|
||||
assert.equal(
|
||||
Number(shipped.GEV_RATELIMIT_GOOGLE_PER_MIN),
|
||||
GOOGLE_DEFAULT_PER_MIN,
|
||||
);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,803 @@
|
||||
/**
|
||||
* Bundled administrative boundaries — countries and states/provinces (Natural
|
||||
* Earth admin-0/admin-1) and US counties (US Census Bureau cartographic boundaries),
|
||||
* so "outline Texas", "outline Bavaria" or "outline Travis County" draws the
|
||||
* real boundary without a network lookup.
|
||||
*
|
||||
* Data: `local_data/natural_earth/{countries,states_provinces}.json` and
|
||||
* `local_data/us_census_counties/counties.json`, public domain and built
|
||||
* by `scripts/build-admin-packs.mjs` (provenance in each folder's README).
|
||||
* Rings are delta-encoded integers; see `decodeRing`.
|
||||
*
|
||||
* PURE data module — no Cesium, node-testable. Each pack is fetched through
|
||||
* `loadBundledJson` on the first lookup that needs it (never at app start),
|
||||
* indexed once, and kept; a failed load is retried later rather than cached
|
||||
* (`createRetryableLoader`). The county pack is only loaded for asks that
|
||||
* name a county (or a county-equivalent city) or that a geocoder typed as one.
|
||||
*
|
||||
* Two entry points:
|
||||
* - `findAdminArea(query, { near })` — name-only. Resolves asks that are
|
||||
* unambiguous from the words alone ("Texas", "TX", "State of Texas",
|
||||
* "Bavaria", "Travis County, Texas", "Orleans Parish"); duplicates are
|
||||
* settled by a state/country qualifier, then by the camera (`near`).
|
||||
* Country/state homonyms such as Georgia use the qualifier or camera;
|
||||
* city/state homonyms such as New York still defer to the geocoder.
|
||||
* - `findAdminAreaAt(names, lat, lon, scope)` — geocoder-confirmed. The
|
||||
* geocoder already typed the place as a country, state or county; the unit must
|
||||
* carry one of the names AND contain the geocoded point.
|
||||
*/
|
||||
|
||||
import { loadBundledJson } from './bundledJson.js';
|
||||
import { createRetryableLoader } from './retryableLoad.js';
|
||||
|
||||
const FOLD = {
|
||||
ß: 'ss',
|
||||
ø: 'o',
|
||||
æ: 'ae',
|
||||
ł: 'l',
|
||||
đ: 'd',
|
||||
ı: 'i',
|
||||
œ: 'oe',
|
||||
þ: 'th',
|
||||
};
|
||||
|
||||
/** Fold a name for matching: case, accents, punctuation, "the", "saint". */
|
||||
export function normalizeAdminName(value) {
|
||||
return String(value || '')
|
||||
.toLowerCase()
|
||||
.replace(/[ßøæłđıœþ]/g, (c) => FOLD[c])
|
||||
.normalize('NFD')
|
||||
.replace(/\p{M}/gu, '')
|
||||
.replace(/[^a-z0-9]+/g, ' ')
|
||||
.trim()
|
||||
.replace(/^the /, '')
|
||||
.replace(/\bsaint\b/g, 'st')
|
||||
.replace(/\bsainte\b/g, 'ste');
|
||||
}
|
||||
|
||||
const PACKS = {
|
||||
countries: {
|
||||
url: new URL('./local_data/natural_earth/countries.json', import.meta.url),
|
||||
},
|
||||
admin1: {
|
||||
url: new URL(
|
||||
'./local_data/natural_earth/states_provinces.json',
|
||||
import.meta.url,
|
||||
),
|
||||
},
|
||||
counties: {
|
||||
url: new URL(
|
||||
'./local_data/us_census_counties/counties.json',
|
||||
import.meta.url,
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
/** Test seam: count pack loads (lazy-loading contract). */
|
||||
export const packLoads = { admin1: 0, counties: 0, countries: 0 };
|
||||
|
||||
const EARTH_RADIUS_KM = 6371;
|
||||
const toRad = (d) => (d * Math.PI) / 180;
|
||||
|
||||
/** Spherical ring area (km²) — same formula as naturalEarthRegions.js. */
|
||||
function ringAreaKm2(ring) {
|
||||
const n = ring.length;
|
||||
if (n < 3) return 0;
|
||||
let sum = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const [lon1, lat1] = ring[i];
|
||||
const [lon2, lat2] = ring[(i + 1) % n];
|
||||
sum +=
|
||||
toRad(lon2 - lon1) * (2 + Math.sin(toRad(lat1)) + Math.sin(toRad(lat2)));
|
||||
}
|
||||
return Math.abs((sum * EARTH_RADIUS_KM * EARTH_RADIUS_KM) / 2);
|
||||
}
|
||||
|
||||
function haversineKm(lat1, lon1, lat2, lon2) {
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLon = toRad(lon2 - lon1);
|
||||
const h =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
||||
return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(h)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode one packed ring: integers in units of 10^-decimals degrees, the
|
||||
* first vertex absolute and each later one a delta from the previous.
|
||||
* @param {number[]} encoded
|
||||
* @param {number} decimals
|
||||
* @returns {Array<[number, number]>} open ring of [lon, lat]
|
||||
*/
|
||||
export function decodeRing(encoded, decimals) {
|
||||
const factor = 10 ** decimals;
|
||||
const ring = [];
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
for (let i = 0; i + 1 < encoded.length; i += 2) {
|
||||
x += encoded[i];
|
||||
y += encoded[i + 1];
|
||||
ring.push([x / factor, y / factor]);
|
||||
}
|
||||
return ring;
|
||||
}
|
||||
|
||||
/** Even-odd point-in-ring test; ring is [[lon, lat], …], open or closed. */
|
||||
export function pointInRing(ring, lat, lon) {
|
||||
if (!Array.isArray(ring) || ring.length < 3) return false;
|
||||
let inside = false;
|
||||
for (let i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) {
|
||||
const [xi, yi] = ring[i];
|
||||
const [xj, yj] = ring[j];
|
||||
if (
|
||||
yi > lat !== yj > lat &&
|
||||
lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi
|
||||
)
|
||||
inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether polygons ([outer, ...holes][]) contain the point: inside an outer
|
||||
* ring and not inside any of that part's holes.
|
||||
*/
|
||||
export function polygonsContain(polygons, lat, lon) {
|
||||
for (const [outer, ...holes] of polygons || []) {
|
||||
if (!pointInRing(outer, lat, lon)) continue;
|
||||
if (!holes.some((hole) => pointInRing(hole, lat, lon))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* A unit's geometry, decoded on first use. Longitudes are unwrapped around
|
||||
* the largest part for the box so units cut at the antimeridian (Alaska,
|
||||
* Chukotka) get a box on the right side of the globe.
|
||||
*/
|
||||
function geometryOf(entry) {
|
||||
if (entry.geometry) return entry.geometry;
|
||||
const decimals = entry.feature.d ?? entry.decimals;
|
||||
const polygons = entry.feature.polygons.map((poly) =>
|
||||
poly.map((ring) => decodeRing(ring, decimals)),
|
||||
);
|
||||
const ref = polygons[0]?.[0]?.[0]?.[0] ?? 0;
|
||||
let west = Infinity;
|
||||
let south = Infinity;
|
||||
let east = -Infinity;
|
||||
let north = -Infinity;
|
||||
let areaKm2 = 0;
|
||||
for (const [outer, ...holes] of polygons) {
|
||||
areaKm2 += ringAreaKm2(outer);
|
||||
for (const hole of holes) areaKm2 -= ringAreaKm2(hole);
|
||||
for (const [lon, lat] of outer) {
|
||||
const x = lon - 360 * Math.round((lon - ref) / 360);
|
||||
if (x < west) west = x;
|
||||
if (x > east) east = x;
|
||||
if (lat < south) south = lat;
|
||||
if (lat > north) north = lat;
|
||||
}
|
||||
}
|
||||
const label = entry.feature.label
|
||||
? { lon: entry.feature.label[0], lat: entry.feature.label[1] }
|
||||
: ringCentroid(polygons[0][0]);
|
||||
entry.geometry = {
|
||||
polygons,
|
||||
bbox: [west, south, east, north],
|
||||
areaKm2: Math.max(0, areaKm2),
|
||||
label,
|
||||
};
|
||||
return entry.geometry;
|
||||
}
|
||||
|
||||
/** Area-weighted centroid of an open ring (planar; fine at unit scale). */
|
||||
function ringCentroid(ring) {
|
||||
let a = 0;
|
||||
let cx = 0;
|
||||
let cy = 0;
|
||||
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
||||
const [x0, y0] = ring[j];
|
||||
const [x1, y1] = ring[i];
|
||||
const f = x0 * y1 - x1 * y0;
|
||||
a += f;
|
||||
cx += (x0 + x1) * f;
|
||||
cy += (y0 + y1) * f;
|
||||
}
|
||||
if (!a) return { lon: ring[0][0], lat: ring[0][1] };
|
||||
return { lon: cx / (3 * a), lat: cy / (3 * a) };
|
||||
}
|
||||
|
||||
/** Km from a point to a unit's box (0 inside it). */
|
||||
function boxDistanceKm(bbox, lat, lon) {
|
||||
const [west, south, east, north] = bbox;
|
||||
const mid = (west + east) / 2;
|
||||
const x = lon - 360 * Math.round((lon - mid) / 360);
|
||||
const clampedLon = Math.min(east, Math.max(west, x));
|
||||
const clampedLat = Math.min(north, Math.max(south, lat));
|
||||
return haversineKm(lat, x, clampedLat, clampedLon);
|
||||
}
|
||||
|
||||
/**
|
||||
* Natural Earth `min_label` (the zoom at which a unit is labelled) up to which
|
||||
* a unit may be named alone: every US state, Canadian province, Mexican and
|
||||
* German state; not English counties, French départements or small districts.
|
||||
*/
|
||||
const BARE_NAME_MAX_RANK = 7;
|
||||
|
||||
/**
|
||||
* Words that name a unit somewhere (Cameroon's "North", Paraguay's
|
||||
* "Central") but, said alone, name a direction or a feature.
|
||||
*/
|
||||
const GENERIC_NAMES = new Set([
|
||||
'north',
|
||||
'south',
|
||||
'east',
|
||||
'west',
|
||||
'northern',
|
||||
'southern',
|
||||
'eastern',
|
||||
'western',
|
||||
'north east',
|
||||
'north west',
|
||||
'south east',
|
||||
'south west',
|
||||
'northeast',
|
||||
'northwest',
|
||||
'southeast',
|
||||
'southwest',
|
||||
'far north',
|
||||
'far west',
|
||||
'central',
|
||||
'centre',
|
||||
'center',
|
||||
'capital',
|
||||
'coast',
|
||||
'island',
|
||||
'islands',
|
||||
'lakes',
|
||||
'highland',
|
||||
'highlands',
|
||||
'interior',
|
||||
'upper',
|
||||
'lower',
|
||||
'valley',
|
||||
'plateau',
|
||||
'plateaux',
|
||||
'littoral',
|
||||
'oriental',
|
||||
'occidental',
|
||||
]);
|
||||
|
||||
/** State codes that, alone, more often mean a city ("LA", "NY"). */
|
||||
const CITY_ABBREVIATIONS = new Set(['la', 'ny']);
|
||||
|
||||
/** Postal abbreviations accepted alone ("TX", "ON"): US states and Canada. */
|
||||
const POSTAL_COUNTRIES = new Set(['US', 'CA']);
|
||||
|
||||
function addKey(index, key, entry, tier) {
|
||||
if (!key) return;
|
||||
entry.keys?.add(key);
|
||||
const list = index.get(key);
|
||||
const item = { entry, tier };
|
||||
if (!list) index.set(key, [item]);
|
||||
else if (!list.some((i) => i.entry === entry)) list.push(item);
|
||||
}
|
||||
|
||||
const loadAdmin1 = createRetryableLoader(async () => {
|
||||
packLoads.admin1 += 1;
|
||||
const pack = await loadBundledJson(PACKS.admin1.url);
|
||||
const decimals = pack.meta?.decimals ?? 3;
|
||||
const index = new Map();
|
||||
for (const feature of pack.features || []) {
|
||||
const entry = {
|
||||
kind: 'state',
|
||||
feature,
|
||||
decimals,
|
||||
amb: new Set(feature.amb || []),
|
||||
ambAbroad: new Set(feature.ambAbroad || []),
|
||||
keys: new Set(),
|
||||
country: normalizeAdminName(feature.country),
|
||||
iso2: String(feature.iso2 || '').toLowerCase(),
|
||||
};
|
||||
addKey(index, normalizeAdminName(feature.name), entry, 0);
|
||||
addKey(index, normalizeAdminName(feature.nameEn), entry, 1);
|
||||
for (const alt of feature.alt || [])
|
||||
addKey(index, normalizeAdminName(alt), entry, 2);
|
||||
if (feature.postal && POSTAL_COUNTRIES.has(feature.iso2))
|
||||
addKey(index, `postal:${feature.postal.toLowerCase()}`, entry, 2);
|
||||
}
|
||||
return { index };
|
||||
});
|
||||
|
||||
const loadCounties = createRetryableLoader(async () => {
|
||||
packLoads.counties += 1;
|
||||
const pack = await loadBundledJson(PACKS.counties.url);
|
||||
const decimals = pack.meta?.decimals ?? 4;
|
||||
const byFull = new Map();
|
||||
const byName = new Map();
|
||||
const states = new Map();
|
||||
for (const feature of pack.features || []) {
|
||||
const entry = {
|
||||
kind: 'county',
|
||||
feature,
|
||||
decimals,
|
||||
state: normalizeAdminName(feature.state),
|
||||
st: String(feature.st || '').toLowerCase(),
|
||||
};
|
||||
addKey(byFull, normalizeAdminName(feature.full), entry, 0);
|
||||
addKey(byName, normalizeAdminName(feature.name), entry, 1);
|
||||
states.set(entry.state, entry.st);
|
||||
}
|
||||
return { byFull, byName, states };
|
||||
});
|
||||
|
||||
const loadCountries = createRetryableLoader(async () => {
|
||||
packLoads.countries += 1;
|
||||
const pack = await loadBundledJson(PACKS.countries.url);
|
||||
const index = new Map();
|
||||
for (const feature of pack.features || []) {
|
||||
const entry = {
|
||||
kind: 'country',
|
||||
feature,
|
||||
decimals: pack.meta.decimals,
|
||||
keys: new Set(),
|
||||
country: normalizeAdminName(feature.country),
|
||||
iso2: feature.iso2?.toLowerCase(),
|
||||
};
|
||||
for (const name of [feature.name, ...(feature.alt || [])])
|
||||
addKey(index, normalizeAdminName(name), entry, 0);
|
||||
}
|
||||
return { index };
|
||||
});
|
||||
|
||||
async function countryCandidates(parsed) {
|
||||
if (!['any', 'country'].includes(parsed.kind)) return [];
|
||||
const { index } = await loadCountries();
|
||||
const key = COUNTRY_TARGET_ALIASES.get(parsed.name) || parsed.name;
|
||||
return (index.get(key) || []).filter(({ entry }) =>
|
||||
parsed.qualifiers.every((q) => qualifierMatches(entry, q)),
|
||||
);
|
||||
}
|
||||
|
||||
/** Country target aliases never broaden constituent-country requests. */
|
||||
const COUNTRY_TARGET_ALIASES = new Map([
|
||||
['usa', 'united states of america'],
|
||||
['us', 'united states of america'],
|
||||
['u s', 'united states of america'],
|
||||
['u s a', 'united states of america'],
|
||||
['united states', 'united states of america'],
|
||||
['america', 'united states of america'],
|
||||
['uk', 'united kingdom'],
|
||||
['u k', 'united kingdom'],
|
||||
['great britain', 'united kingdom'],
|
||||
['britain', 'united kingdom'],
|
||||
['russian federation', 'russia'],
|
||||
['korea', 'south korea'],
|
||||
['czechia', 'czech republic'],
|
||||
['tanzania', 'united republic of tanzania'],
|
||||
['drc', 'democratic republic of the congo'],
|
||||
]);
|
||||
|
||||
const COUNTRY_ALIASES = new Map([
|
||||
...COUNTRY_TARGET_ALIASES,
|
||||
['england', 'united kingdom'],
|
||||
['scotland', 'united kingdom'],
|
||||
['wales', 'united kingdom'],
|
||||
['northern ireland', 'united kingdom'],
|
||||
]);
|
||||
|
||||
/** County-equivalent words, longest first so "city and borough" wins. */
|
||||
const COUNTY_WORDS = [
|
||||
'city and borough',
|
||||
'planning region',
|
||||
'census area',
|
||||
'municipality',
|
||||
'municipio',
|
||||
'borough',
|
||||
'parish',
|
||||
'county',
|
||||
];
|
||||
const COUNTY_WORD_RE = new RegExp(`\\b(${COUNTY_WORDS.join('|')})\\b`);
|
||||
const STATE_PREFIX_RE =
|
||||
/^(?:free state|state|commonwealth|province|territory|prefecture|region|land|canton|department) of (.+)$/;
|
||||
const STATE_SUFFIX_RE = /^(.+) (?:state|province|prefecture|territory)$/;
|
||||
|
||||
/**
|
||||
* Split an ask into what it names and how.
|
||||
* @param {string} text
|
||||
* @returns {{kind:'country'|'county'|'state'|'any', name:string, qualifiers:string[],
|
||||
* postal:string|null, countyWord:string|null}|null}
|
||||
*/
|
||||
export function parseAdminQuery(text) {
|
||||
const raw = String(text || '').trim();
|
||||
if (!raw) return null;
|
||||
const segments = raw.split(',').map(normalizeAdminName).filter(Boolean);
|
||||
if (!segments.length) return null;
|
||||
const head = segments[0];
|
||||
const country =
|
||||
/^(?:country|nation) of (.+)$/.exec(head) ||
|
||||
/^(.+) (?:country|nation)$/.exec(head);
|
||||
if (country)
|
||||
return {
|
||||
kind: 'country',
|
||||
name: country[1],
|
||||
qualifiers: segments.slice(1),
|
||||
postal: null,
|
||||
countyWord: null,
|
||||
full: head,
|
||||
};
|
||||
const qualifiers = segments.slice(1);
|
||||
const postal = /^[A-Za-z]{2}$/.test(raw) ? raw.toLowerCase() : null;
|
||||
// "TX", "DC", "D.C.": written as an abbreviation, not a short word.
|
||||
const abbreviation = /^[A-Z]{2,3}$/.test(raw.replace(/[.\s]/g, ''));
|
||||
|
||||
// "County Cork", "County of Los Angeles"
|
||||
const countyPrefix = /^county (?:of )?(.+)$/.exec(head);
|
||||
if (countyPrefix)
|
||||
return {
|
||||
kind: 'county',
|
||||
name: countyPrefix[1],
|
||||
qualifiers,
|
||||
postal: null,
|
||||
countyWord: 'county',
|
||||
full: head,
|
||||
};
|
||||
// "Travis County", "Travis County Texas", "Orleans Parish, LA"
|
||||
const county = COUNTY_WORD_RE.exec(head);
|
||||
if (county && county.index > 0) {
|
||||
const name = head.slice(0, county.index).trim();
|
||||
const rest = head.slice(county.index + county[1].length).trim();
|
||||
if (rest) qualifiers.unshift(rest);
|
||||
return {
|
||||
kind: 'county',
|
||||
name,
|
||||
qualifiers,
|
||||
postal: null,
|
||||
countyWord: county[1],
|
||||
full: head.slice(0, county.index + county[1].length),
|
||||
};
|
||||
}
|
||||
const prefix = STATE_PREFIX_RE.exec(head);
|
||||
if (prefix)
|
||||
return {
|
||||
kind: 'state',
|
||||
name: prefix[1],
|
||||
qualifiers,
|
||||
postal: null,
|
||||
countyWord: null,
|
||||
full: head,
|
||||
form: 'prefix',
|
||||
};
|
||||
const suffix = STATE_SUFFIX_RE.exec(head);
|
||||
if (suffix)
|
||||
return {
|
||||
kind: 'state',
|
||||
name: suffix[1],
|
||||
qualifiers,
|
||||
postal: null,
|
||||
countyWord: null,
|
||||
full: head,
|
||||
form: 'suffix',
|
||||
};
|
||||
return {
|
||||
kind: 'any',
|
||||
name: head,
|
||||
qualifiers,
|
||||
postal,
|
||||
abbreviation,
|
||||
countyWord: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** A qualifier names the unit's country ("Bavaria, Germany") or the unit
|
||||
* itself ("Washington, DC"). */
|
||||
function qualifierMatches(entry, qualifier) {
|
||||
const q = COUNTRY_ALIASES.get(qualifier) || qualifier;
|
||||
return (
|
||||
entry.country === q ||
|
||||
entry.iso2 === q ||
|
||||
entry.keys.has(qualifier) ||
|
||||
entry.keys.has(`postal:${qualifier}`)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rank candidates: a unit containing `near` first, then the match tier,
|
||||
* then distance from `near`, then larger area. Returns the ranked list.
|
||||
*/
|
||||
function rank(candidates, near) {
|
||||
const hasNear = Number.isFinite(near?.lat) && Number.isFinite(near?.lon);
|
||||
const scored = candidates.map(({ entry, tier }) => {
|
||||
const geometry = geometryOf(entry);
|
||||
const contains = hasNear
|
||||
? polygonsContain(geometry.polygons, near.lat, near.lon)
|
||||
: false;
|
||||
const distanceKm = hasNear
|
||||
? contains
|
||||
? 0
|
||||
: boxDistanceKm(geometry.bbox, near.lat, near.lon)
|
||||
: 0;
|
||||
return { entry, tier, contains, distanceKm, areaKm2: geometry.areaKm2 };
|
||||
});
|
||||
scored.sort(
|
||||
(a, b) =>
|
||||
Number(b.contains) - Number(a.contains) ||
|
||||
a.tier - b.tier ||
|
||||
a.distanceKm - b.distanceKm ||
|
||||
b.areaKm2 - a.areaKm2,
|
||||
);
|
||||
return scored;
|
||||
}
|
||||
|
||||
function toResult(scored, candidateCount) {
|
||||
const { entry } = scored;
|
||||
const { feature } = entry;
|
||||
const geometry = geometryOf(entry);
|
||||
// Parts are stored largest first, so polygons[0][0] is the main outline.
|
||||
const polygons = geometry.polygons;
|
||||
if (entry.kind === 'county') {
|
||||
return {
|
||||
kind: 'county',
|
||||
name: feature.full,
|
||||
region: feature.state,
|
||||
regionCode: feature.st,
|
||||
country: 'United States of America',
|
||||
id: feature.geoid,
|
||||
source: 'us-census',
|
||||
polygons,
|
||||
ring: polygons[0][0],
|
||||
bbox: geometry.bbox,
|
||||
areaKm2: geometry.areaKm2,
|
||||
label: geometry.label,
|
||||
candidates: candidateCount,
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: entry.kind,
|
||||
name: feature.nameEn || feature.name,
|
||||
localName: feature.name,
|
||||
type: feature.type,
|
||||
region: null,
|
||||
regionCode: feature.postal || null,
|
||||
country: feature.country,
|
||||
id: feature.iso || null,
|
||||
source: 'natural-earth',
|
||||
polygons,
|
||||
ring: polygons[0][0],
|
||||
bbox: geometry.bbox,
|
||||
areaKm2: geometry.areaKm2,
|
||||
label: geometry.label,
|
||||
candidates: candidateCount,
|
||||
};
|
||||
}
|
||||
|
||||
async function countyCandidates(parsed) {
|
||||
const { byFull, byName, states } = await loadCounties();
|
||||
// An exact full name ("Orleans Parish") wins outright; without one, the
|
||||
// name with ANY county-equivalent word ("Anchorage Borough" finds
|
||||
// Anchorage Municipality). An independent city is only ever its full name
|
||||
// ("Fairfax city", "Carson City").
|
||||
let list = parsed.countyWord
|
||||
? [
|
||||
...(byFull.get(parsed.full || `${parsed.name} ${parsed.countyWord}`) ||
|
||||
byName.get(parsed.name) ||
|
||||
[]),
|
||||
]
|
||||
: [...(byFull.get(parsed.name) || [])];
|
||||
if (!list.length) return [];
|
||||
// Every qualifier must be a state or the US: "Travis County, TX, USA"
|
||||
// qualifies, "Travis County Courthouse, Texas" names something else.
|
||||
const stateByCode = new Map([...states].map(([name, st]) => [st, name]));
|
||||
const stateQualifiers = [];
|
||||
for (const q of parsed.qualifiers) {
|
||||
const state = states.has(q) ? q : stateByCode.get(q);
|
||||
if (state) stateQualifiers.push(state);
|
||||
else if (!isUnitedStates(q)) return [];
|
||||
}
|
||||
if (stateQualifiers.length)
|
||||
list = list.filter((i) => stateQualifiers.includes(i.entry.state));
|
||||
return list;
|
||||
}
|
||||
|
||||
function isUnitedStates(qualifier) {
|
||||
return (
|
||||
(COUNTRY_ALIASES.get(qualifier) || qualifier) === 'united states of america'
|
||||
);
|
||||
}
|
||||
|
||||
async function stateCandidates(parsed, { allowAmbiguous = false } = {}) {
|
||||
const { index } = await loadAdmin1();
|
||||
const keys = [];
|
||||
if (parsed.full) keys.push(parsed.full);
|
||||
keys.push(parsed.name);
|
||||
if (parsed.postal) keys.push(`postal:${parsed.postal}`);
|
||||
const seen = new Set();
|
||||
let list = [];
|
||||
for (const key of keys) {
|
||||
for (const item of index.get(key) || []) {
|
||||
if (seen.has(item.entry)) continue;
|
||||
seen.add(item.entry);
|
||||
list.push({ ...item, key, tier: key === parsed.full ? 0 : item.tier });
|
||||
}
|
||||
}
|
||||
// Every qualifier must place the unit: "Bavaria, Germany", "Washington,
|
||||
// DC" — not "Santa Barbara, California" (a city the pack does not hold).
|
||||
if (parsed.qualifiers.length)
|
||||
list = list.filter((i) =>
|
||||
parsed.qualifiers.every((q) => qualifierMatches(i.entry, q)),
|
||||
);
|
||||
if (allowAmbiguous) return list;
|
||||
// A bare name answers alone only for a prominent unit — a US state, a
|
||||
// Canadian province, Bavaria — never a French département, an English
|
||||
// county or a Bahamas district ("Long Island", "Santa Barbara", "Kent" are
|
||||
// likelier the places a geocoder knows). It must not also be a country or
|
||||
// a large city ("Georgia", "New York"), and a short code must be written
|
||||
// as one ("TX", not "tx"). Otherwise a qualifier, admin wording or the
|
||||
// geocoder decides.
|
||||
// A country qualifier places a name without making it a state: "New York,
|
||||
// USA" and "Madrid, Spain" stay the geocoder's; "Washington, DC" names the
|
||||
// unit itself.
|
||||
if (parsed.kind === 'any') {
|
||||
const short = parsed.name.replace(/ /g, '').length <= 3;
|
||||
if (short && (!parsed.abbreviation || CITY_ABBREVIATIONS.has(parsed.name)))
|
||||
return [];
|
||||
const namesUnit = (entry) =>
|
||||
parsed.qualifiers.some(
|
||||
(q) => entry.keys.has(q) || entry.keys.has(`postal:${q}`),
|
||||
);
|
||||
if (!parsed.qualifiers.length) {
|
||||
if (GENERIC_NAMES.has(parsed.name)) return [];
|
||||
return list.filter(
|
||||
(i) =>
|
||||
!i.entry.amb.has(i.key) &&
|
||||
!i.entry.ambAbroad.has(i.key) &&
|
||||
((i.entry.feature.rank ?? Infinity) <= BARE_NAME_MAX_RANK ||
|
||||
i.entry.feature.cityState),
|
||||
);
|
||||
}
|
||||
// Qualified: a shared name abroad is settled by the country ("Victoria,
|
||||
// Australia"); one shared at home only by the unit's own name.
|
||||
return list.filter((i) => !i.entry.amb.has(i.key) || namesUnit(i.entry));
|
||||
}
|
||||
// "X State" only settles a name that is ambiguous on its own ("New York
|
||||
// State"); otherwise it is as likely a university ("Ohio State") and a
|
||||
// geocoder decides. A unit literally named so ("Free State") matches.
|
||||
if (parsed.form === 'suffix')
|
||||
return list.filter((i) => i.key === parsed.full || i.entry.amb.has(i.key));
|
||||
return list;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an administrative unit from the ask's words alone.
|
||||
*
|
||||
* @param {string} query e.g. "Texas", "TX", "State of Texas", "Bavaria",
|
||||
* "Bavaria, Germany", "Travis County", "Travis County, TX", "Orleans Parish"
|
||||
* @param {{near?: {lat:number, lon:number}|null}} [options] the camera's
|
||||
* view centre — the tie-break between same-named units
|
||||
* @returns {Promise<AdminArea|null>}
|
||||
*
|
||||
* @typedef {{kind:'country'|'state'|'county', name:string, region:string|null,
|
||||
* regionCode:string|null, country:string, id:string|null,
|
||||
* source:'natural-earth'|'us-census',
|
||||
* polygons:Array<Array<Array<[number,number]>>>, ring:Array<[number,number]>,
|
||||
* bbox:number[], areaKm2:number, label:{lat:number, lon:number},
|
||||
* candidates:number}} AdminArea
|
||||
*/
|
||||
export async function findAdminArea(query, { near = null } = {}) {
|
||||
const parsed = parseAdminQuery(query);
|
||||
if (!parsed || !parsed.name) return null;
|
||||
let candidates = [];
|
||||
if (parsed.kind === 'country') {
|
||||
candidates = await countryCandidates(parsed);
|
||||
} else if (parsed.kind === 'county') {
|
||||
// County wording also names admin-1 units abroad. Neither pack gets
|
||||
// first refusal: qualifiers exclude ineligible countries before ranking.
|
||||
const [counties, admin1] = await Promise.all([
|
||||
countyCandidates(parsed),
|
||||
stateCandidates({ ...parsed, kind: 'state' }),
|
||||
]);
|
||||
candidates = [...counties, ...admin1];
|
||||
} else {
|
||||
candidates = await stateCandidates(parsed);
|
||||
if (!candidates.length || COUNTRY_TARGET_ALIASES.has(parsed.name)) {
|
||||
const countries = await countryCandidates(parsed);
|
||||
if (countries.length) {
|
||||
const states = await stateCandidates(parsed, { allowAmbiguous: true });
|
||||
if (
|
||||
states.length &&
|
||||
!Number.isFinite(near?.lat) &&
|
||||
!parsed.qualifiers.length
|
||||
)
|
||||
return null;
|
||||
candidates = [...countries, ...states];
|
||||
}
|
||||
}
|
||||
// A county-equivalent city: "Baltimore city", "Fairfax city, Virginia".
|
||||
if (!candidates.length && / city$/.test(parsed.name))
|
||||
candidates = await countyCandidates(parsed);
|
||||
}
|
||||
if (!candidates.length) return null;
|
||||
const ranked = rank(candidates, near);
|
||||
return toResult(ranked[0], candidates.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Geocoder-confirmed lookup: the geocoder typed a country, state or
|
||||
* county, so the unit must carry one of `names` AND contain the geocoded
|
||||
* point. Ambiguity marks do not apply — the geocoder already chose.
|
||||
*
|
||||
* @param {string[]} names the ask and the geocoder's own name for it
|
||||
* @param {number} lat
|
||||
* @param {number} lon
|
||||
* @param {'country'|'state'|'county'} scope
|
||||
* @returns {Promise<AdminArea|null>}
|
||||
*/
|
||||
export async function findAdminAreaAt(names, lat, lon, scope) {
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
||||
const parsedList = [...new Set((names || []).filter(Boolean))]
|
||||
.map(parseAdminQuery)
|
||||
.filter((p) => p?.name);
|
||||
if (!parsedList.length) return null;
|
||||
const containing = (items) => {
|
||||
const out = [];
|
||||
for (const item of items)
|
||||
if (
|
||||
!out.some((c) => c.entry === item.entry) &&
|
||||
polygonsContain(geometryOf(item.entry).polygons, lat, lon)
|
||||
)
|
||||
out.push(item);
|
||||
return out;
|
||||
};
|
||||
if (scope === 'country') {
|
||||
const countries = [];
|
||||
for (const parsed of parsedList)
|
||||
countries.push(
|
||||
...(await countryCandidates({
|
||||
...parsed,
|
||||
kind: 'country',
|
||||
qualifiers: [],
|
||||
})),
|
||||
);
|
||||
const found = containing(countries);
|
||||
return found.length
|
||||
? toResult(rank(found, { lat, lon })[0], found.length)
|
||||
: null;
|
||||
}
|
||||
// The geocoder's scope says what kind of unit this is; the ask's wording
|
||||
// may omit it ("Travis" typed as a county). A US county that matches wins
|
||||
// outright — "New York County" is Manhattan, not the state.
|
||||
if (scope === 'county' && inUnitedStatesBox(lat, lon)) {
|
||||
const counties = [];
|
||||
for (const parsed of parsedList)
|
||||
counties.push(
|
||||
...(await countyCandidates({
|
||||
...parsed,
|
||||
kind: 'county',
|
||||
countyWord: parsed.countyWord || 'county',
|
||||
qualifiers: [],
|
||||
})),
|
||||
);
|
||||
const found = containing(counties);
|
||||
if (found.length)
|
||||
return toResult(rank(found, { lat, lon })[0], found.length);
|
||||
}
|
||||
const states = [];
|
||||
for (const parsed of parsedList)
|
||||
states.push(
|
||||
...(await stateCandidates(
|
||||
{ ...parsed, qualifiers: [] },
|
||||
{ allowAmbiguous: true },
|
||||
)),
|
||||
);
|
||||
const found = containing(states);
|
||||
if (!found.length) return null;
|
||||
return toResult(rank(found, { lat, lon })[0], found.length);
|
||||
}
|
||||
|
||||
/** Rough box around the US and its territories (skips the county pack elsewhere). */
|
||||
function inUnitedStatesBox(lat, lon) {
|
||||
return (
|
||||
(lat >= 13 && lat <= 72 && (lon <= -64 || lon >= 144)) ||
|
||||
(lat >= -15 && lat <= -10 && lon <= -168)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
// Bundled state/province and US county boundaries: name matching,
|
||||
// disambiguation, multipolygons and holes, and lazy loading.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, statSync } from 'node:fs';
|
||||
import {
|
||||
decodeRing,
|
||||
findAdminArea,
|
||||
findAdminAreaAt,
|
||||
normalizeAdminName,
|
||||
packLoads,
|
||||
parseAdminQuery,
|
||||
polygonsContain,
|
||||
} from './adminBoundaries.js';
|
||||
|
||||
const ADMIN1 = new URL(
|
||||
'./local_data/natural_earth/states_provinces.json',
|
||||
import.meta.url,
|
||||
);
|
||||
const COUNTIES = new URL(
|
||||
'./local_data/us_census_counties/counties.json',
|
||||
import.meta.url,
|
||||
);
|
||||
|
||||
const AUSTIN = { lat: 30.2672, lon: -97.7431 };
|
||||
const PORTLAND = { lat: 45.5152, lon: -122.6784 };
|
||||
|
||||
// Runs first: nothing loads until a lookup needs it, and a state ask never
|
||||
// pulls the county pack.
|
||||
test('packs load lazily, once, and only the pack an ask needs', async () => {
|
||||
assert.deepEqual(packLoads, { admin1: 0, counties: 0, countries: 0 });
|
||||
assert.equal(parseAdminQuery('Texas').kind, 'any');
|
||||
assert.deepEqual(
|
||||
packLoads,
|
||||
{ admin1: 0, counties: 0, countries: 0 },
|
||||
'parsing is free',
|
||||
);
|
||||
await findAdminArea('Texas');
|
||||
await findAdminArea('Bavaria');
|
||||
assert.deepEqual(packLoads, { admin1: 1, counties: 0, countries: 0 });
|
||||
await findAdminArea('Travis County');
|
||||
await findAdminArea('Orleans Parish');
|
||||
assert.deepEqual(packLoads, { admin1: 1, counties: 1, countries: 0 });
|
||||
});
|
||||
|
||||
test('Texas, TX and "State of Texas" name the same unit', async () => {
|
||||
for (const query of [
|
||||
'Texas',
|
||||
'texas',
|
||||
'TX',
|
||||
'State of Texas',
|
||||
'the State of Texas',
|
||||
'Texas, USA',
|
||||
'Texas, United States',
|
||||
]) {
|
||||
const unit = await findAdminArea(query, { near: AUSTIN });
|
||||
assert.equal(unit?.id, 'US-TX', query);
|
||||
assert.equal(unit.name, 'Texas');
|
||||
assert.equal(unit.kind, 'state');
|
||||
assert.equal(unit.source, 'natural-earth');
|
||||
}
|
||||
const texas = await findAdminArea('Texas');
|
||||
assert.ok(
|
||||
texas.areaKm2 > 600_000 && texas.areaKm2 < 750_000,
|
||||
`Texas area ${texas.areaKm2}`,
|
||||
);
|
||||
assert.ok(polygonsContain(texas.polygons, AUSTIN.lat, AUSTIN.lon));
|
||||
});
|
||||
|
||||
test('provinces and states abroad resolve by English and local names', async () => {
|
||||
for (const query of [
|
||||
'Bavaria',
|
||||
'Bayern',
|
||||
'Bavaria, Germany',
|
||||
'Free State of Bavaria',
|
||||
]) {
|
||||
const unit = await findAdminArea(query);
|
||||
assert.equal(unit?.id, 'DE-BY', query);
|
||||
assert.equal(unit.name, 'Bavaria');
|
||||
assert.equal(unit.country, 'Germany');
|
||||
}
|
||||
assert.equal((await findAdminArea('Ontario'))?.id, 'CA-ON');
|
||||
assert.equal((await findAdminArea('ON'))?.id, 'CA-ON');
|
||||
assert.equal((await findAdminArea('Province of Ontario'))?.id, 'CA-ON');
|
||||
assert.equal((await findAdminArea('Free State'))?.country, 'South Africa');
|
||||
// Same name in two countries: the qualifier decides.
|
||||
assert.equal((await findAdminArea('Punjab, India'))?.id, 'IN-PB');
|
||||
assert.equal((await findAdminArea('Punjab, Pakistan'))?.id, 'PK-PB');
|
||||
// A qualifier may name the unit itself.
|
||||
assert.equal((await findAdminArea('Washington, DC'))?.id, 'US-DC');
|
||||
});
|
||||
|
||||
test('"Washington" is the state; "Washington County" is a county near the camera', async () => {
|
||||
const state = await findAdminArea('Washington', { near: AUSTIN });
|
||||
assert.equal(state?.id, 'US-WA');
|
||||
const fromAustin = await findAdminArea('Washington County', { near: AUSTIN });
|
||||
assert.equal(fromAustin.kind, 'county');
|
||||
assert.equal(fromAustin.name, 'Washington County');
|
||||
assert.equal(fromAustin.region, 'Texas');
|
||||
assert.ok(fromAustin.candidates > 20, 'dozens of Washington Counties');
|
||||
const fromPortland = await findAdminArea('Washington County', {
|
||||
near: PORTLAND,
|
||||
});
|
||||
assert.equal(fromPortland.region, 'Oregon');
|
||||
const arkansas = await findAdminArea('Washington County, Arkansas', {
|
||||
near: PORTLAND,
|
||||
});
|
||||
assert.equal(arkansas.region, 'Arkansas');
|
||||
assert.equal(arkansas.candidates, 1);
|
||||
assert.equal(
|
||||
(await findAdminArea('Washington County, AR'))?.region,
|
||||
'Arkansas',
|
||||
);
|
||||
});
|
||||
|
||||
test('Travis County with and without its state', async () => {
|
||||
for (const query of [
|
||||
'Travis County',
|
||||
'Travis County, Texas',
|
||||
'Travis County, TX',
|
||||
'Travis County Texas',
|
||||
'travis county, texas, usa',
|
||||
]) {
|
||||
const unit = await findAdminArea(query, { near: PORTLAND });
|
||||
assert.equal(unit?.id, '48453', query);
|
||||
assert.equal(unit.name, 'Travis County');
|
||||
assert.equal(unit.region, 'Texas');
|
||||
assert.equal(unit.source, 'us-census');
|
||||
}
|
||||
assert.equal(await findAdminArea('Travis County, Ohio'), null);
|
||||
assert.equal(await findAdminArea('Travis County Courthouse'), null);
|
||||
// Every qualifier must place the county, not name something in it.
|
||||
assert.equal(await findAdminArea('Travis County Courthouse, Texas'), null);
|
||||
assert.equal(
|
||||
await findAdminArea('Orange County Great Park, California'),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('county equivalents: parishes, boroughs, independent cities, and non-US counties', async () => {
|
||||
const orleans = await findAdminArea('Orleans Parish', { near: AUSTIN });
|
||||
assert.equal(orleans?.id, '22071');
|
||||
assert.equal(orleans.region, 'Louisiana');
|
||||
assert.equal((await findAdminArea('Orleans Parish, LA'))?.id, '22071');
|
||||
// The wrong county word still finds the only unit with that name.
|
||||
assert.equal(
|
||||
(await findAdminArea('Anchorage Borough'))?.name,
|
||||
'Anchorage Municipality',
|
||||
);
|
||||
// Outside the US a county is an admin-1 unit.
|
||||
assert.equal((await findAdminArea('County Cork'))?.country, 'Ireland');
|
||||
assert.equal(
|
||||
(await findAdminArea('Kent County, England'))?.country,
|
||||
'United Kingdom',
|
||||
);
|
||||
assert.equal(
|
||||
(await findAdminArea('Kent County', { near: AUSTIN }))?.region,
|
||||
'Texas',
|
||||
);
|
||||
});
|
||||
|
||||
test('a bare name that is also a country or a large city is left to a geocoder', async () => {
|
||||
for (const query of ['New York', 'Georgia', 'Madrid']) {
|
||||
assert.equal(await findAdminArea(query), null, query);
|
||||
}
|
||||
// Admin wording settles it.
|
||||
assert.equal((await findAdminArea('New York State'))?.id, 'US-NY');
|
||||
assert.equal((await findAdminArea('State of New York'))?.id, 'US-NY');
|
||||
assert.equal((await findAdminArea('State of Mexico'))?.country, 'Mexico');
|
||||
// City-states are the city itself.
|
||||
assert.equal((await findAdminArea('Berlin'))?.id, 'DE-BE');
|
||||
assert.equal((await findAdminArea('Vienna'))?.country, 'Austria');
|
||||
// A country qualifier places a name; it does not make a city a state.
|
||||
assert.equal(await findAdminArea('New York, USA'), null);
|
||||
assert.equal(await findAdminArea('Madrid, Spain'), null);
|
||||
// A namesake city abroad: ambiguous alone, settled by naming the country.
|
||||
const santaFe = { lat: 35.687, lon: -105.938 };
|
||||
assert.equal(await findAdminArea('Santa Fe', { near: santaFe }), null);
|
||||
assert.equal(await findAdminArea('Victoria'), null);
|
||||
assert.equal((await findAdminArea('Victoria, Australia'))?.id, 'AU-VIC');
|
||||
});
|
||||
|
||||
test('bare names: only prominent units, no generic words, codes as codes', async () => {
|
||||
// Places a geocoder knows better than a small admin unit of that name.
|
||||
for (const query of [
|
||||
'Long Island',
|
||||
'Santa Barbara',
|
||||
'Kent',
|
||||
'Hamilton',
|
||||
'Milan',
|
||||
])
|
||||
assert.equal(await findAdminArea(query, { near: AUSTIN }), null, query);
|
||||
for (const query of ['North', 'Central', 'Capital', 'Lakes'])
|
||||
assert.equal(await findAdminArea(query), null, query);
|
||||
// Two-letter codes must be written as codes, and not the city ones.
|
||||
assert.equal(await findAdminArea('tx'), null);
|
||||
assert.equal(
|
||||
await findAdminArea('LA', { near: { lat: 34.05, lon: -118.24 } }),
|
||||
null,
|
||||
);
|
||||
assert.equal(await findAdminArea('NY'), null);
|
||||
assert.equal((await findAdminArea('OK'))?.id, 'US-OK');
|
||||
});
|
||||
|
||||
test('non-admin asks never match', async () => {
|
||||
for (const query of [
|
||||
'Texas Capitol',
|
||||
'the Texas State Capitol',
|
||||
'Zilker Park',
|
||||
'Ohio State',
|
||||
'Texas State',
|
||||
'Alps',
|
||||
'',
|
||||
null,
|
||||
]) {
|
||||
assert.equal(await findAdminArea(query), null, String(query));
|
||||
}
|
||||
});
|
||||
|
||||
test('geocoder-confirmed lookup: the named unit that contains the point', async () => {
|
||||
// Atlanta — the geocoder said "state", so the US state wins over the country.
|
||||
const georgia = await findAdminAreaAt(['Georgia'], 33.749, -84.388, 'state');
|
||||
assert.equal(georgia?.id, 'US-GA');
|
||||
// Tbilisi — Georgia the country has no admin-1 unit of that name.
|
||||
assert.equal(await findAdminAreaAt(['Georgia'], 41.7, 44.8, 'state'), null);
|
||||
const newYork = await findAdminAreaAt(['New York'], 42.9, -75.5, 'state');
|
||||
assert.equal(newYork?.id, 'US-NY');
|
||||
// A county the geocoder typed, named without the county word.
|
||||
const travis = await findAdminAreaAt(
|
||||
['Travis', 'Travis County'],
|
||||
30.33,
|
||||
-97.78,
|
||||
'county',
|
||||
);
|
||||
assert.equal(travis?.id, '48453');
|
||||
// A county the geocoder typed stays a county even where a state shares
|
||||
// its name: Manhattan, the Big Island, Arkansas County.
|
||||
assert.equal(
|
||||
(await findAdminAreaAt(['New York County'], 40.783, -73.966, 'county'))?.id,
|
||||
'36061',
|
||||
);
|
||||
assert.equal(
|
||||
(await findAdminAreaAt(['Hawaii County'], 19.6, -155.5, 'county'))?.name,
|
||||
'Hawaii County',
|
||||
);
|
||||
// A geocoded point outside the named unit is not that unit.
|
||||
assert.equal(await findAdminAreaAt(['Texas'], 35.0, -106.6, 'state'), null);
|
||||
// Outside the US a geocoder "county" is an admin-1 unit (Kent).
|
||||
assert.equal(
|
||||
(await findAdminAreaAt(['Kent'], 51.2, 0.7, 'county'))?.country,
|
||||
'United Kingdom',
|
||||
);
|
||||
});
|
||||
|
||||
test('multipolygons: every island is a part, and containment follows the parts', async () => {
|
||||
const hawaii = await findAdminArea('Hawaii');
|
||||
assert.ok(
|
||||
hawaii.polygons.length >= 7,
|
||||
`Hawaii parts ${hawaii.polygons.length}`,
|
||||
);
|
||||
assert.ok(polygonsContain(hawaii.polygons, 19.6, -155.5), 'Big Island');
|
||||
assert.ok(polygonsContain(hawaii.polygons, 20.8, -156.3), 'Maui');
|
||||
assert.ok(polygonsContain(hawaii.polygons, 21.45, -158.0), 'Oahu');
|
||||
assert.ok(
|
||||
!polygonsContain(hawaii.polygons, 21.0, -157.3),
|
||||
'the channel between',
|
||||
);
|
||||
// The main ring is the largest part (the Big Island).
|
||||
assert.equal(hawaii.ring, hawaii.polygons[0][0]);
|
||||
|
||||
// Alaska is cut at the antimeridian: its box stays on one side of the globe.
|
||||
const alaska = await findAdminArea('Alaska');
|
||||
const [west, , east] = alaska.bbox;
|
||||
assert.ok(east - west < 60, `Alaska box width ${east - west}°`);
|
||||
assert.ok(alaska.polygons.length > 10, 'Aleutians and the southeast islands');
|
||||
});
|
||||
|
||||
test('holes: Berlin is cut out of Brandenburg', async () => {
|
||||
const brandenburg = await findAdminArea('Brandenburg');
|
||||
assert.ok(
|
||||
brandenburg.polygons.some((part) => part.length > 1),
|
||||
'Brandenburg has a hole',
|
||||
);
|
||||
assert.ok(!polygonsContain(brandenburg.polygons, 52.52, 13.405), 'Berlin');
|
||||
assert.ok(polygonsContain(brandenburg.polygons, 52.39, 13.06), 'Potsdam');
|
||||
const berlin = await findAdminArea('Berlin');
|
||||
assert.ok(polygonsContain(berlin.polygons, 52.52, 13.405));
|
||||
});
|
||||
|
||||
test('holes: an independent city is cut out of its county', async () => {
|
||||
const fairfax = await findAdminArea('Fairfax County, Virginia');
|
||||
assert.ok(fairfax.polygons[0].length > 1, 'Fairfax County has holes');
|
||||
assert.ok(
|
||||
!polygonsContain(fairfax.polygons, 38.846, -77.306),
|
||||
'Fairfax city',
|
||||
);
|
||||
assert.ok(polygonsContain(fairfax.polygons, 38.9, -77.2), 'Fairfax County');
|
||||
const city = await findAdminArea('Fairfax city, Virginia');
|
||||
assert.ok(polygonsContain(city.polygons, 38.846, -77.306));
|
||||
assert.equal((await findAdminArea('Carson City'))?.region, 'Nevada');
|
||||
for (const query of ['New York City', 'Mexico City', 'Quebec City, Canada'])
|
||||
assert.equal(await findAdminArea(query), null, query);
|
||||
});
|
||||
|
||||
test('rings decode from deltas and are open', () => {
|
||||
assert.deepEqual(decodeRing([-97743, 30267, 10, -5, -3, 8], 3), [
|
||||
[-97.743, 30.267],
|
||||
[-97.733, 30.262],
|
||||
[-97.736, 30.27],
|
||||
]);
|
||||
});
|
||||
|
||||
test('names normalize across accents, punctuation and "Saint"', () => {
|
||||
assert.equal(normalizeAdminName('Québec'), 'quebec');
|
||||
assert.equal(normalizeAdminName(' The State of TEXAS '), 'state of texas');
|
||||
assert.equal(normalizeAdminName('Saint Louis County'), 'st louis county');
|
||||
assert.equal(normalizeAdminName('St. Louis County'), 'st louis county');
|
||||
assert.equal(normalizeAdminName('Schleswig-Holstein'), 'schleswig holstein');
|
||||
});
|
||||
|
||||
test('packs: public-domain provenance, schema, and size budgets', () => {
|
||||
const admin1 = JSON.parse(readFileSync(ADMIN1, 'utf8'));
|
||||
const counties = JSON.parse(readFileSync(COUNTIES, 'utf8'));
|
||||
for (const pack of [admin1, counties]) {
|
||||
assert.match(pack.meta.license, /public domain/i);
|
||||
assert.match(pack.meta.sha256, /^[0-9a-f]{64}$/);
|
||||
assert.equal(pack.meta.script, 'scripts/build-admin-packs.mjs');
|
||||
for (const feature of pack.features) {
|
||||
assert.ok(feature.name, 'named');
|
||||
assert.ok(feature.polygons.length >= 1, `${feature.name} has a part`);
|
||||
for (const poly of feature.polygons)
|
||||
for (const ring of poly) {
|
||||
assert.equal(ring.length % 2, 0);
|
||||
assert.ok(ring.length >= 8, `${feature.name}: ring has 4+ vertices`);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.ok(
|
||||
admin1.features.length > 4500,
|
||||
`${admin1.features.length} states/provinces`,
|
||||
);
|
||||
assert.ok(
|
||||
counties.features.length > 3200,
|
||||
`${counties.features.length} counties`,
|
||||
);
|
||||
assert.ok(statSync(ADMIN1).size <= 4 * 1024 * 1024, 'admin-1 pack ≤ 4 MB');
|
||||
assert.ok(statSync(COUNTIES).size <= 2 * 1024 * 1024, 'county pack ≤ 2 MB');
|
||||
});
|
||||
|
||||
test('county queries compare US and international names before choosing a geography', async () => {
|
||||
const england = { lat: 54.78, lon: -1.58 };
|
||||
const nc = { lat: 36.0, lon: -78.9 };
|
||||
assert.equal(
|
||||
(await findAdminArea('County Durham', { near: england }))?.country,
|
||||
'United Kingdom',
|
||||
);
|
||||
assert.equal(
|
||||
(await findAdminArea('County Durham'))?.country,
|
||||
'United Kingdom',
|
||||
'exact alias survives parsing',
|
||||
);
|
||||
assert.equal(
|
||||
(await findAdminArea('Durham County', { near: nc }))?.region,
|
||||
'North Carolina',
|
||||
);
|
||||
assert.equal(
|
||||
(await findAdminArea('County Durham, England', { near: nc }))?.country,
|
||||
'United Kingdom',
|
||||
);
|
||||
assert.equal(
|
||||
(await findAdminArea('County Durham, USA', { near: england }))?.region,
|
||||
'North Carolina',
|
||||
);
|
||||
assert.equal(
|
||||
(await findAdminArea('Durham County, NC', { near: england }))?.region,
|
||||
'North Carolina',
|
||||
);
|
||||
// Durham is also a city: without county wording leave city geocoding intact.
|
||||
assert.equal(await findAdminArea('Durham, NC', { near: england }), null);
|
||||
assert.equal(
|
||||
(await findAdminAreaAt(['Durham, NC'], nc.lat, nc.lon, 'county'))?.region,
|
||||
'North Carolina',
|
||||
);
|
||||
});
|
||||
|
||||
test('countries resolve aliases, keep substantial islands and disambiguate Georgia by scope and geography', async () => {
|
||||
for (const names of [
|
||||
['USA', 'United States', 'America'],
|
||||
['UK', 'United Kingdom', 'Britain'],
|
||||
['Iran', 'Islamic Republic of Iran'],
|
||||
['Czechia', 'Czech Republic'],
|
||||
['Ivory Coast', "Côte d'Ivoire"],
|
||||
['South Korea', 'Korea'],
|
||||
['Russia', 'Russian Federation'],
|
||||
['Switzerland'],
|
||||
['France'],
|
||||
]) {
|
||||
const resolved = await Promise.all(
|
||||
names.map((name) => findAdminArea(name, { near: AUSTIN })),
|
||||
);
|
||||
assert.ok(
|
||||
resolved.every((r) => r?.kind === 'country'),
|
||||
names.join('/'),
|
||||
);
|
||||
assert.equal(new Set(resolved.map((r) => r.id)).size, 1);
|
||||
}
|
||||
assert.equal(
|
||||
(await findAdminArea('Georgia', { near: AUSTIN })).kind,
|
||||
'state',
|
||||
);
|
||||
assert.equal(
|
||||
(await findAdminArea('Georgia', { near: { lat: 41.7, lon: 44.8 } })).kind,
|
||||
'country',
|
||||
);
|
||||
assert.equal(
|
||||
(await findAdminArea('the country of Georgia', { near: AUSTIN })).kind,
|
||||
'country',
|
||||
);
|
||||
assert.equal(await findAdminArea('New York', { near: AUSTIN }), null);
|
||||
assert.equal(
|
||||
(await findAdminAreaAt(['Iran'], 35.69, 51.39, 'country')).kind,
|
||||
'country',
|
||||
);
|
||||
assert.equal(
|
||||
await findAdminAreaAt(['Iran'], 46.9, 7.4, 'country'),
|
||||
null,
|
||||
'name alone cannot override a mismatched geocoded point',
|
||||
);
|
||||
const japan = await findAdminArea('Japan');
|
||||
for (const [lat, lon] of [
|
||||
[35.68, 139.69],
|
||||
[43.06, 141.35],
|
||||
[33.59, 130.4],
|
||||
[33.56, 133.53],
|
||||
])
|
||||
assert.ok(polygonsContain(japan.polygons, lat, lon));
|
||||
const usa = await findAdminArea('USA');
|
||||
for (const [lat, lon] of [
|
||||
[30.27, -97.74],
|
||||
[61.22, -149.9],
|
||||
[19.7, -155.1],
|
||||
])
|
||||
assert.ok(
|
||||
polygonsContain(usa.polygons, lat, lon),
|
||||
'USA includes mainland, Alaska and Hawaii',
|
||||
);
|
||||
const bytes = statSync(
|
||||
new URL('./local_data/natural_earth/countries.json', import.meta.url),
|
||||
).size;
|
||||
assert.ok(bytes < 1_000_000);
|
||||
});
|
||||
|
||||
test('UK constituent countries retain their own identity and extent from any camera', async () => {
|
||||
const targets = [
|
||||
['Scotland', 'SCT', [55.95, -3.19], [54.6, 61]],
|
||||
['England', 'ENG', [51.5, -0.12], [49.8, 55.9]],
|
||||
['Wales', 'WLS', [51.48, -3.18], [51.3, 53.6]],
|
||||
['Northern Ireland', 'NIR', [54.6, -5.93], [53.9, 55.4]],
|
||||
];
|
||||
for (const [name, id, point, extent] of targets) {
|
||||
const unit = await findAdminArea(name, { near: AUSTIN });
|
||||
assert.equal(unit.id, id);
|
||||
assert.equal(unit.name, name);
|
||||
assert.equal(unit.kind, 'country');
|
||||
assert.ok(
|
||||
unit.bbox[1] >= extent[0] && unit.bbox[3] <= extent[1],
|
||||
`${name}: ${unit.bbox}`,
|
||||
);
|
||||
assert.ok(polygonsContain(unit.polygons, ...point));
|
||||
for (const [other, , otherPoint] of targets)
|
||||
if (other !== name)
|
||||
assert.equal(polygonsContain(unit.polygons, ...otherPoint), false);
|
||||
assert.equal((await findAdminAreaAt([name], ...point, 'country')).id, id);
|
||||
assert.equal(await findAdminAreaAt([name], 30.27, -97.74, 'country'), null);
|
||||
}
|
||||
assert.equal((await findAdminArea('UK', { near: AUSTIN })).id, 'GBR');
|
||||
assert.equal(
|
||||
(await findAdminArea('County Durham, England', { near: AUSTIN })).country,
|
||||
'United Kingdom',
|
||||
);
|
||||
});
|
||||
@@ -43,11 +43,18 @@ export function normalizeAdsbLolAircraftState(aircraft, nowSeconds) {
|
||||
.toLowerCase();
|
||||
const latitude = finiteNumber(aircraft?.lat);
|
||||
const longitude = finiteNumber(aircraft?.lon);
|
||||
if (!hex || latitude === null || longitude === null) return null;
|
||||
if (
|
||||
!hex ||
|
||||
latitude === null ||
|
||||
longitude === null ||
|
||||
Math.abs(latitude) > 90 ||
|
||||
Math.abs(longitude) > 180
|
||||
)
|
||||
return null;
|
||||
|
||||
const seenPosition = Math.max(
|
||||
0,
|
||||
finiteNumber(aircraft?.seen_pos) ?? finiteNumber(aircraft?.seen) ?? 0,
|
||||
finiteNumber(aircraft?.seen_pos) ?? finiteNumber(aircraft?.seen) ?? NaN,
|
||||
);
|
||||
const seen = Math.max(0, finiteNumber(aircraft?.seen) ?? seenPosition);
|
||||
const onGround = aircraft?.alt_baro === 'ground';
|
||||
@@ -62,8 +69,10 @@ export function normalizeAdsbLolAircraftState(aircraft, nowSeconds) {
|
||||
hex,
|
||||
String(aircraft?.flight || aircraft?.r || '').trim() || null,
|
||||
null,
|
||||
Math.max(0, nowSeconds - seenPosition),
|
||||
Math.max(0, nowSeconds - seen),
|
||||
Number.isFinite(seenPosition)
|
||||
? Math.max(0, nowSeconds - seenPosition)
|
||||
: null,
|
||||
Number.isFinite(seen) ? Math.max(0, nowSeconds - seen) : null,
|
||||
longitude,
|
||||
latitude,
|
||||
barometricFeet === null ? null : barometricFeet * FOOT_TO_M,
|
||||
@@ -94,8 +103,28 @@ export function normalizeAdsbLolPointResponse(payload) {
|
||||
: Math.floor(
|
||||
responseNow > 10_000_000_000 ? responseNow / 1000 : responseNow,
|
||||
);
|
||||
const states = (Array.isArray(payload?.ac) ? payload.ac : [])
|
||||
.map((aircraft) => normalizeAdsbLolAircraftState(aircraft, nowSeconds))
|
||||
.filter(Boolean);
|
||||
return { time: nowSeconds, states };
|
||||
const aircraft = Array.isArray(payload?.ac) ? payload.ac : [];
|
||||
const states = [];
|
||||
// Keep the OpenSky vectors unchanged. The regional fallback's extra readsb
|
||||
// status travels alongside them, keyed only to admitted aircraft.
|
||||
const admitted = new Set();
|
||||
const statuses = [];
|
||||
for (const row of aircraft) {
|
||||
const state = normalizeAdsbLolAircraftState(row, nowSeconds);
|
||||
if (!state) continue;
|
||||
states.push(state);
|
||||
// The live contract admits the first valid vector for each ICAO. Its
|
||||
// status must come from that same row, never an invalid or later duplicate.
|
||||
const id = state[0];
|
||||
if (admitted.has(id)) continue;
|
||||
admitted.add(id);
|
||||
if (typeof row.emergency === 'string')
|
||||
statuses.push([id, row.emergency.trim().toLowerCase()]);
|
||||
}
|
||||
const emergencyByIcao = Object.fromEntries(statuses);
|
||||
return {
|
||||
time: nowSeconds,
|
||||
states,
|
||||
...(Object.keys(emergencyByIcao).length ? { emergencyByIcao } : {}),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Transponder emergency status for one aircraft, from the Mode A squawk and
|
||||
* the ADS-B emergency/priority field.
|
||||
*
|
||||
* Emergency squawks are ICAO-standard worldwide: 7500 unlawful interference,
|
||||
* 7600 radio failure, 7700 general emergency. readsb (adsb.lol) also reports
|
||||
* `emergency`, "a superset of the 7x00 squawks": none, general, lifeguard,
|
||||
* minfuel, nordo, unlawful, downed, reserved. OpenSky state vectors carry the
|
||||
* squawk only.
|
||||
*
|
||||
* The status is what the transponder broadcast in THIS poll, never a finding:
|
||||
* crews pass through 7x00 codes while dialling. Highlighting follows the latest
|
||||
* broadcast, which does not confirm an incident or its resolution.
|
||||
*/
|
||||
|
||||
/** @constant {string} Tint for aircraft broadcasting an emergency (not a priority). */
|
||||
export const EMERGENCY_TINT_CSS = '#FF4444';
|
||||
|
||||
const SQUAWK_KINDS = { 7500: 'unlawful', 7600: 'nordo', 7700: 'general' };
|
||||
|
||||
const KINDS = {
|
||||
general: { label: 'General emergency', severity: 'emergency' },
|
||||
unlawful: { label: 'Unlawful interference', severity: 'emergency' },
|
||||
nordo: { label: 'Radio failure', severity: 'emergency' },
|
||||
downed: { label: 'Downed aircraft', severity: 'emergency' },
|
||||
minfuel: { label: 'Minimum fuel', severity: 'priority' },
|
||||
lifeguard: { label: 'Lifeguard (medical priority)', severity: 'priority' },
|
||||
};
|
||||
|
||||
/**
|
||||
* A Mode A code as its four octal digits, else null.
|
||||
* @param {unknown} value
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function normalizeSquawk(value) {
|
||||
const code = String(value ?? '').trim();
|
||||
return /^[0-7]{4}$/.test(code) ? code : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emergency status from one observation. Higher severity wins; an ADS-B
|
||||
* emergency wins a tie. A
|
||||
* 7x00 squawk counts even when the ADS-B field says `none`.
|
||||
* @param {{squawk?: unknown, emergency?: unknown}} [observation]
|
||||
* @returns {{kind: string, label: string, severity: 'emergency'|'priority',
|
||||
* squawk: string|null, source: 'ads-b'|'squawk'}|null} `source` is
|
||||
* 'squawk' when the Mode A code itself names the status (so it can be
|
||||
* quoted), else 'ads-b'.
|
||||
*/
|
||||
export function aircraftEmergency({ squawk, emergency } = {}) {
|
||||
const code = normalizeSquawk(squawk);
|
||||
const reported = String(emergency ?? '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const fromAdsb = Object.hasOwn(KINDS, reported);
|
||||
const squawkKind = SQUAWK_KINDS[code] ?? null;
|
||||
const kind =
|
||||
fromAdsb && (KINDS[reported].severity === 'emergency' || !squawkKind)
|
||||
? reported
|
||||
: squawkKind;
|
||||
if (!kind) return null;
|
||||
return {
|
||||
kind,
|
||||
...KINDS[kind],
|
||||
squawk: code,
|
||||
source: SQUAWK_KINDS[code] === kind ? 'squawk' : 'ads-b',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* How a summary may phrase the status without flattening it into an incident:
|
||||
* quote the code when the Mode A squawk itself names the status, otherwise
|
||||
* attribute it to the ADS-B emergency field.
|
||||
* @param {{kind?: string, squawk?: string|null, source?: string}|null|undefined} status
|
||||
* @returns {string|null} e.g. 'broadcasting squawk 7700 (general emergency)'
|
||||
* or 'ADS-B reports general emergency'; null when there is no status.
|
||||
*/
|
||||
export function emergencyBroadcastPhrase(status) {
|
||||
if (!status || !Object.hasOwn(KINDS, status.kind)) return null;
|
||||
const label = KINDS[status.kind].label.toLowerCase();
|
||||
return status.source === 'squawk' && status.squawk
|
||||
? `broadcasting squawk ${status.squawk} (${label})`
|
||||
: `ADS-B reports ${label}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the aircraft is broadcasting an emergency (not a priority status).
|
||||
* @param {{severity?: string}|null|undefined} status
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isEmergency(status) {
|
||||
return status?.severity === 'emergency';
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// src/data/aircraftEmergency.test.mjs
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
aircraftEmergency,
|
||||
emergencyBroadcastPhrase,
|
||||
isEmergency,
|
||||
normalizeSquawk,
|
||||
} from './aircraftEmergency.js';
|
||||
|
||||
test('the three ICAO emergency squawks map to their kinds', () => {
|
||||
assert.equal(aircraftEmergency({ squawk: '7500' }).kind, 'unlawful');
|
||||
assert.equal(aircraftEmergency({ squawk: '7600' }).kind, 'nordo');
|
||||
const general = aircraftEmergency({ squawk: ' 7700 ' });
|
||||
assert.deepEqual(general, {
|
||||
kind: 'general',
|
||||
label: 'General emergency',
|
||||
severity: 'emergency',
|
||||
squawk: '7700',
|
||||
source: 'squawk',
|
||||
});
|
||||
});
|
||||
|
||||
test('ordinary codes, blanks and non-octal values are not emergencies', () => {
|
||||
for (const squawk of ['1200', '7000', '2000', '', null, undefined, '7800'])
|
||||
assert.equal(aircraftEmergency({ squawk }), null, String(squawk));
|
||||
assert.equal(aircraftEmergency(), null);
|
||||
assert.equal(normalizeSquawk('7800'), null);
|
||||
assert.equal(normalizeSquawk(7700), '7700');
|
||||
assert.equal(normalizeSquawk('770'), null);
|
||||
});
|
||||
|
||||
test('the ADS-B emergency field wins, and `none` defers to the squawk', () => {
|
||||
const minfuel = aircraftEmergency({ squawk: '4521', emergency: 'MinFuel' });
|
||||
assert.equal(minfuel.kind, 'minfuel');
|
||||
assert.equal(minfuel.severity, 'priority');
|
||||
assert.equal(minfuel.source, 'ads-b');
|
||||
assert.equal(minfuel.squawk, '4521');
|
||||
assert.equal(
|
||||
aircraftEmergency({ squawk: '7700', emergency: 'none' }).source,
|
||||
'squawk',
|
||||
);
|
||||
assert.equal(
|
||||
aircraftEmergency({ squawk: '7700', emergency: 'general' }).source,
|
||||
'squawk',
|
||||
'a code that names the status stays quotable',
|
||||
);
|
||||
assert.equal(
|
||||
aircraftEmergency({ squawk: '1200', emergency: 'general' }).source,
|
||||
'ads-b',
|
||||
);
|
||||
assert.equal(aircraftEmergency({ emergency: 'downed' }).kind, 'downed');
|
||||
assert.equal(aircraftEmergency({ emergency: 'none' }), null);
|
||||
assert.equal(aircraftEmergency({ emergency: 'reserved' }), null);
|
||||
});
|
||||
|
||||
test('priority statuses are reported but are not emergencies', () => {
|
||||
assert.equal(isEmergency(aircraftEmergency({ squawk: '7700' })), true);
|
||||
assert.equal(
|
||||
isEmergency(aircraftEmergency({ emergency: 'lifeguard' })),
|
||||
false,
|
||||
);
|
||||
assert.equal(isEmergency(aircraftEmergency({ emergency: 'minfuel' })), false);
|
||||
assert.equal(isEmergency(null), false);
|
||||
});
|
||||
|
||||
test('the broadcast phrase quotes a naming code, else attributes ADS-B', () => {
|
||||
assert.equal(
|
||||
emergencyBroadcastPhrase(aircraftEmergency({ squawk: '7700' })),
|
||||
'broadcasting squawk 7700 (general emergency)',
|
||||
);
|
||||
assert.equal(
|
||||
emergencyBroadcastPhrase(
|
||||
aircraftEmergency({ squawk: '1200', emergency: 'general' }),
|
||||
),
|
||||
'ADS-B reports general emergency',
|
||||
);
|
||||
assert.equal(
|
||||
emergencyBroadcastPhrase(aircraftEmergency({ emergency: 'lifeguard' })),
|
||||
'ADS-B reports lifeguard (medical priority)',
|
||||
);
|
||||
assert.equal(emergencyBroadcastPhrase(null), null);
|
||||
assert.equal(emergencyBroadcastPhrase({ kind: 'reserved' }), null);
|
||||
});
|
||||
|
||||
test('an emergency squawk outranks an ADS-B priority', () => {
|
||||
assert.equal(
|
||||
aircraftEmergency({ squawk: '7700', emergency: 'lifeguard' }).kind,
|
||||
'general',
|
||||
);
|
||||
assert.equal(
|
||||
aircraftEmergency({ squawk: '7500', emergency: 'minfuel' }).kind,
|
||||
'unlawful',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,524 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { TRACKED_ICON_COLOR } from '../layers/military/policy.js';
|
||||
import { createRendering as civilRendering } from '../layers/flights/rendering.js';
|
||||
import { createRendering as militaryRendering } from '../layers/military/rendering.js';
|
||||
import { createMilitarySnapshotRenderer } from '../layers/military/snapshotRenderer.js';
|
||||
import { aircraftEmergency } from './aircraftEmergency.js';
|
||||
// Source-to-voice provenance, status retention/clearing, contact-time contracts,
|
||||
// and civil/military billboard, Cockpit and model tint regressions.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { openSkySnapshot, readsbSnapshot } from '../sources/live/index.js';
|
||||
import { FlightRecords } from '../layers/flights/records.js';
|
||||
import { MilitaryFlightRecords } from '../layers/military/records.js';
|
||||
import { mapAnalystRecord as mapFlight } from './flights.js';
|
||||
import { mapAnalystRecord as mapMilitary } from './militaryFlights.js';
|
||||
import { createAnalystEngine } from './analystEngine.js';
|
||||
import { normalizeAdsbLolPointResponse } from './adsbLolFallback.js';
|
||||
import { createGevActionRunner } from '../voice/gevActions.js';
|
||||
|
||||
const NOW_MS = 1800000000000;
|
||||
|
||||
test('fallback status belongs to the first valid admitted aircraft row', () => {
|
||||
const valid = { hex: 'a1b2c3', lat: 30, lon: -97, squawk: '1200' };
|
||||
for (const status of [undefined, 'general', 'none']) {
|
||||
const payload = normalizeAdsbLolPointResponse({
|
||||
now: NOW_MS,
|
||||
ac: [
|
||||
{ ...valid, lat: 100, emergency: 'general' },
|
||||
{ ...valid, emergency: status },
|
||||
{ ...valid, lat: null, emergency: 'general' },
|
||||
{ ...valid, emergency: status === 'general' ? 'none' : 'general' },
|
||||
],
|
||||
});
|
||||
const snapshot = openSkySnapshot(payload, { now: NOW_MS });
|
||||
assert.equal(snapshot.records.length, 1);
|
||||
assert.equal(snapshot.records[0].emergency, status);
|
||||
assert.equal(snapshot.records[0].latitude, 30);
|
||||
}
|
||||
});
|
||||
|
||||
function civilStore() {
|
||||
return new FlightRecords({
|
||||
geoidHeight: () => 0,
|
||||
cachedGroundFloor: () => null,
|
||||
floorAltitudeM: (value) => value,
|
||||
approxDistanceKm: () => 0,
|
||||
});
|
||||
}
|
||||
const receiveContext = {
|
||||
viewerLatDeg: null,
|
||||
viewerLonDeg: null,
|
||||
trackedId: null,
|
||||
floorWarmPoints: [],
|
||||
};
|
||||
|
||||
test('real civil fallback preserves ADS-B emergency and priority, then clears', () => {
|
||||
const store = civilStore();
|
||||
const aircraft = {
|
||||
hex: 'a1b2c3',
|
||||
flight: 'TEST1',
|
||||
lat: 30,
|
||||
lon: -97,
|
||||
alt_baro: 10000,
|
||||
gs: 150,
|
||||
track: 90,
|
||||
squawk: '1200',
|
||||
seen: 3,
|
||||
seen_pos: 1,
|
||||
};
|
||||
for (const [status, expectedKind, flagged] of [
|
||||
['general', 'general', true],
|
||||
['minfuel', 'minfuel', false],
|
||||
['none', null, false],
|
||||
]) {
|
||||
const payload = normalizeAdsbLolPointResponse({
|
||||
now: NOW_MS,
|
||||
ac: [{ ...aircraft, emergency: status }],
|
||||
});
|
||||
assert.equal(
|
||||
payload.states[0].length,
|
||||
18,
|
||||
'fallback vectors retain the OpenSky shape',
|
||||
);
|
||||
const [observation] = openSkySnapshot(payload, { now: NOW_MS }).records;
|
||||
const result = mapFlight(
|
||||
aircraft.hex,
|
||||
store.receive(observation, receiveContext).meta,
|
||||
);
|
||||
assert.equal(result.emergency, flagged);
|
||||
assert.equal(result.emergencyKind, expectedKind);
|
||||
assert.equal(result.emergencySource, expectedKind ? 'ads-b' : null);
|
||||
assert.equal(result.lastContactAtMs, NOW_MS - 3000);
|
||||
}
|
||||
});
|
||||
|
||||
test('changed status without contact time retains general contact, including voice projection', async () => {
|
||||
const store = civilStore();
|
||||
const [observation] = readsbSnapshot(
|
||||
{
|
||||
ac: [
|
||||
{
|
||||
hex: 'a1b2c3',
|
||||
flight: 'TEST1',
|
||||
lat: 30,
|
||||
lon: -97,
|
||||
alt_baro: 10000,
|
||||
gs: 150,
|
||||
track: 90,
|
||||
squawk: '1200',
|
||||
emergency: 'general',
|
||||
seen: 3,
|
||||
seen_pos: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
{ observedAtMs: NOW_MS, now: NOW_MS },
|
||||
).records;
|
||||
store.receive(observation, receiveContext);
|
||||
const meta = store.receive(
|
||||
{ ...observation, contactTimeMs: null, squawk: '7600', emergency: 'none' },
|
||||
receiveContext,
|
||||
).meta;
|
||||
const record = mapFlight(observation.id, meta);
|
||||
assert.equal(record.emergencyKind, 'nordo');
|
||||
assert.equal(record.lastContactAtMs, NOW_MS - 3000);
|
||||
assert.equal(Object.hasOwn(record, 'observedAtMs'), false);
|
||||
globalThis.window = globalThis.window || {
|
||||
clearTimeout,
|
||||
setTimeout,
|
||||
requestIdleCallback: null,
|
||||
};
|
||||
const flights = {
|
||||
id: 'flights',
|
||||
source: 'fixture',
|
||||
getStats: () => ({ count: 1 }),
|
||||
getAnalystRecords: () => [record],
|
||||
};
|
||||
const runner = createGevActionRunner({
|
||||
viewer: {
|
||||
clock: { onTick: { addEventListener: () => () => {} } },
|
||||
scene: { canvas: { addEventListener() {}, removeEventListener() {} } },
|
||||
camera: {
|
||||
moveEnd: { addEventListener() {} },
|
||||
positionCartographic: {
|
||||
height: 300000,
|
||||
latitude: 0.52,
|
||||
longitude: -1.71,
|
||||
},
|
||||
},
|
||||
},
|
||||
styleManager: {
|
||||
getContextModeState: () => ({ mode: null, active: false }),
|
||||
getCockpitState: () => ({ active: false }),
|
||||
},
|
||||
dataManager: {
|
||||
layers: new Map([['flights', { module: flights }]]),
|
||||
isEnabled: (id) => id === 'flights',
|
||||
getAll: () => [
|
||||
{ id: 'flights', name: 'Flights', enabled: true, stats: { count: 1 } },
|
||||
],
|
||||
},
|
||||
});
|
||||
try {
|
||||
const result = await runner('analyst_query', {
|
||||
layers: ['flights'],
|
||||
scope: { kind: 'anywhere' },
|
||||
filters: [{ field: 'emergency', op: 'eq', value: true }],
|
||||
});
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.items[0].emergency, true);
|
||||
assert.equal(result.items[0].lastContactAtMs, NOW_MS - 3000);
|
||||
assert.equal(result.items[0].emergencySource, 'squawk');
|
||||
assert.equal(
|
||||
result.items[0].emergencyBroadcast,
|
||||
'broadcasting squawk 7600 (radio failure)',
|
||||
);
|
||||
assert.equal(
|
||||
Object.hasOwn(result.items[0], 'time'),
|
||||
false,
|
||||
'general contact is not a generic incident timestamp',
|
||||
);
|
||||
} finally {
|
||||
runner.dispose?.();
|
||||
}
|
||||
});
|
||||
|
||||
// OpenSky /states/all: [icao24, callsign, origin_country, time_position,
|
||||
// last_contact, lon, lat, baro_alt, on_ground, velocity, true_track,
|
||||
// vertical_rate, sensors, geo_alt, squawk, spi, position_source, category].
|
||||
const OPENSKY = {
|
||||
time: NOW_MS / 1000,
|
||||
states: [
|
||||
[
|
||||
'a1b2c3',
|
||||
'SWA696 ',
|
||||
'United States',
|
||||
NOW_MS / 1000 - 6,
|
||||
NOW_MS / 1000 - 3,
|
||||
-97.67,
|
||||
30.19,
|
||||
3000,
|
||||
false,
|
||||
150,
|
||||
180,
|
||||
-5,
|
||||
null,
|
||||
3040,
|
||||
'7700',
|
||||
false,
|
||||
0,
|
||||
4,
|
||||
],
|
||||
],
|
||||
};
|
||||
|
||||
// adsb.lol /v2/mil: readsb aircraft with `seen`/`seen_pos` ages in seconds.
|
||||
const ADSB_LOL = {
|
||||
ac: [
|
||||
{
|
||||
hex: 'ae01ce',
|
||||
flight: 'RCH123 ',
|
||||
t: 'C17',
|
||||
lat: 31.1,
|
||||
lon: -97.4,
|
||||
alt_baro: 18000,
|
||||
gs: 300,
|
||||
track: 90,
|
||||
squawk: '1200',
|
||||
emergency: 'general',
|
||||
seen: 1.5,
|
||||
seen_pos: 2,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function civilAnalystRecord() {
|
||||
const [row] = openSkySnapshot(OPENSKY, { now: NOW_MS }).records;
|
||||
const store = new FlightRecords({
|
||||
geoidHeight: () => 0,
|
||||
cachedGroundFloor: () => null,
|
||||
floorAltitudeM: (altitude) => altitude,
|
||||
approxDistanceKm: () => 0,
|
||||
});
|
||||
const { meta } = store.receive(row, {
|
||||
viewerLatDeg: null,
|
||||
viewerLonDeg: null,
|
||||
trackedId: null,
|
||||
floorWarmPoints: [],
|
||||
});
|
||||
return mapFlight(row.id, meta);
|
||||
}
|
||||
|
||||
function militaryAnalystRecord() {
|
||||
const [row] = readsbSnapshot(ADSB_LOL, {
|
||||
observedAtMs: NOW_MS,
|
||||
now: NOW_MS,
|
||||
}).records;
|
||||
const store = new MilitaryFlightRecords({
|
||||
geoidHeight: () => 0,
|
||||
cachedGroundFloor: () => null,
|
||||
floorAltitudeM: (altitude) => altitude,
|
||||
});
|
||||
const { meta } = store.receive(row, {
|
||||
observedAtMs: NOW_MS,
|
||||
floorWarmPoints: [],
|
||||
modelOwnsVisual: false,
|
||||
});
|
||||
return mapMilitary(row.id, meta);
|
||||
}
|
||||
|
||||
test('OpenSky 7700 and adsb.lol emergency=general differ only in provenance', () => {
|
||||
const civil = civilAnalystRecord();
|
||||
const military = militaryAnalystRecord();
|
||||
|
||||
assert.equal(civil.emergency, true);
|
||||
assert.equal(civil.emergencyKind, 'general');
|
||||
assert.equal(civil.squawk, '7700');
|
||||
assert.equal(civil.emergencySource, 'squawk');
|
||||
assert.equal(
|
||||
civil.emergencyBroadcast,
|
||||
'broadcasting squawk 7700 (general emergency)',
|
||||
);
|
||||
assert.equal(civil.lastContactAtMs, NOW_MS - 3000, 'OpenSky last_contact');
|
||||
|
||||
assert.equal(military.emergency, true);
|
||||
assert.equal(military.emergencyKind, 'general');
|
||||
assert.equal(military.squawk, '1200', 'the code is not an emergency code');
|
||||
assert.equal(military.emergencySource, 'ads-b');
|
||||
assert.equal(military.emergencyBroadcast, 'ADS-B reports general emergency');
|
||||
assert.equal(military.lastContactAtMs, NOW_MS - 1500, 'snapshot time - seen');
|
||||
});
|
||||
|
||||
test('the analyst can filter emergencies by source', async () => {
|
||||
const records = {
|
||||
flights: [civilAnalystRecord()],
|
||||
military: [militaryAnalystRecord()],
|
||||
};
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: (key) => records[key] || [],
|
||||
resolveRegionRing: async () => null,
|
||||
getViewContext: () => ({ lat: 30.5, lon: -97.5, viewRadiusKm: 500 }),
|
||||
});
|
||||
const query = (value) =>
|
||||
engine.query({
|
||||
layers: ['flights', 'military'],
|
||||
scope: { kind: 'anywhere' },
|
||||
filters: [
|
||||
{ field: 'emergency', op: 'eq', value: true },
|
||||
{ field: 'emergencySource', op: 'eq', value },
|
||||
],
|
||||
});
|
||||
const bySquawk = await query('squawk');
|
||||
assert.deepEqual(
|
||||
bySquawk.items.map((item) => [item.icao24, item.emergencyBroadcast]),
|
||||
[['a1b2c3', 'broadcasting squawk 7700 (general emergency)']],
|
||||
);
|
||||
const byAdsb = await query('ads-b');
|
||||
assert.deepEqual(
|
||||
byAdsb.items.map((item) => [item.icao24, item.emergencyBroadcast]),
|
||||
[['ae01ce', 'ADS-B reports general emergency']],
|
||||
);
|
||||
});
|
||||
|
||||
test('military status changes retain only the latest known general contact', () => {
|
||||
const store = new MilitaryFlightRecords({
|
||||
geoidHeight: () => 0,
|
||||
cachedGroundFloor: () => null,
|
||||
floorAltitudeM: (v) => v,
|
||||
});
|
||||
const [observation] = readsbSnapshot(ADSB_LOL, {
|
||||
observedAtMs: NOW_MS,
|
||||
now: NOW_MS,
|
||||
}).records;
|
||||
const context = {
|
||||
observedAtMs: NOW_MS,
|
||||
floorWarmPoints: [],
|
||||
modelOwnsVisual: false,
|
||||
};
|
||||
const unknown = mapMilitary(
|
||||
observation.id,
|
||||
store.receive({ ...observation, contactTimeMs: null }, context).meta,
|
||||
);
|
||||
assert.equal(unknown.lastContactAtMs, null);
|
||||
store.receive(observation, context);
|
||||
const changed = mapMilitary(
|
||||
observation.id,
|
||||
store.receive(
|
||||
{
|
||||
...observation,
|
||||
contactTimeMs: null,
|
||||
squawk: '7600',
|
||||
emergency: 'none',
|
||||
},
|
||||
context,
|
||||
).meta,
|
||||
);
|
||||
assert.equal(changed.emergencyKind, 'nordo');
|
||||
assert.equal(changed.lastContactAtMs, NOW_MS - 1500);
|
||||
});
|
||||
|
||||
test('fallback missing contact age retains known contact through the real converter', () => {
|
||||
const store = civilStore();
|
||||
const row = {
|
||||
hex: 'a1b2c3',
|
||||
lat: 30,
|
||||
lon: -97,
|
||||
squawk: '1200',
|
||||
emergency: 'general',
|
||||
seen: 3,
|
||||
seen_pos: 1,
|
||||
};
|
||||
const receive = (row) =>
|
||||
store.receive(
|
||||
openSkySnapshot(
|
||||
normalizeAdsbLolPointResponse({ now: NOW_MS, ac: [row] }),
|
||||
{ now: NOW_MS },
|
||||
).records[0],
|
||||
receiveContext,
|
||||
).meta;
|
||||
const first = receive(row);
|
||||
const second = receive({ ...row, seen: undefined, seen_pos: undefined });
|
||||
assert.equal(second.lastContactEpochMs, first.lastContactEpochMs);
|
||||
});
|
||||
|
||||
test('one omitted ADS-B status poll is retained; explicit none and changed squawk clear immediately', () => {
|
||||
const store = civilStore();
|
||||
const row = {
|
||||
hex: 'a1b2c3',
|
||||
lat: 30,
|
||||
lon: -97,
|
||||
squawk: '1200',
|
||||
emergency: 'general',
|
||||
seen: 3,
|
||||
};
|
||||
const receive = (row) =>
|
||||
store.receive(
|
||||
openSkySnapshot(
|
||||
normalizeAdsbLolPointResponse({ now: NOW_MS, ac: [row] }),
|
||||
{ now: NOW_MS },
|
||||
).records[0],
|
||||
receiveContext,
|
||||
).meta;
|
||||
assert.equal(receive(row).emergency.kind, 'general');
|
||||
assert.equal(
|
||||
receive({ ...row, emergency: undefined }).emergency?.kind,
|
||||
'general',
|
||||
);
|
||||
assert.equal(receive({ ...row, emergency: undefined }).emergency, null);
|
||||
receive(row);
|
||||
assert.equal(receive({ ...row, emergency: 'none' }).emergency, null);
|
||||
receive(row);
|
||||
assert.equal(
|
||||
receive({ ...row, emergency: undefined, squawk: '2000' }).emergency,
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
function renderHarness(factory) {
|
||||
const flightState = { records: { data: new Map() }, _trackedIcao: null };
|
||||
const rendering = factory({
|
||||
flightState,
|
||||
services: {
|
||||
militaryRegistry: { isMilitaryIcao: (id) => id === 'mil' },
|
||||
aircraftPresentation: {},
|
||||
camera: {},
|
||||
focus: {},
|
||||
recession: {},
|
||||
},
|
||||
parts: {},
|
||||
});
|
||||
return { flightState, rendering };
|
||||
}
|
||||
|
||||
test('tint priorities cover normal, priority, emergency, tracking and Cockpit dots', () => {
|
||||
for (const factory of [civilRendering, militaryRendering]) {
|
||||
const { flightState, rendering } = renderHarness(factory);
|
||||
const normal = rendering._fleetBillboardColor('civil', {});
|
||||
assert.equal(
|
||||
rendering._fleetBillboardColor('civil', {
|
||||
emergency: aircraftEmergency({ emergency: 'minfuel' }),
|
||||
}),
|
||||
normal,
|
||||
);
|
||||
const red = rendering._fleetBillboardColor('civil', {
|
||||
emergency: aircraftEmergency({ squawk: '7700' }),
|
||||
});
|
||||
assert.ok(red.red > 0.9 && red.green < 0.5);
|
||||
flightState.records.data.set('civil', {
|
||||
emergency: aircraftEmergency({ squawk: '7700' }),
|
||||
});
|
||||
assert.equal(rendering._modelColor('civil'), red);
|
||||
if (rendering._cockpitContactColor)
|
||||
assert.equal(rendering._cockpitContactColor('civil'), red);
|
||||
flightState._trackedIcao = 'civil';
|
||||
assert.equal(
|
||||
rendering._modelColor('civil'),
|
||||
factory === civilRendering ? Cesium.Color.CYAN : TRACKED_ICON_COLOR,
|
||||
);
|
||||
flightState._trackedIcao = null;
|
||||
flightState.records.data.set('civil', { emergency: null });
|
||||
assert.equal(rendering._modelColor('civil'), normal);
|
||||
}
|
||||
});
|
||||
|
||||
test('GERMAN: an existing untracked military billboard repaints each poll without alpha loss', () => {
|
||||
const { flightState, rendering } = renderHarness(militaryRendering);
|
||||
const bb = {
|
||||
color: rendering._fleetBillboardColor('abc123').withAlpha(0.65),
|
||||
};
|
||||
Object.assign(flightState, {
|
||||
_billboards: new Map([['abc123', bb]]),
|
||||
_models: new Map(),
|
||||
_positionHistory: new Map(),
|
||||
_cullPositions: new Map(),
|
||||
_groundSnap: { forget() {} },
|
||||
_cockpitContactMode: false,
|
||||
});
|
||||
const records = new MilitaryFlightRecords({
|
||||
geoidHeight: () => 0,
|
||||
cachedGroundFloor: () => null,
|
||||
floorAltitudeM: (value) => value,
|
||||
});
|
||||
flightState.records = records;
|
||||
const apply = createMilitarySnapshotRenderer({
|
||||
flightState,
|
||||
records,
|
||||
groundFloor: { warmGroundFloor() {}, cachedGroundFloor: () => null },
|
||||
meshFloor: { sampleMeshFloorCells() {} },
|
||||
militaryRegistry: { registerMilitaryIcaos() {} },
|
||||
rendering: {
|
||||
...rendering,
|
||||
_syncModelToClass() {},
|
||||
_applyFleetBillboardPresentation() {},
|
||||
},
|
||||
motion: {},
|
||||
tracking: {},
|
||||
queries: {},
|
||||
});
|
||||
for (const [emergency, red] of [
|
||||
['general', true],
|
||||
['none', false],
|
||||
['general', true],
|
||||
]) {
|
||||
const snapshot = readsbSnapshot(
|
||||
{
|
||||
ac: [
|
||||
{
|
||||
hex: 'abc123',
|
||||
lat: 30,
|
||||
lon: -97,
|
||||
alt_baro: 10000,
|
||||
squawk: '1200',
|
||||
emergency,
|
||||
seen_pos: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
{ observedAtMs: NOW_MS },
|
||||
);
|
||||
apply(snapshot, {});
|
||||
assert.equal(bb.color.green < 0.5, red);
|
||||
assert.equal(bb.color.alpha, 0.65);
|
||||
}
|
||||
});
|
||||
@@ -242,7 +242,7 @@ export function applyAircraftBillboardTreatment({
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the already-composed aircraft alpha to an ambient glTF model without
|
||||
* Apply layer-owned hue and composed aircraft alpha to an ambient glTF model without
|
||||
* disturbing Cesium's existing MIX/colorBlendAmount presentation semantics.
|
||||
* @param {object} input
|
||||
* @param {object} input.model
|
||||
@@ -261,7 +261,10 @@ export function applyAircraftModelTreatment({
|
||||
if (!model || !baseColor || !Number.isFinite(alpha)) return 0;
|
||||
if (
|
||||
Number.isFinite(model.color?.alpha) &&
|
||||
Math.abs(model.color.alpha - alpha) <= tuning.writeEpsilon
|
||||
Math.abs(model.color.alpha - alpha) <= tuning.writeEpsilon &&
|
||||
model.color.red === baseColor.red &&
|
||||
model.color.green === baseColor.green &&
|
||||
model.color.blue === baseColor.blue
|
||||
)
|
||||
return 0;
|
||||
model.color = baseColor.withAlpha(alpha);
|
||||
|
||||
@@ -142,3 +142,15 @@ test('ambient model presentation receives composed alpha without changing blend
|
||||
assert.equal(model.colorBlendAmount, 0.9);
|
||||
assert.equal(applyAircraftModelTreatment({ model, baseColor: color, alpha: 0.2, params }), 0);
|
||||
});
|
||||
|
||||
|
||||
test('model hue changes at constant alpha, then clears without redundant writes', () => {
|
||||
const color = (red,green,blue) => ({red,green,blue,withAlpha(alpha){return {red,green,blue,alpha};}});
|
||||
const model = {color:color(1,1,1).withAlpha(0.65)};
|
||||
assert.equal(applyAircraftModelTreatment({model,baseColor:color(1,0.2,0.2),alpha:0.65}),1);
|
||||
assert.equal(model.color.green,0.2);
|
||||
assert.equal(model.color.alpha,0.65);
|
||||
assert.equal(applyAircraftModelTreatment({model,baseColor:color(1,0.2,0.2),alpha:0.65}),0);
|
||||
assert.equal(applyAircraftModelTreatment({model,baseColor:color(1,1,1),alpha:0.65}),1);
|
||||
assert.equal(model.color.green,1);
|
||||
});
|
||||
@@ -28,7 +28,7 @@ test('ais analyst record: full record maps every contract field', () => {
|
||||
courseDeg: 214.0,
|
||||
shipType: 'Cargo',
|
||||
destination: 'OAKLAND',
|
||||
navStatus: null, // /api/ais-live does not surface NavigationalStatus
|
||||
navStatus: null, // /api/vessels does not surface NavigationalStatus
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { createApplicationVessels } from '../app/layers/aisLiveVessels.js';
|
||||
|
||||
import { createAisStreamSource } from '../sources/live/standalone.js';
|
||||
import { createVesselSource } from '../sources/live/standalone.js';
|
||||
|
||||
const aisLiveVesselsLayer = createApplicationVessels({
|
||||
source: createAisStreamSource({
|
||||
apiUrl: import.meta.env?.VITE_AIS_LIVE_API_URL || '/api/ais-live',
|
||||
source: createVesselSource({
|
||||
apiUrl: import.meta.env?.VITE_AIS_LIVE_API_URL || '/api/vessels',
|
||||
// Resolved against the document's address, which a panel host may serve
|
||||
// from its own scheme.
|
||||
origin: () => globalThis.document?.baseURI ?? 'http://localhost',
|
||||
}),
|
||||
options: {
|
||||
maxRows: import.meta.env?.VITE_AIS_LIVE_MAX_ROWS,
|
||||
|
||||
@@ -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);
|
||||
@@ -1643,7 +1668,7 @@ test('a vessel analyst record carries the MMSI the tracker keys on', () => {
|
||||
});
|
||||
|
||||
test('vessel selection passes the opaque source reference to optional history', async () => {
|
||||
const { createAisStreamSource } = await import('../sources/live/standalone.js');
|
||||
const { createVesselSource } = await import('../sources/live/standalone.js');
|
||||
_setVesselStateForTest({ enabled: false });
|
||||
const requests = [];
|
||||
aisLiveVesselsLayer.setSource({
|
||||
@@ -1663,6 +1688,6 @@ test('vessel selection passes the opaque source reference to optional history',
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
_setVesselStateForTest({ enabled: false });
|
||||
aisLiveVesselsLayer.setSource(createAisStreamSource());
|
||||
aisLiveVesselsLayer.setSource(createVesselSource());
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
aisStreamRows,
|
||||
ingestAisStreamEnvelope,
|
||||
} from '../../server/providers/vessels/ais-store.js';
|
||||
|
||||
// ITU-R M.1371 encodes speed over ground in 0.1-knot units and course over
|
||||
// ground in 0.1-degree units, reserving the top code of each field for "not
|
||||
// available": SOG 1023 arrives as 102.3 knots and COG 3600 as 360 degrees.
|
||||
// Both are finite numbers, so they reach consumers as ordinary readings unless
|
||||
// the store rejects them here.
|
||||
const positionReport = (mmsi, body) => ({
|
||||
MessageType: 'PositionReport',
|
||||
Message: { PositionReport: { UserID: Number(mmsi), ...body } },
|
||||
MetaData: {
|
||||
MMSI: mmsi,
|
||||
latitude: 53.40879,
|
||||
longitude: 6.19741,
|
||||
time_utc: '2026-09-17 20:00:00.000000000 +0000 UTC',
|
||||
},
|
||||
});
|
||||
|
||||
const rowFor = (mmsi) => aisStreamRows(50000).find((row) => row.mmsi === mmsi);
|
||||
|
||||
test('speed over ground reported as unavailable is unknown, not 102.3 knots', () => {
|
||||
assert.equal(
|
||||
ingestAisStreamEnvelope(positionReport('244060365', { Sog: 102.3 })),
|
||||
true,
|
||||
);
|
||||
assert.equal(rowFor('244060365').speed, null);
|
||||
});
|
||||
|
||||
test('course over ground reported as unavailable is unknown, not 360 degrees', () => {
|
||||
assert.equal(
|
||||
ingestAisStreamEnvelope(positionReport('245382000', { Cog: 360 })),
|
||||
true,
|
||||
);
|
||||
assert.equal(rowFor('245382000').course, null);
|
||||
});
|
||||
|
||||
test('genuine speed and course readings survive the unavailable check', () => {
|
||||
ingestAisStreamEnvelope(
|
||||
positionReport('341260000', { Sog: 12.9, Cog: 74.6 }),
|
||||
);
|
||||
const row = rowFor('341260000');
|
||||
assert.equal(row.speed, 12.9);
|
||||
assert.equal(row.course, 74.6);
|
||||
});
|
||||
|
||||
test('a stopped vessel keeps its zero speed and zero course', () => {
|
||||
ingestAisStreamEnvelope(positionReport('244374235', { Sog: 0, Cog: 0 }));
|
||||
const row = rowFor('244374235');
|
||||
assert.equal(row.speed, 0);
|
||||
assert.equal(row.course, 0);
|
||||
});
|
||||
|
||||
test('the fastest encodable reading below the unavailable code is kept', () => {
|
||||
ingestAisStreamEnvelope(
|
||||
positionReport('244060366', { Sog: 102.2, Cog: 359.9 }),
|
||||
);
|
||||
const row = rowFor('244060366');
|
||||
assert.equal(row.speed, 102.2);
|
||||
assert.equal(row.course, 359.9);
|
||||
});
|
||||
@@ -450,7 +450,7 @@ export function createAisWatchdog(options = {}) {
|
||||
return generation;
|
||||
}
|
||||
|
||||
/** Status metadata for /api/ais-live. */
|
||||
/** Status metadata for /api/vessels. */
|
||||
function snapshot() {
|
||||
const monoNow = clock.mono();
|
||||
const wallNow = clock.wall();
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { defaultSurface } from './surfaceServices.js';
|
||||
import { createApplicationAlpr } from '../app/layers/alprCameras.js';
|
||||
import { createSourceSlot } from '../sources/sourceSlot.js';
|
||||
import { createOverpassAlprSource } from '../layers/alpr/index.js';
|
||||
import { createAlprTileSource } from '../layers/alpr/index.js';
|
||||
export * from '../layers/alpr/index.js';
|
||||
const slot = createSourceSlot(
|
||||
createOverpassAlprSource(),
|
||||
createAlprTileSource(),
|
||||
['fetch'],
|
||||
'ALPR source',
|
||||
{ destroy: () => {}, detailZoom: () => null },
|
||||
);
|
||||
export const configureAlprSource = slot.configure;
|
||||
export default createApplicationAlpr({
|
||||
|
||||
+437
-70
@@ -3,7 +3,11 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import alprCamerasLayer, { createAlprCamerasLayer } from './alprCameras.js';
|
||||
import alprCamerasLayer, {
|
||||
createAlprCamerasLayer,
|
||||
configureAlprSource,
|
||||
} from './alprCameras.js';
|
||||
import { createAlprTileSource } from '../layers/alpr/source.js';
|
||||
import { DATA_CREDITS } from './dataCredits.js';
|
||||
import {
|
||||
clearSelectedEntityContextForLayer,
|
||||
@@ -262,9 +266,45 @@ function cameraHarness(layer = alprCamerasLayer) {
|
||||
let response = async () => cameraResponse();
|
||||
const requests = [];
|
||||
globalThis.fetch = (...args) => {
|
||||
// Marker floors resolve through the shared terrain service; these tests
|
||||
// count camera requests only.
|
||||
if (String(args[0]).includes('/api/terrain/heights'))
|
||||
return Promise.resolve(Response.json({ results: [] }));
|
||||
requests.push(args);
|
||||
return response(...args);
|
||||
};
|
||||
// Rendering tests use normalized records; tile transport has its own fixture suite.
|
||||
if (layer === alprCamerasLayer)
|
||||
configureAlprSource({
|
||||
...createAlprTileSource(),
|
||||
async fetch(box, signal) {
|
||||
const response = await globalThis.fetch('/test/cameras', {
|
||||
signal,
|
||||
body: `data=${encodeURIComponent(buildOverpassQuery(box.south, box.west, box.north, box.east))}`,
|
||||
});
|
||||
if (!response.ok)
|
||||
throw new Error('Camera source temporarily unavailable');
|
||||
const body = await response.json();
|
||||
if (!Array.isArray(body.elements) || body.remark)
|
||||
throw new Error('Incomplete fixture snapshot');
|
||||
signal?.throwIfAborted();
|
||||
return {
|
||||
records: [
|
||||
...new Map(
|
||||
body.elements
|
||||
.slice(0, QUERY_LIMIT)
|
||||
.map(normalizeAlprNode)
|
||||
.filter(Boolean)
|
||||
.map((r) => [r.id, r]),
|
||||
).values(),
|
||||
],
|
||||
stale: response.headers.get('x-overpass-cache') === 'STALE',
|
||||
saturated: body.elements.length >= QUERY_LIMIT,
|
||||
noCoverage: body.noCoverage,
|
||||
zoomIn: body.zoomIn,
|
||||
};
|
||||
},
|
||||
});
|
||||
let box = { south: 30.26, west: -97.75, north: 30.28, east: -97.73 };
|
||||
let source, click, picked;
|
||||
const credits = new Set();
|
||||
@@ -348,88 +388,28 @@ function cameraHarness(layer = alprCamerasLayer) {
|
||||
};
|
||||
}
|
||||
|
||||
test('OSM attribution introduces displayed data for five seconds, then stays discoverable in the overflow', async (t) => {
|
||||
test('OSM attribution stays inline while ALPR displays and follows toggles', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const h = cameraHarness();
|
||||
try {
|
||||
assert.equal(
|
||||
h.credits.size,
|
||||
0,
|
||||
'do not use up the introduction while data is still loading',
|
||||
);
|
||||
assert.equal(h.credits.size, 0);
|
||||
await alprCamerasLayer.update();
|
||||
assert.equal(h.credits.size, 1);
|
||||
const [credit] = h.credits;
|
||||
assert.equal(credit.showOnScreen, true);
|
||||
assert.match(credit.html, /class="gev-alpr-credit">ALPR:/);
|
||||
assert.match(
|
||||
credit.html,
|
||||
/href="https:\/\/www.openstreetmap.org\/copyright"/,
|
||||
);
|
||||
assert.match(credit.html, /© OpenStreetMap<\/a>/);
|
||||
t.mock.timers.tick(4999);
|
||||
assert.match(credit.html, />© OpenStreetMap</);
|
||||
assert.doesNotMatch(credit.html, /OpenMapTiles|Map and place data/);
|
||||
t.mock.timers.tick(6000);
|
||||
assert.equal(h.credits.size, 1);
|
||||
t.mock.timers.tick(1);
|
||||
assert.equal(h.credits.size, 0);
|
||||
const overflow = DATA_CREDITS.find((entry) => entry.key === 'alpr-osm');
|
||||
assert.match(
|
||||
overflow.html,
|
||||
/ALPR camera locations \(automatic license plate readers\)/,
|
||||
);
|
||||
assert.match(
|
||||
overflow.html,
|
||||
/href="https:\/\/www.openstreetmap.org\/copyright"/,
|
||||
);
|
||||
assert.match(overflow.html, /ODbL 1\.0/);
|
||||
await alprCamerasLayer.update();
|
||||
assert.equal(
|
||||
h.credits.size,
|
||||
0,
|
||||
'refresh must not keep reintroducing the credit',
|
||||
);
|
||||
alprCamerasLayer.disable();
|
||||
assert.equal(h.credits.size, 0);
|
||||
alprCamerasLayer.enable();
|
||||
await alprCamerasLayer.update();
|
||||
t.mock.timers.tick(4000);
|
||||
alprCamerasLayer.enable();
|
||||
assert.equal(h.credits.size, 1);
|
||||
t.mock.timers.tick(1000);
|
||||
assert.equal(
|
||||
h.credits.size,
|
||||
0,
|
||||
'duplicate enable must not restart the timer',
|
||||
assert.match(
|
||||
DATA_CREDITS.find((entry) => entry.key === 'openstreetmap').html,
|
||||
/OpenStreetMap contributors.*ODbL/,
|
||||
);
|
||||
alprCamerasLayer.disable();
|
||||
alprCamerasLayer.enable();
|
||||
await alprCamerasLayer.update();
|
||||
assert.equal(h.credits.size, 1);
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
assert.equal(h.credits.size, 0);
|
||||
t.mock.timers.tick(5000);
|
||||
assert.equal(h.credits.size, 0, 'destroy cancels the presentation timer');
|
||||
});
|
||||
|
||||
test('disabling midway through the introduction cancels its timer before a new enable', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const h = cameraHarness();
|
||||
try {
|
||||
await alprCamerasLayer.update();
|
||||
t.mock.timers.tick(3000);
|
||||
alprCamerasLayer.disable();
|
||||
assert.equal(h.credits.size, 0);
|
||||
alprCamerasLayer.enable();
|
||||
await alprCamerasLayer.update();
|
||||
t.mock.timers.tick(2000);
|
||||
assert.equal(
|
||||
h.credits.size,
|
||||
1,
|
||||
'old deadline must not hide the new presentation',
|
||||
);
|
||||
t.mock.timers.tick(3000);
|
||||
assert.equal(h.credits.size, 0);
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
@@ -765,7 +745,7 @@ test('empty mapped coverage, stale data and a saturated response remain distinct
|
||||
assert.equal(alprCamerasLayer.getStats().status, 'empty');
|
||||
assert.match(
|
||||
alprCamerasLayer.getStats().loadingLabel,
|
||||
/coverage is incomplete/,
|
||||
/No ALPR data for this area/,
|
||||
);
|
||||
h.expire();
|
||||
h.setFetch(async () =>
|
||||
@@ -928,6 +908,122 @@ test('two factories keep their requests, records and destruction independent', a
|
||||
}
|
||||
});
|
||||
|
||||
test('markers sit on the cached floor; unknown floors clamp only until they resolve', async () => {
|
||||
// Ground clamping re-samples every marker whenever a photoreal tile streams
|
||||
// in; a whole-city view (about 1,000 cameras) stuttered while zooming.
|
||||
const floors = new Map([['30.2672', 150]]);
|
||||
let resolveCalls = 0;
|
||||
let releaseResolve;
|
||||
const services = {
|
||||
render: { governorRequestRender() {} },
|
||||
picking: { registerPickOwner() {}, unregisterPickOwner() {} },
|
||||
groundFloor: {
|
||||
cachedGroundFloor: (lat) => floors.get(lat.toFixed(4)) ?? null,
|
||||
resolveGroundFloorCells: async () => {
|
||||
resolveCalls++;
|
||||
await new Promise((resolve) => {
|
||||
releaseResolve = resolve;
|
||||
});
|
||||
floors.set('30.2682', 160);
|
||||
},
|
||||
},
|
||||
context: {
|
||||
clearSelectedEntityContextForLayer() {},
|
||||
getSelectedEntityContext() {
|
||||
return null;
|
||||
},
|
||||
registerEntityContext() {},
|
||||
removeEntityContextsForLayer() {},
|
||||
selectEntityContext() {},
|
||||
},
|
||||
};
|
||||
const layer = createAlprCamerasLayer({
|
||||
services,
|
||||
source: {
|
||||
fetch: async () => ({
|
||||
records: [
|
||||
{ id: 'directory:1', latitude: 30.2672, longitude: -97.7431 },
|
||||
{ id: 'directory:2', latitude: 30.2682, longitude: -97.7431 },
|
||||
],
|
||||
stale: false,
|
||||
saturated: false,
|
||||
}),
|
||||
},
|
||||
});
|
||||
const h = cameraHarness(layer);
|
||||
try {
|
||||
await layer.update();
|
||||
const known = h.source.entities.getById('directory:1');
|
||||
const unknown = h.source.entities.getById('directory:2');
|
||||
assert.equal(
|
||||
known.billboard.heightReference.getValue(),
|
||||
Cesium.HeightReference.NONE,
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(
|
||||
Cesium.Cartographic.fromCartesian(known.position.getValue()).height -
|
||||
151.5,
|
||||
) < 0.01,
|
||||
);
|
||||
assert.equal(
|
||||
unknown.billboard.heightReference.getValue(),
|
||||
Cesium.HeightReference.CLAMP_TO_GROUND,
|
||||
);
|
||||
assert.equal(resolveCalls, 1);
|
||||
releaseResolve();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(
|
||||
unknown.billboard.heightReference.getValue(),
|
||||
Cesium.HeightReference.NONE,
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(
|
||||
Cesium.Cartographic.fromCartesian(unknown.position.getValue()).height -
|
||||
161.5,
|
||||
) < 0.01,
|
||||
);
|
||||
assert.equal(
|
||||
h.source.entities.getById('directory:2'),
|
||||
unknown,
|
||||
'placed in place, not rebuilt',
|
||||
);
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('loaded cameras outside the view say so instead of looking unloaded', async () => {
|
||||
// Field review: "nearby" spans twice the camera-to-ground range, so a view
|
||||
// could hold cameras with none on screen and read as a failed load.
|
||||
const h = cameraHarness();
|
||||
const project = Cesium.SceneTransforms.worldToWindowCoordinates;
|
||||
let inView = false;
|
||||
Cesium.SceneTransforms.worldToWindowCoordinates = () =>
|
||||
inView ? new Cesium.Cartesian2(100, 100) : new Cesium.Cartesian2(-500, 100);
|
||||
try {
|
||||
h.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees(
|
||||
-97.7431,
|
||||
30.2672,
|
||||
2000,
|
||||
);
|
||||
h.viewer.scene.canvas.clientWidth = 800;
|
||||
h.viewer.scene.canvas.clientHeight = 600;
|
||||
await alprCamerasLayer.update();
|
||||
let stats = alprCamerasLayer.getStats();
|
||||
assert.equal(stats.onScreen, 0);
|
||||
assert.match(stats.countLabel, /1 nearby · 0 on screen/);
|
||||
assert.match(stats.loadingLabel, /None on screen/);
|
||||
inView = true;
|
||||
h.viewer.camera.moveEnd.raiseEvent();
|
||||
stats = alprCamerasLayer.getStats();
|
||||
assert.equal(stats.onScreen, 1);
|
||||
assert.doesNotMatch(stats.loadingLabel, /None on screen/);
|
||||
} finally {
|
||||
Cesium.SceneTransforms.worldToWindowCoordinates = project;
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('orbit cache hits and metadata refreshes preserve marker geometry and selection events', async () => {
|
||||
const h = cameraHarness();
|
||||
try {
|
||||
@@ -943,6 +1039,10 @@ test('orbit cache hits and metadata refreshes preserve marker geometry and selec
|
||||
const entity = h.source.entities.getById('alpr:42');
|
||||
const position = entity.position,
|
||||
line = entity.polyline;
|
||||
// Ground-clamped native wedges cost ~40 ms per frame for a downtown view;
|
||||
// bearings are painted by the overlay for the nearest cameras instead.
|
||||
assert.equal(line, undefined);
|
||||
assert.equal(entity.polygon, undefined);
|
||||
let selections = 0,
|
||||
clears = 0,
|
||||
collectionChanges = 0;
|
||||
@@ -1126,3 +1226,270 @@ test('nearby count and discovery control frame a real loaded camera without fetc
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('a non-retryable capability error does not arm the ALPR retry timer', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const h = cameraHarness();
|
||||
try {
|
||||
h.setFetch(async () => {
|
||||
throw Object.assign(new Error('Unavailable'), {
|
||||
code: 'OVERPASS_NOT_CONFIGURED',
|
||||
retryable: false,
|
||||
});
|
||||
});
|
||||
await alprCamerasLayer.update();
|
||||
assert.equal(alprCamerasLayer.getStats().retryAt, 0);
|
||||
const requests = h.requests.length;
|
||||
t.mock.timers.tick(300_000);
|
||||
await Promise.resolve();
|
||||
assert.equal(h.requests.length, requests);
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('unsupported ALPR coverage suppresses the nearby count and names the extract region', async () => {
|
||||
const h = cameraHarness();
|
||||
try {
|
||||
h.setFetch(async () => ({
|
||||
...cameraResponse([]),
|
||||
json: async () => ({ elements: [], noCoverage: true }),
|
||||
}));
|
||||
await alprCamerasLayer.update();
|
||||
const stats = alprCamerasLayer.getStats();
|
||||
assert.equal(stats.noCoverage, true);
|
||||
assert.equal(stats.countLabel, '');
|
||||
assert.equal(
|
||||
stats.loadingLabel,
|
||||
'No ALPR data for this area — US and Canada only',
|
||||
);
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
import { createGroundFloor } from '../services/groundFloor.js';
|
||||
import { createTerrainHeights } from '../services/terrainHeights.js';
|
||||
import { createApplicationRequestServices } from '../services/requests.js';
|
||||
|
||||
test('slow ALPR floors share real terrain requests across preparation, render and pans; disable aborts', async () => {
|
||||
const requests = [];
|
||||
const sourceServices = createApplicationRequestServices({
|
||||
fetchImpl: (url, { signal }) =>
|
||||
new Promise((resolve, reject) => {
|
||||
assert.match(url, /^\/api\/terrain\/heights\?/);
|
||||
const points = new URL(url, 'http://localhost').searchParams
|
||||
.get('points')
|
||||
.split(';');
|
||||
requests.push({
|
||||
signal,
|
||||
points,
|
||||
release: () =>
|
||||
resolve(
|
||||
Response.json({
|
||||
results: points.map(() => ({ ellipsoid: 160 })),
|
||||
}),
|
||||
),
|
||||
});
|
||||
signal.addEventListener('abort', () => reject(signal.reason), {
|
||||
once: true,
|
||||
});
|
||||
}),
|
||||
});
|
||||
const terrain = createTerrainHeights({ source: sourceServices.terrain });
|
||||
const ground = createGroundFloor({ terrain });
|
||||
ground.FLOOR_RESOLVE_DEADLINE_MS = 1;
|
||||
const records = Array.from({ length: 130 }, (_, i) => ({
|
||||
id: `slow:${i}`,
|
||||
latitude: 30.261 + Math.floor(i / 15) * 0.001,
|
||||
longitude: -97.749 + (i % 15) * 0.001,
|
||||
}));
|
||||
const layer = createAlprCamerasLayer({
|
||||
source: {
|
||||
fetch: async () => ({ records, stale: false, saturated: false }),
|
||||
},
|
||||
services: {
|
||||
groundFloor: ground,
|
||||
render: { governorRequestRender() {} },
|
||||
picking: { registerPickOwner() {}, unregisterPickOwner() {} },
|
||||
context: {
|
||||
clearSelectedEntityContextForLayer() {},
|
||||
getSelectedEntityContext() {},
|
||||
registerEntityContext() {},
|
||||
removeEntityContextsForLayer() {},
|
||||
selectEntityContext() {},
|
||||
},
|
||||
},
|
||||
});
|
||||
const h = cameraHarness(layer);
|
||||
try {
|
||||
await layer.update();
|
||||
assert.equal(
|
||||
requests.length,
|
||||
1,
|
||||
'render joins the outstanding preparation batch',
|
||||
);
|
||||
assert.equal(
|
||||
requests[0].points.length,
|
||||
64,
|
||||
'one bounded batch runs at a time',
|
||||
);
|
||||
h.expire();
|
||||
await layer.update();
|
||||
await layer.update();
|
||||
assert.equal(
|
||||
requests.length,
|
||||
1,
|
||||
'further preparation and render reuse cold cell promises',
|
||||
);
|
||||
requests[0].release();
|
||||
await new Promise((done) => setTimeout(done, 0));
|
||||
assert.equal(requests.length, 2);
|
||||
const requested = requests.flatMap((request) => request.points);
|
||||
assert.equal(
|
||||
new Set(requested).size,
|
||||
requested.length,
|
||||
'no duplicate cells over HTTP',
|
||||
);
|
||||
layer.disable();
|
||||
assert.equal(
|
||||
requests[1].signal.aborted,
|
||||
true,
|
||||
'disable cancels the active HTTP request',
|
||||
);
|
||||
requests[1].release();
|
||||
await new Promise((done) => setTimeout(done, 0));
|
||||
assert.equal(requests.length, 2, 'queued final cells are discarded');
|
||||
assert.equal(h.source.entities.values.length, 0);
|
||||
layer.enable();
|
||||
await layer.update();
|
||||
assert.equal(
|
||||
requests.length,
|
||||
3,
|
||||
'a fresh consumer can resume unresolved cells',
|
||||
);
|
||||
layer.destroy();
|
||||
assert.equal(
|
||||
requests[2].signal.aborted,
|
||||
true,
|
||||
'destroy also aborts its consumer',
|
||||
);
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('map-source changes reposition all ALPR entities and saved native appearances in place', async () => {
|
||||
let floor = 180;
|
||||
let listener;
|
||||
let removed = 0;
|
||||
const records = Array.from({ length: 70 }, (_, i) => ({
|
||||
id: `surface:${i}`,
|
||||
latitude: 30.2672 + i * 0.00001,
|
||||
longitude: -97.7431,
|
||||
directionDeg: i === 0 ? null : 90,
|
||||
}));
|
||||
const layer = createAlprCamerasLayer({
|
||||
source: {
|
||||
fetch: async () => ({ records, stale: false, saturated: false }),
|
||||
},
|
||||
services: {
|
||||
groundFloor: { cachedGroundFloor: () => floor },
|
||||
overlays: {
|
||||
subscribeMapStack(fn) {
|
||||
listener = fn;
|
||||
return () => {
|
||||
removed++;
|
||||
};
|
||||
},
|
||||
},
|
||||
render: { governorRequestRender() {} },
|
||||
picking: { registerPickOwner() {}, unregisterPickOwner() {} },
|
||||
context: {
|
||||
clearSelectedEntityContextForLayer() {},
|
||||
getSelectedEntityContext() {},
|
||||
registerEntityContext() {},
|
||||
removeEntityContextsForLayer() {},
|
||||
selectEntityContext() {},
|
||||
},
|
||||
},
|
||||
});
|
||||
const h = cameraHarness(layer);
|
||||
try {
|
||||
await layer.update();
|
||||
const entities = [...h.source.entities.values];
|
||||
const saved = entities[1];
|
||||
saved.gevAlprNativeAppearance = {
|
||||
position: saved.position,
|
||||
heightReference: saved.billboard.heightReference,
|
||||
};
|
||||
saved.position = Cesium.Cartesian3.fromDegrees(-97.7431, 30.26721, 190);
|
||||
saved.gevAlprPickPosition = saved.position;
|
||||
for (const next of [150, 120, null, 180]) {
|
||||
floor = next;
|
||||
// Includes a provider switch with the same globe.show value.
|
||||
h.viewer.scene.globe.show = next !== 180;
|
||||
listener({ detail: { status: 'ready' } });
|
||||
for (const entity of entities) {
|
||||
assert.equal(h.source.entities.getById(entity.id), entity);
|
||||
assert.ok(
|
||||
Math.abs(
|
||||
Cesium.Cartographic.fromCartesian(entity.position.getValue())
|
||||
.height - (next == null ? 0 : next + 1.5),
|
||||
) < 0.01,
|
||||
`${entity.id} refreshes at ${next}`,
|
||||
);
|
||||
assert.equal(
|
||||
entity.billboard.heightReference.getValue(),
|
||||
next == null
|
||||
? Cesium.HeightReference.CLAMP_TO_GROUND
|
||||
: Cesium.HeightReference.NONE,
|
||||
);
|
||||
assert.equal(entity.gevAlprCanvasPosition, null);
|
||||
}
|
||||
assert.equal(
|
||||
saved.gevAlprNativeAppearance,
|
||||
null,
|
||||
'saved appearance restores at the new floor',
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
assert.equal(removed, 1, 'map listener is disposed');
|
||||
});
|
||||
|
||||
test('a repeated move inside an outstanding query keeps the displayed marker set until commit', async () => {
|
||||
const h = cameraHarness();
|
||||
try {
|
||||
await alprCamerasLayer.update();
|
||||
const previous = h.source.entities.getById('alpr:42');
|
||||
const revision = alprCamerasLayer.getStats().renderRevision;
|
||||
let release;
|
||||
h.setFetch(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
);
|
||||
h.setBox({ south: 31, west: -98, north: 31.02, east: -97.98 });
|
||||
const pending = alprCamerasLayer.update();
|
||||
await alprCamerasLayer.update();
|
||||
assert.equal(
|
||||
h.source.entities.getById('alpr:42'),
|
||||
previous,
|
||||
'do not show an intermediate filtered snapshot while the new query prepares',
|
||||
);
|
||||
assert.equal(alprCamerasLayer.getStats().renderRevision, revision);
|
||||
assert.equal(h.requests.length, 2, 'both moves share one new query');
|
||||
release(cameraResponse([cameraNode(43, { lat: 31.01, lon: -97.99 })]));
|
||||
await pending;
|
||||
assert.equal(alprCamerasLayer.getStats().renderRevision, revision + 1);
|
||||
assert.deepEqual(
|
||||
h.source.entities.values.map((e) => e.id),
|
||||
['alpr:43'],
|
||||
);
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { directionToHeading } from './directionText.js';
|
||||
test('conflicting facing directions cannot manufacture one heading', () => {
|
||||
for (const bare of [false, true]) {
|
||||
for (const value of ['NB / SB', 'EASTBOUND WESTBOUND', 'NE SW'])
|
||||
assert.ok(Number.isNaN(directionToHeading(value, bare)), value);
|
||||
assert.equal(directionToHeading('NB northbound', bare), 0);
|
||||
assert.equal(directionToHeading('IH-35 SOUTHBOUND AT 15TH', bare), 180);
|
||||
}
|
||||
assert.ok(Number.isNaN(directionToHeading('North / East', true)));
|
||||
assert.equal(directionToHeading('West / SR-24', true), 270);
|
||||
});
|
||||
|
||||
test('travel tokens retain precedence over quadrant streets and bare words', () => {
|
||||
for (const [text, heading] of [
|
||||
['I-5 NB at NE 45th St', 0],
|
||||
['SR 520 EB at NE 148th', 90],
|
||||
['US 13 SB @ SW Front St', 180],
|
||||
]) {
|
||||
for (const bare of [false, true])
|
||||
assert.equal(directionToHeading(text, bare), heading);
|
||||
}
|
||||
assert.equal(
|
||||
directionToHeading('Hwy 401 east of Keele St - Westbound', true),
|
||||
270,
|
||||
);
|
||||
assert.equal(directionToHeading('NE near West St', true), 45);
|
||||
assert.ok(Number.isNaN(directionToHeading('NB SB near NE St', true)));
|
||||
});
|
||||
+905
-116
File diff suppressed because it is too large.
Load diff
@@ -174,11 +174,95 @@ test('analyst: nearest sorting attaches distanceKm ascending', async () => {
|
||||
|
||||
test('analyst: follow-up re-filters the remembered set without re-snapshot', async () => {
|
||||
const eng = makeEngine();
|
||||
await eng.query({ layers: ['flights'], scope: { kind: 'region', name: 'Texland' } });
|
||||
const r = await eng.query({ followUp: true, filters: [{ field: 'onGround', op: 'eq', value: true }] });
|
||||
const first = await eng.query({
|
||||
layers: ['flights'],
|
||||
scope: { kind: 'region', name: 'Texland' },
|
||||
});
|
||||
const r = await eng.query({
|
||||
followUp: true,
|
||||
filters: [{ field: 'onGround', op: 'eq', value: true }],
|
||||
});
|
||||
assert.equal(r.count, 1);
|
||||
assert.equal(r.items[0].id, 'GND1');
|
||||
assert.equal(r.coverage.followUp, true);
|
||||
assert.equal(r.scopeLabel, first.scopeLabel);
|
||||
assert.equal(r.display.scope, first.display.scope);
|
||||
assert.equal(r.coverage.scope, first.coverage.scope);
|
||||
});
|
||||
|
||||
test('analyst: follow-ups preserve partial and unanswered coverage', async () => {
|
||||
const eng = createAnalystEngine({
|
||||
getRecords: (key) => (key === 'flights' ? FLIGHTS : []),
|
||||
getLayerSnapshot: (key) => ({
|
||||
enabled: key === 'flights',
|
||||
feedState: key === 'flights' ? 'nominal' : 'off',
|
||||
}),
|
||||
getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }),
|
||||
});
|
||||
const first = await eng.query({
|
||||
layers: ['flights', 'military'],
|
||||
scope: { kind: 'anywhere' },
|
||||
});
|
||||
assert.equal(first.partial, true);
|
||||
assert.deepEqual(first.unanswered, ['military']);
|
||||
|
||||
const followUp = await eng.query({ followUp: true, limit: 1 });
|
||||
assert.equal(followUp.partial, true);
|
||||
assert.deepEqual(followUp.unanswered, ['military']);
|
||||
assert.deepEqual(
|
||||
followUp.coverage.layersQueried.map(({ layerKey, status }) => [
|
||||
layerKey,
|
||||
status,
|
||||
]),
|
||||
first.coverage.layersQueried.map(({ layerKey, status }) => [
|
||||
layerKey,
|
||||
status,
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test('analyst: cancelled pre-commit work cannot replace follow-up memory', async () => {
|
||||
const eng = makeEngine();
|
||||
await eng.query({
|
||||
layers: ['flights'],
|
||||
scope: { kind: 'anywhere' },
|
||||
filters: [{ field: 'id', op: 'eq', value: 'SWA1' }],
|
||||
});
|
||||
let current = true;
|
||||
const stale = await eng.query(
|
||||
{ layers: ['local-firms'], scope: { kind: 'anywhere' } },
|
||||
{
|
||||
isCurrent: () => current,
|
||||
beforeCommit: async () => {
|
||||
current = false;
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.equal(stale.code, 'CANCELLED');
|
||||
const followUp = await eng.query({ followUp: true });
|
||||
assert.deepEqual(followUp.items.map((item) => item.id), ['SWA1']);
|
||||
});
|
||||
|
||||
test('analyst: failed pre-commit work cannot replace follow-up memory', async () => {
|
||||
const eng = makeEngine();
|
||||
await eng.query({
|
||||
layers: ['flights'],
|
||||
scope: { kind: 'anywhere' },
|
||||
filters: [{ field: 'id', op: 'eq', value: 'SWA1' }],
|
||||
});
|
||||
await assert.rejects(
|
||||
eng.query(
|
||||
{ layers: ['local-firms'], scope: { kind: 'anywhere' } },
|
||||
{
|
||||
beforeCommit: async () => {
|
||||
throw new Error('projection failed');
|
||||
},
|
||||
},
|
||||
),
|
||||
/projection failed/,
|
||||
);
|
||||
const followUp = await eng.query({ followUp: true });
|
||||
assert.deepEqual(followUp.items.map((item) => item.id), ['SWA1']);
|
||||
});
|
||||
|
||||
test('analyst: unresolved region is an honest failure, not empty success', async () => {
|
||||
@@ -187,6 +271,52 @@ 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: region lookup receives and obeys the owning action signal', async () => {
|
||||
const controller = new AbortController();
|
||||
let receivedSignal = null;
|
||||
const eng = createAnalystEngine({
|
||||
getRecords: () => FLIGHTS,
|
||||
resolveRegionRing: async (_name, signal) => {
|
||||
receivedSignal = signal;
|
||||
await new Promise((resolve) =>
|
||||
signal.addEventListener('abort', resolve, { once: true }),
|
||||
);
|
||||
return null;
|
||||
},
|
||||
getViewContext: () => ({
|
||||
lat: 30.27,
|
||||
lon: -97.74,
|
||||
viewRadiusKm: 150,
|
||||
}),
|
||||
});
|
||||
const pending = eng.query(
|
||||
{ layers: ['flights'], scope: { kind: 'region', name: 'Texas' } },
|
||||
{
|
||||
signal: controller.signal,
|
||||
isCurrent: () => !controller.signal.aborted,
|
||||
},
|
||||
);
|
||||
await Promise.resolve();
|
||||
controller.abort();
|
||||
const result = await pending;
|
||||
assert.equal(receivedSignal, controller.signal);
|
||||
assert.equal(result.code, 'CANCELLED');
|
||||
assert.equal(result.cancelled, true);
|
||||
});
|
||||
|
||||
test('analyst: route fields queryable from cached enrichment only', async () => {
|
||||
const r = await makeEngine().query({
|
||||
layers: ['flights'], scope: { kind: 'anywhere' },
|
||||
@@ -195,9 +325,338 @@ 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('capped cohorts report returned/total/truncated and keep it on follow-up', async () => {
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: () => [{ id: 'sample dam', lat: 0, lon: 0, name: 'sample' }],
|
||||
getRecordCoverage: () => ({ total: 3000, truncated: true }),
|
||||
getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }),
|
||||
});
|
||||
const result = await engine.query({ layers: ['local-dams'], scope: { kind: 'anywhere' }, sortBy: 'distance' });
|
||||
assert.equal(result.count, 1);
|
||||
const layer = result.coverage.layersQueried[0];
|
||||
assert.deepEqual({ returned: layer.returned, total: layer.total, truncated: layer.truncated }, { returned: 1, total: 3000, truncated: true });
|
||||
assert.deepEqual(result.coverage.records, { returned: 1, total: 3000, truncated: true });
|
||||
assert.match(result.display.caveat, /counted 1 of 3,000 loaded records/);
|
||||
const followUp = await engine.query({ followUp: true });
|
||||
assert.equal(followUp.coverage.layersQueried[0].total, 3000);
|
||||
});
|
||||
|
||||
test('an uncapped cohort is complete and carries no caveat', async () => {
|
||||
const result = await makeEngine().query({ layers: ['flights'], scope: { kind: 'anywhere' } });
|
||||
assert.deepEqual(result.coverage.records, { returned: 4, total: 4, truncated: false });
|
||||
assert.equal(result.display.caveat, undefined);
|
||||
assert.equal(result.display.scope, 'anywhere in the loaded data');
|
||||
});
|
||||
|
||||
test('unknown fields, operators, scopes and wrong-typed values are refused with the allowed values', async () => {
|
||||
const engine = makeEngine();
|
||||
const field = await engine.query({ layers: ['flights'], filters: [{ field: 'time', op: 'gte', value: 5 }] });
|
||||
assert.equal(field.ok, false);
|
||||
assert.equal(field.code, 'UNKNOWN_FIELD');
|
||||
assert.ok(field.allowed.includes('altitudeM'));
|
||||
const op = await engine.query({ layers: ['flights'], filters: [{ field: 'altitudeM', op: 'wat', value: 5 }] });
|
||||
assert.equal(op.code, 'BAD_OPERATOR');
|
||||
assert.deepEqual(op.allowed, ['gt', 'gte', 'lt', 'lte', 'eq', 'neq']);
|
||||
const flag = await engine.query({ layers: ['flights'], filters: [{ field: 'military', op: 'eq', value: 'false' }] });
|
||||
assert.equal(flag.code, 'BAD_VALUE', 'the string "false" must not match the TRUE record');
|
||||
const num = await engine.query({ layers: ['flights'], filters: [{ field: 'altitudeM', op: 'gt', value: 'high' }] });
|
||||
assert.equal(num.code, 'BAD_VALUE');
|
||||
const scope = await engine.query({ layers: ['flights'], scope: { kind: 'polygon' } });
|
||||
assert.equal(scope.code, 'UNKNOWN_SCOPE');
|
||||
assert.deepEqual(scope.allowed, ['view', 'region', 'radius', 'anywhere']);
|
||||
const sort = await engine.query({ layers: ['flights'], sortBy: 'loudness' });
|
||||
assert.equal(sort.code, 'UNKNOWN_FIELD');
|
||||
assert.equal(engine.hasMemory(), false, 'refusals never become follow-up memory');
|
||||
});
|
||||
|
||||
test('a typed boolean filter still matches exactly', async () => {
|
||||
const r = await makeEngine().query({ layers: ['flights'], scope: { kind: 'anywhere' }, filters: [{ field: 'military', op: 'eq', value: false }] });
|
||||
assert.deepEqual(r.items.map((i) => i.id).sort(), ['GND1', 'N123', 'SWA1']);
|
||||
});
|
||||
|
||||
test('distance honours sortDir: desc is farthest first', async () => {
|
||||
const far = await makeEngine().query({ layers: ['flights'], scope: { kind: 'anywhere' }, sortBy: 'distance', sortDir: 'desc', limit: 1 });
|
||||
assert.equal(far.items[0].id, 'N123');
|
||||
const near = await makeEngine().query({ layers: ['flights'], scope: { kind: 'anywhere' }, sortBy: 'distance', limit: 1 });
|
||||
assert.equal(near.items[0].id, 'SWA1');
|
||||
});
|
||||
|
||||
test('a follow-up without a previous answer is refused, not a fresh flights query', async () => {
|
||||
const r = await makeEngine().query({ followUp: true, filters: [] });
|
||||
assert.equal(r.ok, false);
|
||||
assert.equal(r.code, 'NO_RESULT_CONTEXT');
|
||||
});
|
||||
|
||||
test('follow-ups never rewrite the remembered rows', async () => {
|
||||
const engine = makeEngine();
|
||||
await engine.query({ layers: ['flights'], scope: { kind: 'anywhere' } });
|
||||
await engine.query({ followUp: true, sortBy: 'distance' });
|
||||
const again = await engine.query({ followUp: true });
|
||||
assert.equal(again.count, 4, 'a follow-up keeps the remembered set, not the view');
|
||||
assert.ok(again.items.every((item) => item.distanceKm === undefined));
|
||||
});
|
||||
|
||||
test('a superseded slow region query cannot overwrite the newer follow-up memory', async () => {
|
||||
let releaseSlow;
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: (key) => ({ flights: FLIGHTS, 'ais-live-vessels': SHIPS }[key] || []),
|
||||
resolveRegionRing: () => new Promise((resolve) => { releaseSlow = () => resolve(TEXLAND); }),
|
||||
getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }),
|
||||
});
|
||||
let generation = 0;
|
||||
const slowGeneration = ++generation;
|
||||
const slow = engine.query({ layers: ['flights'], scope: { kind: 'region', name: 'Texland' } }, { isCurrent: () => generation === slowGeneration });
|
||||
const newGeneration = ++generation;
|
||||
const fresh = await engine.query({ layers: ['ais-live-vessels'], scope: { kind: 'anywhere' } }, { isCurrent: () => generation === newGeneration });
|
||||
assert.equal(fresh.ok, true);
|
||||
releaseSlow();
|
||||
const stale = await slow;
|
||||
assert.equal(stale.code, 'CANCELLED');
|
||||
const followUp = await engine.query({ followUp: true });
|
||||
assert.deepEqual(followUp.items.map((i) => i.layerKey), ['ais-live-vessels', 'ais-live-vessels']);
|
||||
});
|
||||
|
||||
test('off, loading and unavailable layers are refused distinctly; a real zero stays ok', async () => {
|
||||
const engineFor = (snapshot, rows = [], warming = false) => createAnalystEngine({
|
||||
getRecords: () => rows,
|
||||
getLayerSnapshot: () => snapshot,
|
||||
isWarming: () => warming,
|
||||
getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }),
|
||||
});
|
||||
assert.equal((await engineFor({ enabled: false, feedState: 'off' }).query({ layers: ['earthquakes'] })).code, 'LAYER_OFF');
|
||||
assert.equal((await engineFor({ enabled: true, feedState: 'loading' }).query({ layers: ['earthquakes'] })).code, 'NOT_READY');
|
||||
assert.equal((await engineFor({ enabled: true, feedState: 'nominal' }, [], true).query({ layers: ['earthquakes'] })).code, 'NOT_READY');
|
||||
assert.equal((await engineFor({ enabled: true, feedState: 'unavailable' }).query({ layers: ['earthquakes'] })).code, 'FEED_UNAVAILABLE');
|
||||
const zero = await engineFor({ enabled: true, feedState: 'nominal' }, [{ id: 'q', lat: 50, lon: 50, magnitude: 2 }]).query({ layers: ['earthquakes'], filters: [{ field: 'magnitude', op: 'gte', value: 5 }] });
|
||||
assert.equal(zero.ok, true);
|
||||
assert.equal(zero.count, 0);
|
||||
assert.equal(zero.coverage.layersQueried[0].status, 'ok');
|
||||
});
|
||||
|
||||
test('time fields filter by ISO and derive ageHours; the feed window is exposed', async () => {
|
||||
const now = Date.parse('2026-09-23T12:00:00Z');
|
||||
const engine = createAnalystEngine({
|
||||
now: () => now,
|
||||
getRecords: () => [
|
||||
{ id: 'recent', lat: 0, lon: 0, magnitude: 4, timeMs: now - 2 * 3_600_000 },
|
||||
{ id: 'older', lat: 0, lon: 0, magnitude: 5, timeMs: now - 20 * 3_600_000 },
|
||||
],
|
||||
getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }),
|
||||
});
|
||||
const iso = await engine.query({ layers: ['earthquakes'], scope: { kind: 'anywhere' }, filters: [{ field: 'timeMs', op: 'gte', value: '2026-09-23T06:00:00Z' }] });
|
||||
assert.deepEqual(iso.items.map((i) => i.id), ['recent']);
|
||||
const age = await engine.query({ layers: ['earthquakes'], scope: { kind: 'anywhere' }, filters: [{ field: 'ageHours', op: 'lte', value: 6 }] });
|
||||
assert.deepEqual(age.items.map((i) => i.id), ['recent']);
|
||||
assert.equal(age.items[0].ageHours, 2);
|
||||
assert.equal(age.display.window, 'last 24 h');
|
||||
assert.equal(age.coverage.layersQueried[0].window, 'last 24 h');
|
||||
});
|
||||
|
||||
test('follow-up provenance stays attached to old rows after a feed recovers', async () => {
|
||||
let state = 'stale';
|
||||
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');
|
||||
});
|
||||
|
||||
test('a large loaded set sorts and summarizes without overflowing the stack', async () => {
|
||||
const rows = Array.from({ length: 200_000 }, (_, i) => ({ id: `FIRE-${i}`, lat: 0, lon: 0, frp: i }));
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: () => rows,
|
||||
getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }),
|
||||
});
|
||||
const result = await engine.query({ layers: ['local-firms'], scope: { kind: 'anywhere' }, sortBy: 'frp', limit: 1 });
|
||||
assert.equal(result.count, 200_000);
|
||||
assert.equal(result.items[0].frp, 199_999);
|
||||
assert.equal(result.summary.frpMin, 0);
|
||||
});
|
||||
|
||||
test('an enabled but empty layer says why on screen', async () => {
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: () => [],
|
||||
getLayerSnapshot: () => ({ enabled: true, feedState: 'nominal' }),
|
||||
getEmptyNote: () => 'Fly below 400 km to a covered region',
|
||||
getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }),
|
||||
});
|
||||
const result = await engine.query({ layers: ['transit'] });
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.count, 0);
|
||||
assert.equal(result.coverage.layersQueried[0].status, 'empty');
|
||||
assert.match(result.display.caveat, /transit: Fly below 400 km/);
|
||||
});
|
||||
|
||||
test('a radius around a named place without a centre is refused, not measured around the camera', async () => {
|
||||
const r = await makeEngine().query({ layers: ['ais-live-vessels'], scope: { kind: 'radius', name: 'Singapore', km: 200 } });
|
||||
assert.equal(r.ok, false);
|
||||
assert.equal(r.code, 'BAD_SCOPE');
|
||||
const ok = await makeEngine().query({ layers: ['ais-live-vessels'], scope: { kind: 'radius', name: 'Houston', km: 200, center: { lat: 29.6, lon: -95.0 } } });
|
||||
assert.equal(ok.ok, true);
|
||||
});
|
||||
|
||||
test('a field copied with its hinted unit still resolves', async () => {
|
||||
const r = await makeEngine().query({ layers: ['local-firms'], scope: { kind: 'anywhere' }, sortBy: 'frp(MW)', filters: [{ field: 'frp (MW)', op: 'gt', value: 100 }] });
|
||||
assert.equal(r.ok, true);
|
||||
assert.deepEqual(r.items.map((i) => i.id), ['FIRE-3', 'FIRE-1']);
|
||||
});
|
||||
|
||||
test('a written unit converts to the field unit or is refused', async () => {
|
||||
const engine = makeEngine();
|
||||
const feet = await engine.query({ layers: ['flights'], scope: { kind: 'anywhere' }, filters: [{ field: 'altitudeM(ft)', op: 'gt', value: 40000 }] });
|
||||
assert.equal(feet.ok, true);
|
||||
assert.deepEqual(feet.items.map((i) => i.id), ['RCH01'], '40,000 ft is 12,192 m, not 40,000 m');
|
||||
const same = await engine.query({ layers: ['flights'], scope: { kind: 'anywhere' }, filters: [{ field: 'altitudeM (m)', op: 'gt', value: 12192 }] });
|
||||
assert.deepEqual(same.items.map((i) => i.id), ['RCH01']);
|
||||
const knots = await engine.query({ layers: ['flights'], scope: { kind: 'anywhere' }, filters: [{ field: 'speedMps(kts)', op: 'gt', value: 470 }] });
|
||||
assert.deepEqual(knots.items.map((i) => i.id), ['RCH01'], '470 kn is ~242 m/s');
|
||||
const wrong = await engine.query({ layers: ['flights'], filters: [{ field: 'altitudeM(kts)', op: 'gt', value: 1 }] });
|
||||
assert.equal(wrong.code, 'BAD_UNIT');
|
||||
const unitless = await engine.query({ layers: ['flights'], filters: [{ field: 'callsign(ft)', op: 'eq', value: 'X' }] });
|
||||
assert.equal(unitless.code, 'BAD_UNIT');
|
||||
const hinted = await engine.query({ layers: ['local-firms'], scope: { kind: 'anywhere' }, sortBy: 'frp(MW)' });
|
||||
assert.equal(hinted.ok, true);
|
||||
});
|
||||
|
||||
test('unambiguous numeric strings are numbers; blanks, booleans and words are not', async () => {
|
||||
const engine = makeEngine();
|
||||
const text = await engine.query({ layers: ['flights'], scope: { kind: 'anywhere' }, filters: [{ field: 'altitudeM', op: 'gt', value: '12192' }] });
|
||||
assert.equal(text.ok, true);
|
||||
assert.deepEqual(text.items.map((i) => i.id), ['RCH01']);
|
||||
for (const value of ['', ' ', true, 'high', '12k'])
|
||||
assert.equal((await engine.query({ layers: ['flights'], filters: [{ field: 'altitudeM', op: 'gt', value }] })).code, 'BAD_VALUE', JSON.stringify(value));
|
||||
});
|
||||
|
||||
test('any supplied centre must be a real coordinate', async () => {
|
||||
const engine = makeEngine();
|
||||
for (const center of [{ lat: 0 }, { lat: 999, lon: 0 }, { lat: 10, lon: 200 }, { lat: '10', lon: 20 }])
|
||||
assert.equal((await engine.query({ layers: ['flights'], scope: { kind: 'radius', km: 100, center } })).code, 'BAD_SCOPE', JSON.stringify(center));
|
||||
assert.equal((await engine.query({ layers: ['flights'], scope: { kind: 'radius', km: 100, center: { lat: 30.2, lon: -97.7 } } })).ok, true);
|
||||
});
|
||||
|
||||
test('mixed off/loading/unavailable layers refuse; a partly answerable query says so', async () => {
|
||||
const snapshots = { earthquakes: { enabled: true, feedState: 'unavailable' }, 'local-firms': { enabled: true, feedState: 'loading' }, flights: { enabled: false, feedState: 'off' } };
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: (key) => (key === 'ais-live-vessels' ? SHIPS : []),
|
||||
getLayerSnapshot: (key) => snapshots[key] || { enabled: true, feedState: 'nominal' },
|
||||
getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }),
|
||||
});
|
||||
const none = await engine.query({ layers: ['earthquakes', 'local-firms', 'flights'] });
|
||||
assert.equal(none.ok, false);
|
||||
assert.equal(none.code, 'NOT_READY');
|
||||
assert.deepEqual(none.layerStatus.map((l) => l.status), ['error', 'loading', 'off']);
|
||||
assert.equal(engine.hasMemory(), false, 'a refusal never becomes follow-up memory');
|
||||
const down = await engine.query({ layers: ['earthquakes', 'flights'] });
|
||||
assert.equal(down.code, 'FEED_UNAVAILABLE');
|
||||
const partial = await engine.query({ layers: ['ais-live-vessels', 'earthquakes'], scope: { kind: 'anywhere' } });
|
||||
assert.equal(partial.ok, true);
|
||||
assert.equal(partial.partial, true);
|
||||
assert.deepEqual(partial.unanswered, ['earthquakes']);
|
||||
assert.match(partial.display.caveat, /earthquakes error/);
|
||||
});
|
||||
|
||||
test('returned items are copies: mutating them cannot change the remembered answer', async () => {
|
||||
const engine = makeEngine();
|
||||
const first = await engine.query({ layers: ['flights'], scope: { kind: 'anywhere' }, sortBy: 'altitudeM' });
|
||||
for (const item of first.items) {
|
||||
item.altitudeM = -1;
|
||||
item.id = 'MUTATED';
|
||||
}
|
||||
const again = await engine.query({ followUp: true, sortBy: 'altitudeM' });
|
||||
assert.deepEqual(again.items.map((i) => i.id), ['RCH01', 'SWA1', 'N123', 'GND1']);
|
||||
assert.equal(FLIGHTS[1].altitudeM, 13500, 'the provider rows are untouched');
|
||||
});
|
||||
|
||||
test('ranking 250k rows stays within a budget and yields to the page', async () => {
|
||||
const rows = Array.from({ length: 250_000 }, (_, i) => ({ id: `F${i}`, lat: (i % 170) - 85, lon: (i % 350) - 175, frp: (i * 7919) % 100_003 }));
|
||||
const engine = createAnalystEngine({ getRecords: () => rows, getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }) });
|
||||
let ticks = 0;
|
||||
const timer = setInterval(() => ticks++, 0);
|
||||
const started = performance.now();
|
||||
const result = await engine.query({ layers: ['local-firms'], scope: { kind: 'anywhere' }, sortBy: 'frp', limit: 1 });
|
||||
const elapsed = performance.now() - started;
|
||||
clearInterval(timer);
|
||||
assert.equal(result.count, 250_000);
|
||||
assert.equal(result.items[0].frp, 100_002);
|
||||
assert.ok(elapsed < 400, `took ${Math.round(elapsed)} ms`);
|
||||
assert.ok(ticks > 0, 'the scan yielded between slices');
|
||||
let current = true;
|
||||
const pending = engine.query({ layers: ['local-firms'], scope: { kind: 'anywhere' }, sortBy: 'distance' }, { isCurrent: () => current });
|
||||
current = false;
|
||||
assert.equal((await pending).code, 'CANCELLED', 'a superseded scan stops at the next slice');
|
||||
});
|
||||
|
||||
test('analyst: a follow-up naming other layers than the last answer is refused', async () => {
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: (key) => ({ flights: FLIGHTS, 'local-firms': FIRES }[key] || []),
|
||||
resolveRegionRing: async () => null,
|
||||
getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }),
|
||||
});
|
||||
await engine.query({ layers: ['flights'], scope: { kind: 'anywhere' } });
|
||||
const r = await engine.query({ layers: ['local-firms'], followUp: true });
|
||||
assert.equal(r.ok, false);
|
||||
assert.equal(r.code, 'FOLLOW_UP_MISMATCH');
|
||||
assert.equal(engine.hasMemory(), true, 'the earlier answer is still the last answer');
|
||||
});
|
||||
@@ -595,8 +595,15 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
facebookLoader = loadFacebookSdk,
|
||||
youtubeLoader = loadYouTubeApi,
|
||||
} = {}) {
|
||||
if (!documentRef?.createElement || !viewer) {
|
||||
// Pinokio externalizes HTTPS iframe navigation, including hidden preloads.
|
||||
// Use the existing source-card/local-clip fallback before allocating provider
|
||||
// resources. Browsers visiting the same Pinokio-launched server keep embeds.
|
||||
const pinokioShell = /(?:^|\s)Pinokio\/[^\s]+/i.test(
|
||||
globalRef.navigator?.userAgent || '',
|
||||
);
|
||||
if (pinokioShell || !documentRef?.createElement || !viewer) {
|
||||
return {
|
||||
supportsPlayback: false,
|
||||
warm: () => false,
|
||||
show: () => false,
|
||||
play: () => false,
|
||||
@@ -1078,6 +1085,7 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
}
|
||||
|
||||
return {
|
||||
supportsPlayback: true,
|
||||
warm,
|
||||
show,
|
||||
getPlaybackState: () => record?.youtubeSession?.getState() || null,
|
||||
|
||||
@@ -10,6 +10,57 @@ import {
|
||||
resolveEmbeddedMediaSource,
|
||||
} from './bhoteKoshiEmbeddedMedia.js';
|
||||
|
||||
test('Pinokio fallback never creates provider DOM or loads SDKs', async () => {
|
||||
const unexpected = () => { throw new Error('Provider resource allocated in Pinokio'); };
|
||||
const media = createBhoteKoshiEmbeddedMedia({
|
||||
viewer: {},
|
||||
documentRef: { createElement: unexpected },
|
||||
globalRef: {
|
||||
navigator: { userAgent: 'Mozilla/5.0 Chrome/140.0.0.0 Safari/537.36 Pinokio/8.0.40' },
|
||||
setTimeout: unexpected,
|
||||
requestAnimationFrame: unexpected,
|
||||
},
|
||||
facebookLoader: unexpected,
|
||||
youtubeLoader: unexpected,
|
||||
xLoader: unexpected,
|
||||
});
|
||||
for (const sourceUrl of [
|
||||
'https://www.youtube.com/watch?v=abcdefghijk',
|
||||
'https://www.facebook.com/watch/?v=123456789',
|
||||
'https://x.com/example/status/123456789',
|
||||
]) {
|
||||
const options = { observation: { media: { sourceUrl } }, autoplay: true };
|
||||
assert.equal(media.warm(options), false, 'no hidden preload');
|
||||
assert.equal(media.show(options), false, 'retain the existing fallback card');
|
||||
assert.equal(media.play(), false);
|
||||
assert.equal(media.pause(), false);
|
||||
media.hide();
|
||||
}
|
||||
media.destroy();
|
||||
media.hide();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
test('ordinary browsers retain real preloads on the same launcher URL', () => {
|
||||
for (const userAgent of [
|
||||
'Mozilla/5.0 Chrome/140.0.0.0 Safari/537.36',
|
||||
'Mozilla/5.0 Version/18.0 Safari/605.1.15',
|
||||
'Mozilla/5.0 Electron/38.0.0',
|
||||
'',
|
||||
]) {
|
||||
const { media, root } = preloadFixture({ globalRef: {
|
||||
navigator: { userAgent }, location: { href: 'http://127.0.0.1:42003/' },
|
||||
} });
|
||||
assert.equal(media.warm({ observation: { media: {
|
||||
sourceUrl: 'https://www.youtube.com/watch?v=abcdefghijk',
|
||||
} } }), true);
|
||||
assert.equal(root.children.length, 1);
|
||||
media.pause();
|
||||
assert.equal(root.children.length, 0);
|
||||
media.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
function trimmedPlaybackFixture() {
|
||||
let now = 0;
|
||||
let nextId = 0;
|
||||
|
||||
+79
-137
@@ -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;
|
||||
@@ -1000,6 +999,8 @@ export function createBhoteKoshiEventLayer({
|
||||
let _sceneMediaPlaybackBeatId = null;
|
||||
let _sceneMediaPlaybackCompletedBeatId = null;
|
||||
let _sceneMediaPlaybackTimer = null;
|
||||
let _sceneMediaOwner = null;
|
||||
let _removeSceneMediaAbort = null;
|
||||
let _witnessEmbedActive = false;
|
||||
let _sceneRevealAnimationFrame = null;
|
||||
let _sceneRevealHoldActive = false;
|
||||
@@ -1020,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;
|
||||
@@ -1261,8 +1259,41 @@ export function createBhoteKoshiEventLayer({
|
||||
return true;
|
||||
}
|
||||
|
||||
// Playback authority is supplied by the Director, never restored from params.
|
||||
function setSceneMediaPlayback(owner = null) {
|
||||
_removeSceneMediaAbort?.();
|
||||
_removeSceneMediaAbort = null;
|
||||
_sceneMediaOwner = owner;
|
||||
stopSceneMediaPlayback();
|
||||
releaseEvidenceVideo({ restorePoster: false });
|
||||
_embeddedMedia?.hide?.({ immediate: true });
|
||||
_embeddedEvidenceIndex = -1;
|
||||
_sceneMediaPlaybackCompletedBeatId = null;
|
||||
if (owner?.token?.signal) {
|
||||
const signal = owner.token.signal;
|
||||
const cancel = () => setSceneMediaPlayback();
|
||||
if (signal.aborted) cancel();
|
||||
else {
|
||||
signal.addEventListener('abort', cancel, { once: true });
|
||||
_removeSceneMediaAbort = () =>
|
||||
signal.removeEventListener('abort', cancel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sceneMediaPlaybackActive() {
|
||||
return (
|
||||
_sceneMediaOwner != null &&
|
||||
!_sceneMediaOwner.token.cancelled &&
|
||||
!_sceneMediaOwner.token.signal?.aborted &&
|
||||
_sceneMediaOwner.sceneId === _sceneContext?.sceneId &&
|
||||
_sceneMediaOwner.shotId === _sceneContext?.shotId
|
||||
);
|
||||
}
|
||||
|
||||
function sceneEvidenceMediaAutoplays() {
|
||||
return (
|
||||
sceneMediaPlaybackActive() &&
|
||||
_presentation === BHOTE_KOSHI_SCENE_PRESENTATION &&
|
||||
_sceneSurface === BHOTE_KOSHI_SCENE_EVIDENCE_BEAT &&
|
||||
_sceneControls.evidenceMediaAutoplay === true &&
|
||||
@@ -1333,7 +1364,7 @@ export function createBhoteKoshiEventLayer({
|
||||
stopSceneMediaPlayback({ pauseMedia: false });
|
||||
_sceneMediaPlaybackBeatId = beatId;
|
||||
_embeddedMedia?.play?.();
|
||||
if (sceneUsesTrimmedMedia(beatId)) {
|
||||
if (sceneUsesTrimmedMedia(beatId) && _embeddedEvidenceIndex === index) {
|
||||
const deadline = Date.now() + 18000;
|
||||
const check = () => {
|
||||
if (
|
||||
@@ -1415,6 +1446,14 @@ export function createBhoteKoshiEventLayer({
|
||||
!sceneUsesTrimmedMedia(beatId)
|
||||
)
|
||||
return null;
|
||||
const media = _evidenceTimeline.find(
|
||||
(item) => item.observation?.id === beatId,
|
||||
)?.observation?.media;
|
||||
// Deliberate provider suppression is a source card, not a player waiting
|
||||
// to load. Returning no gate selects the Director's authored card dwell.
|
||||
// A local clip still owns playback; a starting supported provider still waits.
|
||||
if (_embeddedMedia?.supportsPlayback === false && !media?.videoPath)
|
||||
return null;
|
||||
return {
|
||||
pending:
|
||||
_sceneMediaPlaybackCompletedBeatId !== beatId ||
|
||||
@@ -2103,6 +2142,7 @@ export function createBhoteKoshiEventLayer({
|
||||
releaseEvidenceVideo();
|
||||
_embeddedEvidenceIndex = -1;
|
||||
const warmingEvidence =
|
||||
sceneMediaPlaybackActive() &&
|
||||
_presentation === BHOTE_KOSHI_SCENE_PRESENTATION &&
|
||||
_sceneSurface === BHOTE_KOSHI_SCENE_EVIDENCE_BEAT;
|
||||
// Keep the same preload across camera frames; stop/disable still cancels it.
|
||||
@@ -2512,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() {
|
||||
@@ -3167,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) {
|
||||
@@ -3284,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');
|
||||
}
|
||||
@@ -3384,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.
|
||||
@@ -3393,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, {
|
||||
@@ -3476,6 +3435,7 @@ export function createBhoteKoshiEventLayer({
|
||||
}
|
||||
|
||||
async function disable() {
|
||||
setSceneMediaPlayback();
|
||||
_enabled = false;
|
||||
++_comparisonSurfaceGeneration;
|
||||
_mapStackController?.clearTerrainComparison?.();
|
||||
@@ -3528,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';
|
||||
@@ -3556,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');
|
||||
}
|
||||
@@ -3770,6 +3711,7 @@ export function createBhoteKoshiEventLayer({
|
||||
setParams,
|
||||
getParams,
|
||||
getSceneShotMediaHold,
|
||||
setSceneMediaPlayback,
|
||||
getStats() {
|
||||
return {
|
||||
count: _enabled ? 1 : 0,
|
||||
|
||||
@@ -30,6 +30,8 @@ import {
|
||||
} from './bhoteKoshiEvent.js';
|
||||
import { SCENE_RECIPES } from '../scenes/recipes.js';
|
||||
import { LayerLifecycle } from './lifecycle.js';
|
||||
import { createBhoteKoshiEmbeddedMedia } from './bhoteKoshiEmbeddedMedia.js';
|
||||
import { SceneDirector } from '../scenes/director.js';
|
||||
|
||||
const eventUrl = new URL('../../public/events/bhote-koshi-2026/event.json', import.meta.url);
|
||||
const eventModuleUrl = new URL('./bhoteKoshiEvent.js', import.meta.url);
|
||||
@@ -277,8 +279,9 @@ test('trimmed scene media holds through provider startup, playback and fade, the
|
||||
try {
|
||||
const viewer = eventViewer();
|
||||
await layer.init(viewer);
|
||||
layer.setSceneMediaPlayback({ sceneId: 'scene', shotId: 'shot', token: { cancelled: false } });
|
||||
layer.setParams({ presentation: 'scene-beat', sceneSurface: 'evidence-beat', beatId: 'mailung-bazzar',
|
||||
sceneContext: { holdSec: 7.65 },
|
||||
sceneContext: { sceneId: 'scene', shotId: 'shot', holdSec: 7.65 },
|
||||
sceneControls: { evidenceMediaAutoplay: true, cameraSettled: true, mediaExitDurationSec: 0.65 } }, { origin: 'scene' });
|
||||
await layer.enable(viewer, { origin: 'scene' });
|
||||
assert.equal(layer.getSceneShotMediaHold('mailung-bazzar').pending, true);
|
||||
@@ -301,6 +304,94 @@ test('trimmed scene media holds through provider startup, playback and fade, the
|
||||
}
|
||||
});
|
||||
|
||||
test('Pinokio source-card fallback selects authored Director dwell, not an impossible player hold', async () => {
|
||||
const previous = { document: globalThis.document, window: globalThis.window };
|
||||
installEventDom();
|
||||
globalThis.window = { setTimeout: () => 1, clearTimeout() {} };
|
||||
const event = JSON.parse(await readFile(eventUrl, 'utf8'));
|
||||
const layer = createBhoteKoshiEventLayer({
|
||||
eventLoader: async () => event,
|
||||
imageryProviderFactory: async url => ({ url }),
|
||||
terrainSampler: async (_viewer, observations) => observations.map(() => ({ height: 800 })),
|
||||
mediaLoader: async () => { throw Object.assign(new Error('No bundled clip'), { name: 'AbortError' }); },
|
||||
overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} },
|
||||
renderHost: { request() {}, hold() {}, release() {} },
|
||||
embeddedMediaFactory: options => createBhoteKoshiEmbeddedMedia({ ...options,
|
||||
globalRef: { navigator: { userAgent: 'Chrome Pinokio/8.0.40' } },
|
||||
documentRef: { createElement() { assert.fail('unsupported host must allocate no provider DOM'); } },
|
||||
}),
|
||||
});
|
||||
const token = { cancelled: false, signal: new AbortController().signal };
|
||||
const params = { presentation: 'scene-beat', sceneSurface: 'evidence-beat', beatId: 'mailung-bazzar',
|
||||
sceneContext: { sceneId: 'scene', shotId: 'shot', holdSec: 7.65 },
|
||||
sceneControls: { evidenceMediaAutoplay: true, mediaPlaybackHoldSec: 7, cameraSettled: true } };
|
||||
try {
|
||||
const viewer = eventViewer();
|
||||
await layer.init(viewer);
|
||||
layer.setSceneMediaPlayback({ sceneId: 'scene', shotId: 'shot', token });
|
||||
layer.setParams(params, { origin: 'scene' });
|
||||
await layer.enable(viewer, { origin: 'scene' });
|
||||
assert.equal(layer.getSceneShotMediaHold('mailung-bazzar'), null);
|
||||
const waits = [];
|
||||
await SceneDirector.prototype._holdShot.call({
|
||||
_layerStatesForShot: () => ({ [layer.id]: { enabled: true, params } }),
|
||||
dataManager: { layers: new Map([[layer.id, { module: layer }]]) },
|
||||
_effectiveShotHoldSec: () => 7.65,
|
||||
_sleep: async ms => waits.push(ms),
|
||||
stopScene: () => assert.fail('a source card must not time out'),
|
||||
}, { id: 'scene' }, { id: 'shot' }, token);
|
||||
assert.deepEqual(waits, [7650], 'use the authored dwell instead of completing instantly or polling 20 seconds');
|
||||
event.evidenceSpine.find(item => item.id === 'mailung-bazzar').media.videoPath = 'fixture-approved.mp4';
|
||||
assert.equal(layer.getSceneShotMediaHold('mailung-bazzar').pending, true,
|
||||
'an approved local clip remains a real playback owner even with the provider suppressed');
|
||||
} finally { await layer.destroy(); Object.assign(globalThis, previous); }
|
||||
});
|
||||
|
||||
test('saved autoplay controls cannot authorize media; live shot ownership is cancellable and never serialized', async () => {
|
||||
const previous = { document: globalThis.document, window: globalThis.window };
|
||||
installEventDom();
|
||||
const timers = new Map(); let timerId = 0;
|
||||
globalThis.window = { setTimeout: fn => { timers.set(++timerId, fn); return timerId; }, clearTimeout: id => timers.delete(id) };
|
||||
const calls = [];
|
||||
const event = JSON.parse(await readFile(eventUrl, 'utf8'));
|
||||
const layer = createBhoteKoshiEventLayer({
|
||||
eventLoader: async () => event,
|
||||
imageryProviderFactory: async url => ({ url }),
|
||||
terrainSampler: async (_viewer, observations) => observations.map(() => ({ height: 800 })),
|
||||
mediaLoader: async () => { throw Object.assign(new Error('No bundled clip'), { name: 'AbortError' }); },
|
||||
overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} },
|
||||
renderHost: { request() {}, hold() {}, release() {} },
|
||||
embeddedMediaFactory: () => ({ supportsPlayback: true, warm: () => true,
|
||||
show: options => { calls.push(options.autoplay); return true; },
|
||||
getPlaybackState: () => null, play() {}, pause() {}, hide() {}, destroy() {} }),
|
||||
});
|
||||
const params = { presentation: 'scene-beat', sceneSurface: 'evidence-beat', beatId: 'mailung-bazzar',
|
||||
sceneContext: { sceneId: 'scene', shotId: 'shot', holdSec: 7.65 },
|
||||
sceneControls: { evidenceMediaAutoplay: true, cameraSettled: true, mediaPlaybackHoldSec: 7 } };
|
||||
try {
|
||||
const viewer = eventViewer(); await layer.init(viewer);
|
||||
layer.setParams(params, { origin: 'local-restore' }); await layer.enable(viewer);
|
||||
assert.ok(calls.length); assert.ok(calls.every(value => value === false));
|
||||
assert.equal(layer.getSceneShotMediaHold('mailung-bazzar'), null);
|
||||
const saved = layer.getParams();
|
||||
const abort = new AbortController();
|
||||
layer.setSceneMediaPlayback({ sceneId: 'scene', shotId: 'shot', token: { cancelled: false, signal: abort.signal } });
|
||||
layer.setParams(params, { origin: 'scene' });
|
||||
assert.equal(calls.at(-1), true);
|
||||
assert.equal(layer.getSceneShotMediaHold('mailung-bazzar').pending, true,
|
||||
'a temporarily null supported-provider state is still startup, not unsupported');
|
||||
assert.deepEqual(layer.getParams(), saved, 'live playback authority must not enter saved params');
|
||||
const late = [...timers.values()];
|
||||
abort.abort(); late.forEach(fn => fn());
|
||||
assert.equal(layer.getSceneShotMediaHold('mailung-bazzar'), null);
|
||||
layer.setParams(params, { origin: 'local-restore' });
|
||||
assert.equal(calls.at(-1), false, 'late callbacks and restored flags cannot restart revoked playback');
|
||||
layer.setSceneMediaPlayback({ sceneId: 'scene', shotId: 'different-shot', token: { cancelled: false } });
|
||||
layer.setParams(params, { origin: 'scene' });
|
||||
assert.equal(calls.at(-1), false, 'a different shot cannot borrow this playback owner');
|
||||
} finally { await layer.destroy(); Object.assign(globalThis, previous); }
|
||||
});
|
||||
|
||||
test('Bhote Koshi timeline helpers clamp untrusted UI values', () => {
|
||||
assert.equal(clampUnit(-1), 0);
|
||||
assert.equal(clampUnit(0.42), 0.42);
|
||||
@@ -1695,7 +1786,9 @@ test('Upper Valley Collapse autoplays its muted local fallback without starting
|
||||
sceneControls: { evidenceMediaAutoplay: true },
|
||||
beatId: 'immediate-collapse-viewpoint',
|
||||
beatReveal: 0.36,
|
||||
sceneContext: { sceneId: 'scene', shotId: 'shot' },
|
||||
}, { origin: 'scene' });
|
||||
layer.setSceneMediaPlayback({ sceneId: 'scene', shotId: 'shot', token: { cancelled: false } });
|
||||
await layer.enable(viewer, { origin: 'scene' });
|
||||
assert.equal(videos.length, 1);
|
||||
const video = videos[0];
|
||||
@@ -1874,6 +1967,7 @@ test('Nepal evidence shots resume the standalone callout and flood sequence from
|
||||
sceneSurface: 'evidence-beat',
|
||||
beatId: 'gyirong-border-gate',
|
||||
beatReveal: 0.36,
|
||||
sceneContext: { sceneId: 'scene', shotId: 'shot' },
|
||||
sceneControls: {
|
||||
evidenceSequence: true,
|
||||
evidenceSequenceDurationSec: 0.9,
|
||||
@@ -1886,6 +1980,7 @@ test('Nepal evidence shots resume the standalone callout and flood sequence from
|
||||
try {
|
||||
await layer.init(viewer);
|
||||
layer.setParams(params, { origin: 'scene' });
|
||||
layer.setSceneMediaPlayback({ sceneId: 'scene', shotId: 'shot', token: { cancelled: false } });
|
||||
await layer.enable(viewer, { origin: 'scene' });
|
||||
const card = entries.find(({ id }) => id === 'evidence-card-gyirong-border-gate');
|
||||
const flood = viewer._test.dataSources[0].entities.getById('bhote-koshi-flood-core');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -5,6 +5,8 @@ import { INCIDENT_OVERVIEW_HOLD_SEC, INCIDENT_OVERVIEW_PLACES } from './bhoteKos
|
||||
import { BHOTE_KOSHI_FLOOD_PATH } from './bhoteKoshiFloodPath.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { SceneDirector } from '../scenes/director.js';
|
||||
import { createPlaybackClock } from '../director/clock.js';
|
||||
import { createDefaultScenePacks } from '../scenes/packs/defaults.js';
|
||||
import {
|
||||
anchoredCalloutPresentation,
|
||||
BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION,
|
||||
@@ -25,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); },
|
||||
@@ -503,11 +505,14 @@ for (const phase of ['timer', 'move-end', 'approach', 'orbit']) {
|
||||
...nonRunningAnimation(),
|
||||
});
|
||||
const director = {
|
||||
_clock: createPlaybackClock({ isRunning: () => false, timingForShot: () => null, onProgress() {} }),
|
||||
_scenePacks: createDefaultScenePacks(),
|
||||
dataManager: { layers: new Map([[layer.id, { module: layer }]]) },
|
||||
viewer,
|
||||
_sceneSeekGeneration: 0,
|
||||
_loadGeneration: 0,
|
||||
_cancelActiveSceneTravel: SceneDirector.prototype._cancelActiveSceneTravel,
|
||||
_setSceneMediaPlayback: SceneDirector.prototype._setSceneMediaPlayback,
|
||||
};
|
||||
try {
|
||||
layer.setParams({ presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION });
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* @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 file is read
|
||||
* from disk instead.
|
||||
*
|
||||
* Nothing here imports the JSON as a module. A JSON-attributed import cannot
|
||||
* serve the browser (the Vite dev server answers it with a JavaScript module,
|
||||
* which the browser rejects), and a literal `import('./….json')` makes the
|
||||
* production build emit a JavaScript copy of every pack that the browser
|
||||
* never loads.
|
||||
*
|
||||
* @module data/bundledJson
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {URL} url - Resolved against the calling module's `import.meta.url`.
|
||||
* @param {{signal?: AbortSignal}} [options]
|
||||
* @returns {Promise<any>} The parsed JSON.
|
||||
*/
|
||||
export async function loadBundledJson(url, { signal } = {}) {
|
||||
signal?.throwIfAborted();
|
||||
if (url.protocol === 'file:') {
|
||||
// Node's built-in, reached without an import, so the browser build never
|
||||
// bundles or rewrites it; only Node, reading `file:` URLs, gets here. The
|
||||
// read is synchronous so the result settles as promptly as the cached
|
||||
// module import it replaces; callers already memoize each pack.
|
||||
const getBuiltinModule = globalThis.process?.getBuiltinModule;
|
||||
if (typeof getBuiltinModule !== 'function')
|
||||
throw new Error(
|
||||
`God's Eye View needs Node 24.14 or newer to read ${url.pathname.split('/').pop()} (running ${globalThis.process?.version ?? 'an unknown runtime'})`,
|
||||
);
|
||||
const { readFileSync } = getBuiltinModule('node:fs');
|
||||
return JSON.parse(readFileSync(url, 'utf8'));
|
||||
}
|
||||
const response = await fetch(url, { signal });
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status} for ${url.pathname}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
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,
|
||||
);
|
||||
|
||||
test('bundled JSON: a file URL is read from disk under Node', async (t) => {
|
||||
const fetchMock = t.mock.method(globalThis, 'fetch');
|
||||
const pack = await loadBundledJson(MARINE);
|
||||
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',
|
||||
);
|
||||
assert.deepEqual(await loadBundledJson(url), { features: [] });
|
||||
assert.deepEqual(requests, [url.href]);
|
||||
});
|
||||
|
||||
test('bundled JSON: an HTTP error rejects so the retryable loader can retry', async (t) => {
|
||||
t.mock.method(
|
||||
globalThis,
|
||||
'fetch',
|
||||
async () => new Response('', { status: 404 }),
|
||||
);
|
||||
await assert.rejects(
|
||||
loadBundledJson(new URL('http://localhost/missing.json')),
|
||||
/HTTP 404/,
|
||||
);
|
||||
});
|
||||
|
||||
test('bundled JSON: an aborted load rejects before reading', async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await assert.rejects(loadBundledJson(MARINE, { signal: controller.signal }), {
|
||||
name: 'AbortError',
|
||||
});
|
||||
});
|
||||
|
||||
test('bundled JSON: no data pack is imported as a module', async () => {
|
||||
// A literal JSON import in app code makes the production build emit a
|
||||
// JavaScript copy of the pack beside the fetched JSON, which the browser
|
||||
// never loads. Tests are not bundled, so they may still import JSON.
|
||||
const { spawnSync } = await import('node:child_process');
|
||||
const result = spawnSync(
|
||||
'git',
|
||||
[
|
||||
'grep',
|
||||
'-nE',
|
||||
String.raw`import\(\s*['"][^'"]*local_data/[^'"]*\.json`,
|
||||
'--',
|
||||
'src',
|
||||
':!*.test.mjs',
|
||||
],
|
||||
{ cwd: new URL('../../', import.meta.url), encoding: 'utf8' },
|
||||
);
|
||||
// git grep exits 1 when nothing matches.
|
||||
assert.equal(result.status, 1, result.stdout);
|
||||
});
|
||||
|
||||
test('bundled JSON: an older Node says which version is needed', async (t) => {
|
||||
// Node before 20.16 / 22.3 has no process.getBuiltinModule.
|
||||
const original = process.getBuiltinModule;
|
||||
t.after(() => {
|
||||
process.getBuiltinModule = original;
|
||||
});
|
||||
process.getBuiltinModule = undefined;
|
||||
await assert.rejects(loadBundledJson(MARINE), {
|
||||
message: /needs Node 24\.14 or newer to read marine\.json/,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
/** Arrival corrections belong to one viewer and yield to its next camera owner. */
|
||||
const arrivals = new WeakMap();
|
||||
|
||||
/** Cancel an outstanding arrival correction before navigation or teardown. */
|
||||
export function cancelCameraArrival(viewer) {
|
||||
if (viewer) arrivals.get(viewer)?.();
|
||||
}
|
||||
|
||||
/** Register a correction and return an idempotent ownership release. */
|
||||
export function ownCameraArrival(viewer, cancel) {
|
||||
cancelCameraArrival(viewer);
|
||||
arrivals.set(viewer, cancel);
|
||||
return () => {
|
||||
if (arrivals.get(viewer) === cancel) arrivals.delete(viewer);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
/** Camera range with breathing room in both viewport dimensions. */
|
||||
export function rangeForBoundingSphere(viewer, radius) {
|
||||
const frustum = viewer.camera?.frustum;
|
||||
const verticalFov = Number(frustum?.fov) || Cesium.Math.toRadians(60);
|
||||
const aspectRatio = Math.max(0.5, Number(frustum?.aspectRatio) || 1);
|
||||
const horizontalFov = 2 * Math.atan(Math.tan(verticalFov / 2) * aspectRatio);
|
||||
const limitingFov = Math.min(verticalFov, horizontalFov);
|
||||
// Target 57% sphere occupancy so perspective still leaves at least 40%
|
||||
// measured roof-to-base breathing room in ordinary oblique building views.
|
||||
const occupiedViewportFraction = 0.57;
|
||||
const desiredAngularRadius = (limitingFov * occupiedViewportFraction) / 2;
|
||||
return (radius / Math.sin(desiredAngularRadius)) * 1.05;
|
||||
}
|
||||
@@ -128,6 +128,14 @@ test('the catalog applies the fair cap to configured packs end to end', async ()
|
||||
sources.slice(0, 3).map((s) => s.id),
|
||||
['env-0', 'env-1', 'env-2'],
|
||||
);
|
||||
assert.equal(sources[0].pack, 'env');
|
||||
const [trimmed] = getSources.trimmedPacks();
|
||||
assert.deepEqual(
|
||||
{ ...trimmed, region: undefined },
|
||||
{ pack: 'env', available: 12, served: 9, region: undefined },
|
||||
);
|
||||
assert.equal(trimmed.region.south, 30);
|
||||
assert.ok(Math.abs(trimmed.region.north - 30.11) < 1e-9);
|
||||
} finally {
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in saved)) delete process.env[key];
|
||||
@@ -135,3 +143,20 @@ test('the catalog applies the fair cap to configured packs end to end', async ()
|
||||
Object.assign(process.env, saved);
|
||||
}
|
||||
});
|
||||
|
||||
test('a pack trimmed to its nearest cameras reports how many it offered', async () => {
|
||||
const { prioritizeSources } =
|
||||
await import('../../server/providers/cctv/normalize.js');
|
||||
const cameras = Array.from({ length: 5 }, (_, i) => ({
|
||||
id: `c${i}`,
|
||||
lat: i,
|
||||
lon: 0,
|
||||
}));
|
||||
const kept = prioritizeSources(cameras, 2, [{ lat: 0, lon: 0 }]);
|
||||
assert.deepEqual(
|
||||
kept.map((camera) => camera.id),
|
||||
['c0', 'c1'],
|
||||
);
|
||||
assert.equal(kept.available, 5);
|
||||
assert.deepEqual(kept.region, { west: 0, south: 0, east: 0, north: 4 });
|
||||
});
|
||||
@@ -0,0 +1,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();
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -0,0 +1,391 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
loadNycSourcesFromOpenData,
|
||||
nycCameraIsOnline,
|
||||
nycCameraName,
|
||||
nycCameraToSource,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
import {
|
||||
DEFAULT_CCTV_MAX_SOURCES,
|
||||
DEFAULT_NYC_MAX_SOURCES,
|
||||
NYC_CAMERAS_URL,
|
||||
NYC_GROUND_ELEVATION_M,
|
||||
NYC_IMAGE_ORIGIN,
|
||||
NYC_IMAGE_URL,
|
||||
NYC_MAX_CATALOG_BYTES,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import {
|
||||
CAMERA_CODE_MAX_CHARS,
|
||||
isLikelyNycCoordinate,
|
||||
} from '../../server/providers/cctv/normalize.js';
|
||||
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
|
||||
|
||||
/**
|
||||
* A response whose body is a live stream, plus a flag that flips when the
|
||||
* stream is cancelled. A rejection path that returns without cancelling holds
|
||||
* the transport open, so the flag is what the refusal tests actually assert.
|
||||
*/
|
||||
const streamingResponse = (init = {}) => {
|
||||
const state = { cancelled: false };
|
||||
const body = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode('['));
|
||||
},
|
||||
cancel() {
|
||||
state.cancelled = true;
|
||||
},
|
||||
});
|
||||
return { response: new Response(body, init), state };
|
||||
};
|
||||
|
||||
const MIDTOWN_ID = '8a6bc417-4877-4ebe-8052-88c1b261baf1';
|
||||
const BROOKLYN_ID = '11111111-2222-3333-4444-555555555555';
|
||||
|
||||
/** One TMC camera row, shaped like the live `webcams.nyctmc.org` payload. */
|
||||
const row = (overrides = {}) => ({
|
||||
id: MIDTOWN_ID,
|
||||
name: 'Central Park West @ 86 St',
|
||||
latitude: 40.785302,
|
||||
longitude: -73.969353,
|
||||
area: 'Manhattan',
|
||||
isOnline: 'true',
|
||||
imageUrl: NYC_IMAGE_URL(MIDTOWN_ID),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test('a NYC row maps to a source on the pinned HTTPS frame host', () => {
|
||||
const source = nycCameraToSource(row());
|
||||
assert.equal(source.id, `nyc-${MIDTOWN_ID}`);
|
||||
assert.equal(source.name, 'Central Park West @ 86 St');
|
||||
assert.equal(source.city, 'Manhattan');
|
||||
assert.equal(source.cityId, 'nyc');
|
||||
assert.equal(source.provider, 'NYC DOT Traffic Management Center');
|
||||
assert.equal(source.lat, 40.785302);
|
||||
assert.equal(source.lon, -73.969353);
|
||||
assert.equal(source.feedType, 'image');
|
||||
assert.equal(source.sourceKind, 'nyc-dot-tmc');
|
||||
assert.equal(
|
||||
source.license,
|
||||
'Public NYC DOT Traffic Management Center traffic camera',
|
||||
);
|
||||
assert.equal(source.groundElevationM, NYC_GROUND_ELEVATION_M);
|
||||
assert.equal(source.url, NYC_IMAGE_URL(MIDTOWN_ID));
|
||||
assert.equal(source.url, source.snapshotUrl);
|
||||
assert.ok(source.url.startsWith(NYC_IMAGE_ORIGIN));
|
||||
assert.equal(source.headingConfidence, 'low');
|
||||
assert.ok(Number.isFinite(source.headingDeg));
|
||||
});
|
||||
|
||||
test('frame URLs are synthesized from the UUID; the payload URL is ignored', () => {
|
||||
const source = nycCameraToSource(
|
||||
row({ imageUrl: 'https://evil.example/cameras/x/image' }),
|
||||
);
|
||||
assert.equal(source.url, NYC_IMAGE_URL(MIDTOWN_ID));
|
||||
assert.equal(source.snapshotUrl, source.url);
|
||||
});
|
||||
|
||||
test('offline cameras, off-city geometry and unusable ids are dropped', () => {
|
||||
assert.equal(nycCameraToSource(row({ isOnline: 'false' })), null);
|
||||
assert.equal(nycCameraToSource(row({ isOnline: false })), null);
|
||||
assert.equal(
|
||||
nycCameraToSource(row({ isOnline: 'TRUE' })).id,
|
||||
`nyc-${MIDTOWN_ID}`,
|
||||
);
|
||||
assert.equal(
|
||||
nycCameraToSource(row({ isOnline: true })).id,
|
||||
`nyc-${MIDTOWN_ID}`,
|
||||
);
|
||||
// Austin: a plausible lat/lon, but not a New York camera.
|
||||
assert.equal(
|
||||
nycCameraToSource(row({ latitude: 30.2672, longitude: -97.7431 })),
|
||||
null,
|
||||
);
|
||||
assert.equal(nycCameraToSource(row({ latitude: 0, longitude: 0 })), null);
|
||||
assert.equal(nycCameraToSource(row({ id: '../etc/passwd' })), null);
|
||||
assert.equal(nycCameraToSource(row({ id: 'not-a-uuid' })), null);
|
||||
assert.equal(nycCameraToSource(row({ id: `${MIDTOWN_ID}/../x` })), null);
|
||||
assert.equal(nycCameraToSource(null), null);
|
||||
});
|
||||
|
||||
test('ids lower-case the UUID and unknown boroughs fall back to New York', () => {
|
||||
const mixed = nycCameraToSource(
|
||||
row({ id: MIDTOWN_ID.toUpperCase(), area: 'Long Island' }),
|
||||
);
|
||||
assert.equal(mixed.id, `nyc-${MIDTOWN_ID}`);
|
||||
assert.equal(mixed.city, 'New York');
|
||||
assert.equal(
|
||||
mixed.url,
|
||||
NYC_IMAGE_URL(MIDTOWN_ID.toUpperCase()),
|
||||
'the registered frame path keeps the catalog UUID spelling',
|
||||
);
|
||||
});
|
||||
|
||||
test('a nameless row still gets a label', () => {
|
||||
assert.equal(
|
||||
nycCameraName({ name: ' FDR Drive @ 23 St ' }, 'nyc-x'),
|
||||
'FDR Drive @ 23 St',
|
||||
);
|
||||
assert.equal(nycCameraName({}, `nyc-${MIDTOWN_ID}`), 'NYC Camera 8a6bc417');
|
||||
assert.equal(nycCameraIsOnline('true'), true);
|
||||
assert.equal(nycCameraIsOnline('false'), false);
|
||||
});
|
||||
|
||||
test('the unselected label is the intersection, trimmed to the code width', () => {
|
||||
assert.equal(nycCameraToSource(row()).code, 'CENTRAL PARK WEST @ 86 ST');
|
||||
const long = nycCameraToSource(
|
||||
row({
|
||||
name: 'Brooklyn Queens Expressway @ Atlantic Avenue / Flatbush Avenue Extension',
|
||||
}),
|
||||
);
|
||||
assert.ok(long.code.length <= CAMERA_CODE_MAX_CHARS);
|
||||
assert.ok(long.code.endsWith('…'));
|
||||
assert.ok(long.code.startsWith('BROOKLYN'));
|
||||
});
|
||||
|
||||
test('the loader reads the keyless catalog and collapses duplicate ids', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
const requested = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url, init) => {
|
||||
requested.push([String(url), init.redirect]);
|
||||
return Response.json([
|
||||
row({ latitude: 40.758, longitude: -73.9855 }),
|
||||
row({
|
||||
name: 'Central Park West @ 86 St (duplicate)',
|
||||
latitude: 40.758,
|
||||
longitude: -73.9855,
|
||||
}),
|
||||
row({
|
||||
id: BROOKLYN_ID,
|
||||
name: 'Atlantic Ave @ Flatbush Ave',
|
||||
latitude: 40.65,
|
||||
longitude: -73.96,
|
||||
area: 'Brooklyn',
|
||||
imageUrl: NYC_IMAGE_URL(BROOKLYN_ID),
|
||||
}),
|
||||
row({ isOnline: 'false', name: 'Offline camera' }),
|
||||
row({ id: 'https://evil.example/x' }),
|
||||
]);
|
||||
});
|
||||
const cameras = await loadNycSourcesFromOpenData();
|
||||
assert.deepEqual(requested, [[NYC_CAMERAS_URL, 'manual']]);
|
||||
assert.deepEqual(
|
||||
cameras.map((camera) => camera.id),
|
||||
// Nearest to a borough anchor first: the Times Square camera before Brooklyn.
|
||||
[`nyc-${MIDTOWN_ID}`, `nyc-${BROOKLYN_ID}`],
|
||||
);
|
||||
assert.equal(cameras[0].name, 'Central Park West @ 86 St');
|
||||
assert.equal(cameras[1].city, 'Brooklyn');
|
||||
});
|
||||
|
||||
test('an upstream failure yields an empty pack and releases the response', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const failed = streamingResponse({ status: 503 });
|
||||
t.mock.method(globalThis, 'fetch', async () => failed.response);
|
||||
assert.deepEqual(await loadNycSourcesFromOpenData(), []);
|
||||
assert.equal(failed.state.cancelled, true, 'the failed body is cancelled');
|
||||
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
throw new Error('network down');
|
||||
});
|
||||
assert.deepEqual(await loadNycSourcesFromOpenData(), []);
|
||||
});
|
||||
|
||||
test('the catalog fetch refuses redirects and oversized bodies', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const seen = [];
|
||||
const redirected = streamingResponse({
|
||||
status: 302,
|
||||
headers: { location: 'https://evil.example/cameras.json' },
|
||||
});
|
||||
t.mock.method(globalThis, 'fetch', async (url, init) => {
|
||||
seen.push([String(url), init.redirect]);
|
||||
return redirected.response;
|
||||
});
|
||||
assert.deepEqual(await loadNycSourcesFromOpenData(), []);
|
||||
assert.deepEqual(seen, [[NYC_CAMERAS_URL, 'manual']]);
|
||||
assert.equal(
|
||||
redirected.state.cancelled,
|
||||
true,
|
||||
'the redirect body is cancelled',
|
||||
);
|
||||
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
const body = JSON.stringify([row()]);
|
||||
return new Response(body, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'content-length': String(NYC_MAX_CATALOG_BYTES + 1),
|
||||
},
|
||||
});
|
||||
});
|
||||
assert.deepEqual(await loadNycSourcesFromOpenData(), []);
|
||||
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
const oversized = 'x'.repeat(NYC_MAX_CATALOG_BYTES + 1024);
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(oversized));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
{ headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
});
|
||||
assert.deepEqual(await loadNycSourcesFromOpenData(), []);
|
||||
});
|
||||
|
||||
test('a reduced pack cap keeps cameras nearest the borough anchors', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
const saved = process.env.CCTV_NYC_MAX_SOURCES;
|
||||
t.after(() => {
|
||||
if (saved === undefined) delete process.env.CCTV_NYC_MAX_SOURCES;
|
||||
else process.env.CCTV_NYC_MAX_SOURCES = saved;
|
||||
});
|
||||
process.env.CCTV_NYC_MAX_SOURCES = '8';
|
||||
const rows = [
|
||||
// Far southern Staten Island, listed first so only distance can keep them.
|
||||
...[40.5, 40.505, 40.51, 40.515, 40.52, 40.525].map((lat, i) =>
|
||||
row({
|
||||
id: `aaaaaaaa-bbbb-cccc-dddd-${String(i).padStart(12, '0')}`,
|
||||
latitude: lat,
|
||||
longitude: -74.24,
|
||||
area: 'Staten Island',
|
||||
name: `SI ${i}`,
|
||||
}),
|
||||
),
|
||||
...[40.758, 40.757, 40.756, 40.755, 40.754, 40.753].map((lat, i) =>
|
||||
row({
|
||||
id: `bbbbbbbb-cccc-dddd-eeee-${String(i).padStart(12, '0')}`,
|
||||
latitude: lat,
|
||||
longitude: -73.9855,
|
||||
area: 'Manhattan',
|
||||
name: `Midtown ${i}`,
|
||||
}),
|
||||
),
|
||||
];
|
||||
t.mock.method(globalThis, 'fetch', async () => Response.json(rows));
|
||||
const cameras = await loadNycSourcesFromOpenData();
|
||||
assert.equal(cameras.length, 8);
|
||||
assert.deepEqual(
|
||||
cameras.map((camera) => camera.city),
|
||||
[
|
||||
'Manhattan',
|
||||
'Manhattan',
|
||||
'Manhattan',
|
||||
'Manhattan',
|
||||
'Manhattan',
|
||||
'Manhattan',
|
||||
'Staten Island',
|
||||
'Staten Island',
|
||||
],
|
||||
'Midtown fills first; the Staten Island anchor still keeps a share',
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Serve the NYC catalog to the NYC endpoint and an empty payload to every
|
||||
* other pack, so one catalog refresh exercises the registration without
|
||||
* reaching the network.
|
||||
*/
|
||||
const runCatalogWithMockedUpstreams = async (t) => {
|
||||
const requested = [];
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
const href = String(url);
|
||||
requested.push(href);
|
||||
if (href === NYC_CAMERAS_URL) {
|
||||
return Response.json([
|
||||
row({ latitude: 40.758, longitude: -73.9855 }),
|
||||
row({
|
||||
id: BROOKLYN_ID,
|
||||
name: 'Atlantic Ave @ Flatbush Ave',
|
||||
latitude: 40.65,
|
||||
longitude: -73.96,
|
||||
area: 'Brooklyn',
|
||||
}),
|
||||
]);
|
||||
}
|
||||
return Response.json([]);
|
||||
});
|
||||
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
|
||||
return { requested, sources };
|
||||
};
|
||||
|
||||
/** Run one catalog refresh with CCTV_NYC_ENABLED set to `flag` (undefined
|
||||
* leaves it unset), restoring the environment afterwards. */
|
||||
const runCatalogWithNycFlag = async (t, flag) => {
|
||||
const saved = { ...process.env };
|
||||
try {
|
||||
delete process.env.CCTV_SOURCES_FILE;
|
||||
delete process.env.CCTV_SOURCES_JSON;
|
||||
if (flag === undefined) delete process.env.CCTV_NYC_ENABLED;
|
||||
else process.env.CCTV_NYC_ENABLED = flag;
|
||||
return await runCatalogWithMockedUpstreams(t);
|
||||
} finally {
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in saved)) delete process.env[key];
|
||||
}
|
||||
Object.assign(process.env, saved);
|
||||
}
|
||||
};
|
||||
|
||||
test('CCTV_NYC_ENABLED=1 wires the NYC lane into the catalog', async (t) => {
|
||||
const { requested, sources } = await runCatalogWithNycFlag(t, '1');
|
||||
assert.ok(
|
||||
requested.includes(NYC_CAMERAS_URL),
|
||||
'the catalog refresh invokes the NYC loader',
|
||||
);
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.cityId === 'nyc').map((s) => s.id),
|
||||
[`nyc-${MIDTOWN_ID}`, `nyc-${BROOKLYN_ID}`],
|
||||
'NYC cameras reach the served catalog through the registered lane',
|
||||
);
|
||||
});
|
||||
|
||||
// NYC DOT requires a data-sharing agreement for this feed, so an install that
|
||||
// has not opted in must never contact it.
|
||||
for (const [label, flag] of [
|
||||
['an unset CCTV_NYC_ENABLED', undefined],
|
||||
['an empty CCTV_NYC_ENABLED', ''],
|
||||
['CCTV_NYC_ENABLED=0', '0'],
|
||||
['CCTV_NYC_ENABLED=true', 'true'],
|
||||
]) {
|
||||
test(`${label} makes no NYC request`, async (t) => {
|
||||
const { requested, sources } = await runCatalogWithNycFlag(t, flag);
|
||||
assert.ok(requested.length > 0, 'the other packs still refreshed');
|
||||
assert.equal(
|
||||
requested.some((href) => href.includes('nyctmc.org')),
|
||||
false,
|
||||
'the lane never reaches its upstream',
|
||||
);
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.cityId === 'nyc'),
|
||||
[],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('the shipped catalog ceiling is not raised to make room for this pack', () => {
|
||||
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000);
|
||||
assert.equal(DEFAULT_NYC_MAX_SOURCES, 250);
|
||||
});
|
||||
|
||||
test('isLikelyNycCoordinate spans the five boroughs and rejects the rest', () => {
|
||||
assert.equal(isLikelyNycCoordinate(40.758, -73.9855), true); // Times Square
|
||||
assert.equal(isLikelyNycCoordinate(40.5795, -74.1502), true); // Staten Island
|
||||
assert.equal(isLikelyNycCoordinate(40.8448, -73.8648), true); // Bronx
|
||||
assert.equal(isLikelyNycCoordinate(40.5077, -74.2304), true); // southern SI slack
|
||||
assert.equal(isLikelyNycCoordinate(30.2672, -97.7431), false); // Austin
|
||||
assert.equal(isLikelyNycCoordinate(0, 0), false);
|
||||
assert.equal(isLikelyNycCoordinate(NaN, -73.98), false);
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
loadVegvesenSourcesFromOpenData,
|
||||
vegvesenCameraToSource,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
import {
|
||||
DEFAULT_VEGVESEN_CCTV_URL,
|
||||
VEGVESEN_IMAGE_ORIGIN,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
|
||||
|
||||
/** One `datex_3_1:CctvSimple` feature, shaped like the live OGC payload. */
|
||||
const feature = (props = {}, coordinates = [5.459189, 61.832706]) => ({
|
||||
type: 'Feature',
|
||||
geometry: { type: 'Point', coordinates },
|
||||
properties: {
|
||||
stillImageUrl: 'https://kamera.atlas.vegvesen.no/api/images/3000063_1',
|
||||
cameraId: '3000063_1',
|
||||
roadNumber: 'F614',
|
||||
'status.stillImageAvailability': 'videoOrImagesAvailable',
|
||||
orientationDescription: 'Svelgen',
|
||||
description: 'Langesi',
|
||||
...props,
|
||||
},
|
||||
});
|
||||
|
||||
test('a Vegvesen feature maps to a source on the pinned frame host', () => {
|
||||
const source = vegvesenCameraToSource(feature());
|
||||
assert.equal(source.id, 'no-vegvesen-3000063_1');
|
||||
assert.equal(source.name, 'F614 Langesi → Svelgen');
|
||||
assert.equal(source.city, 'Norway');
|
||||
assert.equal(source.cityId, 'norway');
|
||||
assert.equal(source.provider, 'Statens vegvesen');
|
||||
assert.equal(source.lat, 61.832706);
|
||||
assert.equal(source.lon, 5.459189);
|
||||
assert.equal(source.headingConfidence, 'low');
|
||||
assert.equal(source.sourceKind, 'vegvesen-datex');
|
||||
assert.equal(source.code, 'LANGESI');
|
||||
assert.ok(source.url.startsWith(VEGVESEN_IMAGE_ORIGIN));
|
||||
assert.equal(source.snapshotUrl, source.url);
|
||||
});
|
||||
|
||||
test('cameras that publish HLS get live video with the still as fallback', (t) => {
|
||||
const video = {
|
||||
videoServiceLevel: 1,
|
||||
videoEncodingStandard: 'hls',
|
||||
videoUrl: 'https://kamera.vegvesen.no/public/3000063_1/manifest.m3u8',
|
||||
};
|
||||
const source = vegvesenCameraToSource(feature(video));
|
||||
assert.equal(source.feedType, 'hls');
|
||||
assert.equal(source.url, video.videoUrl);
|
||||
assert.equal(
|
||||
source.snapshotUrl,
|
||||
'https://kamera.atlas.vegvesen.no/api/images/3000063_1',
|
||||
);
|
||||
// A manifest anywhere but the camera's own path stays a still.
|
||||
const offPath = vegvesenCameraToSource(
|
||||
feature({ ...video, videoUrl: 'https://evil.test/x/manifest.m3u8' }),
|
||||
);
|
||||
assert.equal(offPath.feedType, 'image');
|
||||
assert.equal(offPath.url, offPath.snapshotUrl);
|
||||
// The kill switch keeps every camera on stills.
|
||||
const saved = process.env.CCTV_VEGVESEN_VIDEO;
|
||||
t.after(() => {
|
||||
if (saved === undefined) delete process.env.CCTV_VEGVESEN_VIDEO;
|
||||
else process.env.CCTV_VEGVESEN_VIDEO = saved;
|
||||
});
|
||||
process.env.CCTV_VEGVESEN_VIDEO = '0';
|
||||
assert.equal(vegvesenCameraToSource(feature(video)).feedType, 'image');
|
||||
});
|
||||
|
||||
test('faulty cameras, off-host frames and bad geometry are dropped', () => {
|
||||
assert.equal(
|
||||
vegvesenCameraToSource(
|
||||
feature({
|
||||
'status.stillImageAvailability':
|
||||
'videoOrImagesUnavailableDueToCameraFault',
|
||||
}),
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
vegvesenCameraToSource(
|
||||
feature({ stillImageUrl: 'https://evil.test/api/images/3000063_1' }),
|
||||
),
|
||||
null,
|
||||
);
|
||||
// The frame URL must be exactly the camera's own image path.
|
||||
assert.equal(
|
||||
vegvesenCameraToSource(
|
||||
feature({
|
||||
stillImageUrl: 'https://kamera.atlas.vegvesen.no/api/images/999_1',
|
||||
}),
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(vegvesenCameraToSource(feature({ cameraId: '../x' })), null);
|
||||
// Copenhagen: a plausible coordinate, but outside the Norway box.
|
||||
assert.equal(vegvesenCameraToSource(feature({}, [12.57, 55.68])), null);
|
||||
assert.equal(vegvesenCameraToSource(feature({}, [5.4])), null);
|
||||
assert.equal(vegvesenCameraToSource(null), null);
|
||||
});
|
||||
|
||||
test('the loader dedupes, and failures degrade to an empty pack', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const requested = [];
|
||||
const fetchMock = t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
requested.push(String(url));
|
||||
return Response.json({
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
feature(),
|
||||
feature(),
|
||||
feature(
|
||||
{
|
||||
cameraId: '0629001_1',
|
||||
stillImageUrl:
|
||||
'https://kamera.atlas.vegvesen.no/api/images/0629001_1',
|
||||
description: 'Oslo S',
|
||||
},
|
||||
[10.752, 59.911],
|
||||
),
|
||||
],
|
||||
});
|
||||
});
|
||||
const cameras = await loadVegvesenSourcesFromOpenData();
|
||||
assert.deepEqual(requested, [DEFAULT_VEGVESEN_CCTV_URL]);
|
||||
// Nearest-to-anchor first: Oslo leads.
|
||||
assert.deepEqual(
|
||||
cameras.map((c) => c.id),
|
||||
['no-vegvesen-0629001_1', 'no-vegvesen-3000063_1'],
|
||||
);
|
||||
|
||||
fetchMock.mock.mockImplementation(
|
||||
async () => new Response('nope', { status: 503 }),
|
||||
);
|
||||
assert.deepEqual(await loadVegvesenSourcesFromOpenData(), []);
|
||||
fetchMock.mock.mockImplementation(async () => {
|
||||
throw new Error('offline');
|
||||
});
|
||||
assert.deepEqual(await loadVegvesenSourcesFromOpenData(), []);
|
||||
});
|
||||
|
||||
const runCatalog = async (t) => {
|
||||
const requested = [];
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
const href = String(url);
|
||||
requested.push(href);
|
||||
if (href === DEFAULT_VEGVESEN_CCTV_URL) {
|
||||
return Response.json({
|
||||
type: 'FeatureCollection',
|
||||
features: [feature()],
|
||||
});
|
||||
}
|
||||
return Response.json([]);
|
||||
});
|
||||
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
|
||||
return { requested, sources };
|
||||
};
|
||||
|
||||
const withEnv = async (patch, fn) => {
|
||||
const saved = { ...process.env };
|
||||
try {
|
||||
delete process.env.CCTV_SOURCES_FILE;
|
||||
delete process.env.CCTV_SOURCES_JSON;
|
||||
delete process.env.CCTV_VEGVESEN_ENABLED;
|
||||
Object.assign(process.env, patch);
|
||||
await fn();
|
||||
} finally {
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in saved)) delete process.env[key];
|
||||
}
|
||||
Object.assign(process.env, saved);
|
||||
}
|
||||
};
|
||||
|
||||
test('the Vegvesen lane is wired into the catalog', async (t) => {
|
||||
await withEnv({}, async () => {
|
||||
const { requested, sources } = await runCatalog(t);
|
||||
assert.ok(requested.includes(DEFAULT_VEGVESEN_CCTV_URL));
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.cityId === 'norway').map((s) => s.id),
|
||||
['no-vegvesen-3000063_1'],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('CCTV_VEGVESEN_ENABLED=0 keeps the lane from being loaded', async (t) => {
|
||||
await withEnv({ CCTV_VEGVESEN_ENABLED: '0' }, async () => {
|
||||
const { requested, sources } = await runCatalog(t);
|
||||
assert.equal(requested.includes(DEFAULT_VEGVESEN_CCTV_URL), false);
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.cityId === 'norway'),
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
+196
-54
@@ -25,6 +25,79 @@ import * as Cesium from 'cesium';
|
||||
* follows DATA_SOURCES.md (live sources, then bundled snapshots).
|
||||
* @type {{ key: string, html: string }[]}
|
||||
*/
|
||||
export const OSM_CREDIT = {
|
||||
key: 'openstreetmap',
|
||||
html: 'Map and place data <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> (<a href="https://opendatacommons.org/licenses/odbl/1-0/" target="_blank" rel="noopener">ODbL</a>)',
|
||||
};
|
||||
export const OPENMAPTILES_CREDIT = {
|
||||
key: 'openfreemap',
|
||||
html: 'Vector tiles: <a href="https://openfreemap.org" target="_blank" rel="noopener">OpenFreeMap</a> © <a href="https://openmaptiles.org" target="_blank" rel="noopener">OpenMapTiles</a>',
|
||||
};
|
||||
|
||||
const osmDisplays = new WeakMap();
|
||||
|
||||
function updateOsmDisplay(viewer, state) {
|
||||
const osm = state.owners.size > 0;
|
||||
let tiles = false;
|
||||
for (const usesTiles of state.owners.values()) tiles ||= usesTiles;
|
||||
for (const [key, visible] of [
|
||||
['osm', osm],
|
||||
['tiles', tiles],
|
||||
]) {
|
||||
if (state[key].visible === visible) continue;
|
||||
state[key].visible = visible;
|
||||
viewer.creditDisplay[visible ? 'addStaticCredit' : 'removeStaticCredit'](
|
||||
state[key].credit,
|
||||
);
|
||||
viewer.scene?.requestRender?.();
|
||||
}
|
||||
}
|
||||
|
||||
/** Keep one short inline credit while an OSM display owner has visible data. */
|
||||
export function showOsmCredit(viewer, owner, { openMapTiles = false } = {}) {
|
||||
if (!viewer?.creditDisplay?.addStaticCredit || !owner) return false;
|
||||
let state = osmDisplays.get(viewer);
|
||||
if (!state) {
|
||||
state = {
|
||||
owners: new Map(),
|
||||
osm: {
|
||||
visible: false,
|
||||
credit: new Cesium.Credit(
|
||||
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap</a>',
|
||||
true,
|
||||
),
|
||||
},
|
||||
tiles: {
|
||||
visible: false,
|
||||
credit: new Cesium.Credit(
|
||||
'<a href="https://openmaptiles.org" target="_blank" rel="noopener">© OpenMapTiles</a>',
|
||||
true,
|
||||
),
|
||||
},
|
||||
};
|
||||
osmDisplays.set(viewer, state);
|
||||
}
|
||||
if (state.owners.get(owner) === openMapTiles) return false;
|
||||
state.owners.set(owner, openMapTiles);
|
||||
updateOsmDisplay(viewer, state);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Release only this owner's credit; other displayed OSM sources retain theirs. */
|
||||
export function hideOsmCredit(viewer, owner) {
|
||||
const state = osmDisplays.get(viewer);
|
||||
if (!state?.owners.delete(owner)) return false;
|
||||
updateOsmDisplay(viewer, state);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** NYC DOT asks every application that uses its data to carry this
|
||||
* disclaimer verbatim (nyc.gov/html/dot/html/about/datafeeds.shtml). */
|
||||
export const NYC_DOT_DISCLAIMER =
|
||||
'This application may contain and/or utilize information which was originally compiled by the New York City Department of Transportation (NYC DOT) for governmental purposes; the information may subsequently been modified by entity/entities other than NYC DOT. ' +
|
||||
'NYC DOT and the City of New York make no representation as to the accuracy or usefulness of the information provided by this application or the information's suitability for any purpose and disclaim any liability for omissions or errors that may be contained therein. ' +
|
||||
'The public is advised to observe posted signage for compliance with applicable laws and regulations.';
|
||||
|
||||
export const DATA_CREDITS = [
|
||||
// ── Live sources ────────────────────────────────────────────────
|
||||
{
|
||||
@@ -38,10 +111,21 @@ export const DATA_CREDITS = [
|
||||
{
|
||||
key: 'adsblol',
|
||||
html:
|
||||
'Military flights, aircraft traces & bounded regional flight fallback: ' +
|
||||
'Military flights, aircraft traces, bounded regional flight fallback & GNSS navigation integrity: ' +
|
||||
'<a href="https://adsb.lol" target="_blank" rel="noopener">adsb.lol</a> ' +
|
||||
'(ODbL 1.0)',
|
||||
},
|
||||
{
|
||||
key: 'adsbdb',
|
||||
html:
|
||||
'Aircraft type, registration & flight routes: ' +
|
||||
'<a href="https://www.adsbdb.com" target="_blank" rel="noopener">adsbdb</a> ' +
|
||||
'· aircraft data from PlaneBase · ICAO-to-N-number conversion by ' +
|
||||
'Guillaume Michel · route data is the work of ' +
|
||||
'David Taylor, Edinburgh, and Jim Mason, Glasgow, and may not be ' +
|
||||
'copied, published, or incorporated into other databases without the ' +
|
||||
'explicit permission of David J Taylor, Edinburgh',
|
||||
},
|
||||
{
|
||||
key: 'aisstream',
|
||||
html:
|
||||
@@ -67,41 +151,38 @@ 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: 'eccc-aqhi',
|
||||
html:
|
||||
'Air quality (AQHI): Environment and Climate Change Canada — MSC GeoMet ' +
|
||||
'(<a href="https://eccc-msc.github.io/open-data/" target="_blank" rel="noopener">eccc-msc.github.io/open-data</a>) ' +
|
||||
'· <a href="https://eccc-msc.github.io/open-data/licence/readme_en/" target="_blank" rel="noopener">ECCC Data Servers End-use Licence</a>',
|
||||
},
|
||||
{
|
||||
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',
|
||||
@@ -136,6 +217,10 @@ export const DATA_CREDITS = [
|
||||
'CCTV cameras & 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:
|
||||
@@ -175,9 +260,45 @@ export const DATA_CREDITS = [
|
||||
'Traffic cameras (Calgary): contains information licensed under the ' +
|
||||
'<a href="https://data.calgary.ca/stories/s/Open-Calgary-Terms-of-Use/u45n-7awa" target="_blank" rel="noopener">Open Government Licence – City of Calgary</a>',
|
||||
},
|
||||
{
|
||||
key: 'vegvesen-cctv',
|
||||
html:
|
||||
'Road cameras (Norway): contains data under the ' +
|
||||
'<a href="https://data.norge.no/nlod/en/2.0" target="_blank" rel="noopener">Norwegian licence for Open Government data (NLOD)</a> ' +
|
||||
'distributed by Statens vegvesen',
|
||||
},
|
||||
{
|
||||
key: 'nyc-cctv',
|
||||
html:
|
||||
'CCTV cameras & frames (New York City, when enabled): ' +
|
||||
'<a href="https://webcams.nyctmc.org/" target="_blank" rel="noopener">NYC DOT Traffic Management Center</a>. ' +
|
||||
NYC_DOT_DISCLAIMER,
|
||||
},
|
||||
{
|
||||
key: 'gbfs',
|
||||
html: 'Bikeshare availability: GBFS operator feeds (e.g. Austin BCycle)',
|
||||
html:
|
||||
'Bikeshare availability: GBFS operator feeds ' +
|
||||
'(e.g. Austin BCycle; Bike Share Toronto, operated by the Toronto Parking Authority)',
|
||||
},
|
||||
{
|
||||
key: 'urbansharing-gbfs',
|
||||
html:
|
||||
'City bikes (Norway): contains data under the ' +
|
||||
'<a href="https://data.norge.no/nlod/en/2.0" target="_blank" rel="noopener">Norwegian licence for Open Government data (NLOD)</a> ' +
|
||||
'made available by Oslo Bysykkel, Bergen Bysykkel and Trondheim Bysykkel',
|
||||
},
|
||||
{
|
||||
key: 'entur-mobility-gbfs',
|
||||
html:
|
||||
'City bikes (Stavanger, Skien/Porsgrunn): contains data under the ' +
|
||||
'<a href="https://data.norge.no/nlod/en/2.0" target="_blank" rel="noopener">Norwegian licence for Open Government data (NLOD)</a> ' +
|
||||
'made available by Entur (Kolumbus Bysykkel, Farte Bysykkel)',
|
||||
},
|
||||
{
|
||||
key: 'jcdecaux-gbfs',
|
||||
html:
|
||||
'City bikes (Lillestrøm): Bysykkel Lillestrøm, JCDecaux — ' +
|
||||
'<a href="https://developer.jcdecaux.com/files/Open-Licence-fr.pdf" target="_blank" rel="noopener">Licence Ouverte</a>',
|
||||
},
|
||||
{
|
||||
key: 'osrm-routing',
|
||||
@@ -186,7 +307,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>',
|
||||
},
|
||||
{
|
||||
@@ -207,20 +327,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',
|
||||
@@ -253,9 +391,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',
|
||||
@@ -288,15 +424,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).
|
||||
@@ -332,10 +477,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)',
|
||||
};
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { DATA_CREDITS, NYC_DOT_DISCLAIMER } from './dataCredits.js';
|
||||
|
||||
test('every credit carries a unique key and some markup to render', () => {
|
||||
const keys = DATA_CREDITS.map((entry) => entry.key);
|
||||
assert.equal(
|
||||
new Set(keys).size,
|
||||
keys.length,
|
||||
'a duplicate key would silently shadow one provider’s credit',
|
||||
);
|
||||
for (const entry of DATA_CREDITS) {
|
||||
assert.ok(entry.key, 'a credit without a key cannot be registered');
|
||||
assert.ok(
|
||||
entry.html && entry.html.trim().length > 0,
|
||||
`credit ${entry.key} has nothing to show`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('adsbdb is credited and carries its published route-data restriction', () => {
|
||||
const credit = DATA_CREDITS.find((entry) => entry.key === 'adsbdb');
|
||||
assert.ok(
|
||||
credit,
|
||||
'adsbdb supplies aircraft type and routes and must be credited',
|
||||
);
|
||||
// adsbdb publishes this restriction for its route data. Pin the provider's
|
||||
// credits and restriction here so a later edit cannot silently remove them.
|
||||
assert.match(credit.html, /David Taylor, Edinburgh/);
|
||||
assert.match(credit.html, /Jim Mason, Glasgow/);
|
||||
assert.match(
|
||||
credit.html,
|
||||
/may not be\s+copied, published, or incorporated into other databases/,
|
||||
);
|
||||
assert.match(credit.html, /explicit permission of David J Taylor, Edinburgh/);
|
||||
assert.match(credit.html, /PlaneBase/);
|
||||
assert.match(credit.html, /Guillaume Michel/);
|
||||
assert.match(credit.html, /href="https:\/\/www\.adsbdb\.com"/);
|
||||
});
|
||||
|
||||
test('OpenStreetMap has one generic data credit with separate tile and names distributors', () => {
|
||||
const osm = DATA_CREDITS.filter((entry) =>
|
||||
entry.html.includes('openstreetmap.org/copyright'),
|
||||
);
|
||||
assert.equal(osm.length, 1);
|
||||
assert.equal(osm[0].key, 'openstreetmap');
|
||||
assert.match(osm[0].html, /Map and place data/);
|
||||
assert.match(osm[0].html, /© OpenStreetMap contributors/);
|
||||
assert.match(
|
||||
DATA_CREDITS.find((entry) => entry.key === 'openfreemap').html,
|
||||
/Vector tiles:/,
|
||||
);
|
||||
assert.match(
|
||||
DATA_CREDITS.find((entry) => entry.key === 'overture-military-names').html,
|
||||
/Overture Maps Foundation/,
|
||||
);
|
||||
});
|
||||
|
||||
test('inline OSM attribution persists until its last display owner leaves', async (t) => {
|
||||
const { showOsmCredit, hideOsmCredit } = await import('./dataCredits.js');
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const visible = new Set();
|
||||
const viewer = {
|
||||
creditDisplay: {
|
||||
addStaticCredit: (c) => visible.add(c),
|
||||
removeStaticCredit: (c) => visible.delete(c),
|
||||
},
|
||||
scene: { requestRender() {} },
|
||||
};
|
||||
assert.equal(showOsmCredit(viewer, 'alpr'), true);
|
||||
assert.equal(showOsmCredit(viewer, 'alpr'), false);
|
||||
assert.equal(visible.size, 1);
|
||||
assert.match(
|
||||
[...visible][0].html,
|
||||
/href="https:\/\/www.openstreetmap.org\/copyright"/,
|
||||
);
|
||||
assert.match([...visible][0].html, />© OpenStreetMap</);
|
||||
assert.ok([...visible][0].showOnScreen);
|
||||
t.mock.timers.tick(60000);
|
||||
assert.equal(visible.size, 1);
|
||||
showOsmCredit(viewer, 'traffic', { openMapTiles: true });
|
||||
assert.equal(visible.size, 2);
|
||||
hideOsmCredit(viewer, 'alpr');
|
||||
assert.equal(visible.size, 2, 'traffic still owns both credits');
|
||||
showOsmCredit(viewer, 'datacenters');
|
||||
hideOsmCredit(viewer, 'traffic');
|
||||
assert.equal(
|
||||
visible.size,
|
||||
1,
|
||||
'bundled data does not retain the tile distributor',
|
||||
);
|
||||
hideOsmCredit(viewer, 'datacenters');
|
||||
assert.equal(visible.size, 0);
|
||||
assert.equal(hideOsmCredit(viewer, 'datacenters'), false);
|
||||
showOsmCredit(viewer, 'alpr');
|
||||
assert.equal(visible.size, 1, 'reenabling restores the short credit');
|
||||
});
|
||||
|
||||
test('tile attribution follows source changes and viewers have independent ownership', async () => {
|
||||
const { showOsmCredit, hideOsmCredit } = await import('./dataCredits.js');
|
||||
const create = () => {
|
||||
const credits = new Set();
|
||||
return {
|
||||
credits,
|
||||
creditDisplay: {
|
||||
addStaticCredit: (c) => credits.add(c),
|
||||
removeStaticCredit: (c) => credits.delete(c),
|
||||
},
|
||||
};
|
||||
};
|
||||
const a = create(),
|
||||
b = create();
|
||||
showOsmCredit(a, 'installations', { openMapTiles: true });
|
||||
showOsmCredit(b, 'installations');
|
||||
assert.equal(a.credits.size, 2);
|
||||
assert.equal(b.credits.size, 1);
|
||||
showOsmCredit(a, 'installations');
|
||||
assert.equal(a.credits.size, 1, 'wide names no longer use tiles');
|
||||
hideOsmCredit(a, 'installations');
|
||||
assert.equal(a.credits.size, 0);
|
||||
assert.equal(b.credits.size, 1);
|
||||
});
|
||||
|
||||
test('the NYC camera credit carries NYC DOT’s requested disclaimer', () => {
|
||||
const credit = DATA_CREDITS.find((entry) => entry.key === 'nyc-cctv');
|
||||
assert.ok(credit, 'NYC DOT camera frames must be credited');
|
||||
assert.ok(credit.html.includes(NYC_DOT_DISCLAIMER));
|
||||
assert.match(credit.html, /make no representation as to the accuracy/);
|
||||
assert.match(credit.html, /observe posted signage/);
|
||||
});
|
||||
+63
-4
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Loaded 100 of 1150 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user