mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Merge current upstream and resolve PR 581 conflicts
This commit is contained in:
commit
d866f9b01c
211 files changed
+28120
-1988
No files matched your search
@@ -0,0 +1,236 @@
|
||||
// Area annotations draw bundled state/province/county outlines with no
|
||||
// network lookup, and multi-part outlines reach the renderer intact.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { FEATURE_SOURCE_METHODS } from '../sources/featureSource.js';
|
||||
import { createAnnotationResolver } from './resolver.js';
|
||||
import { createAnnotationEngine } from './annotationEngine.js';
|
||||
import { _resetRenderGovernorForTest } from '../renderGovernor.js';
|
||||
|
||||
function viewerAt(lat, lon, height = 20_000) {
|
||||
return {
|
||||
camera: {
|
||||
positionCartographic: {
|
||||
latitude: (lat * Math.PI) / 180,
|
||||
longitude: (lon * Math.PI) / 180,
|
||||
height,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A resolver whose boundary lookups and geocoder record every call. */
|
||||
function harness(place = null) {
|
||||
const lookups = [];
|
||||
const featureSource = {};
|
||||
for (const method of FEATURE_SOURCE_METHODS)
|
||||
featureSource[method] = async () => {
|
||||
lookups.push(method);
|
||||
return null;
|
||||
};
|
||||
const geocodes = [];
|
||||
const placeSearch = {
|
||||
async geocode(query) {
|
||||
geocodes.push(query);
|
||||
return { place };
|
||||
},
|
||||
async textSearch() {
|
||||
geocodes.push('text-search');
|
||||
return [];
|
||||
},
|
||||
};
|
||||
const { resolveAnnotationTarget } = createAnnotationResolver({
|
||||
featureSource,
|
||||
});
|
||||
const resolve = (target, viewer, extra = {}) =>
|
||||
resolveAnnotationTarget({
|
||||
placeSearch,
|
||||
viewer,
|
||||
target,
|
||||
footprint: true,
|
||||
deferFootprint: true,
|
||||
...extra,
|
||||
});
|
||||
return { resolve, lookups, geocodes };
|
||||
}
|
||||
|
||||
const AUSTIN = viewerAt(30.2672, -97.7431, 3000);
|
||||
|
||||
test('"Texas", "Travis County, Texas" and "Bavaria" outline offline', async () => {
|
||||
const { resolve, lookups, geocodes } = harness();
|
||||
for (const [target, label] of [
|
||||
['Texas', 'Texas'],
|
||||
['Travis County, Texas', 'Travis County'],
|
||||
// Far from the camera, but named outright: no proximity gate applies.
|
||||
['Bavaria', 'Bavaria'],
|
||||
]) {
|
||||
const resolved = await resolve(target, AUSTIN);
|
||||
assert.ok(resolved, target);
|
||||
assert.equal(resolved.source, 'bundled', target);
|
||||
assert.equal(resolved.label, label);
|
||||
assert.equal(resolved.footprintKind, 'area');
|
||||
assert.equal(resolved.synthesized, false);
|
||||
assert.equal(resolved.resolveOutline, undefined, 'drawn at once');
|
||||
assert.ok(resolved.ring.length >= 9, `${target} ring`);
|
||||
assert.deepEqual(resolved.ring[0], resolved.ring.at(-1), 'ring closed');
|
||||
assert.ok(resolved.polygons.length >= 1, `${target} parts`);
|
||||
assert.equal(resolved.polygons[0][0].length + 1, resolved.ring.length);
|
||||
assert.ok(resolved.viewport?.low && resolved.viewport?.high);
|
||||
}
|
||||
assert.deepEqual(geocodes, [], 'no geocoding');
|
||||
assert.deepEqual(lookups, [], 'no boundary lookups');
|
||||
});
|
||||
|
||||
test('duplicate county names go to the one near the camera', async () => {
|
||||
const { resolve } = harness();
|
||||
const nearPortland = await resolve(
|
||||
'Washington County',
|
||||
viewerAt(45.52, -122.68),
|
||||
);
|
||||
assert.ok(nearPortland.lat > 45 && nearPortland.lon < -122, 'Oregon');
|
||||
const nearAustin = await resolve('Washington County', AUSTIN);
|
||||
assert.ok(nearAustin.lat > 29.9 && nearAustin.lat < 30.5, 'Texas');
|
||||
assert.ok(nearAustin.lon > -96.8 && nearAustin.lon < -96, 'Texas');
|
||||
});
|
||||
|
||||
test('multi-part outlines carry every part and hole', async () => {
|
||||
const { resolve } = harness();
|
||||
const hawaii = await resolve('Hawaii', AUSTIN);
|
||||
assert.ok(hawaii.polygons.length >= 7, 'islands');
|
||||
const brandenburg = await resolve('Brandenburg', AUSTIN);
|
||||
assert.ok(
|
||||
brandenburg.polygons.some((part) => part.length === 2),
|
||||
'Berlin hole',
|
||||
);
|
||||
});
|
||||
|
||||
test('Georgia resolves from camera geography without a geocoder', async () => {
|
||||
const { resolve, lookups, geocodes } = harness();
|
||||
for (const [query, viewer, latitude] of [
|
||||
['Georgia', viewerAt(33.75, -84.39, 400_000), 32],
|
||||
['the country of Georgia', AUSTIN, 42],
|
||||
['Georgia', viewerAt(41.7, 44.8), 42],
|
||||
]) {
|
||||
const resolved = await resolve(query, viewer);
|
||||
assert.equal(resolved.source, 'bundled');
|
||||
assert.ok(Math.abs(resolved.lat - latitude) < 2);
|
||||
assert.ok(resolved.ring.length > 20);
|
||||
}
|
||||
assert.deepEqual(lookups, []);
|
||||
assert.deepEqual(geocodes, []);
|
||||
});
|
||||
|
||||
test('country asks and geocoded country scopes use the bundled outline', async () => {
|
||||
const direct = harness();
|
||||
for (const name of ['Switzerland', 'Iran', 'France', 'Japan']) {
|
||||
const resolved = await direct.resolve(name, AUSTIN);
|
||||
assert.equal(resolved.source, 'bundled');
|
||||
assert.ok(resolved.polygons.length);
|
||||
if (name === 'Japan') assert.ok(resolved.polygons.length > 3);
|
||||
}
|
||||
assert.deepEqual(direct.lookups, []);
|
||||
assert.deepEqual(direct.geocodes, []);
|
||||
const geocoded = harness({
|
||||
lat: 46.95,
|
||||
lng: 7.45,
|
||||
label: 'Switzerland',
|
||||
name: 'Switzerland',
|
||||
types: ['country', 'political'],
|
||||
});
|
||||
const resolved = await geocoded.resolve(
|
||||
'Swiss country outline',
|
||||
viewerAt(46.95, 7.45),
|
||||
);
|
||||
assert.equal(resolved.source, 'geocode');
|
||||
const outline = await resolved.resolveOutline();
|
||||
assert.ok(outline.polygons.length);
|
||||
assert.deepEqual(geocoded.lookups, []);
|
||||
});
|
||||
|
||||
test('non-admin asks and entity facts keep the existing path', async () => {
|
||||
const { resolve, geocodes } = harness();
|
||||
await resolve('Texas Capitol', AUSTIN);
|
||||
await resolve('Texas', AUSTIN, { entityKind: 'building' });
|
||||
await resolve('Zilker Park', AUSTIN);
|
||||
assert.equal(
|
||||
geocodes.filter((q) => q !== 'text-search').length,
|
||||
3,
|
||||
'each ask geocoded',
|
||||
);
|
||||
// Points and explicit coordinates never use the pack.
|
||||
const point = await resolve('Texas', AUSTIN, { footprint: false });
|
||||
assert.notEqual(point?.source, 'bundled');
|
||||
});
|
||||
|
||||
test('the engine keeps every part on the mark, drawn at once or upgraded', async (t) => {
|
||||
globalThis.requestAnimationFrame ??= () => 1;
|
||||
globalThis.cancelAnimationFrame ??= () => {};
|
||||
_resetRenderGovernorForTest();
|
||||
t.after(() => _resetRenderGovernorForTest());
|
||||
const polygons = [
|
||||
[
|
||||
[
|
||||
[0, 0],
|
||||
[2, 0],
|
||||
[2, 2],
|
||||
[0, 2],
|
||||
],
|
||||
[
|
||||
[0.5, 0.5],
|
||||
[0.5, 1],
|
||||
[1, 1],
|
||||
[1, 0.5],
|
||||
],
|
||||
],
|
||||
[
|
||||
[
|
||||
[3, 3],
|
||||
[4, 3],
|
||||
[4, 4],
|
||||
],
|
||||
],
|
||||
];
|
||||
const ring = [...polygons[0][0], polygons[0][0][0]];
|
||||
const seen = [];
|
||||
const renderer = {
|
||||
add: (anno) => seen.push(['add', anno.polygons]),
|
||||
update: (anno) => seen.push(['update', anno.polygons]),
|
||||
remove() {},
|
||||
sync() {},
|
||||
destroy() {},
|
||||
};
|
||||
const engine = createAnnotationEngine({
|
||||
viewer: {},
|
||||
renderer,
|
||||
resolveTarget: async ({ target }) =>
|
||||
target === 'now'
|
||||
? { lon: 1, lat: 1, height: 0, ring, polygons, footprintKind: 'area' }
|
||||
: {
|
||||
lon: 1,
|
||||
lat: 1,
|
||||
height: 0,
|
||||
ring: null,
|
||||
resolveOutline: async () => ({
|
||||
ring,
|
||||
polygons,
|
||||
footprintKind: 'area',
|
||||
lat: 1,
|
||||
lon: 1,
|
||||
height: 0,
|
||||
}),
|
||||
},
|
||||
});
|
||||
await engine.annotate([{ type: 'area', target: 'now' }]);
|
||||
await engine.annotate([{ type: 'area', target: 'later', label: 'later' }]);
|
||||
for (let i = 0; i < 20; i += 1) await Promise.resolve();
|
||||
const marks = engine.list();
|
||||
assert.equal(marks[0].polygons, polygons);
|
||||
assert.ok(
|
||||
marks.some((m) => m.label === 'later' && m.polygons === polygons),
|
||||
'upgrade carries the parts',
|
||||
);
|
||||
assert.ok(
|
||||
seen.some(([kind, parts]) => kind === 'update' && parts === polygons),
|
||||
);
|
||||
engine.destroy();
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isUnavailableCapability } from '../sources/capability.js';
|
||||
import { defaultGeospatial } from '../search/defaults.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
@@ -81,6 +82,7 @@ export async function resolveOutlineWithRetry(
|
||||
} catch {
|
||||
fp = null; // hard failure — definitive, keep the honest point
|
||||
}
|
||||
if (isUnavailableCapability(fp)) return fp;
|
||||
if (isRateLimitedOutcome(fp)) {
|
||||
// A throttle gets one deliberately spaced retry. If that retry is throttled
|
||||
// too, stop this mark's outline task instead of replaying the batch storm.
|
||||
@@ -333,6 +335,7 @@ export function createAnnotationEngine({
|
||||
// refreshes it — usually instantly from the footprint cache.
|
||||
if (anno.pendingOutline && !anno.ring && dup.ring) {
|
||||
anno.ring = dup.ring;
|
||||
anno.polygons = dup.polygons || null;
|
||||
anno.footprintKind = dup.footprintKind || null;
|
||||
anno.buildingHeight = dup.buildingHeight || null;
|
||||
anno.synthesized = Boolean(dup.synthesized);
|
||||
@@ -673,8 +676,11 @@ export function createAnnotationEngine({
|
||||
if (myGen !== generation || controller.signal.aborted) return; // board superseded
|
||||
if (!annotations.has(anno.id)) return; // mark replaced/removed while resolving
|
||||
anno.pendingOutline = false;
|
||||
if (fp) {
|
||||
anno.outlineUnavailable = isUnavailableCapability(fp);
|
||||
if (anno.outlineUnavailable) renderer.update(anno);
|
||||
if (fp && !anno.outlineUnavailable) {
|
||||
anno.ring = fp.ring;
|
||||
anno.polygons = fp.polygons || null;
|
||||
anno.footprintKind = fp.footprintKind || null;
|
||||
anno.buildingHeight = fp.buildingHeight || null;
|
||||
anno.synthesized = Boolean(fp.synthesized);
|
||||
@@ -725,7 +731,14 @@ export function createAnnotationEngine({
|
||||
id: anno.id,
|
||||
label: anno.label || null,
|
||||
target: anno.targetKey || null,
|
||||
status: fp ? 'resolved' : 'failed',
|
||||
status: anno.outlineUnavailable
|
||||
? 'unavailable'
|
||||
: fp
|
||||
? 'resolved'
|
||||
: 'failed',
|
||||
...(anno.outlineUnavailable
|
||||
? { message: 'Detailed outline unavailable' }
|
||||
: {}),
|
||||
...(fp && anno.synthesized ? { approximate: true } : {}),
|
||||
});
|
||||
} finally {
|
||||
@@ -753,6 +766,9 @@ export function createAnnotationEngine({
|
||||
// layer narrate without waiting out a slow Overpass — and without calling the
|
||||
// missing outline a failure.
|
||||
...(anno.pendingOutline ? { outlinePending: true } : {}),
|
||||
...(anno.outlineUnavailable
|
||||
? { outlineUnavailable: true, message: 'Detailed outline unavailable' }
|
||||
: {}),
|
||||
// approximate=true means the area was SYNTHESIZED (a buffered blob around a label
|
||||
// point), not a real OSM boundary — so the voice layer can be honest.
|
||||
...(anno.synthesized ? { approximate: true } : {}),
|
||||
@@ -919,6 +935,9 @@ export function createAnnotationEngine({
|
||||
anchor: { lon: resolved.lon, lat: resolved.lat, height: resolved.height },
|
||||
to: null,
|
||||
ring: resolved.ring || null,
|
||||
// Every part with its holes ([outer, ...holes][]), when the outline has
|
||||
// more than the main ring (Hawaii's islands, Berlin inside Brandenburg).
|
||||
polygons: resolved.polygons || null,
|
||||
footprintKind: resolved.footprintKind || null, // 'building' | 'area'
|
||||
buildingHeight: resolved.buildingHeight || null, // meters, for extruded volume
|
||||
synthesized: Boolean(resolved.synthesized), // approximate buffered area → dashed render
|
||||
@@ -926,6 +945,7 @@ export function createAnnotationEngine({
|
||||
// the upgrade task fills ring/kind in place when it lands. Transient render state
|
||||
// (not persisted).
|
||||
pendingOutline: typeof resolved.resolveOutline === 'function',
|
||||
outlineUnavailable: Boolean(resolved.outlineUnavailable),
|
||||
// Which THING + SHAPE was asked for — the dedup identity while geometry is still
|
||||
// pending (see findDuplicate). targetKey is the normalized place name with trailing
|
||||
// locality qualifiers stripped ("California, United States" ≡ "California"; null for
|
||||
@@ -1047,7 +1067,11 @@ export function createAnnotationEngine({
|
||||
// Places viewport box when we have one, so a big compound isn't framed at
|
||||
// building scale while its outline is traced. Never re-fly when the ring lands.
|
||||
const range = anno.ring
|
||||
? ringRange(anno.ring)
|
||||
? ringRange(
|
||||
anno.polygons?.length
|
||||
? anno.polygons.flatMap((poly) => poly[0])
|
||||
: anno.ring,
|
||||
)
|
||||
: viewportRange(anno.viewport) || 600;
|
||||
viewer.camera.flyToBoundingSphere(
|
||||
new Cesium.BoundingSphere(
|
||||
@@ -1388,7 +1412,11 @@ function ringRange(ring) {
|
||||
let maxLat = -Infinity;
|
||||
let minLon = Infinity;
|
||||
let maxLon = -Infinity;
|
||||
for (const [lon, lat] of ring) {
|
||||
// Unwrap around the first vertex so parts cut at the antimeridian (the
|
||||
// Aleutians) measure as neighbours, not as a globe-wide span.
|
||||
const ref = ring[0]?.[0] ?? 0;
|
||||
for (const [rawLon, lat] of ring) {
|
||||
const lon = rawLon - 360 * Math.round((rawLon - ref) / 360);
|
||||
if (lat < minLat) minLat = lat;
|
||||
if (lat > maxLat) maxLat = lat;
|
||||
if (lon < minLon) minLon = lon;
|
||||
|
||||
@@ -254,7 +254,9 @@ test('retry: HTTP 429 Retry-After 5s gets one ladder-spaced retry; a second 429
|
||||
t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now: 10_000 });
|
||||
globalThis.window = { setTimeout: globalThis.setTimeout, clearTimeout: globalThis.clearTimeout };
|
||||
const requestTimes = [];
|
||||
globalThis.fetch = async () => {
|
||||
globalThis.fetch = async (url) => {
|
||||
// The one-time capability probe is not an outline request.
|
||||
if (String(url).endsWith('/status')) return httpFailure(404);
|
||||
requestTimes.push(Date.now());
|
||||
return httpFailure(429, '5');
|
||||
};
|
||||
|
||||
@@ -77,7 +77,11 @@ export function createHybridAnnotationRenderer(viewer) {
|
||||
add(anno);
|
||||
return;
|
||||
}
|
||||
if (anno.type !== 'area' || !anno.ring || anno.ring.length < 3) return;
|
||||
if (anno.type !== 'area' || !anno.ring || anno.ring.length < 3) {
|
||||
if (anno.outlineUnavailable && entry.screenProxy)
|
||||
screen.update(entry.screenProxy);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!entry.worldProxy) {
|
||||
entry.worldProxy = liveProxy(anno, { label: null });
|
||||
|
||||
@@ -10,20 +10,33 @@ function featureSource(overrides = {}) {
|
||||
return Object.assign(source, overrides);
|
||||
}
|
||||
|
||||
const texasGeocode = {
|
||||
// A state-typed place outside every bundled boundary, so the admin-boundary
|
||||
// feature lookup (not the bundled packs) answers.
|
||||
const offPackGeocode = {
|
||||
async geocode() {
|
||||
return {
|
||||
place: {
|
||||
lat: 31.0,
|
||||
lng: -99.9,
|
||||
label: 'Texas, USA',
|
||||
name: 'Texas',
|
||||
lat: 0.5,
|
||||
lng: -150.5,
|
||||
label: 'Atlantis',
|
||||
name: 'Atlantis',
|
||||
types: ['administrative_area_level_1', 'political'],
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
function geocoderFor(place) {
|
||||
const calls = [];
|
||||
return {
|
||||
calls,
|
||||
async geocode(query) {
|
||||
calls.push(query);
|
||||
return { place };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('region ring: Natural Earth names resolve without a geocoder', async () => {
|
||||
const { resolveRegionRingForQuery } = createAnnotationResolver({
|
||||
featureSource: featureSource(),
|
||||
@@ -50,13 +63,13 @@ test('region ring: a slow admin-boundary lookup returns region-timeout within th
|
||||
});
|
||||
const started = Date.now();
|
||||
const region = await resolveRegionRingForQuery(
|
||||
'Texas',
|
||||
'Atlantis',
|
||||
undefined,
|
||||
texasGeocode,
|
||||
offPackGeocode,
|
||||
{ budgetMs: 50 },
|
||||
);
|
||||
assert.deepEqual(region, {
|
||||
name: 'Texas',
|
||||
name: 'Atlantis',
|
||||
ring: null,
|
||||
error: 'region-timeout',
|
||||
});
|
||||
@@ -77,10 +90,64 @@ test('region ring: an unbounded budget waits for the lookup', async () => {
|
||||
}),
|
||||
});
|
||||
const region = await resolveRegionRingForQuery(
|
||||
'Texas',
|
||||
'Atlantis',
|
||||
undefined,
|
||||
texasGeocode,
|
||||
offPackGeocode,
|
||||
{ budgetMs: Infinity },
|
||||
);
|
||||
assert.equal(region, null);
|
||||
});
|
||||
|
||||
test('region ring: bundled states and counties resolve without a geocoder', async () => {
|
||||
const lookups = [];
|
||||
const { resolveRegionRingForQuery } = createAnnotationResolver({
|
||||
featureSource: featureSource({
|
||||
getAdministrativeAreas: async () => {
|
||||
lookups.push('admin');
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
});
|
||||
const geocoder = geocoderFor(null);
|
||||
for (const [query, name] of [
|
||||
['Texas', 'Texas'],
|
||||
['Bavaria', 'Bavaria'],
|
||||
['Travis County, Texas', 'Travis County'],
|
||||
]) {
|
||||
const region = await resolveRegionRingForQuery(query, undefined, geocoder);
|
||||
assert.equal(region?.name, name, query);
|
||||
assert.ok(region.ring.length >= 8, `${query} has a real ring`);
|
||||
}
|
||||
assert.deepEqual(geocoder.calls, [], 'no geocoding');
|
||||
assert.deepEqual(lookups, [], 'no boundary lookups');
|
||||
});
|
||||
|
||||
test('region ring: a geocoder-typed state resolves from the bundled pack', async () => {
|
||||
const lookups = [];
|
||||
const { resolveRegionRingForQuery } = createAnnotationResolver({
|
||||
featureSource: featureSource({
|
||||
getAdministrativeAreas: async () => {
|
||||
lookups.push('admin');
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
});
|
||||
// "Georgia" alone is also a country, so the words don't settle it; the
|
||||
// geocoder's state type and point inside the US state do.
|
||||
const geocoder = geocoderFor({
|
||||
lat: 32.16,
|
||||
lng: -82.9,
|
||||
label: 'Georgia, USA',
|
||||
name: 'Georgia',
|
||||
types: ['administrative_area_level_1', 'political'],
|
||||
});
|
||||
const region = await resolveRegionRingForQuery(
|
||||
'Georgia',
|
||||
undefined,
|
||||
geocoder,
|
||||
);
|
||||
assert.equal(region?.name, 'Georgia');
|
||||
assert.ok(region.ring.length >= 8);
|
||||
assert.deepEqual(geocoder.calls, ['Georgia']);
|
||||
assert.deepEqual(lookups, [], 'no boundary lookups');
|
||||
});
|
||||
+157
-13
@@ -1,3 +1,4 @@
|
||||
import { isUnavailableCapability } from '../sources/capability.js';
|
||||
import { requireFeatureSource } from '../sources/featureSource.js';
|
||||
import { createOverpassFeatureSource } from '../sources/overpassFeatures.js';
|
||||
import {
|
||||
@@ -18,9 +19,11 @@ import {
|
||||
lookupNaturalRegionOutline,
|
||||
findNaturalRegion,
|
||||
} from '../data/naturalEarthRegions.js';
|
||||
import { findAdminArea, findAdminAreaAt } from '../data/adminBoundaries.js';
|
||||
import {
|
||||
registerDynamicCredit,
|
||||
NATURAL_EARTH_CREDIT,
|
||||
US_CENSUS_CREDIT,
|
||||
} from '../data/dataCredits.js';
|
||||
import { unavailablePlaceSearch } from '../search/placeSearch.js';
|
||||
import { isPickedWorldPosition } from '../data/scenePick.js';
|
||||
@@ -28,6 +31,14 @@ import { isPickedWorldPosition } from '../data/scenePick.js';
|
||||
/** Ms the analyst region lookup waits on geocode + admin boundary. */
|
||||
export const REGION_FALLBACK_BUDGET_MS = 3_000;
|
||||
|
||||
/** Entity facts that rule out a state/county reading of the ask. */
|
||||
const NON_ADMIN_ENTITY_KINDS = new Set([
|
||||
'building',
|
||||
'compound',
|
||||
'street',
|
||||
'point_feature',
|
||||
]);
|
||||
|
||||
/** Own annotation lookup caches and ranking of supplied feature candidates. */
|
||||
export function createAnnotationResolver({
|
||||
boundarySource,
|
||||
@@ -176,6 +187,23 @@ export function createAnnotationResolver({
|
||||
// ("the Texas Capitol" → the state), so geocode types must never grant it.
|
||||
const bypassNearViewGuards = Boolean(adminScopeFromAsk(target, entityKind));
|
||||
|
||||
// Bundled administrative outlines resolve settled names without a lookup.
|
||||
// Georgia uses its qualifier or camera; city/state homonyms defer to geocoding.
|
||||
if (
|
||||
footprint &&
|
||||
(!Number.isFinite(lat) || !Number.isFinite(lon)) &&
|
||||
trace.query &&
|
||||
!NON_ADMIN_ENTITY_KINDS.has(entityKind)
|
||||
) {
|
||||
const center =
|
||||
pickWorldFromScreen(viewer, 0.5, 0.5) || viewportProximity(viewer);
|
||||
const admin = await findAdminArea(trace.query, { near: center }).catch(
|
||||
() => null,
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
if (admin) return bundledAdminTarget(viewer, admin, trace.query);
|
||||
}
|
||||
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
|
||||
const query = String(target || '').trim();
|
||||
if (query) {
|
||||
@@ -474,8 +502,32 @@ export function createAnnotationResolver({
|
||||
// GROUNDS-like ask must not short-circuit here: the model phrases "the Capitol
|
||||
// grounds" as around_the_thing, but the grounds ARE the thing — the real enclosing
|
||||
// polygon (below) beats a 400 m disc (field test 8's "spherical round one").
|
||||
const availability = await fetchEnclosingArea(
|
||||
lat,
|
||||
lon,
|
||||
signal,
|
||||
matchName,
|
||||
);
|
||||
if (
|
||||
isUnavailableCapability(availability) ||
|
||||
isRateLimitedOutcome(availability)
|
||||
)
|
||||
return availability;
|
||||
if (availability === undefined) return undefined;
|
||||
fp = synthesizeBufferedArea(lat, lon, AROUND_LANDMARK_RADIUS_M);
|
||||
} else if (isAdmin) {
|
||||
// A country, state or county the geocoder typed: the bundled unit that carries the
|
||||
// name AND contains the geocoded point (offline; the pack's ambiguity
|
||||
// marks don't apply — the geocoder already chose "Georgia" the state).
|
||||
if (scope === 'state' || scope === 'county' || scope === 'country') {
|
||||
const admin = await findAdminAreaAt(
|
||||
[target, matchName],
|
||||
lat,
|
||||
lon,
|
||||
scope,
|
||||
).catch(() => null);
|
||||
if (admin) return bundledAdminOutline(viewer, admin);
|
||||
}
|
||||
// Pure admin: only an admin boundary is correct — never fall back to a
|
||||
// building/landuse (a city is never a single building).
|
||||
fp = await fetchAdminArea(lat, lon, matchName, scope, signal);
|
||||
@@ -501,6 +553,7 @@ export function createAnnotationResolver({
|
||||
// A neighborhood whose canonical name carries a city suffix ("Presidio of San
|
||||
// Francisco") can match the CITY admin; treat an oversized admin as a DEFINITIVE
|
||||
// no-neighborhood-polygon so it still falls through to the named-landuse path.
|
||||
if (isUnavailableCapability(adminFp)) return adminFp;
|
||||
if (adminFp && exceedsScopeArea(adminFp, scope)) adminFp = null;
|
||||
if (adminFp) {
|
||||
fp = adminFp;
|
||||
@@ -515,6 +568,7 @@ export function createAnnotationResolver({
|
||||
signal,
|
||||
'strict',
|
||||
);
|
||||
if (isUnavailableCapability(footFp)) return footFp;
|
||||
if (footFp) {
|
||||
fp = footFp;
|
||||
} else if (footFp === null) {
|
||||
@@ -531,6 +585,7 @@ export function createAnnotationResolver({
|
||||
signal,
|
||||
'loose',
|
||||
);
|
||||
if (isUnavailableCapability(looseFp)) return looseFp;
|
||||
if (looseFp && looseFp.kind !== 'building') fp = looseFp;
|
||||
else if (looseFp === null)
|
||||
fp = synthesizeBufferedArea(lat, lon, NEIGHBORHOOD_RADIUS_M);
|
||||
@@ -580,6 +635,7 @@ export function createAnnotationResolver({
|
||||
// synthesized disc only when OSM DEFINITIVELY has none.
|
||||
if (groundsLike && (fp === null || fp?.kind === 'building')) {
|
||||
const area = await fetchEnclosingArea(lat, lon, signal, matchName);
|
||||
if (isUnavailableCapability(area)) return area;
|
||||
if (area) {
|
||||
fp = area; // real grounds polygon (synthesized:false) — beats both the dome and a disc
|
||||
// It's a grounds/compound feature (already capped at SCOPE_AREA_CAP_M2.compound inside
|
||||
@@ -619,7 +675,7 @@ export function createAnnotationResolver({
|
||||
) {
|
||||
fp = null;
|
||||
}
|
||||
if (isRateLimitedOutcome(fp)) return fp;
|
||||
if (isUnavailableCapability(fp) || isRateLimitedOutcome(fp)) return fp;
|
||||
if (fp === undefined) return undefined; // TRANSIENT — a backoff retry may still find it
|
||||
if (!fp || !Array.isArray(fp.ring) || fp.ring.length < 3) return null;
|
||||
const centroid = ringCentroid(fp.ring);
|
||||
@@ -646,10 +702,12 @@ export function createAnnotationResolver({
|
||||
let ring = null;
|
||||
let footprintKind = null; // 'building' | 'area'
|
||||
let buildingHeight = null; // meters, only for buildings
|
||||
let outlineUnavailable = false;
|
||||
let synthesized = false; // true = buffered/approximate area, render dashed/feathered
|
||||
if (footprint && !deferFootprint) {
|
||||
const fp = await resolveOutline();
|
||||
if (fp && !isRateLimitedOutcome(fp)) {
|
||||
outlineUnavailable = isUnavailableCapability(fp);
|
||||
if (fp && !outlineUnavailable && !isRateLimitedOutcome(fp)) {
|
||||
ring = fp.ring;
|
||||
footprintKind = fp.footprintKind;
|
||||
buildingHeight = fp.buildingHeight;
|
||||
@@ -680,6 +738,7 @@ export function createAnnotationResolver({
|
||||
label,
|
||||
source,
|
||||
synthesized,
|
||||
outlineUnavailable,
|
||||
viewport: placeViewport,
|
||||
...(footprint && deferFootprint ? { resolveOutline } : {}),
|
||||
};
|
||||
@@ -720,6 +779,65 @@ export function createAnnotationResolver({
|
||||
const GROUNDS_RADIUS_MIN_M = 150; // viewport-derived grounds disc is clamped to this band so a tiny
|
||||
const GROUNDS_RADIUS_MAX_M = 1200; // place can't shrink to a dot, nor a city-wide viewport balloon
|
||||
|
||||
/** Credit the pack a bundled boundary came from. */
|
||||
function creditAdminSource(viewer, admin) {
|
||||
registerDynamicCredit(
|
||||
viewer,
|
||||
admin.source === 'us-census' ? US_CENSUS_CREDIT : NATURAL_EARTH_CREDIT,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Outline patch (resolveOutline's contract) for a bundled administrative unit.
|
||||
* `ring` is the main part, closed; `polygons` carries every part with its
|
||||
* holes (Hawaii's islands, Berlin inside Brandenburg) for renderers that
|
||||
* draw them. Like the Natural Earth rung, it bypasses the scope caps and the
|
||||
* centroid drift bound: the unit IS the asked scope.
|
||||
*/
|
||||
function bundledAdminOutline(viewer, admin) {
|
||||
creditAdminSource(viewer, admin);
|
||||
const { lat, lon } = admin.label;
|
||||
return {
|
||||
ring: closeRing([...admin.ring]), // copy: closeRing mutates, the pack is shared
|
||||
polygons: admin.polygons,
|
||||
footprintKind: 'area',
|
||||
buildingHeight: null,
|
||||
synthesized: false,
|
||||
adminArea: admin.name,
|
||||
lat,
|
||||
lon,
|
||||
height: sampleGroundHeight(viewer, lon, lat),
|
||||
};
|
||||
}
|
||||
|
||||
/** A complete resolved target for a bundled administrative unit (no outline pending). */
|
||||
function bundledAdminTarget(viewer, admin, query) {
|
||||
const outline = bundledAdminOutline(viewer, admin);
|
||||
const [west, south, east, north] = admin.bbox;
|
||||
console.log(
|
||||
`[Resolver] "${query}": bundled ${admin.kind} "${admin.name}"` +
|
||||
`${admin.region ? `, ${admin.region}` : ''} (${admin.source}, ` +
|
||||
`${admin.polygons.length} part(s), ${admin.candidates} candidate(s)) → FINAL source=bundled`,
|
||||
);
|
||||
return {
|
||||
lon: outline.lon,
|
||||
lat: outline.lat,
|
||||
height: outline.height,
|
||||
ring: outline.ring,
|
||||
polygons: outline.polygons,
|
||||
footprintKind: 'area',
|
||||
buildingHeight: null,
|
||||
label: admin.name,
|
||||
source: 'bundled',
|
||||
synthesized: false,
|
||||
outlineUnavailable: false,
|
||||
viewport: {
|
||||
low: { latitude: south, longitude: west },
|
||||
high: { latitude: north, longitude: east },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Synthesize an approximate circular AREA by buffering a label point. Returns a ring
|
||||
* marked `synthesized:true` so the renderers draw it "approximate" (dashed/feathered),
|
||||
@@ -1004,7 +1122,8 @@ export function createAnnotationResolver({
|
||||
{ lat, lon },
|
||||
{ signal: signal },
|
||||
);
|
||||
if (isRateLimitedOutcome(candidates)) return candidates;
|
||||
if (isUnavailableCapability(candidates) || isRateLimitedOutcome(candidates))
|
||||
return candidates;
|
||||
if (!candidates) return undefined; // transient upstream failure (vs null = definitive miss)
|
||||
|
||||
const queryWords = normalizedWords(query);
|
||||
@@ -1067,7 +1186,8 @@ export function createAnnotationResolver({
|
||||
// without first trying the place= polygon (and a named landuse via the caller).
|
||||
if (scope === 'neighborhood' && (!best || bestCoverage < 0.8)) {
|
||||
const place = await fetchPlaceArea(lat, lon, query, signal);
|
||||
if (isRateLimitedOutcome(place)) return place;
|
||||
if (isUnavailableCapability(place) || isRateLimitedOutcome(place))
|
||||
return place;
|
||||
if (place) {
|
||||
cacheWrite(footprintCache, cacheKey, place);
|
||||
return place;
|
||||
@@ -1097,7 +1217,8 @@ export function createAnnotationResolver({
|
||||
const relEls = await featureSource.getAreaGeometry(cand.el.id, {
|
||||
signal: signal,
|
||||
});
|
||||
if (isRateLimitedOutcome(relEls)) return relEls;
|
||||
if (isUnavailableCapability(relEls) || isRateLimitedOutcome(relEls))
|
||||
return relEls;
|
||||
if (relEls === null) {
|
||||
transient = true;
|
||||
break;
|
||||
@@ -1126,7 +1247,8 @@ export function createAnnotationResolver({
|
||||
if (exceedsScopeArea(fp, scope)) {
|
||||
if (scope === 'neighborhood') {
|
||||
const place = await fetchPlaceArea(lat, lon, query, signal);
|
||||
if (isRateLimitedOutcome(place)) return place;
|
||||
if (isUnavailableCapability(place) || isRateLimitedOutcome(place))
|
||||
return place;
|
||||
if (place) {
|
||||
cacheWrite(footprintCache, cacheKey, place);
|
||||
return place;
|
||||
@@ -1149,7 +1271,8 @@ export function createAnnotationResolver({
|
||||
// caller relies on that to gate named-landuse fallback / synthesis.
|
||||
if (scope === 'neighborhood') {
|
||||
const place = await fetchPlaceArea(lat, lon, query, signal);
|
||||
if (isRateLimitedOutcome(place)) return place;
|
||||
if (isUnavailableCapability(place) || isRateLimitedOutcome(place))
|
||||
return place;
|
||||
if (place) {
|
||||
cacheWrite(footprintCache, cacheKey, place);
|
||||
return place;
|
||||
@@ -1171,7 +1294,7 @@ export function createAnnotationResolver({
|
||||
{ lat, lon },
|
||||
{ signal: signal },
|
||||
);
|
||||
if (isRateLimitedOutcome(els)) return els;
|
||||
if (isUnavailableCapability(els) || isRateLimitedOutcome(els)) return els;
|
||||
if (els === null) return undefined; // transient upstream failure (vs [] = no match)
|
||||
let bestRing = null;
|
||||
let bestScore = 0;
|
||||
@@ -1220,7 +1343,8 @@ export function createAnnotationResolver({
|
||||
{ lat, lon },
|
||||
{ signal: signal },
|
||||
);
|
||||
if (isRateLimitedOutcome(areaEls)) return areaEls;
|
||||
if (isUnavailableCapability(areaEls) || isRateLimitedOutcome(areaEls))
|
||||
return areaEls;
|
||||
if (areaEls) {
|
||||
let bestRing = null;
|
||||
let bestScore = 0;
|
||||
@@ -1253,7 +1377,8 @@ export function createAnnotationResolver({
|
||||
{ lat, lon },
|
||||
{ signal: signal },
|
||||
);
|
||||
if (isRateLimitedOutcome(wayEls)) return wayEls;
|
||||
if (isUnavailableCapability(wayEls) || isRateLimitedOutcome(wayEls))
|
||||
return wayEls;
|
||||
if (wayEls) {
|
||||
const segments = [];
|
||||
for (const el of wayEls) {
|
||||
@@ -1326,7 +1451,8 @@ export function createAnnotationResolver({
|
||||
{ lat, lon },
|
||||
{ signal: signal },
|
||||
);
|
||||
if (isRateLimitedOutcome(elements)) return elements;
|
||||
if (isUnavailableCapability(elements) || isRateLimitedOutcome(elements))
|
||||
return elements;
|
||||
if (elements === null || signal?.aborted) return undefined;
|
||||
const fp = selectFootprint(elements, lat, lon, query, mode);
|
||||
if (fp) {
|
||||
@@ -1374,7 +1500,8 @@ export function createAnnotationResolver({
|
||||
{ lat, lon },
|
||||
{ signal: signal },
|
||||
);
|
||||
if (isRateLimitedOutcome(elements)) return elements;
|
||||
if (isUnavailableCapability(elements) || isRateLimitedOutcome(elements))
|
||||
return elements;
|
||||
if (elements === null) return undefined; // transient upstream failure (vs [] = definitive no-match)
|
||||
|
||||
const queryWords = normalizedWords(query);
|
||||
@@ -1497,7 +1624,11 @@ export function createAnnotationResolver({
|
||||
pending = featureSource
|
||||
.getMonuments({ lat, lon }, { signal: undefined })
|
||||
.then((elements) => {
|
||||
if (elements === null || isRateLimitedOutcome(elements))
|
||||
if (
|
||||
elements === null ||
|
||||
isUnavailableCapability(elements) ||
|
||||
isRateLimitedOutcome(elements)
|
||||
)
|
||||
return null; // transient — NEVER cached (a poisoned bucket
|
||||
// would silently disable the snap for the whole session, field test 7 §1)
|
||||
const feats = [];
|
||||
@@ -1912,6 +2043,10 @@ export function createAnnotationResolver({
|
||||
const ring = [...ne.polygons].sort((a, b) => b.length - a.length)[0];
|
||||
if (ring?.length >= 3) return { name: ne.name, ring };
|
||||
}
|
||||
// Bundled states/provinces/counties by name (offline); the main part only,
|
||||
// like the Natural Earth rung above.
|
||||
const admin = await findAdminArea(q).catch(() => null);
|
||||
if (admin) return { name: admin.name, ring: [...admin.ring] };
|
||||
const lookup = resolveAdminRegionRing(q, signal, placeSearch);
|
||||
if (!Number.isFinite(budgetMs)) return lookup;
|
||||
let timer;
|
||||
@@ -1935,6 +2070,15 @@ export function createAnnotationResolver({
|
||||
if (!geo) return null;
|
||||
const scope = scopeFromTypes(geo.types);
|
||||
if (!['country', 'state', 'county', 'city'].includes(scope)) return null;
|
||||
if (scope === 'state' || scope === 'county' || scope === 'country') {
|
||||
const admin = await findAdminAreaAt(
|
||||
[q, geo.primaryName],
|
||||
geo.lat,
|
||||
geo.lon,
|
||||
scope,
|
||||
).catch(() => null);
|
||||
if (admin) return { name: q, ring: [...admin.ring] };
|
||||
}
|
||||
const fp = await fetchAdminArea(geo.lat, geo.lon, q, scope, signal).catch(
|
||||
() => null,
|
||||
);
|
||||
|
||||
@@ -161,7 +161,12 @@ export function createScreenAnnotationRenderer(
|
||||
filter: 'url(#gev-sketch)',
|
||||
});
|
||||
group.appendChild(parts.poly);
|
||||
parts.label = makeCallout(anno.label, c);
|
||||
parts.label = makeCallout(
|
||||
anno.outlineUnavailable
|
||||
? `${anno.label || ''} · Detailed outline unavailable`
|
||||
: anno.label,
|
||||
c,
|
||||
);
|
||||
if (parts.label) group.appendChild(parts.label.node);
|
||||
} else if (anno.type === 'arrow' && anno.to) {
|
||||
parts.path = svgEl('path', {
|
||||
@@ -174,7 +179,12 @@ export function createScreenAnnotationRenderer(
|
||||
});
|
||||
ensureArrowMarker(defs, anno.color || 'primary', c);
|
||||
group.appendChild(parts.path);
|
||||
parts.label = makeCallout(anno.label, c);
|
||||
parts.label = makeCallout(
|
||||
anno.outlineUnavailable
|
||||
? `${anno.label || ''} · Detailed outline unavailable`
|
||||
: anno.label,
|
||||
c,
|
||||
);
|
||||
if (parts.label) group.appendChild(parts.label.node);
|
||||
} else if (
|
||||
anno.type === 'route' &&
|
||||
@@ -203,7 +213,12 @@ export function createScreenAnnotationRenderer(
|
||||
group.appendChild(dot);
|
||||
return dot;
|
||||
});
|
||||
parts.label = makeCallout(anno.label, c);
|
||||
parts.label = makeCallout(
|
||||
anno.outlineUnavailable
|
||||
? `${anno.label || ''} · Detailed outline unavailable`
|
||||
: anno.label,
|
||||
c,
|
||||
);
|
||||
if (parts.label) group.appendChild(parts.label.node);
|
||||
} else {
|
||||
// pin / highlight / label — pulsing target rings + a marker dot + callout
|
||||
@@ -242,7 +257,12 @@ export function createScreenAnnotationRenderer(
|
||||
'stroke-opacity': '0.7',
|
||||
});
|
||||
group.appendChild(parts.leader);
|
||||
parts.label = makeCallout(anno.label, c);
|
||||
parts.label = makeCallout(
|
||||
anno.outlineUnavailable
|
||||
? `${anno.label || ''} · Detailed outline unavailable`
|
||||
: anno.label,
|
||||
c,
|
||||
);
|
||||
if (parts.label) group.appendChild(parts.label.node);
|
||||
}
|
||||
|
||||
@@ -301,6 +321,10 @@ export function createScreenAnnotationRenderer(
|
||||
return;
|
||||
}
|
||||
rec.anno = anno;
|
||||
if (anno.outlineUnavailable && rec.parts.label) {
|
||||
rec.parts.label.text.textContent = `${anno.label || ''} · Detailed outline unavailable`;
|
||||
sizeCallout(rec.parts.label);
|
||||
}
|
||||
|
||||
if (empty) {
|
||||
for (const child of Array.from(rec.group.children)) child.remove();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { showOsmCredit, hideOsmCredit } from '../data/dataCredits.js';
|
||||
|
||||
/**
|
||||
* World-space annotation renderer (Direction A).
|
||||
@@ -10,7 +11,7 @@ import * as Cesium from 'cesium';
|
||||
* IMPORTANT — drawing on Google Photorealistic 3D Tiles:
|
||||
* The Cesium globe is hidden, so there is no terrain to clamp to. Ground
|
||||
* geometry (areas, rings, connectors) is draped onto the photoreal tiles with
|
||||
* `classificationType: CESIUM_3D_TILE`; points and labels clamp to the tile
|
||||
* `classificationType: CESIUM_3D_TILE`; points clamp to the tile
|
||||
* surface with `heightReference: CLAMP_TO_GROUND` (which requires the tileset
|
||||
* to have `enableCollision = true`, set in initAnnotations). This keeps marks
|
||||
* sitting ON the world instead of buried at sea level.
|
||||
@@ -49,6 +50,7 @@ const CLAMP = Cesium.HeightReference.CLAMP_TO_GROUND;
|
||||
export function createWorldAnnotationRenderer(viewer) {
|
||||
const dataSource = new Cesium.CustomDataSource('gev-annotations');
|
||||
viewer.dataSources.add(dataSource);
|
||||
const osmAnnotations = new Set();
|
||||
|
||||
// Register the GevRouteFlow fabric once so Cesium's Material.fromType() can build the
|
||||
// material the route pipeline renders. The animated `time` uniform is read straight
|
||||
@@ -159,51 +161,74 @@ export function createWorldAnnotationRenderer(viewer) {
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (anno.label) entities.push(labelMarker(anno, base, { point: false }));
|
||||
} else if (anno.ring && anno.ring.length >= 3) {
|
||||
// Larger area (district / compound / park) → flat fill draped on the tiles
|
||||
// + a glowing outline. Draping is right here: you can't extrude a whole
|
||||
// neighbourhood, and the GIS overlay shows the boundary clearly.
|
||||
const fillPositions = Cesium.Cartesian3.fromDegreesArray(
|
||||
anno.ring.flat(),
|
||||
);
|
||||
entities.push(
|
||||
dataSource.entities.add({
|
||||
polygon: {
|
||||
hierarchy: new Cesium.PolygonHierarchy(fillPositions),
|
||||
// Synthesized (approximate) areas get a fainter fill so they don't read as solid.
|
||||
material: new Cesium.ColorMaterialProperty(
|
||||
liveColor(anno, base, {
|
||||
alpha: anno.synthesized ? 0.1 : 0.2,
|
||||
pulse: true,
|
||||
}),
|
||||
),
|
||||
classificationType: CLASSIFY,
|
||||
},
|
||||
// Larger area (district / compound / park / state) → flat fill draped on
|
||||
// the tiles + a glowing outline. Draping is right here: you can't extrude
|
||||
// a whole neighbourhood, and the GIS overlay shows the boundary clearly.
|
||||
// A multi-part outline (`anno.polygons`: Hawaii's islands, Berlin as a
|
||||
// hole in Brandenburg) draws every part with its holes. All parts share
|
||||
// ONE fill and ONE line material, so Cesium batches the whole mark into a
|
||||
// ground primitive per kind instead of one per island.
|
||||
const parts =
|
||||
Array.isArray(anno.polygons) && anno.polygons.length
|
||||
? anno.polygons
|
||||
: [[anno.ring]];
|
||||
const fillMaterial = new Cesium.ColorMaterialProperty(
|
||||
// Synthesized (approximate) areas get a fainter fill so they don't read as solid.
|
||||
liveColor(anno, base, {
|
||||
alpha: anno.synthesized ? 0.1 : 0.2,
|
||||
pulse: true,
|
||||
}),
|
||||
);
|
||||
entities.push(
|
||||
dataSource.entities.add({
|
||||
polyline: {
|
||||
positions: fillPositions,
|
||||
width: 6,
|
||||
// Synthesized → DASHED outline (signals "approximate, not an authoritative
|
||||
// boundary", research §8.4/§8.6); real footprints → solid glow.
|
||||
material: anno.synthesized
|
||||
? new Cesium.PolylineDashMaterialProperty({
|
||||
color: liveColor(anno, base, { alpha: 0.95 }),
|
||||
dashLength: 24,
|
||||
})
|
||||
: new Cesium.PolylineGlowMaterialProperty({
|
||||
glowPower: 0.35,
|
||||
color: liveColor(anno, base, { alpha: 1 }),
|
||||
}),
|
||||
clampToGround: true,
|
||||
classificationType: CLASSIFY,
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (anno.label) entities.push(labelMarker(anno, base, { point: false }));
|
||||
// Synthesized → DASHED outline (signals "approximate, not an authoritative
|
||||
// boundary", research §8.4/§8.6); real footprints → solid glow.
|
||||
const lineMaterial = anno.synthesized
|
||||
? new Cesium.PolylineDashMaterialProperty({
|
||||
color: liveColor(anno, base, { alpha: 0.95 }),
|
||||
dashLength: 24,
|
||||
})
|
||||
: new Cesium.PolylineGlowMaterialProperty({
|
||||
glowPower: 0.35,
|
||||
color: liveColor(anno, base, { alpha: 1 }),
|
||||
});
|
||||
for (const [outer, ...holes] of parts) {
|
||||
if (!Array.isArray(outer) || outer.length < 3) continue;
|
||||
const rings = [outer, ...holes.filter((h) => h?.length >= 3)];
|
||||
entities.push(
|
||||
dataSource.entities.add({
|
||||
polygon: {
|
||||
hierarchy: new Cesium.PolygonHierarchy(
|
||||
Cesium.Cartesian3.fromDegreesArray(outer.flat()),
|
||||
rings
|
||||
.slice(1)
|
||||
.map(
|
||||
(hole) =>
|
||||
new Cesium.PolygonHierarchy(
|
||||
Cesium.Cartesian3.fromDegreesArray(hole.flat()),
|
||||
),
|
||||
),
|
||||
),
|
||||
material: fillMaterial,
|
||||
classificationType: CLASSIFY,
|
||||
},
|
||||
}),
|
||||
);
|
||||
for (const ring of rings) {
|
||||
entities.push(
|
||||
dataSource.entities.add({
|
||||
polyline: {
|
||||
positions: Cesium.Cartesian3.fromDegreesArray(
|
||||
closedRing(ring).flat(),
|
||||
),
|
||||
width: 6,
|
||||
material: lineMaterial,
|
||||
clampToGround: true,
|
||||
classificationType: CLASSIFY,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
anno.type === 'route' &&
|
||||
Array.isArray(anno.path) &&
|
||||
@@ -228,7 +253,8 @@ export function createWorldAnnotationRenderer(viewer) {
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (anno.label) entities.push(labelMarker(anno, base, { point: false }));
|
||||
osmAnnotations.add(anno);
|
||||
showOsmCredit(viewer, anno);
|
||||
} else if (anno.type === 'arrow' && anno.to) {
|
||||
// Connector draped across the ground from origin to destination.
|
||||
const positions = [
|
||||
@@ -248,18 +274,6 @@ export function createWorldAnnotationRenderer(viewer) {
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (anno.label) {
|
||||
const mid = {
|
||||
lon: (anno.anchor.lon + anno.to.lon) / 2,
|
||||
lat: (anno.anchor.lat + anno.to.lat) / 2,
|
||||
};
|
||||
entities.push(
|
||||
dataSource.entities.add({
|
||||
position: Cesium.Cartesian3.fromDegrees(mid.lon, mid.lat),
|
||||
label: labelGraphic(anno, base),
|
||||
}),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// pin / highlight / label — a camera-proportional target ring + a marker.
|
||||
if (anno.type !== 'label') {
|
||||
@@ -284,54 +298,28 @@ export function createWorldAnnotationRenderer(viewer) {
|
||||
}),
|
||||
);
|
||||
}
|
||||
entities.push(labelMarker(anno, base, { point: true }));
|
||||
entities.push(pointMarker(anno, base));
|
||||
}
|
||||
}
|
||||
|
||||
// A clamped point + (optional) label that sits on the tile surface.
|
||||
function labelMarker(anno, base, { point }) {
|
||||
// Captions belong to the hybrid renderer's screen-space callouts.
|
||||
function pointMarker(anno, base) {
|
||||
return dataSource.entities.add({
|
||||
position: Cesium.Cartesian3.fromDegrees(anno.anchor.lon, anno.anchor.lat),
|
||||
point: point
|
||||
? {
|
||||
pixelSize: anno.type === 'label' ? 8 : 14,
|
||||
color: liveColor(anno, base, { alpha: 1 }),
|
||||
outlineColor: liveColor(anno, Cesium.Color.WHITE, { alpha: 0.95 }),
|
||||
outlineWidth: 3,
|
||||
heightReference: CLAMP,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
}
|
||||
: undefined,
|
||||
label: anno.label ? labelGraphic(anno, base) : undefined,
|
||||
point: {
|
||||
pixelSize: anno.type === 'label' ? 8 : 14,
|
||||
color: liveColor(anno, base, { alpha: 1 }),
|
||||
outlineColor: liveColor(anno, Cesium.Color.WHITE, { alpha: 0.95 }),
|
||||
outlineWidth: 3,
|
||||
heightReference: CLAMP,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function labelGraphic(anno, base) {
|
||||
return {
|
||||
text: anno.label,
|
||||
font: '600 14px "Inter", system-ui, sans-serif',
|
||||
fillColor: liveColor(anno, Cesium.Color.WHITE, { alpha: 1 }),
|
||||
outlineColor: liveColor(anno, Cesium.Color.BLACK, { alpha: 0.85 }),
|
||||
outlineWidth: 3,
|
||||
style: Cesium.LabelStyle.FILL_AND_OUTLINE,
|
||||
verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
|
||||
pixelOffset: new Cesium.Cartesian2(0, -16),
|
||||
showBackground: true,
|
||||
backgroundColor: new Cesium.CallbackProperty(
|
||||
() =>
|
||||
Cesium.Color.fromCssColorString('#0b1622').withAlpha(
|
||||
0.72 * (anno.alpha ?? 1),
|
||||
),
|
||||
false,
|
||||
),
|
||||
backgroundPadding: new Cesium.Cartesian2(8, 5),
|
||||
heightReference: CLAMP,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
scaleByDistance: new Cesium.NearFarScalar(500, 1.05, 14000, 0.55),
|
||||
};
|
||||
}
|
||||
|
||||
function remove(anno) {
|
||||
hideOsmCredit(viewer, anno);
|
||||
osmAnnotations.delete(anno);
|
||||
if (!anno?._entities) return;
|
||||
for (const entity of anno._entities) {
|
||||
try {
|
||||
@@ -348,6 +336,8 @@ export function createWorldAnnotationRenderer(viewer) {
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
for (const anno of osmAnnotations) hideOsmCredit(viewer, anno);
|
||||
osmAnnotations.clear();
|
||||
try {
|
||||
viewer.dataSources.remove(dataSource, true);
|
||||
} catch {
|
||||
@@ -452,6 +442,13 @@ FlowMaterialProperty.prototype.equals = function equals(other) {
|
||||
return this === other;
|
||||
};
|
||||
|
||||
/** The ring with its first vertex repeated at the end, so an outline closes. */
|
||||
function closedRing(ring) {
|
||||
const first = ring[0];
|
||||
const last = ring[ring.length - 1];
|
||||
return first[0] === last[0] && first[1] === last[1] ? ring : [...ring, first];
|
||||
}
|
||||
|
||||
/** Evenly down-sample a [[lon,lat],...] ring to at most n points (keeps shape). */
|
||||
function decimateRing(ring, n) {
|
||||
if (ring.length <= n) return ring;
|
||||
|
||||
@@ -150,7 +150,7 @@ export function createApplicationCatalog({
|
||||
createApplicationAlpr({ surface, source: sources.alpr }),
|
||||
satellites,
|
||||
createApplicationLaunches({ source: sources.launches, satellites }),
|
||||
createApplicationTraffic({ source: sources.traffic }),
|
||||
createApplicationTraffic({ source: sources.traffic, surface }),
|
||||
createApplicationCctv({ surface, source: sources.cctv }),
|
||||
createApplicationRadio({ surface, source: sources.radio }),
|
||||
createApplicationTransit({ surface, source: sources.transit }),
|
||||
|
||||
@@ -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,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,
|
||||
});
|
||||
@@ -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])
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -614,3 +614,9 @@ test('the credit line is never suppressed to make room', () => {
|
||||
}
|
||||
assert.match(css, /body\.ui-clean-view #cesium-credits,\s*\n\s*body\.recording-mode #cesium-credits \{[^}]*bottom: 36px;/);
|
||||
});
|
||||
|
||||
|
||||
test('OSM-derived layers never force the desktop credit row into a stack', () => {
|
||||
assert.doesNotMatch(css, /body:has\([^)]*alpr-cameras[^}]*#cesium-credits/);
|
||||
assert.doesNotMatch(css, /#cesium-credits\s+\.cesium-credit-expand-link\s*\{[^}]*display:\s*block/);
|
||||
});
|
||||
@@ -0,0 +1,821 @@
|
||||
/**
|
||||
* Bundled administrative boundaries — countries and states/provinces (Natural
|
||||
* Earth admin-0/admin-1) and US counties (US Census Bureau cartographic boundaries),
|
||||
* so "outline Texas", "outline Bavaria" or "outline Travis County" draws the
|
||||
* real boundary without a network lookup.
|
||||
*
|
||||
* Data: `local_data/natural_earth/{countries,states_provinces}.json` and
|
||||
* `local_data/us_census_counties/counties.json`, public domain and built
|
||||
* by `scripts/build-admin-packs.mjs` (provenance in each folder's README).
|
||||
* Rings are delta-encoded integers; see `decodeRing`.
|
||||
*
|
||||
* PURE data module — no Cesium, node-testable. Each pack is fetched through
|
||||
* `loadBundledJson` on the first lookup that needs it (never at app start),
|
||||
* indexed once, and kept; a failed load is retried later rather than cached
|
||||
* (`createRetryableLoader`). The county pack is only loaded for asks that
|
||||
* name a county (or a county-equivalent city) or that a geocoder typed as one.
|
||||
*
|
||||
* Two entry points:
|
||||
* - `findAdminArea(query, { near })` — name-only. Resolves asks that are
|
||||
* unambiguous from the words alone ("Texas", "TX", "State of Texas",
|
||||
* "Bavaria", "Travis County, Texas", "Orleans Parish"); duplicates are
|
||||
* settled by a state/country qualifier, then by the camera (`near`).
|
||||
* Country/state homonyms such as Georgia use the qualifier or camera;
|
||||
* city/state homonyms such as New York still defer to the geocoder.
|
||||
* - `findAdminAreaAt(names, lat, lon, scope)` — geocoder-confirmed. The
|
||||
* geocoder already typed the place as a country, state or county; the unit must
|
||||
* carry one of the names AND contain the geocoded point.
|
||||
*/
|
||||
|
||||
import { loadBundledJson } from './bundledJson.js';
|
||||
import { createRetryableLoader } from './retryableLoad.js';
|
||||
|
||||
const FOLD = {
|
||||
ß: 'ss',
|
||||
ø: 'o',
|
||||
æ: 'ae',
|
||||
ł: 'l',
|
||||
đ: 'd',
|
||||
ı: 'i',
|
||||
œ: 'oe',
|
||||
þ: 'th',
|
||||
};
|
||||
|
||||
/** Fold a name for matching: case, accents, punctuation, "the", "saint". */
|
||||
export function normalizeAdminName(value) {
|
||||
return String(value || '')
|
||||
.toLowerCase()
|
||||
.replace(/[ßøæłđıœþ]/g, (c) => FOLD[c])
|
||||
.normalize('NFD')
|
||||
.replace(/\p{M}/gu, '')
|
||||
.replace(/[^a-z0-9]+/g, ' ')
|
||||
.trim()
|
||||
.replace(/^the /, '')
|
||||
.replace(/\bsaint\b/g, 'st')
|
||||
.replace(/\bsainte\b/g, 'ste');
|
||||
}
|
||||
|
||||
const PACKS = {
|
||||
countries: {
|
||||
url: new URL('./local_data/natural_earth/countries.json', import.meta.url),
|
||||
importJson: () =>
|
||||
import('./local_data/natural_earth/countries.json', {
|
||||
with: { type: 'json' },
|
||||
}),
|
||||
},
|
||||
admin1: {
|
||||
url: new URL(
|
||||
'./local_data/natural_earth/states_provinces.json',
|
||||
import.meta.url,
|
||||
),
|
||||
importJson: () =>
|
||||
import('./local_data/natural_earth/states_provinces.json', {
|
||||
with: { type: 'json' },
|
||||
}),
|
||||
},
|
||||
counties: {
|
||||
url: new URL(
|
||||
'./local_data/us_census_counties/counties.json',
|
||||
import.meta.url,
|
||||
),
|
||||
importJson: () =>
|
||||
import('./local_data/us_census_counties/counties.json', {
|
||||
with: { type: 'json' },
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
/** Test seam: count pack loads (lazy-loading contract). */
|
||||
export const packLoads = { admin1: 0, counties: 0, countries: 0 };
|
||||
|
||||
const EARTH_RADIUS_KM = 6371;
|
||||
const toRad = (d) => (d * Math.PI) / 180;
|
||||
|
||||
/** Spherical ring area (km²) — same formula as naturalEarthRegions.js. */
|
||||
function ringAreaKm2(ring) {
|
||||
const n = ring.length;
|
||||
if (n < 3) return 0;
|
||||
let sum = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const [lon1, lat1] = ring[i];
|
||||
const [lon2, lat2] = ring[(i + 1) % n];
|
||||
sum +=
|
||||
toRad(lon2 - lon1) * (2 + Math.sin(toRad(lat1)) + Math.sin(toRad(lat2)));
|
||||
}
|
||||
return Math.abs((sum * EARTH_RADIUS_KM * EARTH_RADIUS_KM) / 2);
|
||||
}
|
||||
|
||||
function haversineKm(lat1, lon1, lat2, lon2) {
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLon = toRad(lon2 - lon1);
|
||||
const h =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
||||
return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(h)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode one packed ring: integers in units of 10^-decimals degrees, the
|
||||
* first vertex absolute and each later one a delta from the previous.
|
||||
* @param {number[]} encoded
|
||||
* @param {number} decimals
|
||||
* @returns {Array<[number, number]>} open ring of [lon, lat]
|
||||
*/
|
||||
export function decodeRing(encoded, decimals) {
|
||||
const factor = 10 ** decimals;
|
||||
const ring = [];
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
for (let i = 0; i + 1 < encoded.length; i += 2) {
|
||||
x += encoded[i];
|
||||
y += encoded[i + 1];
|
||||
ring.push([x / factor, y / factor]);
|
||||
}
|
||||
return ring;
|
||||
}
|
||||
|
||||
/** Even-odd point-in-ring test; ring is [[lon, lat], …], open or closed. */
|
||||
export function pointInRing(ring, lat, lon) {
|
||||
if (!Array.isArray(ring) || ring.length < 3) return false;
|
||||
let inside = false;
|
||||
for (let i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) {
|
||||
const [xi, yi] = ring[i];
|
||||
const [xj, yj] = ring[j];
|
||||
if (
|
||||
yi > lat !== yj > lat &&
|
||||
lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi
|
||||
)
|
||||
inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether polygons ([outer, ...holes][]) contain the point: inside an outer
|
||||
* ring and not inside any of that part's holes.
|
||||
*/
|
||||
export function polygonsContain(polygons, lat, lon) {
|
||||
for (const [outer, ...holes] of polygons || []) {
|
||||
if (!pointInRing(outer, lat, lon)) continue;
|
||||
if (!holes.some((hole) => pointInRing(hole, lat, lon))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* A unit's geometry, decoded on first use. Longitudes are unwrapped around
|
||||
* the largest part for the box so units cut at the antimeridian (Alaska,
|
||||
* Chukotka) get a box on the right side of the globe.
|
||||
*/
|
||||
function geometryOf(entry) {
|
||||
if (entry.geometry) return entry.geometry;
|
||||
const decimals = entry.feature.d ?? entry.decimals;
|
||||
const polygons = entry.feature.polygons.map((poly) =>
|
||||
poly.map((ring) => decodeRing(ring, decimals)),
|
||||
);
|
||||
const ref = polygons[0]?.[0]?.[0]?.[0] ?? 0;
|
||||
let west = Infinity;
|
||||
let south = Infinity;
|
||||
let east = -Infinity;
|
||||
let north = -Infinity;
|
||||
let areaKm2 = 0;
|
||||
for (const [outer, ...holes] of polygons) {
|
||||
areaKm2 += ringAreaKm2(outer);
|
||||
for (const hole of holes) areaKm2 -= ringAreaKm2(hole);
|
||||
for (const [lon, lat] of outer) {
|
||||
const x = lon - 360 * Math.round((lon - ref) / 360);
|
||||
if (x < west) west = x;
|
||||
if (x > east) east = x;
|
||||
if (lat < south) south = lat;
|
||||
if (lat > north) north = lat;
|
||||
}
|
||||
}
|
||||
const label = entry.feature.label
|
||||
? { lon: entry.feature.label[0], lat: entry.feature.label[1] }
|
||||
: ringCentroid(polygons[0][0]);
|
||||
entry.geometry = {
|
||||
polygons,
|
||||
bbox: [west, south, east, north],
|
||||
areaKm2: Math.max(0, areaKm2),
|
||||
label,
|
||||
};
|
||||
return entry.geometry;
|
||||
}
|
||||
|
||||
/** Area-weighted centroid of an open ring (planar; fine at unit scale). */
|
||||
function ringCentroid(ring) {
|
||||
let a = 0;
|
||||
let cx = 0;
|
||||
let cy = 0;
|
||||
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
||||
const [x0, y0] = ring[j];
|
||||
const [x1, y1] = ring[i];
|
||||
const f = x0 * y1 - x1 * y0;
|
||||
a += f;
|
||||
cx += (x0 + x1) * f;
|
||||
cy += (y0 + y1) * f;
|
||||
}
|
||||
if (!a) return { lon: ring[0][0], lat: ring[0][1] };
|
||||
return { lon: cx / (3 * a), lat: cy / (3 * a) };
|
||||
}
|
||||
|
||||
/** Km from a point to a unit's box (0 inside it). */
|
||||
function boxDistanceKm(bbox, lat, lon) {
|
||||
const [west, south, east, north] = bbox;
|
||||
const mid = (west + east) / 2;
|
||||
const x = lon - 360 * Math.round((lon - mid) / 360);
|
||||
const clampedLon = Math.min(east, Math.max(west, x));
|
||||
const clampedLat = Math.min(north, Math.max(south, lat));
|
||||
return haversineKm(lat, x, clampedLat, clampedLon);
|
||||
}
|
||||
|
||||
/**
|
||||
* Natural Earth `min_label` (the zoom at which a unit is labelled) up to which
|
||||
* a unit may be named alone: every US state, Canadian province, Mexican and
|
||||
* German state; not English counties, French départements or small districts.
|
||||
*/
|
||||
const BARE_NAME_MAX_RANK = 7;
|
||||
|
||||
/**
|
||||
* Words that name a unit somewhere (Cameroon's "North", Paraguay's
|
||||
* "Central") but, said alone, name a direction or a feature.
|
||||
*/
|
||||
const GENERIC_NAMES = new Set([
|
||||
'north',
|
||||
'south',
|
||||
'east',
|
||||
'west',
|
||||
'northern',
|
||||
'southern',
|
||||
'eastern',
|
||||
'western',
|
||||
'north east',
|
||||
'north west',
|
||||
'south east',
|
||||
'south west',
|
||||
'northeast',
|
||||
'northwest',
|
||||
'southeast',
|
||||
'southwest',
|
||||
'far north',
|
||||
'far west',
|
||||
'central',
|
||||
'centre',
|
||||
'center',
|
||||
'capital',
|
||||
'coast',
|
||||
'island',
|
||||
'islands',
|
||||
'lakes',
|
||||
'highland',
|
||||
'highlands',
|
||||
'interior',
|
||||
'upper',
|
||||
'lower',
|
||||
'valley',
|
||||
'plateau',
|
||||
'plateaux',
|
||||
'littoral',
|
||||
'oriental',
|
||||
'occidental',
|
||||
]);
|
||||
|
||||
/** State codes that, alone, more often mean a city ("LA", "NY"). */
|
||||
const CITY_ABBREVIATIONS = new Set(['la', 'ny']);
|
||||
|
||||
/** Postal abbreviations accepted alone ("TX", "ON"): US states and Canada. */
|
||||
const POSTAL_COUNTRIES = new Set(['US', 'CA']);
|
||||
|
||||
function addKey(index, key, entry, tier) {
|
||||
if (!key) return;
|
||||
entry.keys?.add(key);
|
||||
const list = index.get(key);
|
||||
const item = { entry, tier };
|
||||
if (!list) index.set(key, [item]);
|
||||
else if (!list.some((i) => i.entry === entry)) list.push(item);
|
||||
}
|
||||
|
||||
const loadAdmin1 = createRetryableLoader(async () => {
|
||||
packLoads.admin1 += 1;
|
||||
const pack = await loadBundledJson(PACKS.admin1.url, PACKS.admin1.importJson);
|
||||
const decimals = pack.meta?.decimals ?? 3;
|
||||
const index = new Map();
|
||||
for (const feature of pack.features || []) {
|
||||
const entry = {
|
||||
kind: 'state',
|
||||
feature,
|
||||
decimals,
|
||||
amb: new Set(feature.amb || []),
|
||||
ambAbroad: new Set(feature.ambAbroad || []),
|
||||
keys: new Set(),
|
||||
country: normalizeAdminName(feature.country),
|
||||
iso2: String(feature.iso2 || '').toLowerCase(),
|
||||
};
|
||||
addKey(index, normalizeAdminName(feature.name), entry, 0);
|
||||
addKey(index, normalizeAdminName(feature.nameEn), entry, 1);
|
||||
for (const alt of feature.alt || [])
|
||||
addKey(index, normalizeAdminName(alt), entry, 2);
|
||||
if (feature.postal && POSTAL_COUNTRIES.has(feature.iso2))
|
||||
addKey(index, `postal:${feature.postal.toLowerCase()}`, entry, 2);
|
||||
}
|
||||
return { index };
|
||||
});
|
||||
|
||||
const loadCounties = createRetryableLoader(async () => {
|
||||
packLoads.counties += 1;
|
||||
const pack = await loadBundledJson(
|
||||
PACKS.counties.url,
|
||||
PACKS.counties.importJson,
|
||||
);
|
||||
const decimals = pack.meta?.decimals ?? 4;
|
||||
const byFull = new Map();
|
||||
const byName = new Map();
|
||||
const states = new Map();
|
||||
for (const feature of pack.features || []) {
|
||||
const entry = {
|
||||
kind: 'county',
|
||||
feature,
|
||||
decimals,
|
||||
state: normalizeAdminName(feature.state),
|
||||
st: String(feature.st || '').toLowerCase(),
|
||||
};
|
||||
addKey(byFull, normalizeAdminName(feature.full), entry, 0);
|
||||
addKey(byName, normalizeAdminName(feature.name), entry, 1);
|
||||
states.set(entry.state, entry.st);
|
||||
}
|
||||
return { byFull, byName, states };
|
||||
});
|
||||
|
||||
const loadCountries = createRetryableLoader(async () => {
|
||||
packLoads.countries += 1;
|
||||
const pack = await loadBundledJson(
|
||||
PACKS.countries.url,
|
||||
PACKS.countries.importJson,
|
||||
);
|
||||
const index = new Map();
|
||||
for (const feature of pack.features || []) {
|
||||
const entry = {
|
||||
kind: 'country',
|
||||
feature,
|
||||
decimals: pack.meta.decimals,
|
||||
keys: new Set(),
|
||||
country: normalizeAdminName(feature.country),
|
||||
iso2: feature.iso2?.toLowerCase(),
|
||||
};
|
||||
for (const name of [feature.name, ...(feature.alt || [])])
|
||||
addKey(index, normalizeAdminName(name), entry, 0);
|
||||
}
|
||||
return { index };
|
||||
});
|
||||
|
||||
async function countryCandidates(parsed) {
|
||||
if (!['any', 'country'].includes(parsed.kind)) return [];
|
||||
const { index } = await loadCountries();
|
||||
const key = COUNTRY_TARGET_ALIASES.get(parsed.name) || parsed.name;
|
||||
return (index.get(key) || []).filter(({ entry }) =>
|
||||
parsed.qualifiers.every((q) => qualifierMatches(entry, q)),
|
||||
);
|
||||
}
|
||||
|
||||
/** Country target aliases never broaden constituent-country requests. */
|
||||
const COUNTRY_TARGET_ALIASES = new Map([
|
||||
['usa', 'united states of america'],
|
||||
['us', 'united states of america'],
|
||||
['u s', 'united states of america'],
|
||||
['u s a', 'united states of america'],
|
||||
['united states', 'united states of america'],
|
||||
['america', 'united states of america'],
|
||||
['uk', 'united kingdom'],
|
||||
['u k', 'united kingdom'],
|
||||
['great britain', 'united kingdom'],
|
||||
['britain', 'united kingdom'],
|
||||
['russian federation', 'russia'],
|
||||
['korea', 'south korea'],
|
||||
['czechia', 'czech republic'],
|
||||
['tanzania', 'united republic of tanzania'],
|
||||
['drc', 'democratic republic of the congo'],
|
||||
]);
|
||||
|
||||
const COUNTRY_ALIASES = new Map([
|
||||
...COUNTRY_TARGET_ALIASES,
|
||||
['england', 'united kingdom'],
|
||||
['scotland', 'united kingdom'],
|
||||
['wales', 'united kingdom'],
|
||||
['northern ireland', 'united kingdom'],
|
||||
]);
|
||||
|
||||
/** County-equivalent words, longest first so "city and borough" wins. */
|
||||
const COUNTY_WORDS = [
|
||||
'city and borough',
|
||||
'planning region',
|
||||
'census area',
|
||||
'municipality',
|
||||
'municipio',
|
||||
'borough',
|
||||
'parish',
|
||||
'county',
|
||||
];
|
||||
const COUNTY_WORD_RE = new RegExp(`\\b(${COUNTY_WORDS.join('|')})\\b`);
|
||||
const STATE_PREFIX_RE =
|
||||
/^(?:free state|state|commonwealth|province|territory|prefecture|region|land|canton|department) of (.+)$/;
|
||||
const STATE_SUFFIX_RE = /^(.+) (?:state|province|prefecture|territory)$/;
|
||||
|
||||
/**
|
||||
* Split an ask into what it names and how.
|
||||
* @param {string} text
|
||||
* @returns {{kind:'country'|'county'|'state'|'any', name:string, qualifiers:string[],
|
||||
* postal:string|null, countyWord:string|null}|null}
|
||||
*/
|
||||
export function parseAdminQuery(text) {
|
||||
const raw = String(text || '').trim();
|
||||
if (!raw) return null;
|
||||
const segments = raw.split(',').map(normalizeAdminName).filter(Boolean);
|
||||
if (!segments.length) return null;
|
||||
const head = segments[0];
|
||||
const country =
|
||||
/^(?:country|nation) of (.+)$/.exec(head) ||
|
||||
/^(.+) (?:country|nation)$/.exec(head);
|
||||
if (country)
|
||||
return {
|
||||
kind: 'country',
|
||||
name: country[1],
|
||||
qualifiers: segments.slice(1),
|
||||
postal: null,
|
||||
countyWord: null,
|
||||
full: head,
|
||||
};
|
||||
const qualifiers = segments.slice(1);
|
||||
const postal = /^[A-Za-z]{2}$/.test(raw) ? raw.toLowerCase() : null;
|
||||
// "TX", "DC", "D.C.": written as an abbreviation, not a short word.
|
||||
const abbreviation = /^[A-Z]{2,3}$/.test(raw.replace(/[.\s]/g, ''));
|
||||
|
||||
// "County Cork", "County of Los Angeles"
|
||||
const countyPrefix = /^county (?:of )?(.+)$/.exec(head);
|
||||
if (countyPrefix)
|
||||
return {
|
||||
kind: 'county',
|
||||
name: countyPrefix[1],
|
||||
qualifiers,
|
||||
postal: null,
|
||||
countyWord: 'county',
|
||||
full: head,
|
||||
};
|
||||
// "Travis County", "Travis County Texas", "Orleans Parish, LA"
|
||||
const county = COUNTY_WORD_RE.exec(head);
|
||||
if (county && county.index > 0) {
|
||||
const name = head.slice(0, county.index).trim();
|
||||
const rest = head.slice(county.index + county[1].length).trim();
|
||||
if (rest) qualifiers.unshift(rest);
|
||||
return {
|
||||
kind: 'county',
|
||||
name,
|
||||
qualifiers,
|
||||
postal: null,
|
||||
countyWord: county[1],
|
||||
full: head.slice(0, county.index + county[1].length),
|
||||
};
|
||||
}
|
||||
const prefix = STATE_PREFIX_RE.exec(head);
|
||||
if (prefix)
|
||||
return {
|
||||
kind: 'state',
|
||||
name: prefix[1],
|
||||
qualifiers,
|
||||
postal: null,
|
||||
countyWord: null,
|
||||
full: head,
|
||||
form: 'prefix',
|
||||
};
|
||||
const suffix = STATE_SUFFIX_RE.exec(head);
|
||||
if (suffix)
|
||||
return {
|
||||
kind: 'state',
|
||||
name: suffix[1],
|
||||
qualifiers,
|
||||
postal: null,
|
||||
countyWord: null,
|
||||
full: head,
|
||||
form: 'suffix',
|
||||
};
|
||||
return {
|
||||
kind: 'any',
|
||||
name: head,
|
||||
qualifiers,
|
||||
postal,
|
||||
abbreviation,
|
||||
countyWord: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** A qualifier names the unit's country ("Bavaria, Germany") or the unit
|
||||
* itself ("Washington, DC"). */
|
||||
function qualifierMatches(entry, qualifier) {
|
||||
const q = COUNTRY_ALIASES.get(qualifier) || qualifier;
|
||||
return (
|
||||
entry.country === q ||
|
||||
entry.iso2 === q ||
|
||||
entry.keys.has(qualifier) ||
|
||||
entry.keys.has(`postal:${qualifier}`)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rank candidates: a unit containing `near` first, then the match tier,
|
||||
* then distance from `near`, then larger area. Returns the ranked list.
|
||||
*/
|
||||
function rank(candidates, near) {
|
||||
const hasNear = Number.isFinite(near?.lat) && Number.isFinite(near?.lon);
|
||||
const scored = candidates.map(({ entry, tier }) => {
|
||||
const geometry = geometryOf(entry);
|
||||
const contains = hasNear
|
||||
? polygonsContain(geometry.polygons, near.lat, near.lon)
|
||||
: false;
|
||||
const distanceKm = hasNear
|
||||
? contains
|
||||
? 0
|
||||
: boxDistanceKm(geometry.bbox, near.lat, near.lon)
|
||||
: 0;
|
||||
return { entry, tier, contains, distanceKm, areaKm2: geometry.areaKm2 };
|
||||
});
|
||||
scored.sort(
|
||||
(a, b) =>
|
||||
Number(b.contains) - Number(a.contains) ||
|
||||
a.tier - b.tier ||
|
||||
a.distanceKm - b.distanceKm ||
|
||||
b.areaKm2 - a.areaKm2,
|
||||
);
|
||||
return scored;
|
||||
}
|
||||
|
||||
function toResult(scored, candidateCount) {
|
||||
const { entry } = scored;
|
||||
const { feature } = entry;
|
||||
const geometry = geometryOf(entry);
|
||||
// Parts are stored largest first, so polygons[0][0] is the main outline.
|
||||
const polygons = geometry.polygons;
|
||||
if (entry.kind === 'county') {
|
||||
return {
|
||||
kind: 'county',
|
||||
name: feature.full,
|
||||
region: feature.state,
|
||||
regionCode: feature.st,
|
||||
country: 'United States of America',
|
||||
id: feature.geoid,
|
||||
source: 'us-census',
|
||||
polygons,
|
||||
ring: polygons[0][0],
|
||||
bbox: geometry.bbox,
|
||||
areaKm2: geometry.areaKm2,
|
||||
label: geometry.label,
|
||||
candidates: candidateCount,
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: entry.kind,
|
||||
name: feature.nameEn || feature.name,
|
||||
localName: feature.name,
|
||||
type: feature.type,
|
||||
region: null,
|
||||
regionCode: feature.postal || null,
|
||||
country: feature.country,
|
||||
id: feature.iso || null,
|
||||
source: 'natural-earth',
|
||||
polygons,
|
||||
ring: polygons[0][0],
|
||||
bbox: geometry.bbox,
|
||||
areaKm2: geometry.areaKm2,
|
||||
label: geometry.label,
|
||||
candidates: candidateCount,
|
||||
};
|
||||
}
|
||||
|
||||
async function countyCandidates(parsed) {
|
||||
const { byFull, byName, states } = await loadCounties();
|
||||
// An exact full name ("Orleans Parish") wins outright; without one, the
|
||||
// name with ANY county-equivalent word ("Anchorage Borough" finds
|
||||
// Anchorage Municipality). An independent city is only ever its full name
|
||||
// ("Fairfax city", "Carson City").
|
||||
let list = parsed.countyWord
|
||||
? [
|
||||
...(byFull.get(parsed.full || `${parsed.name} ${parsed.countyWord}`) ||
|
||||
byName.get(parsed.name) ||
|
||||
[]),
|
||||
]
|
||||
: [...(byFull.get(parsed.name) || [])];
|
||||
if (!list.length) return [];
|
||||
// Every qualifier must be a state or the US: "Travis County, TX, USA"
|
||||
// qualifies, "Travis County Courthouse, Texas" names something else.
|
||||
const stateByCode = new Map([...states].map(([name, st]) => [st, name]));
|
||||
const stateQualifiers = [];
|
||||
for (const q of parsed.qualifiers) {
|
||||
const state = states.has(q) ? q : stateByCode.get(q);
|
||||
if (state) stateQualifiers.push(state);
|
||||
else if (!isUnitedStates(q)) return [];
|
||||
}
|
||||
if (stateQualifiers.length)
|
||||
list = list.filter((i) => stateQualifiers.includes(i.entry.state));
|
||||
return list;
|
||||
}
|
||||
|
||||
function isUnitedStates(qualifier) {
|
||||
return (
|
||||
(COUNTRY_ALIASES.get(qualifier) || qualifier) === 'united states of america'
|
||||
);
|
||||
}
|
||||
|
||||
async function stateCandidates(parsed, { allowAmbiguous = false } = {}) {
|
||||
const { index } = await loadAdmin1();
|
||||
const keys = [];
|
||||
if (parsed.full) keys.push(parsed.full);
|
||||
keys.push(parsed.name);
|
||||
if (parsed.postal) keys.push(`postal:${parsed.postal}`);
|
||||
const seen = new Set();
|
||||
let list = [];
|
||||
for (const key of keys) {
|
||||
for (const item of index.get(key) || []) {
|
||||
if (seen.has(item.entry)) continue;
|
||||
seen.add(item.entry);
|
||||
list.push({ ...item, key, tier: key === parsed.full ? 0 : item.tier });
|
||||
}
|
||||
}
|
||||
// Every qualifier must place the unit: "Bavaria, Germany", "Washington,
|
||||
// DC" — not "Santa Barbara, California" (a city the pack does not hold).
|
||||
if (parsed.qualifiers.length)
|
||||
list = list.filter((i) =>
|
||||
parsed.qualifiers.every((q) => qualifierMatches(i.entry, q)),
|
||||
);
|
||||
if (allowAmbiguous) return list;
|
||||
// A bare name answers alone only for a prominent unit — a US state, a
|
||||
// Canadian province, Bavaria — never a French département, an English
|
||||
// county or a Bahamas district ("Long Island", "Santa Barbara", "Kent" are
|
||||
// likelier the places a geocoder knows). It must not also be a country or
|
||||
// a large city ("Georgia", "New York"), and a short code must be written
|
||||
// as one ("TX", not "tx"). Otherwise a qualifier, admin wording or the
|
||||
// geocoder decides.
|
||||
// A country qualifier places a name without making it a state: "New York,
|
||||
// USA" and "Madrid, Spain" stay the geocoder's; "Washington, DC" names the
|
||||
// unit itself.
|
||||
if (parsed.kind === 'any') {
|
||||
const short = parsed.name.replace(/ /g, '').length <= 3;
|
||||
if (short && (!parsed.abbreviation || CITY_ABBREVIATIONS.has(parsed.name)))
|
||||
return [];
|
||||
const namesUnit = (entry) =>
|
||||
parsed.qualifiers.some(
|
||||
(q) => entry.keys.has(q) || entry.keys.has(`postal:${q}`),
|
||||
);
|
||||
if (!parsed.qualifiers.length) {
|
||||
if (GENERIC_NAMES.has(parsed.name)) return [];
|
||||
return list.filter(
|
||||
(i) =>
|
||||
!i.entry.amb.has(i.key) &&
|
||||
!i.entry.ambAbroad.has(i.key) &&
|
||||
((i.entry.feature.rank ?? Infinity) <= BARE_NAME_MAX_RANK ||
|
||||
i.entry.feature.cityState),
|
||||
);
|
||||
}
|
||||
// Qualified: a shared name abroad is settled by the country ("Victoria,
|
||||
// Australia"); one shared at home only by the unit's own name.
|
||||
return list.filter((i) => !i.entry.amb.has(i.key) || namesUnit(i.entry));
|
||||
}
|
||||
// "X State" only settles a name that is ambiguous on its own ("New York
|
||||
// State"); otherwise it is as likely a university ("Ohio State") and a
|
||||
// geocoder decides. A unit literally named so ("Free State") matches.
|
||||
if (parsed.form === 'suffix')
|
||||
return list.filter((i) => i.key === parsed.full || i.entry.amb.has(i.key));
|
||||
return list;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an administrative unit from the ask's words alone.
|
||||
*
|
||||
* @param {string} query e.g. "Texas", "TX", "State of Texas", "Bavaria",
|
||||
* "Bavaria, Germany", "Travis County", "Travis County, TX", "Orleans Parish"
|
||||
* @param {{near?: {lat:number, lon:number}|null}} [options] the camera's
|
||||
* view centre — the tie-break between same-named units
|
||||
* @returns {Promise<AdminArea|null>}
|
||||
*
|
||||
* @typedef {{kind:'country'|'state'|'county', name:string, region:string|null,
|
||||
* regionCode:string|null, country:string, id:string|null,
|
||||
* source:'natural-earth'|'us-census',
|
||||
* polygons:Array<Array<Array<[number,number]>>>, ring:Array<[number,number]>,
|
||||
* bbox:number[], areaKm2:number, label:{lat:number, lon:number},
|
||||
* candidates:number}} AdminArea
|
||||
*/
|
||||
export async function findAdminArea(query, { near = null } = {}) {
|
||||
const parsed = parseAdminQuery(query);
|
||||
if (!parsed || !parsed.name) return null;
|
||||
let candidates = [];
|
||||
if (parsed.kind === 'country') {
|
||||
candidates = await countryCandidates(parsed);
|
||||
} else if (parsed.kind === 'county') {
|
||||
// County wording also names admin-1 units abroad. Neither pack gets
|
||||
// first refusal: qualifiers exclude ineligible countries before ranking.
|
||||
const [counties, admin1] = await Promise.all([
|
||||
countyCandidates(parsed),
|
||||
stateCandidates({ ...parsed, kind: 'state' }),
|
||||
]);
|
||||
candidates = [...counties, ...admin1];
|
||||
} else {
|
||||
candidates = await stateCandidates(parsed);
|
||||
if (!candidates.length || COUNTRY_TARGET_ALIASES.has(parsed.name)) {
|
||||
const countries = await countryCandidates(parsed);
|
||||
if (countries.length) {
|
||||
const states = await stateCandidates(parsed, { allowAmbiguous: true });
|
||||
if (
|
||||
states.length &&
|
||||
!Number.isFinite(near?.lat) &&
|
||||
!parsed.qualifiers.length
|
||||
)
|
||||
return null;
|
||||
candidates = [...countries, ...states];
|
||||
}
|
||||
}
|
||||
// A county-equivalent city: "Baltimore city", "Fairfax city, Virginia".
|
||||
if (!candidates.length && / city$/.test(parsed.name))
|
||||
candidates = await countyCandidates(parsed);
|
||||
}
|
||||
if (!candidates.length) return null;
|
||||
const ranked = rank(candidates, near);
|
||||
return toResult(ranked[0], candidates.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Geocoder-confirmed lookup: the geocoder typed a country, state or
|
||||
* county, so the unit must carry one of `names` AND contain the geocoded
|
||||
* point. Ambiguity marks do not apply — the geocoder already chose.
|
||||
*
|
||||
* @param {string[]} names the ask and the geocoder's own name for it
|
||||
* @param {number} lat
|
||||
* @param {number} lon
|
||||
* @param {'country'|'state'|'county'} scope
|
||||
* @returns {Promise<AdminArea|null>}
|
||||
*/
|
||||
export async function findAdminAreaAt(names, lat, lon, scope) {
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
||||
const parsedList = [...new Set((names || []).filter(Boolean))]
|
||||
.map(parseAdminQuery)
|
||||
.filter((p) => p?.name);
|
||||
if (!parsedList.length) return null;
|
||||
const containing = (items) => {
|
||||
const out = [];
|
||||
for (const item of items)
|
||||
if (
|
||||
!out.some((c) => c.entry === item.entry) &&
|
||||
polygonsContain(geometryOf(item.entry).polygons, lat, lon)
|
||||
)
|
||||
out.push(item);
|
||||
return out;
|
||||
};
|
||||
if (scope === 'country') {
|
||||
const countries = [];
|
||||
for (const parsed of parsedList)
|
||||
countries.push(
|
||||
...(await countryCandidates({
|
||||
...parsed,
|
||||
kind: 'country',
|
||||
qualifiers: [],
|
||||
})),
|
||||
);
|
||||
const found = containing(countries);
|
||||
return found.length
|
||||
? toResult(rank(found, { lat, lon })[0], found.length)
|
||||
: null;
|
||||
}
|
||||
// The geocoder's scope says what kind of unit this is; the ask's wording
|
||||
// may omit it ("Travis" typed as a county). A US county that matches wins
|
||||
// outright — "New York County" is Manhattan, not the state.
|
||||
if (scope === 'county' && inUnitedStatesBox(lat, lon)) {
|
||||
const counties = [];
|
||||
for (const parsed of parsedList)
|
||||
counties.push(
|
||||
...(await countyCandidates({
|
||||
...parsed,
|
||||
kind: 'county',
|
||||
countyWord: parsed.countyWord || 'county',
|
||||
qualifiers: [],
|
||||
})),
|
||||
);
|
||||
const found = containing(counties);
|
||||
if (found.length)
|
||||
return toResult(rank(found, { lat, lon })[0], found.length);
|
||||
}
|
||||
const states = [];
|
||||
for (const parsed of parsedList)
|
||||
states.push(
|
||||
...(await stateCandidates(
|
||||
{ ...parsed, qualifiers: [] },
|
||||
{ allowAmbiguous: true },
|
||||
)),
|
||||
);
|
||||
const found = containing(states);
|
||||
if (!found.length) return null;
|
||||
return toResult(rank(found, { lat, lon })[0], found.length);
|
||||
}
|
||||
|
||||
/** Rough box around the US and its territories (skips the county pack elsewhere). */
|
||||
function inUnitedStatesBox(lat, lon) {
|
||||
return (
|
||||
(lat >= 13 && lat <= 72 && (lon <= -64 || lon >= 144)) ||
|
||||
(lat >= -15 && lat <= -10 && lon <= -168)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
// Bundled state/province and US county boundaries: name matching,
|
||||
// disambiguation, multipolygons and holes, and lazy loading.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, statSync } from 'node:fs';
|
||||
import {
|
||||
decodeRing,
|
||||
findAdminArea,
|
||||
findAdminAreaAt,
|
||||
normalizeAdminName,
|
||||
packLoads,
|
||||
parseAdminQuery,
|
||||
polygonsContain,
|
||||
} from './adminBoundaries.js';
|
||||
|
||||
const ADMIN1 = new URL(
|
||||
'./local_data/natural_earth/states_provinces.json',
|
||||
import.meta.url,
|
||||
);
|
||||
const COUNTIES = new URL(
|
||||
'./local_data/us_census_counties/counties.json',
|
||||
import.meta.url,
|
||||
);
|
||||
|
||||
const AUSTIN = { lat: 30.2672, lon: -97.7431 };
|
||||
const PORTLAND = { lat: 45.5152, lon: -122.6784 };
|
||||
|
||||
// Runs first: nothing loads until a lookup needs it, and a state ask never
|
||||
// pulls the county pack.
|
||||
test('packs load lazily, once, and only the pack an ask needs', async () => {
|
||||
assert.deepEqual(packLoads, { admin1: 0, counties: 0, countries: 0 });
|
||||
assert.equal(parseAdminQuery('Texas').kind, 'any');
|
||||
assert.deepEqual(
|
||||
packLoads,
|
||||
{ admin1: 0, counties: 0, countries: 0 },
|
||||
'parsing is free',
|
||||
);
|
||||
await findAdminArea('Texas');
|
||||
await findAdminArea('Bavaria');
|
||||
assert.deepEqual(packLoads, { admin1: 1, counties: 0, countries: 0 });
|
||||
await findAdminArea('Travis County');
|
||||
await findAdminArea('Orleans Parish');
|
||||
assert.deepEqual(packLoads, { admin1: 1, counties: 1, countries: 0 });
|
||||
});
|
||||
|
||||
test('Texas, TX and "State of Texas" name the same unit', async () => {
|
||||
for (const query of [
|
||||
'Texas',
|
||||
'texas',
|
||||
'TX',
|
||||
'State of Texas',
|
||||
'the State of Texas',
|
||||
'Texas, USA',
|
||||
'Texas, United States',
|
||||
]) {
|
||||
const unit = await findAdminArea(query, { near: AUSTIN });
|
||||
assert.equal(unit?.id, 'US-TX', query);
|
||||
assert.equal(unit.name, 'Texas');
|
||||
assert.equal(unit.kind, 'state');
|
||||
assert.equal(unit.source, 'natural-earth');
|
||||
}
|
||||
const texas = await findAdminArea('Texas');
|
||||
assert.ok(
|
||||
texas.areaKm2 > 600_000 && texas.areaKm2 < 750_000,
|
||||
`Texas area ${texas.areaKm2}`,
|
||||
);
|
||||
assert.ok(polygonsContain(texas.polygons, AUSTIN.lat, AUSTIN.lon));
|
||||
});
|
||||
|
||||
test('provinces and states abroad resolve by English and local names', async () => {
|
||||
for (const query of [
|
||||
'Bavaria',
|
||||
'Bayern',
|
||||
'Bavaria, Germany',
|
||||
'Free State of Bavaria',
|
||||
]) {
|
||||
const unit = await findAdminArea(query);
|
||||
assert.equal(unit?.id, 'DE-BY', query);
|
||||
assert.equal(unit.name, 'Bavaria');
|
||||
assert.equal(unit.country, 'Germany');
|
||||
}
|
||||
assert.equal((await findAdminArea('Ontario'))?.id, 'CA-ON');
|
||||
assert.equal((await findAdminArea('ON'))?.id, 'CA-ON');
|
||||
assert.equal((await findAdminArea('Province of Ontario'))?.id, 'CA-ON');
|
||||
assert.equal((await findAdminArea('Free State'))?.country, 'South Africa');
|
||||
// Same name in two countries: the qualifier decides.
|
||||
assert.equal((await findAdminArea('Punjab, India'))?.id, 'IN-PB');
|
||||
assert.equal((await findAdminArea('Punjab, Pakistan'))?.id, 'PK-PB');
|
||||
// A qualifier may name the unit itself.
|
||||
assert.equal((await findAdminArea('Washington, DC'))?.id, 'US-DC');
|
||||
});
|
||||
|
||||
test('"Washington" is the state; "Washington County" is a county near the camera', async () => {
|
||||
const state = await findAdminArea('Washington', { near: AUSTIN });
|
||||
assert.equal(state?.id, 'US-WA');
|
||||
const fromAustin = await findAdminArea('Washington County', { near: AUSTIN });
|
||||
assert.equal(fromAustin.kind, 'county');
|
||||
assert.equal(fromAustin.name, 'Washington County');
|
||||
assert.equal(fromAustin.region, 'Texas');
|
||||
assert.ok(fromAustin.candidates > 20, 'dozens of Washington Counties');
|
||||
const fromPortland = await findAdminArea('Washington County', {
|
||||
near: PORTLAND,
|
||||
});
|
||||
assert.equal(fromPortland.region, 'Oregon');
|
||||
const arkansas = await findAdminArea('Washington County, Arkansas', {
|
||||
near: PORTLAND,
|
||||
});
|
||||
assert.equal(arkansas.region, 'Arkansas');
|
||||
assert.equal(arkansas.candidates, 1);
|
||||
assert.equal(
|
||||
(await findAdminArea('Washington County, AR'))?.region,
|
||||
'Arkansas',
|
||||
);
|
||||
});
|
||||
|
||||
test('Travis County with and without its state', async () => {
|
||||
for (const query of [
|
||||
'Travis County',
|
||||
'Travis County, Texas',
|
||||
'Travis County, TX',
|
||||
'Travis County Texas',
|
||||
'travis county, texas, usa',
|
||||
]) {
|
||||
const unit = await findAdminArea(query, { near: PORTLAND });
|
||||
assert.equal(unit?.id, '48453', query);
|
||||
assert.equal(unit.name, 'Travis County');
|
||||
assert.equal(unit.region, 'Texas');
|
||||
assert.equal(unit.source, 'us-census');
|
||||
}
|
||||
assert.equal(await findAdminArea('Travis County, Ohio'), null);
|
||||
assert.equal(await findAdminArea('Travis County Courthouse'), null);
|
||||
// Every qualifier must place the county, not name something in it.
|
||||
assert.equal(await findAdminArea('Travis County Courthouse, Texas'), null);
|
||||
assert.equal(
|
||||
await findAdminArea('Orange County Great Park, California'),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('county equivalents: parishes, boroughs, independent cities, and non-US counties', async () => {
|
||||
const orleans = await findAdminArea('Orleans Parish', { near: AUSTIN });
|
||||
assert.equal(orleans?.id, '22071');
|
||||
assert.equal(orleans.region, 'Louisiana');
|
||||
assert.equal((await findAdminArea('Orleans Parish, LA'))?.id, '22071');
|
||||
// The wrong county word still finds the only unit with that name.
|
||||
assert.equal(
|
||||
(await findAdminArea('Anchorage Borough'))?.name,
|
||||
'Anchorage Municipality',
|
||||
);
|
||||
// Outside the US a county is an admin-1 unit.
|
||||
assert.equal((await findAdminArea('County Cork'))?.country, 'Ireland');
|
||||
assert.equal(
|
||||
(await findAdminArea('Kent County, England'))?.country,
|
||||
'United Kingdom',
|
||||
);
|
||||
assert.equal(
|
||||
(await findAdminArea('Kent County', { near: AUSTIN }))?.region,
|
||||
'Texas',
|
||||
);
|
||||
});
|
||||
|
||||
test('a bare name that is also a country or a large city is left to a geocoder', async () => {
|
||||
for (const query of ['New York', 'Georgia', 'Madrid']) {
|
||||
assert.equal(await findAdminArea(query), null, query);
|
||||
}
|
||||
// Admin wording settles it.
|
||||
assert.equal((await findAdminArea('New York State'))?.id, 'US-NY');
|
||||
assert.equal((await findAdminArea('State of New York'))?.id, 'US-NY');
|
||||
assert.equal((await findAdminArea('State of Mexico'))?.country, 'Mexico');
|
||||
// City-states are the city itself.
|
||||
assert.equal((await findAdminArea('Berlin'))?.id, 'DE-BE');
|
||||
assert.equal((await findAdminArea('Vienna'))?.country, 'Austria');
|
||||
// A country qualifier places a name; it does not make a city a state.
|
||||
assert.equal(await findAdminArea('New York, USA'), null);
|
||||
assert.equal(await findAdminArea('Madrid, Spain'), null);
|
||||
// A namesake city abroad: ambiguous alone, settled by naming the country.
|
||||
const santaFe = { lat: 35.687, lon: -105.938 };
|
||||
assert.equal(await findAdminArea('Santa Fe', { near: santaFe }), null);
|
||||
assert.equal(await findAdminArea('Victoria'), null);
|
||||
assert.equal((await findAdminArea('Victoria, Australia'))?.id, 'AU-VIC');
|
||||
});
|
||||
|
||||
test('bare names: only prominent units, no generic words, codes as codes', async () => {
|
||||
// Places a geocoder knows better than a small admin unit of that name.
|
||||
for (const query of [
|
||||
'Long Island',
|
||||
'Santa Barbara',
|
||||
'Kent',
|
||||
'Hamilton',
|
||||
'Milan',
|
||||
])
|
||||
assert.equal(await findAdminArea(query, { near: AUSTIN }), null, query);
|
||||
for (const query of ['North', 'Central', 'Capital', 'Lakes'])
|
||||
assert.equal(await findAdminArea(query), null, query);
|
||||
// Two-letter codes must be written as codes, and not the city ones.
|
||||
assert.equal(await findAdminArea('tx'), null);
|
||||
assert.equal(
|
||||
await findAdminArea('LA', { near: { lat: 34.05, lon: -118.24 } }),
|
||||
null,
|
||||
);
|
||||
assert.equal(await findAdminArea('NY'), null);
|
||||
assert.equal((await findAdminArea('OK'))?.id, 'US-OK');
|
||||
});
|
||||
|
||||
test('non-admin asks never match', async () => {
|
||||
for (const query of [
|
||||
'Texas Capitol',
|
||||
'the Texas State Capitol',
|
||||
'Zilker Park',
|
||||
'Ohio State',
|
||||
'Texas State',
|
||||
'Alps',
|
||||
'',
|
||||
null,
|
||||
]) {
|
||||
assert.equal(await findAdminArea(query), null, String(query));
|
||||
}
|
||||
});
|
||||
|
||||
test('geocoder-confirmed lookup: the named unit that contains the point', async () => {
|
||||
// Atlanta — the geocoder said "state", so the US state wins over the country.
|
||||
const georgia = await findAdminAreaAt(['Georgia'], 33.749, -84.388, 'state');
|
||||
assert.equal(georgia?.id, 'US-GA');
|
||||
// Tbilisi — Georgia the country has no admin-1 unit of that name.
|
||||
assert.equal(await findAdminAreaAt(['Georgia'], 41.7, 44.8, 'state'), null);
|
||||
const newYork = await findAdminAreaAt(['New York'], 42.9, -75.5, 'state');
|
||||
assert.equal(newYork?.id, 'US-NY');
|
||||
// A county the geocoder typed, named without the county word.
|
||||
const travis = await findAdminAreaAt(
|
||||
['Travis', 'Travis County'],
|
||||
30.33,
|
||||
-97.78,
|
||||
'county',
|
||||
);
|
||||
assert.equal(travis?.id, '48453');
|
||||
// A county the geocoder typed stays a county even where a state shares
|
||||
// its name: Manhattan, the Big Island, Arkansas County.
|
||||
assert.equal(
|
||||
(await findAdminAreaAt(['New York County'], 40.783, -73.966, 'county'))?.id,
|
||||
'36061',
|
||||
);
|
||||
assert.equal(
|
||||
(await findAdminAreaAt(['Hawaii County'], 19.6, -155.5, 'county'))?.name,
|
||||
'Hawaii County',
|
||||
);
|
||||
// A geocoded point outside the named unit is not that unit.
|
||||
assert.equal(await findAdminAreaAt(['Texas'], 35.0, -106.6, 'state'), null);
|
||||
// Outside the US a geocoder "county" is an admin-1 unit (Kent).
|
||||
assert.equal(
|
||||
(await findAdminAreaAt(['Kent'], 51.2, 0.7, 'county'))?.country,
|
||||
'United Kingdom',
|
||||
);
|
||||
});
|
||||
|
||||
test('multipolygons: every island is a part, and containment follows the parts', async () => {
|
||||
const hawaii = await findAdminArea('Hawaii');
|
||||
assert.ok(
|
||||
hawaii.polygons.length >= 7,
|
||||
`Hawaii parts ${hawaii.polygons.length}`,
|
||||
);
|
||||
assert.ok(polygonsContain(hawaii.polygons, 19.6, -155.5), 'Big Island');
|
||||
assert.ok(polygonsContain(hawaii.polygons, 20.8, -156.3), 'Maui');
|
||||
assert.ok(polygonsContain(hawaii.polygons, 21.45, -158.0), 'Oahu');
|
||||
assert.ok(
|
||||
!polygonsContain(hawaii.polygons, 21.0, -157.3),
|
||||
'the channel between',
|
||||
);
|
||||
// The main ring is the largest part (the Big Island).
|
||||
assert.equal(hawaii.ring, hawaii.polygons[0][0]);
|
||||
|
||||
// Alaska is cut at the antimeridian: its box stays on one side of the globe.
|
||||
const alaska = await findAdminArea('Alaska');
|
||||
const [west, , east] = alaska.bbox;
|
||||
assert.ok(east - west < 60, `Alaska box width ${east - west}°`);
|
||||
assert.ok(alaska.polygons.length > 10, 'Aleutians and the southeast islands');
|
||||
});
|
||||
|
||||
test('holes: Berlin is cut out of Brandenburg', async () => {
|
||||
const brandenburg = await findAdminArea('Brandenburg');
|
||||
assert.ok(
|
||||
brandenburg.polygons.some((part) => part.length > 1),
|
||||
'Brandenburg has a hole',
|
||||
);
|
||||
assert.ok(!polygonsContain(brandenburg.polygons, 52.52, 13.405), 'Berlin');
|
||||
assert.ok(polygonsContain(brandenburg.polygons, 52.39, 13.06), 'Potsdam');
|
||||
const berlin = await findAdminArea('Berlin');
|
||||
assert.ok(polygonsContain(berlin.polygons, 52.52, 13.405));
|
||||
});
|
||||
|
||||
test('holes: an independent city is cut out of its county', async () => {
|
||||
const fairfax = await findAdminArea('Fairfax County, Virginia');
|
||||
assert.ok(fairfax.polygons[0].length > 1, 'Fairfax County has holes');
|
||||
assert.ok(
|
||||
!polygonsContain(fairfax.polygons, 38.846, -77.306),
|
||||
'Fairfax city',
|
||||
);
|
||||
assert.ok(polygonsContain(fairfax.polygons, 38.9, -77.2), 'Fairfax County');
|
||||
const city = await findAdminArea('Fairfax city, Virginia');
|
||||
assert.ok(polygonsContain(city.polygons, 38.846, -77.306));
|
||||
assert.equal((await findAdminArea('Carson City'))?.region, 'Nevada');
|
||||
for (const query of ['New York City', 'Mexico City', 'Quebec City, Canada'])
|
||||
assert.equal(await findAdminArea(query), null, query);
|
||||
});
|
||||
|
||||
test('rings decode from deltas and are open', () => {
|
||||
assert.deepEqual(decodeRing([-97743, 30267, 10, -5, -3, 8], 3), [
|
||||
[-97.743, 30.267],
|
||||
[-97.733, 30.262],
|
||||
[-97.736, 30.27],
|
||||
]);
|
||||
});
|
||||
|
||||
test('names normalize across accents, punctuation and "Saint"', () => {
|
||||
assert.equal(normalizeAdminName('Québec'), 'quebec');
|
||||
assert.equal(normalizeAdminName(' The State of TEXAS '), 'state of texas');
|
||||
assert.equal(normalizeAdminName('Saint Louis County'), 'st louis county');
|
||||
assert.equal(normalizeAdminName('St. Louis County'), 'st louis county');
|
||||
assert.equal(normalizeAdminName('Schleswig-Holstein'), 'schleswig holstein');
|
||||
});
|
||||
|
||||
test('packs: public-domain provenance, schema, and size budgets', () => {
|
||||
const admin1 = JSON.parse(readFileSync(ADMIN1, 'utf8'));
|
||||
const counties = JSON.parse(readFileSync(COUNTIES, 'utf8'));
|
||||
for (const pack of [admin1, counties]) {
|
||||
assert.match(pack.meta.license, /public domain/i);
|
||||
assert.match(pack.meta.sha256, /^[0-9a-f]{64}$/);
|
||||
assert.equal(pack.meta.script, 'scripts/build-admin-packs.mjs');
|
||||
for (const feature of pack.features) {
|
||||
assert.ok(feature.name, 'named');
|
||||
assert.ok(feature.polygons.length >= 1, `${feature.name} has a part`);
|
||||
for (const poly of feature.polygons)
|
||||
for (const ring of poly) {
|
||||
assert.equal(ring.length % 2, 0);
|
||||
assert.ok(ring.length >= 8, `${feature.name}: ring has 4+ vertices`);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.ok(
|
||||
admin1.features.length > 4500,
|
||||
`${admin1.features.length} states/provinces`,
|
||||
);
|
||||
assert.ok(
|
||||
counties.features.length > 3200,
|
||||
`${counties.features.length} counties`,
|
||||
);
|
||||
assert.ok(statSync(ADMIN1).size <= 4 * 1024 * 1024, 'admin-1 pack ≤ 4 MB');
|
||||
assert.ok(statSync(COUNTIES).size <= 2 * 1024 * 1024, 'county pack ≤ 2 MB');
|
||||
});
|
||||
|
||||
test('county queries compare US and international names before choosing a geography', async () => {
|
||||
const england = { lat: 54.78, lon: -1.58 };
|
||||
const nc = { lat: 36.0, lon: -78.9 };
|
||||
assert.equal(
|
||||
(await findAdminArea('County Durham', { near: england }))?.country,
|
||||
'United Kingdom',
|
||||
);
|
||||
assert.equal(
|
||||
(await findAdminArea('County Durham'))?.country,
|
||||
'United Kingdom',
|
||||
'exact alias survives parsing',
|
||||
);
|
||||
assert.equal(
|
||||
(await findAdminArea('Durham County', { near: nc }))?.region,
|
||||
'North Carolina',
|
||||
);
|
||||
assert.equal(
|
||||
(await findAdminArea('County Durham, England', { near: nc }))?.country,
|
||||
'United Kingdom',
|
||||
);
|
||||
assert.equal(
|
||||
(await findAdminArea('County Durham, USA', { near: england }))?.region,
|
||||
'North Carolina',
|
||||
);
|
||||
assert.equal(
|
||||
(await findAdminArea('Durham County, NC', { near: england }))?.region,
|
||||
'North Carolina',
|
||||
);
|
||||
// Durham is also a city: without county wording leave city geocoding intact.
|
||||
assert.equal(await findAdminArea('Durham, NC', { near: england }), null);
|
||||
assert.equal(
|
||||
(await findAdminAreaAt(['Durham, NC'], nc.lat, nc.lon, 'county'))?.region,
|
||||
'North Carolina',
|
||||
);
|
||||
});
|
||||
|
||||
test('countries resolve aliases, keep substantial islands and disambiguate Georgia by scope and geography', async () => {
|
||||
for (const names of [
|
||||
['USA', 'United States', 'America'],
|
||||
['UK', 'United Kingdom', 'Britain'],
|
||||
['Iran', 'Islamic Republic of Iran'],
|
||||
['Czechia', 'Czech Republic'],
|
||||
['Ivory Coast', "Côte d'Ivoire"],
|
||||
['South Korea', 'Korea'],
|
||||
['Russia', 'Russian Federation'],
|
||||
['Switzerland'],
|
||||
['France'],
|
||||
]) {
|
||||
const resolved = await Promise.all(
|
||||
names.map((name) => findAdminArea(name, { near: AUSTIN })),
|
||||
);
|
||||
assert.ok(
|
||||
resolved.every((r) => r?.kind === 'country'),
|
||||
names.join('/'),
|
||||
);
|
||||
assert.equal(new Set(resolved.map((r) => r.id)).size, 1);
|
||||
}
|
||||
assert.equal(
|
||||
(await findAdminArea('Georgia', { near: AUSTIN })).kind,
|
||||
'state',
|
||||
);
|
||||
assert.equal(
|
||||
(await findAdminArea('Georgia', { near: { lat: 41.7, lon: 44.8 } })).kind,
|
||||
'country',
|
||||
);
|
||||
assert.equal(
|
||||
(await findAdminArea('the country of Georgia', { near: AUSTIN })).kind,
|
||||
'country',
|
||||
);
|
||||
assert.equal(await findAdminArea('New York', { near: AUSTIN }), null);
|
||||
assert.equal(
|
||||
(await findAdminAreaAt(['Iran'], 35.69, 51.39, 'country')).kind,
|
||||
'country',
|
||||
);
|
||||
assert.equal(
|
||||
await findAdminAreaAt(['Iran'], 46.9, 7.4, 'country'),
|
||||
null,
|
||||
'name alone cannot override a mismatched geocoded point',
|
||||
);
|
||||
const japan = await findAdminArea('Japan');
|
||||
for (const [lat, lon] of [
|
||||
[35.68, 139.69],
|
||||
[43.06, 141.35],
|
||||
[33.59, 130.4],
|
||||
[33.56, 133.53],
|
||||
])
|
||||
assert.ok(polygonsContain(japan.polygons, lat, lon));
|
||||
const usa = await findAdminArea('USA');
|
||||
for (const [lat, lon] of [
|
||||
[30.27, -97.74],
|
||||
[61.22, -149.9],
|
||||
[19.7, -155.1],
|
||||
])
|
||||
assert.ok(
|
||||
polygonsContain(usa.polygons, lat, lon),
|
||||
'USA includes mainland, Alaska and Hawaii',
|
||||
);
|
||||
const bytes = statSync(
|
||||
new URL('./local_data/natural_earth/countries.json', import.meta.url),
|
||||
).size;
|
||||
assert.ok(bytes < 1_000_000);
|
||||
});
|
||||
|
||||
test('UK constituent countries retain their own identity and extent from any camera', async () => {
|
||||
const targets = [
|
||||
['Scotland', 'SCT', [55.95, -3.19], [54.6, 61]],
|
||||
['England', 'ENG', [51.5, -0.12], [49.8, 55.9]],
|
||||
['Wales', 'WLS', [51.48, -3.18], [51.3, 53.6]],
|
||||
['Northern Ireland', 'NIR', [54.6, -5.93], [53.9, 55.4]],
|
||||
];
|
||||
for (const [name, id, point, extent] of targets) {
|
||||
const unit = await findAdminArea(name, { near: AUSTIN });
|
||||
assert.equal(unit.id, id);
|
||||
assert.equal(unit.name, name);
|
||||
assert.equal(unit.kind, 'country');
|
||||
assert.ok(
|
||||
unit.bbox[1] >= extent[0] && unit.bbox[3] <= extent[1],
|
||||
`${name}: ${unit.bbox}`,
|
||||
);
|
||||
assert.ok(polygonsContain(unit.polygons, ...point));
|
||||
for (const [other, , otherPoint] of targets)
|
||||
if (other !== name)
|
||||
assert.equal(polygonsContain(unit.polygons, ...otherPoint), false);
|
||||
assert.equal((await findAdminAreaAt([name], ...point, 'country')).id, id);
|
||||
assert.equal(await findAdminAreaAt([name], 30.27, -97.74, 'country'), null);
|
||||
}
|
||||
assert.equal((await findAdminArea('UK', { near: AUSTIN })).id, 'GBR');
|
||||
assert.equal(
|
||||
(await findAdminArea('County Durham, England', { near: AUSTIN })).country,
|
||||
'United Kingdom',
|
||||
);
|
||||
});
|
||||
@@ -1,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();
|
||||
}
|
||||
});
|
||||
@@ -7,6 +7,8 @@ import { governorRequestRender } from '../renderGovernor.js';
|
||||
import {
|
||||
BHOTE_KOSHI_LOCATOR_CREDIT,
|
||||
registerDynamicCredit,
|
||||
showOsmCredit,
|
||||
hideOsmCredit,
|
||||
} from './dataCredits.js';
|
||||
import { BHOTE_KOSHI_FLOOD_PATH } from './bhoteKoshiFloodPath.js';
|
||||
import { INCIDENT_OVERVIEW_PLACES } from './bhoteKoshiIncidentPlaces.js';
|
||||
@@ -1035,6 +1037,7 @@ export function createBhoteKoshiLocatorLayer({
|
||||
renderPresentation();
|
||||
viewer.dataSources.add(dataSource);
|
||||
registerDynamicCredit(viewer, BHOTE_KOSHI_LOCATOR_CREDIT);
|
||||
showOsmCredit(viewer, BHOTE_KOSHI_LOCATOR_LAYER_ID);
|
||||
requestRender('bhote-koshi-locator-callout');
|
||||
|
||||
try {
|
||||
@@ -1070,6 +1073,7 @@ export function createBhoteKoshiLocatorLayer({
|
||||
}
|
||||
|
||||
async function disable() {
|
||||
hideOsmCredit(_viewer, BHOTE_KOSHI_LOCATOR_LAYER_ID);
|
||||
_enabled = false;
|
||||
_boundaryReady = false;
|
||||
_boundaryResolved = false;
|
||||
|
||||
@@ -27,7 +27,7 @@ function viewerFixture() {
|
||||
const added = [];
|
||||
const removed = [];
|
||||
return {
|
||||
creditDisplay: { addStaticCredit() {} },
|
||||
creditDisplay: { addStaticCredit() {}, removeStaticCredit() {} },
|
||||
dataSources: {
|
||||
add(source) { added.push(source); },
|
||||
remove(source) { removed.push(source); },
|
||||
|
||||
@@ -19,9 +19,10 @@
|
||||
* `import('./….json', { with: { type: 'json' } })`, used only under Node.
|
||||
* @returns {Promise<any>} The parsed JSON.
|
||||
*/
|
||||
export async function loadBundledJson(url, importJson) {
|
||||
export async function loadBundledJson(url, importJson, { signal } = {}) {
|
||||
if (url.protocol === 'file:') return (await importJson()).default;
|
||||
const response = await fetch(url);
|
||||
signal?.throwIfAborted();
|
||||
const response = await fetch(url, { signal });
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status} for ${url.pathname}`);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
+86
-54
@@ -25,6 +25,72 @@ import * as Cesium from 'cesium';
|
||||
* follows DATA_SOURCES.md (live sources, then bundled snapshots).
|
||||
* @type {{ key: string, html: string }[]}
|
||||
*/
|
||||
export const OSM_CREDIT = {
|
||||
key: 'openstreetmap',
|
||||
html: 'Map and place data <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> (<a href="https://opendatacommons.org/licenses/odbl/1-0/" target="_blank" rel="noopener">ODbL</a>)',
|
||||
};
|
||||
export const OPENMAPTILES_CREDIT = {
|
||||
key: 'openfreemap',
|
||||
html: 'Vector tiles: <a href="https://openfreemap.org" target="_blank" rel="noopener">OpenFreeMap</a> © <a href="https://openmaptiles.org" target="_blank" rel="noopener">OpenMapTiles</a>',
|
||||
};
|
||||
|
||||
const osmDisplays = new WeakMap();
|
||||
|
||||
function updateOsmDisplay(viewer, state) {
|
||||
const osm = state.owners.size > 0;
|
||||
let tiles = false;
|
||||
for (const usesTiles of state.owners.values()) tiles ||= usesTiles;
|
||||
for (const [key, visible] of [
|
||||
['osm', osm],
|
||||
['tiles', tiles],
|
||||
]) {
|
||||
if (state[key].visible === visible) continue;
|
||||
state[key].visible = visible;
|
||||
viewer.creditDisplay[visible ? 'addStaticCredit' : 'removeStaticCredit'](
|
||||
state[key].credit,
|
||||
);
|
||||
viewer.scene?.requestRender?.();
|
||||
}
|
||||
}
|
||||
|
||||
/** Keep one short inline credit while an OSM display owner has visible data. */
|
||||
export function showOsmCredit(viewer, owner, { openMapTiles = false } = {}) {
|
||||
if (!viewer?.creditDisplay?.addStaticCredit || !owner) return false;
|
||||
let state = osmDisplays.get(viewer);
|
||||
if (!state) {
|
||||
state = {
|
||||
owners: new Map(),
|
||||
osm: {
|
||||
visible: false,
|
||||
credit: new Cesium.Credit(
|
||||
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap</a>',
|
||||
true,
|
||||
),
|
||||
},
|
||||
tiles: {
|
||||
visible: false,
|
||||
credit: new Cesium.Credit(
|
||||
'<a href="https://openmaptiles.org" target="_blank" rel="noopener">© OpenMapTiles</a>',
|
||||
true,
|
||||
),
|
||||
},
|
||||
};
|
||||
osmDisplays.set(viewer, state);
|
||||
}
|
||||
if (state.owners.get(owner) === openMapTiles) return false;
|
||||
state.owners.set(owner, openMapTiles);
|
||||
updateOsmDisplay(viewer, state);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Release only this owner's credit; other displayed OSM sources retain theirs. */
|
||||
export function hideOsmCredit(viewer, owner) {
|
||||
const state = osmDisplays.get(viewer);
|
||||
if (!state?.owners.delete(owner)) return false;
|
||||
updateOsmDisplay(viewer, state);
|
||||
return true;
|
||||
}
|
||||
|
||||
export const DATA_CREDITS = [
|
||||
// ── Live sources ────────────────────────────────────────────────
|
||||
{
|
||||
@@ -94,42 +160,15 @@ export const DATA_CREDITS = [
|
||||
' · Incident information: ' +
|
||||
'<a href="https://inciweb.wildfire.gov/" target="_blank" rel="noopener">InciWeb</a>',
|
||||
},
|
||||
OSM_CREDIT,
|
||||
OPENMAPTILES_CREDIT,
|
||||
{
|
||||
key: 'overpass',
|
||||
html:
|
||||
'Road geometry (traffic): ' +
|
||||
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> ' +
|
||||
'(ODbL 1.0)',
|
||||
key: 'overture-military-names',
|
||||
html: 'Military area names: <a href="https://overturemaps.org" target="_blank" rel="noopener">Overture Maps Foundation</a> (ODbL)',
|
||||
},
|
||||
{
|
||||
key: 'photon-geocoder',
|
||||
html:
|
||||
'Keyless place search: ' +
|
||||
'<a href="https://photon.komoot.io" target="_blank" rel="noopener">Photon</a> (komoot) over ' +
|
||||
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> ' +
|
||||
'(ODbL 1.0)',
|
||||
},
|
||||
{
|
||||
key: 'alpr-osm',
|
||||
html:
|
||||
'ALPR camera locations (automatic license plate readers): ' +
|
||||
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> ' +
|
||||
'(<a href="https://opendatacommons.org/licenses/odbl/1-0/" target="_blank" rel="noopener">ODbL 1.0</a>); ' +
|
||||
'community mapping includes <a href="https://deflock.org" target="_blank" rel="noopener">DeFlock</a>',
|
||||
},
|
||||
{
|
||||
key: 'military-installations-osm',
|
||||
html:
|
||||
'Mapped installation context: ' +
|
||||
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> ' +
|
||||
'(ODbL 1.0; incomplete mapped context)',
|
||||
},
|
||||
{
|
||||
key: 'cockpit-place-osm',
|
||||
html:
|
||||
'Cockpit place context and last-resort place search: ' +
|
||||
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> ' +
|
||||
'via Nominatim (ODbL 1.0)',
|
||||
html: 'Keyless place search: <a href="https://photon.komoot.io" target="_blank" rel="noopener">Photon</a> (komoot)',
|
||||
},
|
||||
{
|
||||
key: 'open-meteo',
|
||||
@@ -212,7 +251,6 @@ export const DATA_CREDITS = [
|
||||
html:
|
||||
'Routing (voice routes and Directions): OSRM on the FOSSGIS servers — ' +
|
||||
'<a href="https://routing.openstreetmap.de/about.html" target="_blank" rel="noopener">routing.openstreetmap.de</a> · ' +
|
||||
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> (ODbL) · ' +
|
||||
'<a href="https://www.openstreetmap.org/fixthemap" target="_blank" rel="noopener">fix the map</a>',
|
||||
},
|
||||
{
|
||||
@@ -262,19 +300,9 @@ export const DATA_CREDITS = [
|
||||
'ECMWF does not accept any liability whatsoever for any error or omission in the data, their availability, or for any loss or damage arising from their use.',
|
||||
},
|
||||
// ── Bundled snapshots ───────────────────────────────────────────
|
||||
{
|
||||
key: 'datacenters',
|
||||
html:
|
||||
'Datacenters: ' +
|
||||
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> ' +
|
||||
'(ODbL 1.0)',
|
||||
},
|
||||
{
|
||||
key: 'dams',
|
||||
html:
|
||||
'Dams: ' +
|
||||
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> ' +
|
||||
'(ODbL 1.0) + Open Infrastructure Map',
|
||||
html: 'Dams: Open Infrastructure Map',
|
||||
},
|
||||
{
|
||||
key: 'firms',
|
||||
@@ -307,9 +335,7 @@ export const DATA_CREDITS = [
|
||||
},
|
||||
{
|
||||
key: 'warendorf-cctv',
|
||||
html:
|
||||
'Webcam (Warendorf): <a href="https://www.warendorf.de/" target="_blank" rel="noopener">Stadt Warendorf</a> (courtesy); ' +
|
||||
'camera poses derived from OpenStreetMap geometry, © OpenStreetMap contributors (ODbL)',
|
||||
html: 'Webcam (Warendorf): <a href="https://www.warendorf.de/" target="_blank" rel="noopener">Stadt Warendorf</a> (courtesy)',
|
||||
},
|
||||
{
|
||||
key: 'nsw-cctv',
|
||||
@@ -342,15 +368,24 @@ export const TOMTOM_CREDIT = {
|
||||
'<a href="https://www.tomtom.com" target="_blank" rel="noopener">TomTom</a>',
|
||||
};
|
||||
|
||||
/** Registered when the first Natural Earth region outline resolves (public
|
||||
* domain — no attribution required; credited as a courtesy). */
|
||||
/** Registered when the first Natural Earth region or state/province outline
|
||||
* resolves (public domain — no attribution required; credited as a courtesy). */
|
||||
export const NATURAL_EARTH_CREDIT = {
|
||||
key: 'natural-earth',
|
||||
html:
|
||||
'Physical region boundaries from ' +
|
||||
'Country, state/province and physical region boundaries from ' +
|
||||
'<a href="https://www.naturalearthdata.com" target="_blank" rel="noopener">Natural Earth</a> (public domain)',
|
||||
};
|
||||
|
||||
/** Registered when the first bundled US county outline resolves (public
|
||||
* domain — credited as a courtesy). */
|
||||
export const US_CENSUS_CREDIT = {
|
||||
key: 'us-census-counties',
|
||||
html:
|
||||
'US county boundaries from the ' +
|
||||
'<a href="https://www.census.gov/geographies/mapping-files/time-series/geo/cartographic-boundary.html" target="_blank" rel="noopener">U.S. Census Bureau</a> (public domain)',
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-feed transit credit, registered the first time that feed's vehicles
|
||||
* render (see `src/data/transitFeeds.js` for the license of each).
|
||||
@@ -386,10 +421,7 @@ export const BHOTE_KOSHI_CREDIT = {
|
||||
export const BHOTE_KOSHI_LOCATOR_CREDIT = {
|
||||
key: 'bhote-koshi-locator',
|
||||
html:
|
||||
'Nepal administrative boundary and nearby-city locations: ' +
|
||||
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> ' +
|
||||
'(ODbL 1.0); incident-place anchors use an owner-curated, source-verified geolocation union; ' +
|
||||
'flood corridor derived from the ' +
|
||||
'Flood corridor derived from the ' +
|
||||
'<a href="https://github.com/geo-pera/bhotekoshi-2026-reconstruction/blob/main/vectors/river_centerline.geojson" target="_blank" rel="noopener">GeoPera river centerline</a> ' +
|
||||
'(CC BY-NC 4.0)',
|
||||
};
|
||||
|
||||
@@ -37,3 +37,86 @@ test('adsbdb is credited and carries its published route-data restriction', () =
|
||||
assert.match(credit.html, /Guillaume Michel/);
|
||||
assert.match(credit.html, /href="https:\/\/www\.adsbdb\.com"/);
|
||||
});
|
||||
|
||||
test('OpenStreetMap has one generic data credit with separate tile and names distributors', () => {
|
||||
const osm = DATA_CREDITS.filter((entry) =>
|
||||
entry.html.includes('openstreetmap.org/copyright'),
|
||||
);
|
||||
assert.equal(osm.length, 1);
|
||||
assert.equal(osm[0].key, 'openstreetmap');
|
||||
assert.match(osm[0].html, /Map and place data/);
|
||||
assert.match(osm[0].html, /© OpenStreetMap contributors/);
|
||||
assert.match(
|
||||
DATA_CREDITS.find((entry) => entry.key === 'openfreemap').html,
|
||||
/Vector tiles:/,
|
||||
);
|
||||
assert.match(
|
||||
DATA_CREDITS.find((entry) => entry.key === 'overture-military-names').html,
|
||||
/Overture Maps Foundation/,
|
||||
);
|
||||
});
|
||||
|
||||
test('inline OSM attribution persists until its last display owner leaves', async (t) => {
|
||||
const { showOsmCredit, hideOsmCredit } = await import('./dataCredits.js');
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const visible = new Set();
|
||||
const viewer = {
|
||||
creditDisplay: {
|
||||
addStaticCredit: (c) => visible.add(c),
|
||||
removeStaticCredit: (c) => visible.delete(c),
|
||||
},
|
||||
scene: { requestRender() {} },
|
||||
};
|
||||
assert.equal(showOsmCredit(viewer, 'alpr'), true);
|
||||
assert.equal(showOsmCredit(viewer, 'alpr'), false);
|
||||
assert.equal(visible.size, 1);
|
||||
assert.match(
|
||||
[...visible][0].html,
|
||||
/href="https:\/\/www.openstreetmap.org\/copyright"/,
|
||||
);
|
||||
assert.match([...visible][0].html, />© OpenStreetMap</);
|
||||
assert.ok([...visible][0].showOnScreen);
|
||||
t.mock.timers.tick(60000);
|
||||
assert.equal(visible.size, 1);
|
||||
showOsmCredit(viewer, 'traffic', { openMapTiles: true });
|
||||
assert.equal(visible.size, 2);
|
||||
hideOsmCredit(viewer, 'alpr');
|
||||
assert.equal(visible.size, 2, 'traffic still owns both credits');
|
||||
showOsmCredit(viewer, 'datacenters');
|
||||
hideOsmCredit(viewer, 'traffic');
|
||||
assert.equal(
|
||||
visible.size,
|
||||
1,
|
||||
'bundled data does not retain the tile distributor',
|
||||
);
|
||||
hideOsmCredit(viewer, 'datacenters');
|
||||
assert.equal(visible.size, 0);
|
||||
assert.equal(hideOsmCredit(viewer, 'datacenters'), false);
|
||||
showOsmCredit(viewer, 'alpr');
|
||||
assert.equal(visible.size, 1, 'reenabling restores the short credit');
|
||||
});
|
||||
|
||||
test('tile attribution follows source changes and viewers have independent ownership', async () => {
|
||||
const { showOsmCredit, hideOsmCredit } = await import('./dataCredits.js');
|
||||
const create = () => {
|
||||
const credits = new Set();
|
||||
return {
|
||||
credits,
|
||||
creditDisplay: {
|
||||
addStaticCredit: (c) => credits.add(c),
|
||||
removeStaticCredit: (c) => credits.delete(c),
|
||||
},
|
||||
};
|
||||
};
|
||||
const a = create(),
|
||||
b = create();
|
||||
showOsmCredit(a, 'installations', { openMapTiles: true });
|
||||
showOsmCredit(b, 'installations');
|
||||
assert.equal(a.credits.size, 2);
|
||||
assert.equal(b.credits.size, 1);
|
||||
showOsmCredit(a, 'installations');
|
||||
assert.equal(a.credits.size, 1, 'wide names no longer use tiles');
|
||||
hideOsmCredit(a, 'installations');
|
||||
assert.equal(a.credits.size, 0);
|
||||
assert.equal(b.credits.size, 1);
|
||||
});
|
||||
@@ -10,6 +10,7 @@
|
||||
* the layer's pure helpers for the tests and the share-link code.
|
||||
*/
|
||||
|
||||
import * as credits from './dataCredits.js';
|
||||
import * as render from '../renderGovernor.js';
|
||||
import * as sprites from './spriteOrder.js';
|
||||
import * as picking from './pickRegistry.js';
|
||||
@@ -23,6 +24,7 @@ import { createDirectionsLayer } from '../layers/directions/index.js';
|
||||
|
||||
/** The shared scene owners every Directions instance runs on. */
|
||||
export const directionsServices = Object.freeze({
|
||||
credits,
|
||||
render,
|
||||
sprites,
|
||||
picking,
|
||||
|
||||
@@ -20,3 +20,24 @@
|
||||
time is interpolated between the frames whose decoded position equals a
|
||||
dump1090 fix, timed as dump1090's `now − seen_pos`. Used by the decoder's
|
||||
category, speed-check and track-accuracy tests; never loaded at runtime.
|
||||
- `ofm-austin-{14-3743-6745,12-935-1686}.pbf` and
|
||||
`ofm-camp-mabry-12-935-1685.pbf` — OpenFreeMap / OpenMapTiles tiles from
|
||||
`https://tiles.openfreemap.org/planet/20260913_164504_pt/{z}/{x}/{y}.pbf`,
|
||||
retrieved 2026-09-23. Trimmed to one transportation feature per class/direction
|
||||
and one military landuse feature, preserving original geometry and dictionaries.
|
||||
OpenFreeMap © OpenMapTiles Data from OpenStreetMap; © OpenStreetMap
|
||||
contributors, ODbL 1.0 (https://www.openstreetmap.org/copyright).
|
||||
These modified data fixtures retain ODbL attribution and database share-alike;
|
||||
commercial use and redistribution are permitted under that license. Test-only.
|
||||
- `osm-alpr-austin-11-467-843.pbf` — one OSM camera feature (original geometry
|
||||
and attribute dictionary) from the community-hosted hourly US extract,
|
||||
`https://tiles.dontgetflocked.com/cameras-us-hourly/11/467/843.mvt`,
|
||||
retrieved 2026-09-23. © OpenStreetMap contributors, ODbL 1.0; modified by
|
||||
trimming to one feature, test-only. Same attribution/share-alike terms above.
|
||||
|
||||
- `traffic-road-access.json` — representative transportation properties and two
|
||||
original vertices per feature near the Texas Capitol, Camp Mabry greenbelt,
|
||||
and Austin commercial car parks, from OpenFreeMap z14 tiles, retrieved
|
||||
2026-09-25. Each record names its XYZ tile and expected traffic eligibility.
|
||||
Modified, test-only OpenStreetMap data; same ODbL attribution and database
|
||||
share-alike terms as the OpenFreeMap fixtures above.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large.
Load diff
+125
-82
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* @file Flow→road matching: assign TomTom congestion levels to Overpass roads.
|
||||
* @file Flow→road matching: assign TomTom congestion levels to OpenStreetMap roads.
|
||||
*
|
||||
* The traffic layer renders dots along OSM road polylines (Overpass), while
|
||||
* In OpenStreetMap road mode the layer renders OpenFreeMap polylines, while
|
||||
* TomTom flow tiles carry their own (differently segmented) polylines with
|
||||
* `traffic_level`. This module snaps flow onto roads geometrically:
|
||||
*
|
||||
@@ -11,8 +11,10 @@
|
||||
* projected to local meters (degree cell size cos(lat)-adjusted).
|
||||
* 2. Each road is sampled at up to 7 evenly-spaced points along its length;
|
||||
* each sample looks for the nearest flow segment within 35 m whose
|
||||
* bearing agrees within 30° (folded mod 180° so two-way roads match
|
||||
* opposite-direction flow lines).
|
||||
* bearing agrees with the road travel direction within 30°. Two-way
|
||||
* roads are represented as separate travel directions by the layer. A
|
||||
* flow line whose `coverage` is `full` describes both directions of its
|
||||
* road, so either travel bearing may match it.
|
||||
* 3. A road matches when at least half its samples (minimum 2) matched; its
|
||||
* level is the MEDIAN of the matched samples' trafficLevels, and closure
|
||||
* is true if ANY matched segment is closed.
|
||||
@@ -29,7 +31,7 @@ const M_PER_DEG_LAT = 111320;
|
||||
const CELL_SIZE_M = 100;
|
||||
/** @const {number} Max snap distance from a road sample to a flow segment. */
|
||||
const MATCH_RADIUS_M = 35;
|
||||
/** @const {number} Max bearing disagreement (degrees, folded mod 180). */
|
||||
/** @const {number} Max travel-bearing disagreement (degrees). */
|
||||
const BEARING_TOLERANCE_DEG = 30;
|
||||
/** @const {number} Evenly-spaced samples per road. */
|
||||
const ROAD_SAMPLES = 7;
|
||||
@@ -56,15 +58,14 @@ function bearingDeg(dx, dy) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Bearing disagreement folded mod 180° — a flow line drawn in the opposite
|
||||
* direction of travel still describes the same two-way road.
|
||||
* Travel-bearing disagreement; opposing flow never matches.
|
||||
* @param {number} a - Bearing (degrees). @param {number} b - Bearing (degrees).
|
||||
* @returns {number} min(|Δ|, 180 − |Δ|) in [0, 90].
|
||||
* @returns {number} Angular difference in [0, 180].
|
||||
*/
|
||||
function bearingDiffDeg(a, b) {
|
||||
let d = Math.abs(a - b) % 360;
|
||||
if (d > 180) d = 360 - d;
|
||||
return Math.min(d, 180 - d);
|
||||
return d;
|
||||
}
|
||||
|
||||
/** Squared distance from point (px,py) to segment (ax,ay)-(bx,by), meters². */
|
||||
@@ -80,61 +81,45 @@ function pointSegDist2(px, py, ax, ay, bx, by) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Match flow segments onto roads.
|
||||
* Index flow polylines for nearest-segment queries in local meters.
|
||||
*
|
||||
* @param {Array<{coords:number[][], type:string}>} roads
|
||||
* Parsed Overpass road objects ([[lon,lat],…] polylines).
|
||||
* @param {Array<{coords:number[][], trafficLevel:number, roadType:string, closure:boolean}>} flowSegments
|
||||
* Decoded flow polylines from `flowTiles.js`.
|
||||
* @returns {{
|
||||
* matches: Array<{level:number, closure:boolean}|null>,
|
||||
* matchedCount: number,
|
||||
* candidateCount: number,
|
||||
* }}
|
||||
* `matches` is PARALLEL to `roads` (index i describes roads[i]; null = no
|
||||
* flow data for that road, render it exactly as today). `candidateCount` is
|
||||
* the number of roads with at least one flow segment inside the 35 m search
|
||||
* radius regardless of bearing — the denominator for coverage stats.
|
||||
* Shared by congestion matching and the Hybrid road-source dedupe, so both
|
||||
* use one distance, bearing and direction rule.
|
||||
*
|
||||
* @param {Array<{coords:number[][], trafficLevel:number, closure:boolean, coverage?:string}>} flowSegments
|
||||
* @returns {null|{
|
||||
* project: (lon:number, lat:number) => [number, number],
|
||||
* nearest: (x:number, y:number, bearing:number, radiusFor?:(segment:object) => number) => {best:object|null, ambiguous:boolean, candidate:boolean},
|
||||
* }} Null when no usable segment exists.
|
||||
*/
|
||||
export function matchFlowToRoads(roads, flowSegments) {
|
||||
const roadCount = Array.isArray(roads) ? roads.length : 0;
|
||||
const matches = new Array(roadCount).fill(null);
|
||||
const empty = { matches, matchedCount: 0, candidateCount: 0 };
|
||||
if (
|
||||
roadCount === 0 ||
|
||||
!Array.isArray(flowSegments) ||
|
||||
flowSegments.length === 0
|
||||
) {
|
||||
return empty;
|
||||
}
|
||||
|
||||
export function createFlowSegmentIndex(flowSegments) {
|
||||
if (!Array.isArray(flowSegments)) return null;
|
||||
// Local equirectangular projection anchored at the first flow coordinate —
|
||||
// over a ≤0.05° fetch box the distortion is negligible.
|
||||
const anchor = flowSegments.find(
|
||||
(f) => Array.isArray(f?.coords) && f.coords.length >= 2,
|
||||
);
|
||||
if (!anchor) return empty;
|
||||
if (!anchor) return null;
|
||||
const [refLon, refLat] = anchor.coords[0];
|
||||
const mPerDegLon = M_PER_DEG_LAT * Math.cos((refLat * Math.PI) / 180);
|
||||
const projX = (lon) => (lon - refLon) * mPerDegLon;
|
||||
const projY = (lat) => (lat - refLat) * M_PER_DEG_LAT;
|
||||
const project = (lon, lat) => [
|
||||
(lon - refLon) * mPerDegLon,
|
||||
(lat - refLat) * M_PER_DEG_LAT,
|
||||
];
|
||||
|
||||
// ── 1. Spatial hash of flow segments (midpoint-keyed 100 m cells) ──
|
||||
/** @type {Map<string, Array<{ax:number,ay:number,bx:number,by:number,bearing:number,level:number,closure:boolean}>>} */
|
||||
// Spatial hash of flow segments (midpoint-keyed 100 m cells).
|
||||
const grid = new Map();
|
||||
const cellOf = (x, y) =>
|
||||
`${Math.floor(x / CELL_SIZE_M)},${Math.floor(y / CELL_SIZE_M)}`;
|
||||
|
||||
const cellKey = (cx, cy) => (cx + 0x8000) * 0x10000 + (cy + 0x8000);
|
||||
for (const flow of flowSegments) {
|
||||
const coords = flow?.coords;
|
||||
if (!Array.isArray(coords) || coords.length < 2) continue;
|
||||
const level = flow.trafficLevel;
|
||||
const closure = flow.closure === true;
|
||||
const bothDirections = flow.coverage === 'full';
|
||||
const roadType = flow.roadType;
|
||||
for (let i = 0; i < coords.length - 1; i++) {
|
||||
const ax = projX(coords[i][0]);
|
||||
const ay = projY(coords[i][1]);
|
||||
const bx = projX(coords[i + 1][0]);
|
||||
const by = projY(coords[i + 1][1]);
|
||||
const [ax, ay] = project(coords[i][0], coords[i][1]);
|
||||
const [bx, by] = project(coords[i + 1][0], coords[i + 1][1]);
|
||||
const segLen = Math.hypot(bx - ax, by - ay);
|
||||
if (!(segLen > 0)) continue;
|
||||
const bearing = bearingDeg(bx - ax, by - ay);
|
||||
@@ -150,10 +135,15 @@ export function matchFlowToRoads(roads, flowSegments) {
|
||||
bx: ax + (bx - ax) * t1,
|
||||
by: ay + (by - ay) * t1,
|
||||
bearing,
|
||||
bothDirections,
|
||||
roadType,
|
||||
level,
|
||||
closure,
|
||||
};
|
||||
const key = cellOf((seg.ax + seg.bx) / 2, (seg.ay + seg.by) / 2);
|
||||
const key = cellKey(
|
||||
Math.floor((seg.ax + seg.bx) / 2 / CELL_SIZE_M),
|
||||
Math.floor((seg.ay + seg.by) / 2 / CELL_SIZE_M),
|
||||
);
|
||||
let bucket = grid.get(key);
|
||||
if (!bucket) {
|
||||
bucket = [];
|
||||
@@ -163,13 +153,87 @@ export function matchFlowToRoads(roads, flowSegments) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (grid.size === 0) return empty;
|
||||
if (grid.size === 0) return null;
|
||||
|
||||
const radius2 = MATCH_RADIUS_M * MATCH_RADIUS_M;
|
||||
/**
|
||||
* Nearest direction-compatible segment within 35 m of a projected point.
|
||||
* `radiusFor(segment)` may tighten the radius per segment (never widen it).
|
||||
*/
|
||||
function nearest(px, py, bearing, radiusFor = null) {
|
||||
const cx = Math.floor(px / CELL_SIZE_M);
|
||||
const cy = Math.floor(py / CELL_SIZE_M);
|
||||
let best = null;
|
||||
let bestDist2 = radius2;
|
||||
let ambiguous = false;
|
||||
let candidate = false;
|
||||
for (let gy = cy - 1; gy <= cy + 1; gy++) {
|
||||
for (let gx = cx - 1; gx <= cx + 1; gx++) {
|
||||
const bucket = grid.get(cellKey(gx, gy));
|
||||
if (!bucket) continue;
|
||||
for (const seg of bucket) {
|
||||
const d2 = pointSegDist2(px, py, seg.ax, seg.ay, seg.bx, seg.by);
|
||||
if (d2 > radius2) continue;
|
||||
candidate = true; // within radius, bearing not yet checked
|
||||
if (radiusFor) {
|
||||
const radius = Math.min(MATCH_RADIUS_M, radiusFor(seg));
|
||||
if (d2 > radius * radius) continue;
|
||||
}
|
||||
const diff = bearingDiffDeg(seg.bearing, bearing);
|
||||
if (
|
||||
diff >= BEARING_TOLERANCE_DEG &&
|
||||
!(seg.bothDirections && 180 - diff < BEARING_TOLERANCE_DEG)
|
||||
)
|
||||
continue;
|
||||
if (d2 < bestDist2 - 1) {
|
||||
bestDist2 = d2;
|
||||
best = seg;
|
||||
ambiguous = false;
|
||||
} else if (Math.abs(d2 - bestDist2) <= 1) {
|
||||
if (
|
||||
best &&
|
||||
(best.level !== seg.level || best.closure !== seg.closure)
|
||||
)
|
||||
ambiguous = true;
|
||||
else if (!best) best = seg;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { best, ambiguous, candidate };
|
||||
}
|
||||
return { project, nearest };
|
||||
}
|
||||
|
||||
/**
|
||||
* Match flow segments onto roads.
|
||||
*
|
||||
* @param {Array<{coords:number[][], type:string, oneway?:number}>} roads
|
||||
* Parsed OpenStreetMap road objects ([[lon,lat],…] polylines).
|
||||
* @param {Array<{coords:number[][], trafficLevel:number, roadType:string, closure:boolean, coverage?:string}>} flowSegments
|
||||
* Decoded flow polylines from `flowDecode.js`.
|
||||
* @returns {{
|
||||
* matches: Array<{level:number, closure:boolean}|null>,
|
||||
* matchedCount: number,
|
||||
* candidateCount: number,
|
||||
* }}
|
||||
* `matches` is PARALLEL to `roads` (index i describes roads[i]; null = no
|
||||
* flow data for that road, render it exactly as today). `candidateCount` is
|
||||
* the number of roads with at least one flow segment inside the 35 m search
|
||||
* radius regardless of bearing before bearing rejection.
|
||||
*/
|
||||
export function matchFlowToRoads(roads, flowSegments) {
|
||||
const roadCount = Array.isArray(roads) ? roads.length : 0;
|
||||
const matches = new Array(roadCount).fill(null);
|
||||
const empty = { matches, matchedCount: 0, candidateCount: 0 };
|
||||
if (roadCount === 0) return empty;
|
||||
const index = createFlowSegmentIndex(flowSegments);
|
||||
if (!index) return empty;
|
||||
|
||||
let matchedCount = 0;
|
||||
let candidateCount = 0;
|
||||
|
||||
// ── 2+3. Sample each road and vote ──
|
||||
// Sample each road and vote.
|
||||
for (let r = 0; r < roadCount; r++) {
|
||||
const coords = roads[r]?.coords;
|
||||
if (!Array.isArray(coords) || coords.length < 2) continue;
|
||||
@@ -180,8 +244,7 @@ export function matchFlowToRoads(roads, flowSegments) {
|
||||
const cum = new Array(coords.length);
|
||||
cum[0] = 0;
|
||||
for (let i = 0; i < coords.length; i++) {
|
||||
xs[i] = projX(coords[i][0]);
|
||||
ys[i] = projY(coords[i][1]);
|
||||
[xs[i], ys[i]] = index.project(coords[i][0], coords[i][1]);
|
||||
if (i > 0)
|
||||
cum[i] = cum[i - 1] + Math.hypot(xs[i] - xs[i - 1], ys[i] - ys[i - 1]);
|
||||
}
|
||||
@@ -202,37 +265,17 @@ export function matchFlowToRoads(roads, flowSegments) {
|
||||
(target - cum[cursor - 1]) / (cum[cursor] - cum[cursor - 1] || 1);
|
||||
const px = xs[cursor - 1] + (xs[cursor] - xs[cursor - 1]) * segT;
|
||||
const py = ys[cursor - 1] + (ys[cursor] - ys[cursor - 1]) * segT;
|
||||
const sampleBearing = bearingDeg(
|
||||
xs[cursor] - xs[cursor - 1],
|
||||
ys[cursor] - ys[cursor - 1],
|
||||
);
|
||||
const sampleBearing =
|
||||
(roads[r].oneway === -1 ? 180 : 0) +
|
||||
bearingDeg(xs[cursor] - xs[cursor - 1], ys[cursor] - ys[cursor - 1]);
|
||||
|
||||
// 3×3 cell probe around the sample.
|
||||
const cx = Math.floor(px / CELL_SIZE_M);
|
||||
const cy = Math.floor(py / CELL_SIZE_M);
|
||||
let best = null;
|
||||
let bestDist2 = radius2;
|
||||
for (let gy = cy - 1; gy <= cy + 1; gy++) {
|
||||
for (let gx = cx - 1; gx <= cx + 1; gx++) {
|
||||
const bucket = grid.get(`${gx},${gy}`);
|
||||
if (!bucket) continue;
|
||||
for (const seg of bucket) {
|
||||
const d2 = pointSegDist2(px, py, seg.ax, seg.ay, seg.bx, seg.by);
|
||||
if (d2 > radius2) continue;
|
||||
hadCandidate = true; // within radius, bearing not yet checked
|
||||
if (
|
||||
bearingDiffDeg(seg.bearing, sampleBearing) >=
|
||||
BEARING_TOLERANCE_DEG
|
||||
)
|
||||
continue;
|
||||
if (d2 <= bestDist2) {
|
||||
bestDist2 = d2;
|
||||
best = seg;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (best) {
|
||||
const { best, ambiguous, candidate } = index.nearest(
|
||||
px,
|
||||
py,
|
||||
sampleBearing,
|
||||
);
|
||||
if (candidate) hadCandidate = true;
|
||||
if (best && !ambiguous) {
|
||||
matchedLevels.push(best.level);
|
||||
if (best.closure) matchedClosure = true;
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ test('coincident parallel road matches with the flow level', () => {
|
||||
assert.equal(matches[0].closure, false);
|
||||
});
|
||||
|
||||
test('opposite-direction flow still matches (two-way bearing fold)', () => {
|
||||
test('opposite-direction flow matches only the reverse travel direction)', () => {
|
||||
// Same line drawn south->north vs road north->south equivalence.
|
||||
const reversed = line({ lengthM: 500, bearingDeg: 0, eastM: 4 }).reverse();
|
||||
const { matches } = matchFlowToRoads([ROAD_NS], [flow(reversed, 0.3)]);
|
||||
const { matches } = matchFlowToRoads([{...ROAD_NS, oneway:-1}], [flow(reversed, 0.3)]);
|
||||
assert.ok(matches[0], 'reversed flow should still match');
|
||||
assert.ok(Math.abs(matches[0].level - 0.3) < 1e-9);
|
||||
});
|
||||
@@ -159,3 +159,28 @@ test('median: single value and empty', () => {
|
||||
assert.equal(median([0.7]), 0.7);
|
||||
assert.equal(median([]), null);
|
||||
});
|
||||
|
||||
test('one-way rejects opposite closure and two travel directions retain separate congestion', () => {
|
||||
const coords = [[0,0],[0.002,0]];
|
||||
const forward = {coords, oneway:1};
|
||||
const reverse = {coords, oneway:-1};
|
||||
const east = {coords, trafficLevel:1, closure:false};
|
||||
const west = {coords:[...coords].reverse(), trafficLevel:0, closure:true};
|
||||
assert.equal(matchFlowToRoads([forward],[west]).matches[0], null);
|
||||
for (const flows of [[east,west],[west,east]]) {
|
||||
assert.deepEqual(matchFlowToRoads([forward,reverse],flows).matches,
|
||||
[{level:1,closure:false},{level:0,closure:true}]);
|
||||
}
|
||||
assert.equal(matchFlowToRoads([forward],[east,{...east,trafficLevel:0,closure:true}]).matches[0], null);
|
||||
});
|
||||
|
||||
test('a full-coverage flow line matches both travel directions; one-side does not', () => {
|
||||
const coords = [[0,0],[0.002,0]];
|
||||
const directions = [{coords, oneway:1}, {coords, oneway:-1}];
|
||||
const full = {coords, trafficLevel:0.4, closure:false, coverage:'full'};
|
||||
assert.deepEqual(matchFlowToRoads(directions, [full]).matches,
|
||||
[{level:0.4,closure:false},{level:0.4,closure:false}]);
|
||||
const oneSide = {...full, coverage:'one_side'};
|
||||
assert.deepEqual(matchFlowToRoads(directions, [oneSide]).matches,
|
||||
[{level:0.4,closure:false}, null]);
|
||||
});
|
||||
@@ -22,6 +22,7 @@ export function createInfrastructureLayers(services) {
|
||||
color: '#00ffff', // Cyan
|
||||
icon: '▣',
|
||||
source: 'Local',
|
||||
osmDerived: true,
|
||||
labels: true,
|
||||
labelMax: 700,
|
||||
labelGridPx: 138,
|
||||
@@ -37,6 +38,7 @@ export function createInfrastructureLayers(services) {
|
||||
color: '#0088ff', // Blue
|
||||
icon: '▰',
|
||||
source: 'USACE',
|
||||
osmDerived: true,
|
||||
labels: true,
|
||||
labelMax: 900,
|
||||
labelGridPx: 132,
|
||||
|
||||
@@ -8,9 +8,12 @@ import { createLocalGeoJsonLayer } from 'gods-eye-view/infrastructure/geojson';
|
||||
|
||||
function services() {
|
||||
const records = new Map();
|
||||
const credited = [];
|
||||
let selection;
|
||||
return {
|
||||
records,
|
||||
credited,
|
||||
showOsmCredit: (viewer) => credited.push(viewer),
|
||||
overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} },
|
||||
registerEntityContext(entity, metadata) {
|
||||
records.set(metadata.id, { entity, ...metadata });
|
||||
@@ -121,6 +124,7 @@ test('two viewers use their supplied contexts and dispose independently', async
|
||||
{
|
||||
id: 'local-dams',
|
||||
name: 'Dams',
|
||||
osmDerived: true,
|
||||
color: '#0088ff',
|
||||
url: '/dams.geojsonl',
|
||||
screenSpaceEventHandlerFactory: () => ({
|
||||
@@ -150,6 +154,10 @@ test('two viewers use their supplied contexts and dispose independently', async
|
||||
);
|
||||
await Promise.all(instances.map(({ layer, viewer }) => layer.enable(viewer)));
|
||||
assert.equal(hosts[0].records.size, 1);
|
||||
assert.deepEqual(
|
||||
hosts.map((host) => host.credited),
|
||||
instances.map(({ viewer }) => [viewer]),
|
||||
);
|
||||
assert.equal(hosts[1].records.size, 1);
|
||||
instances[0].click();
|
||||
assert.ok(hosts[0].selection());
|
||||
@@ -184,6 +192,7 @@ test('consumer build includes only infrastructure code and resolves assets under
|
||||
const sources = Object.keys(entry.modules).filter((id) => id.endsWith('.js'));
|
||||
assert.deepEqual(sources.map((id) => id.split('/').at(-1)).sort(), [
|
||||
'infrastructure.js',
|
||||
'infrastructureOverlayEntry.js',
|
||||
'localGeojsonCore.js',
|
||||
'localGeojsonLod.js',
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
const LOCAL_OVERLAY_MAX_DISTANCE_M = 14000000;
|
||||
const LOCAL_OVERLAY_FADE_START_M = 250000;
|
||||
const LOCAL_OVERLAY_FADE_START_RATIO =
|
||||
LOCAL_OVERLAY_FADE_START_M / LOCAL_OVERLAY_MAX_DISTANCE_M;
|
||||
|
||||
/** Shared ambient infrastructure card contract; the host owns projection and persistence. */
|
||||
export function createInfrastructureOverlayEntry({
|
||||
id,
|
||||
source,
|
||||
position,
|
||||
title,
|
||||
details = [],
|
||||
accent,
|
||||
priority,
|
||||
}) {
|
||||
return {
|
||||
id: String(id),
|
||||
source: source,
|
||||
position,
|
||||
variant: 'card',
|
||||
title: title,
|
||||
details: details,
|
||||
accent,
|
||||
priority,
|
||||
collisionGroup: 'ambient-card',
|
||||
zIndex: 30,
|
||||
interactive: false,
|
||||
minDistance: 0,
|
||||
maxDistance: LOCAL_OVERLAY_MAX_DISTANCE_M,
|
||||
distanceFadeStartRatio: LOCAL_OVERLAY_FADE_START_RATIO,
|
||||
distanceScale: {
|
||||
near: 250000,
|
||||
nearValue: 1,
|
||||
far: 9000000,
|
||||
farValue: 0.62,
|
||||
},
|
||||
edgeFade: 'keyhole',
|
||||
horizonCull: true,
|
||||
terrainOcclusion: false,
|
||||
gapPx: 15,
|
||||
placement: 'above',
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,8 @@ export function installationFeedback(stats = {}, now = Date.now()) {
|
||||
rate_limited: 'Overpass rate-limited',
|
||||
timeout: 'Overpass timed out',
|
||||
query_failed: 'Overpass could not complete the query',
|
||||
tiles_unavailable: 'Map tiles temporarily unavailable',
|
||||
names_unavailable: 'Mapped names temporarily unavailable',
|
||||
};
|
||||
const reason =
|
||||
reasons[stats.failureReason] || 'Overpass temporarily unavailable';
|
||||
@@ -18,5 +20,16 @@ export function installationFeedback(stats = {}, now = Date.now()) {
|
||||
return 'Zoom in to search mapped installations';
|
||||
if (stats.stale) return 'Showing cached mapped sites';
|
||||
if (stats.status === 'idle') return 'Mapped sites not loaded';
|
||||
// With a count, say what was found and where; an empty area is not "loaded".
|
||||
if (Number.isFinite(stats.count)) {
|
||||
const km =
|
||||
stats.coverage?.kind === 'subject' &&
|
||||
Number.isFinite(stats.coverage.radiusM)
|
||||
? Math.round(stats.coverage.radiusM / 1000)
|
||||
: null;
|
||||
const where = km ? ` within ${km} km of the contact` : ' in view';
|
||||
if (stats.count === 0) return `No mapped sites${where}`;
|
||||
return `${stats.count} mapped site${stats.count === 1 ? '' : 's'}${where}`;
|
||||
}
|
||||
return 'Mapped sites loaded';
|
||||
}
|
||||
@@ -19,3 +19,16 @@ test('first fetch, retry, cached data and success have distinct copy', () => {
|
||||
assert.equal(installationFeedback({ status: 'ready' }), 'Mapped sites loaded');
|
||||
assert.equal(installationFeedback({ stale: true }), 'Showing cached mapped sites');
|
||||
});
|
||||
test('a counted result names what was found and where', () => {
|
||||
assert.equal(installationFeedback({ status: 'ready', count: 3 }), '3 mapped sites in view');
|
||||
assert.equal(installationFeedback({ status: 'empty', count: 0 }), 'No mapped sites in view');
|
||||
const coverage = { kind: 'subject', radiusM: 100_000 };
|
||||
assert.equal(
|
||||
installationFeedback({ status: 'ready', count: 1, coverage }),
|
||||
'1 mapped site within 100 km of the contact',
|
||||
);
|
||||
assert.equal(
|
||||
installationFeedback({ status: 'unavailable', failureReason: 'tiles_unavailable' }),
|
||||
'Map tiles temporarily unavailable',
|
||||
);
|
||||
});
|
||||
@@ -10,6 +10,9 @@ const FADE_IN_MS = 150;
|
||||
const FADE_OUT_MS = 300;
|
||||
const MIN_LIFETIME_MS = 2500;
|
||||
const COOLDOWN_MS = 1200;
|
||||
function zeroCount(_value, key, map) {
|
||||
map.set(key, 0);
|
||||
}
|
||||
|
||||
function clampInt(value, min, max) {
|
||||
return Math.max(min, Math.min(max, Math.floor(Number(value) || 0)));
|
||||
@@ -757,6 +760,8 @@ export class LabelArbiter {
|
||||
this._layerBuckets = new Map();
|
||||
this._demand = new Map();
|
||||
this._quotas = new Map();
|
||||
this._acceptedByLayer = new Map();
|
||||
this._paintedByLayer = new Map();
|
||||
this._weightedQuotaScratch = [];
|
||||
this._quotaPriorityScratch = [];
|
||||
this._quotaRemainderScratch = [];
|
||||
@@ -770,6 +775,12 @@ export class LabelArbiter {
|
||||
this._queue = new SpatialCandidateQueue();
|
||||
}
|
||||
|
||||
/** Release an identity when its source changes between ambient and protected paint. */
|
||||
remove(key) {
|
||||
if (this.states.delete(key)) this._stateListDirty = true;
|
||||
this.selectedKeys.delete(key);
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.states.clear();
|
||||
this.selectedKeys.clear();
|
||||
@@ -781,6 +792,8 @@ export class LabelArbiter {
|
||||
this._candidates.length = 0;
|
||||
this._layerBuckets.clear();
|
||||
this._quotas.clear();
|
||||
this._acceptedByLayer.clear();
|
||||
this._paintedByLayer.clear();
|
||||
this._selectedCandidates.length = 0;
|
||||
this._selectedPlacements.length = 0;
|
||||
this._droppedKeys.length = 0;
|
||||
@@ -889,6 +902,12 @@ export class LabelArbiter {
|
||||
demand.set(layerId, bucket.count);
|
||||
});
|
||||
|
||||
const limits = options.limitsByLayer;
|
||||
demand.forEach((count, layerId) => {
|
||||
demand.set(layerId, Math.min(count, limits?.get(layerId) ?? Infinity));
|
||||
});
|
||||
const acceptedByLayer = this._acceptedByLayer;
|
||||
acceptedByLayer.forEach(zeroCount);
|
||||
const activeLayers = this._activeLayersScratch;
|
||||
let activeCount = 0;
|
||||
let totalDemand = 0;
|
||||
@@ -937,6 +956,9 @@ export class LabelArbiter {
|
||||
)
|
||||
return false;
|
||||
attemptStamps.set(candidate.key, stamp);
|
||||
const layerCount = acceptedByLayer.get(candidate.layerId) || 0;
|
||||
if (layerCount >= (limits?.get(candidate.layerId) ?? Infinity))
|
||||
return false;
|
||||
const previous = this.states.get(candidate.key);
|
||||
const stateless = candidate.stateless === true;
|
||||
// Stateless candidates ignore the re-entry cooldown (they return on the
|
||||
@@ -955,6 +977,7 @@ export class LabelArbiter {
|
||||
selectedCandidates[selectedCount] = candidate;
|
||||
selectedPlacements[selectedCount] = placement;
|
||||
selectedCount++;
|
||||
acceptedByLayer.set(candidate.layerId, layerCount + 1);
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -968,7 +991,8 @@ export class LabelArbiter {
|
||||
if (preserveIncumbents) {
|
||||
for (let i = 0; i < layerCount && accepted < target; i++) {
|
||||
const candidate = layerList[i];
|
||||
if (!this.states.get(candidate.key)?.selected) continue;
|
||||
if (candidate.stateless || !this.states.get(candidate.key)?.selected)
|
||||
continue;
|
||||
if (attempt(candidate, true)) accepted++;
|
||||
}
|
||||
}
|
||||
@@ -1139,7 +1163,7 @@ export class LabelArbiter {
|
||||
* Reproject accepted/fading identities from the current frame candidate map.
|
||||
* A caller-owned output array enables allocation-free per-frame rendering.
|
||||
*/
|
||||
renderEntries(currentCandidates, now = Date.now(), out = []) {
|
||||
renderEntries(currentCandidates, now = Date.now(), out = [], limits = null) {
|
||||
const current =
|
||||
currentCandidates instanceof Map
|
||||
? currentCandidates
|
||||
@@ -1151,6 +1175,20 @@ export class LabelArbiter {
|
||||
);
|
||||
const states = this._refreshStateList();
|
||||
let outIndex = 0;
|
||||
const painted = this._paintedByLayer;
|
||||
painted.forEach(zeroCount);
|
||||
// Reserve the live winners before spending any remaining slots on exit fades.
|
||||
if (limits)
|
||||
for (let i = 0; i < states.length; i++) {
|
||||
const state = states[i];
|
||||
if (!state.selected) continue;
|
||||
const candidate = current.get(state.key) || state.lastCandidate;
|
||||
if (candidate)
|
||||
painted.set(
|
||||
candidate.layerId,
|
||||
(painted.get(candidate.layerId) || 0) + 1,
|
||||
);
|
||||
}
|
||||
for (let i = 0; i < states.length; i++) {
|
||||
const state = states[i];
|
||||
let temporalAlpha;
|
||||
@@ -1171,9 +1209,14 @@ export class LabelArbiter {
|
||||
if (temporalAlpha <= 0) continue;
|
||||
const candidate = current.get(state.key) || state.lastCandidate;
|
||||
if (!candidate) continue;
|
||||
if (limits && !state.selected) {
|
||||
const count = painted.get(candidate.layerId) || 0;
|
||||
if (count >= (limits.get(candidate.layerId) ?? Infinity)) continue;
|
||||
painted.set(candidate.layerId, count + 1);
|
||||
}
|
||||
const placement = renderPlacement(
|
||||
candidate,
|
||||
state.stateless ? undefined : state.corner,
|
||||
state.corner,
|
||||
state.lastPlacement,
|
||||
);
|
||||
if (!placement) continue;
|
||||
|
||||
@@ -345,6 +345,9 @@ test('a stateless candidate keeps no corner, no cooldown, and no fades', () => {
|
||||
arbiter.solve([blocker, mover], { capacity: 2, now: 1000 });
|
||||
assert.equal(arbiter.states.get('cctv:mover').corner, 'below',
|
||||
'it took the free corner while the preferred one was blocked');
|
||||
const paint = arbiter.renderEntries(new Map([[blocker.key, blocker], [mover.key, mover]]), 1000, []);
|
||||
assert.equal(paint.find((entry) => entry.candidate.key === mover.key).placement.corner, 'below',
|
||||
'painting honors this solve’s collision-free corner even without sticky placement');
|
||||
|
||||
// Corner is re-decided from geometry, NOT remembered: with the blocker gone the
|
||||
// card returns to its preferred placement immediately.
|
||||
@@ -504,3 +507,44 @@ test('live identities expose selected and temporal fading membership by layer/so
|
||||
]));
|
||||
assert.equal(arbiter.liveIdentities({ includeFading: true, now: 1501 }).size, 0);
|
||||
});
|
||||
|
||||
|
||||
test('stateless labels yield occupied slots and re-rank by area on each solve', () => {
|
||||
const make = (key, x, priority) => ({ key, layerId: 'installations', sourceId: key,
|
||||
priority, keyholeAlpha: 1, stateless: true, screenX: x, screenY: 100,
|
||||
placements: [{ corner: 'above', rect: { x, y: 100, w: 100, h: 20 } }],
|
||||
});
|
||||
const large = make('large', 100, 100), small = make('small', 300, 10);
|
||||
const arbiter = new LabelArbiter();
|
||||
arbiter.solve([large, small], { capacity: 2, now: 1000 });
|
||||
assert.equal(arbiter.selectedKeys.size, 2);
|
||||
small.placements[0].rect.x = 100;
|
||||
arbiter.solve([large, small], { capacity: 2, now: 1016 });
|
||||
assert.deepEqual([...arbiter.selectedKeys], ['large'], 'incumbency never forces a stateless overlap');
|
||||
const larger = make('larger', 100, 1000);
|
||||
arbiter.solve([large, small, larger], { capacity: 2, now: 1032 });
|
||||
assert.deepEqual([...arbiter.selectedKeys], ['larger'], 'area priority outranks a smaller incumbent');
|
||||
});
|
||||
|
||||
|
||||
test('a source ceiling bounds shared-domain winners and exit fades without evicting incumbents', () => {
|
||||
const arbiter = new LabelArbiter();
|
||||
const sites = Array.from({ length: 40 }, (_, i) => candidate('installations', i));
|
||||
const centers = Array.from({ length: 30 }, (_, i) => candidate('flights', i));
|
||||
const limits = new Map([['installations', 24]]);
|
||||
const solve = (list, now) => arbiter.solve(list, { capacity: 54, limitsByLayer: limits, now });
|
||||
solve([...sites, ...centers], 0);
|
||||
const first = new Set(arbiter.selectedKeys);
|
||||
assert.equal([...first].filter(key => key.startsWith('installations:')).length, 24);
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
for (const site of sites) site.placements[0].rect.x += 0.25;
|
||||
solve([...sites, ...centers], i * 16);
|
||||
assert.deepEqual(arbiter.selectedKeys, first);
|
||||
}
|
||||
const replacements = sites.map((site, i) => ({ ...site, key: `installations:new${i}` }));
|
||||
const current = [...replacements, ...centers];
|
||||
solve(current, 2000);
|
||||
const rendered = arbiter.renderEntries(new Map(current.map(c => [c.key, c])), 2100, [], limits);
|
||||
assert.equal(rendered.filter(r => r.candidate.layerId === 'installations').length, 24);
|
||||
assert.equal(rendered.filter(r => r.candidate.layerId === 'flights').length, 30);
|
||||
});
|
||||
+219
-15
@@ -1,3 +1,5 @@
|
||||
import reservationRows from './layerStateTokenReservations.json' with { type: 'json' };
|
||||
|
||||
const VALID_DISPOSITIONS = new Set([
|
||||
'enabled-only',
|
||||
'enabled+options',
|
||||
@@ -5,6 +7,10 @@ const VALID_DISPOSITIONS = new Set([
|
||||
]);
|
||||
|
||||
export const LAYER_STATE_VERSION = 2;
|
||||
export const LAYER_STATE_TOKEN_ALPHABET =
|
||||
'0123456789abcdefghijklmnopqrstuvwxyz';
|
||||
const LAYER_STATE_TOKEN_PATTERN = /^[a-z0-9]{1,2}$/;
|
||||
const NEW_SINGLE_CHARACTER_TOKEN_PATTERN = /^[0-9]$/;
|
||||
/** Re-check cadence while a shared subject waits for its feed row to arrive. */
|
||||
const PENDING_TRACKING_POLL_MS = 1_000;
|
||||
/**
|
||||
@@ -17,12 +23,13 @@ const PENDING_TRACKING_POLL_MS = 1_000;
|
||||
*/
|
||||
const TRACKING_ID_GRAMMAR = /^[0-9a-z~_-]{1,16}$/;
|
||||
/**
|
||||
* Ceilings for the untrusted v2 layer fields. Both are far above any legitimate
|
||||
* payload (16 one-character tokens; a dozen short option assignments), so a
|
||||
* value past them is malformed or hostile. Reject the WHOLE payload, matching
|
||||
* the unknown-token rule — never salvage a prefix.
|
||||
* Ceilings for the untrusted v2 layer fields. The layer ceiling covers the
|
||||
* complete reserved one-character space plus every two-character base-36
|
||||
* allocation, while the option ceiling covers a dozen short assignments.
|
||||
* Reject the WHOLE payload, matching the unknown-token rule — never salvage a
|
||||
* prefix.
|
||||
*/
|
||||
const MAX_ENABLED_LAYERS_CHARS = 64;
|
||||
const MAX_ENABLED_LAYERS_CHARS = 4_096;
|
||||
const MAX_LAYER_OPTIONS_CHARS = 512;
|
||||
export const LAYER_STATE_STORAGE_KEY = 'gev:layer-state:v2';
|
||||
export const LAYER_RESTORE_ORIGINS = Object.freeze({
|
||||
@@ -291,6 +298,13 @@ function imageryPinOption(key, token) {
|
||||
}
|
||||
|
||||
const OPTION_GROUPS = Object.freeze({
|
||||
traffic: Object.freeze([
|
||||
enumOption('roadMode', 'r', null, ['tomtom', 'osm', 'hybrid'], {
|
||||
tomtom: 't',
|
||||
osm: 'o',
|
||||
hybrid: 'h',
|
||||
}),
|
||||
]),
|
||||
'weather-lightning': Object.freeze([
|
||||
enumOption('opacity', 'o', 'strong', ['light', 'strong'], {
|
||||
light: 'l',
|
||||
@@ -446,6 +460,94 @@ export const SHARE_TRACKING_RESTORE_POLICIES = Object.freeze({
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* The original single-character assignments are a closed compatibility set.
|
||||
* Keep their exact mapping pinned by the independent snapshot in the tests.
|
||||
*/
|
||||
export const LEGACY_LAYER_STATE_TOKENS = Object.freeze({
|
||||
'ais-live-vessels': 'a',
|
||||
'alpr-cameras': 'p',
|
||||
'bhote-koshi-2026': 'h',
|
||||
'bhote-koshi-locator': 'z',
|
||||
bikeshare: 'b',
|
||||
cctv: 'c',
|
||||
directions: 'n',
|
||||
earthquakes: 'e',
|
||||
'fire-perimeters': '2',
|
||||
flights: 'f',
|
||||
'local-dams': 'q',
|
||||
'local-datacenters': 'd',
|
||||
'local-firms': 'w',
|
||||
military: 'm',
|
||||
'military-awareness': 'g',
|
||||
'military-installations': 'i',
|
||||
radio: 'r',
|
||||
'recent-imagery': '1',
|
||||
'rocket-launches': 'x',
|
||||
satellites: 's',
|
||||
'telegeography-submarine-cables': 'u',
|
||||
traffic: 't',
|
||||
transit: 'j',
|
||||
'weather-cyclones': 'y',
|
||||
'weather-lightning': 'l',
|
||||
'weather-radar': 'v',
|
||||
'weather-satellite': 'o',
|
||||
wind: 'k',
|
||||
});
|
||||
|
||||
/**
|
||||
* Permanent token ownership. Existing share links are public authored state,
|
||||
* so an allocation stays here even if its layer is later removed. The JSON
|
||||
* ledger is also read directly from the published Git base by the checker.
|
||||
*/
|
||||
export function parseLayerStateTokenReservations(rows) {
|
||||
if (!Array.isArray(rows)) {
|
||||
throw new Error('Layer-state token ledger must be an array');
|
||||
}
|
||||
const reservations = Object.create(null);
|
||||
const reservedIdsByToken = new Map();
|
||||
for (const row of rows) {
|
||||
if (
|
||||
!Array.isArray(row) ||
|
||||
row.length !== 2 ||
|
||||
typeof row[0] !== 'string' ||
|
||||
typeof row[1] !== 'string'
|
||||
) {
|
||||
throw new Error('Invalid layer-state token ledger row');
|
||||
}
|
||||
const [id, token] = row;
|
||||
if (!/^[a-z0-9-]+$/.test(id) || !LAYER_STATE_TOKEN_PATTERN.test(token)) {
|
||||
throw new Error(`Invalid layer-state token reservation: ${id}`);
|
||||
}
|
||||
if (Object.hasOwn(reservations, id)) {
|
||||
throw new Error(`Duplicate layer-state token reservation id: ${id}`);
|
||||
}
|
||||
if (reservedIdsByToken.has(token)) {
|
||||
throw new Error(`Duplicate layer-state token reservation: ${token}`);
|
||||
}
|
||||
if (
|
||||
(Object.hasOwn(LEGACY_LAYER_STATE_TOKENS, id) &&
|
||||
token !== LEGACY_LAYER_STATE_TOKENS[id]) ||
|
||||
(token.length === 1 &&
|
||||
!NEW_SINGLE_CHARACTER_TOKEN_PATTERN.test(token) &&
|
||||
LEGACY_LAYER_STATE_TOKENS[id] !== token)
|
||||
) {
|
||||
throw new Error(`Legacy layer-state token is immutable: ${id}`);
|
||||
}
|
||||
reservations[id] = token;
|
||||
reservedIdsByToken.set(token, id);
|
||||
}
|
||||
for (const [id, token] of Object.entries(LEGACY_LAYER_STATE_TOKENS)) {
|
||||
if (reservations[id] !== token) {
|
||||
throw new Error(`Missing or changed legacy layer-state token: ${id}`);
|
||||
}
|
||||
}
|
||||
return Object.freeze(reservations);
|
||||
}
|
||||
|
||||
export const LAYER_STATE_TOKEN_RESERVATIONS =
|
||||
parseLayerStateTokenReservations(reservationRows);
|
||||
|
||||
/**
|
||||
* Canonical serialization registry. Its order, not runtime registration order,
|
||||
* owns stable URL ordering.
|
||||
@@ -542,7 +644,12 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
|
||||
token: 'u',
|
||||
disposition: 'enabled-only',
|
||||
}),
|
||||
Object.freeze({ id: 'traffic', token: 't', disposition: 'enabled-only' }),
|
||||
Object.freeze({
|
||||
id: 'traffic',
|
||||
token: 't',
|
||||
disposition: 'enabled+options',
|
||||
optionOwner: 'traffic',
|
||||
}),
|
||||
Object.freeze({ id: 'transit', token: 'j', disposition: 'enabled-only' }),
|
||||
Object.freeze({
|
||||
id: 'weather-cyclones',
|
||||
@@ -579,6 +686,60 @@ export const REGISTERED_LAYER_IDS = Object.freeze(
|
||||
LAYER_STATE_REGISTRY.map((entry) => entry.id),
|
||||
);
|
||||
|
||||
/** Consume unreserved digits before the two-character base-36 namespace. */
|
||||
export function nextLayerStateToken(
|
||||
reservations = LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
) {
|
||||
const occupied = new Set(Object.values(reservations || {}));
|
||||
for (const digit of '0123456789') {
|
||||
if (!occupied.has(digit)) return digit;
|
||||
}
|
||||
for (const first of LAYER_STATE_TOKEN_ALPHABET) {
|
||||
for (const second of LAYER_STATE_TOKEN_ALPHABET) {
|
||||
const candidate = `${first}${second}`;
|
||||
if (!occupied.has(candidate)) return candidate;
|
||||
}
|
||||
}
|
||||
throw new Error('Layer-state token namespace exhausted');
|
||||
}
|
||||
|
||||
function allocationRank(token) {
|
||||
if (NEW_SINGLE_CHARACTER_TOKEN_PATTERN.test(token)) return Number(token);
|
||||
if (typeof token !== 'string' || token.length !== 2) return Infinity;
|
||||
const first = LAYER_STATE_TOKEN_ALPHABET.indexOf(token[0]);
|
||||
const second = LAYER_STATE_TOKEN_ALPHABET.indexOf(token[1]);
|
||||
return first < 0 || second < 0 ? Infinity : 10 + first * 36 + second;
|
||||
}
|
||||
|
||||
/** Check a proposed ledger against the published merge-time base. */
|
||||
export function validateLayerStateAllocations(
|
||||
baseReservations,
|
||||
reservations = LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
) {
|
||||
if (!baseReservations || typeof baseReservations !== 'object') {
|
||||
throw new Error('Base layer-state token reservations are required');
|
||||
}
|
||||
for (const [id, token] of Object.entries(baseReservations)) {
|
||||
if (reservations[id] !== token) {
|
||||
throw new Error(`Published layer-state token changed or removed: ${id}`);
|
||||
}
|
||||
}
|
||||
const newlyReserved = Object.entries(reservations)
|
||||
.filter(([id]) => !Object.hasOwn(baseReservations, id))
|
||||
.sort((left, right) => allocationRank(left[1]) - allocationRank(right[1]));
|
||||
const occupied = { ...baseReservations };
|
||||
for (const [id, token] of newlyReserved) {
|
||||
const expected = nextLayerStateToken(occupied);
|
||||
if (token !== expected) {
|
||||
throw new Error(
|
||||
`Layer-state token for ${id} must be the next free token ${expected}`,
|
||||
);
|
||||
}
|
||||
occupied[id] = token;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const REGISTRY_BY_ID = new Map(
|
||||
LAYER_STATE_REGISTRY.map((entry) => [entry.id, entry]),
|
||||
);
|
||||
@@ -619,10 +780,39 @@ export function isExplicitLayerStateOrigin(origin) {
|
||||
}
|
||||
|
||||
/** Validate the static registry itself before it is used to seal a manager. */
|
||||
export function validateLayerStateRegistry(registry = LAYER_STATE_REGISTRY) {
|
||||
export function validateLayerStateRegistry(
|
||||
registry = LAYER_STATE_REGISTRY,
|
||||
reservations = LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
) {
|
||||
if (!Array.isArray(registry) || registry.length === 0) {
|
||||
throw new Error('Layer-state registry must be a non-empty array');
|
||||
}
|
||||
if (
|
||||
!reservations ||
|
||||
typeof reservations !== 'object' ||
|
||||
Array.isArray(reservations)
|
||||
) {
|
||||
throw new Error('Layer-state token reservations must be an object');
|
||||
}
|
||||
const reservedIdsByToken = new Map();
|
||||
for (const [id, token] of Object.entries(reservations)) {
|
||||
if (!/^[a-z0-9-]+$/.test(id) || !LAYER_STATE_TOKEN_PATTERN.test(token)) {
|
||||
throw new Error(`Invalid layer-state token reservation: ${id}`);
|
||||
}
|
||||
if (
|
||||
(Object.hasOwn(LEGACY_LAYER_STATE_TOKENS, id) &&
|
||||
token !== LEGACY_LAYER_STATE_TOKENS[id]) ||
|
||||
(token.length === 1 &&
|
||||
!NEW_SINGLE_CHARACTER_TOKEN_PATTERN.test(token) &&
|
||||
LEGACY_LAYER_STATE_TOKENS[id] !== token)
|
||||
) {
|
||||
throw new Error(`Legacy layer-state token is immutable: ${id}`);
|
||||
}
|
||||
if (reservedIdsByToken.has(token)) {
|
||||
throw new Error(`Duplicate layer-state token reservation: ${token}`);
|
||||
}
|
||||
reservedIdsByToken.set(token, id);
|
||||
}
|
||||
const ids = new Set();
|
||||
const tokens = new Set();
|
||||
for (const entry of registry) {
|
||||
@@ -633,8 +823,14 @@ export function validateLayerStateRegistry(registry = LAYER_STATE_REGISTRY) {
|
||||
if (ids.has(entry.id))
|
||||
throw new Error(`Duplicate layer-state id: ${entry.id}`);
|
||||
ids.add(entry.id);
|
||||
if (!/^[a-z0-9]$/.test(entry.token || ''))
|
||||
if (!LAYER_STATE_TOKEN_PATTERN.test(entry.token || ''))
|
||||
throw new Error(`Invalid layer-state token: ${entry.id}`);
|
||||
if (reservations[entry.id] !== entry.token) {
|
||||
throw new Error(`Unreserved layer-state token: ${entry.id}`);
|
||||
}
|
||||
if (reservedIdsByToken.get(entry.token) !== entry.id) {
|
||||
throw new Error(`Layer-state token reservation mismatch: ${entry.token}`);
|
||||
}
|
||||
if (tokens.has(entry.token))
|
||||
throw new Error(`Duplicate layer-state token: ${entry.token}`);
|
||||
tokens.add(entry.token);
|
||||
@@ -756,18 +952,26 @@ export function encodeLayerStateParams(params, state) {
|
||||
|
||||
/** Decode v2 fields. Null means that the layer payload is absent. */
|
||||
export function decodeLayerStateParams(params) {
|
||||
if (params.get('v') !== String(LAYER_STATE_VERSION) || !params.has('l'))
|
||||
const layerFields = params.getAll('l');
|
||||
if (
|
||||
params.get('v') !== String(LAYER_STATE_VERSION) ||
|
||||
layerFields.length !== 1
|
||||
)
|
||||
return null;
|
||||
const rawLayers = String(params.get('l') || '');
|
||||
const rawLayers = layerFields[0];
|
||||
const rawOptionsField = String(params.get('lo') || '');
|
||||
// Fail closed on an oversized payload rather than decoding a truncated one.
|
||||
if (rawLayers.length > MAX_ENABLED_LAYERS_CHARS) return null;
|
||||
if (rawOptionsField.length > MAX_LAYER_OPTIONS_CHARS) return null;
|
||||
const layerTokens = rawLayers.split('.').filter(Boolean);
|
||||
// `l=` is the one valid explicit-empty representation. Any non-empty token
|
||||
// set containing an unknown member rejects the complete layer payload so a
|
||||
// typo or future token cannot silently become an authoritative empty set.
|
||||
if (layerTokens.some((token) => !REGISTRY_BY_TOKEN.has(token))) return null;
|
||||
const layerTokens = rawLayers ? rawLayers.split('.') : [];
|
||||
// `l=` is the one valid explicit-empty representation. Reject repeated
|
||||
// fields, empty members, duplicate members, or unknown members; silently
|
||||
// removing one would turn a malformed share into a different state.
|
||||
if (
|
||||
layerTokens.some((token) => !token || !REGISTRY_BY_TOKEN.has(token)) ||
|
||||
new Set(layerTokens).size !== layerTokens.length
|
||||
)
|
||||
return null;
|
||||
const enabledLayerIds = layerTokens.map(
|
||||
(token) => REGISTRY_BY_TOKEN.get(token).id,
|
||||
);
|
||||
|
||||
@@ -6,17 +6,22 @@ import assert from 'node:assert/strict';
|
||||
|
||||
import { DataLayerManager } from './manager.js';
|
||||
import {
|
||||
LEGACY_LAYER_STATE_TOKENS,
|
||||
LAYER_STATE_REGISTRY,
|
||||
LAYER_STATE_STORAGE_KEY,
|
||||
LAYER_STATE_TOKEN_ALPHABET,
|
||||
LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
LayerStateCoordinator,
|
||||
REGISTERED_LAYER_IDS,
|
||||
SHARE_TRACKING_RESTORE_POLICIES,
|
||||
createDefaultLayerState,
|
||||
decodeLayerStateParams,
|
||||
encodeLayerStateParams,
|
||||
nextLayerStateToken,
|
||||
normalizeLayerState,
|
||||
parseStoredLayerState,
|
||||
serializeStoredLayerState,
|
||||
validateLayerStateAllocations,
|
||||
validateLayerStateRegistry,
|
||||
} from './layerState.js';
|
||||
import radioLayer from './radio.js';
|
||||
@@ -198,6 +203,144 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
||||
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 28);
|
||||
assert.ok(REGISTERED_LAYER_IDS.includes('transit'));
|
||||
assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort());
|
||||
assert.deepEqual(LEGACY_LAYER_STATE_TOKENS, {
|
||||
'ais-live-vessels': 'a',
|
||||
'alpr-cameras': 'p',
|
||||
'bhote-koshi-2026': 'h',
|
||||
'bhote-koshi-locator': 'z',
|
||||
bikeshare: 'b',
|
||||
cctv: 'c',
|
||||
directions: 'n',
|
||||
earthquakes: 'e',
|
||||
'fire-perimeters': '2',
|
||||
flights: 'f',
|
||||
'local-dams': 'q',
|
||||
'local-datacenters': 'd',
|
||||
'local-firms': 'w',
|
||||
military: 'm',
|
||||
'military-awareness': 'g',
|
||||
'military-installations': 'i',
|
||||
radio: 'r',
|
||||
'recent-imagery': '1',
|
||||
'rocket-launches': 'x',
|
||||
satellites: 's',
|
||||
'telegeography-submarine-cables': 'u',
|
||||
traffic: 't',
|
||||
transit: 'j',
|
||||
'weather-cyclones': 'y',
|
||||
'weather-lightning': 'l',
|
||||
'weather-radar': 'v',
|
||||
'weather-satellite': 'o',
|
||||
wind: 'k',
|
||||
});
|
||||
for (const { id, token } of LAYER_STATE_REGISTRY) {
|
||||
assert.equal(LAYER_STATE_TOKEN_RESERVATIONS[id], token);
|
||||
}
|
||||
for (const [id, token] of Object.entries(LEGACY_LAYER_STATE_TOKENS)) {
|
||||
assert.equal(LAYER_STATE_TOKEN_RESERVATIONS[id], token);
|
||||
}
|
||||
assert.equal(nextLayerStateToken(), '0');
|
||||
assert.equal(
|
||||
nextLayerStateToken({ ...LAYER_STATE_TOKEN_RESERVATIONS, alpha: '0', bravo: '3' }),
|
||||
'4',
|
||||
);
|
||||
const digitsExhausted = {
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
...Object.fromEntries(
|
||||
[...'03456789'].map((digit) => [`prior-${digit}`, digit]),
|
||||
),
|
||||
};
|
||||
assert.equal(nextLayerStateToken(digitsExhausted), '00');
|
||||
assert.equal(
|
||||
nextLayerStateToken({ ...digitsExhausted, retired: '00', used: '01' }),
|
||||
'02',
|
||||
);
|
||||
assert.equal(
|
||||
nextLayerStateToken({
|
||||
...digitsExhausted,
|
||||
...Object.fromEntries(
|
||||
[...LAYER_STATE_TOKEN_ALPHABET].map((second) => [
|
||||
`pair-0${second}`,
|
||||
`0${second}`,
|
||||
]),
|
||||
),
|
||||
}),
|
||||
'10',
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
nextLayerStateToken(
|
||||
Object.fromEntries(
|
||||
[
|
||||
...'0123456789',
|
||||
...[...LAYER_STATE_TOKEN_ALPHABET].flatMap((first) =>
|
||||
[...LAYER_STATE_TOKEN_ALPHABET].map((second) => `${first}${second}`),
|
||||
),
|
||||
].map((token, index) => [`occupied-${index}`, token]),
|
||||
),
|
||||
),
|
||||
/namespace exhausted/,
|
||||
);
|
||||
assert.equal(
|
||||
validateLayerStateRegistry(
|
||||
[{ id: 'future-layer', token: '0', disposition: 'enabled-only' }],
|
||||
{ 'future-layer': '0' },
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
validateLayerStateRegistry(
|
||||
[{ id: 'future-layer', token: '01', disposition: 'enabled-only' }],
|
||||
{ retired: '00', 'future-layer': '01' },
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
validateLayerStateAllocations(
|
||||
LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS, future: '0', next: '3' },
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.throws(
|
||||
() => validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
future: '00',
|
||||
}),
|
||||
/next free token 0/,
|
||||
);
|
||||
const beforeLastDigit = { ...digitsExhausted };
|
||||
delete beforeLastDigit['prior-9'];
|
||||
assert.equal(
|
||||
validateLayerStateAllocations(
|
||||
beforeLastDigit,
|
||||
{ ...beforeLastDigit, futurePair: '00', futureDigit: '9' },
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
validateLayerStateAllocations(
|
||||
digitsExhausted,
|
||||
{ ...digitsExhausted, pairB: '01', pairA: '00' },
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
validateLayerStateAllocations(
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '0' },
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '0', competing: '0' },
|
||||
),
|
||||
/next free token 3/,
|
||||
);
|
||||
assert.throws(
|
||||
() => validateLayerStateAllocations({ future: '00' }, { future: '01' }),
|
||||
/changed or removed/,
|
||||
);
|
||||
assert.throws(
|
||||
() => validateLayerStateAllocations({ retired: '00' }, { newcomer: '00' }),
|
||||
/changed or removed/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
validateLayerStateRegistry([
|
||||
@@ -206,6 +349,30 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
||||
]),
|
||||
/Duplicate layer-state id/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
validateLayerStateRegistry(
|
||||
[{ id: 'future-layer', token: '000', disposition: 'enabled-only' }],
|
||||
{ 'future-layer': '000' },
|
||||
),
|
||||
/Invalid layer-state token reservation/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
validateLayerStateRegistry(
|
||||
[{ id: 'future-layer', token: 'a', disposition: 'enabled-only' }],
|
||||
{ 'future-layer': 'a' },
|
||||
),
|
||||
/Legacy layer-state token is immutable/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
validateLayerStateRegistry(
|
||||
[{ id: 'flights', token: '0', disposition: 'enabled-only' }],
|
||||
{ flights: '0' },
|
||||
),
|
||||
/Legacy layer-state token is immutable/,
|
||||
);
|
||||
|
||||
const manager = new DataLayerManager({});
|
||||
manager.register(fakeLayer('earthquakes'));
|
||||
@@ -289,6 +456,25 @@ test('v2 codec distinguishes absent from empty and keeps canonical deterministic
|
||||
);
|
||||
});
|
||||
|
||||
test('all production layers and options round-trip through a v2 share URL', () => {
|
||||
const allEnabled = normalizeLayerState({
|
||||
enabledLayerIds: REGISTERED_LAYER_IDS,
|
||||
options: {
|
||||
cctv: { coverageMode: 'viewshed', showProjection: false, autoHop: true },
|
||||
flights: { models3d: true, models3dMode: 'all' },
|
||||
radio: { filter: 'news', volume: 0.37 },
|
||||
},
|
||||
});
|
||||
const shareUrl = new URL('https://example.invalid/');
|
||||
shareUrl.hash = encode(allEnabled);
|
||||
const params = new URLSearchParams(shareUrl.hash.slice(1));
|
||||
const restored = decodeLayerStateParams(params);
|
||||
|
||||
assert.equal(params.get('l')?.split('.').length, REGISTERED_LAYER_IDS.length);
|
||||
assert.deepEqual(restored?.enabledLayerIds, REGISTERED_LAYER_IDS);
|
||||
assert.deepEqual(restored?.options, allEnabled.options);
|
||||
});
|
||||
|
||||
test('unknown enabled-layer tokens reject the payload instead of becoming an empty set', () => {
|
||||
assert.equal(
|
||||
decodeLayerStateParams(new URLSearchParams('v=2&l=unknown')),
|
||||
@@ -300,6 +486,27 @@ test('unknown enabled-layer tokens reject the payload instead of becoming an emp
|
||||
);
|
||||
});
|
||||
|
||||
test('malformed enabled-layer lists reject the entire payload', () => {
|
||||
for (const value of ['.c', 'c.', 'c..e', 'c.c', '00', 'c.00']) {
|
||||
assert.equal(
|
||||
decodeLayerStateParams(new URLSearchParams(`v=2&l=${value}`)),
|
||||
null,
|
||||
`l=${value}`,
|
||||
);
|
||||
}
|
||||
assert.deepEqual(
|
||||
decodeLayerStateParams(new URLSearchParams('v=2&l=c.e')).enabledLayerIds,
|
||||
['cctv', 'earthquakes'],
|
||||
);
|
||||
for (const fields of ['l=f&l=f', 'l=f&l=unknown', 'l=&l=f']) {
|
||||
assert.equal(
|
||||
decodeLayerStateParams(new URLSearchParams(`v=2&${fields}`)),
|
||||
null,
|
||||
fields,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('Nepal event and locator have distinct enabled-only share tokens', () => {
|
||||
const decoded = decodeLayerStateParams(new URLSearchParams('v=2&l=h.z'));
|
||||
assert.deepEqual(decoded.enabledLayerIds, [
|
||||
@@ -2421,7 +2628,17 @@ test('the recent-imagery split is share-link only: never stored locally, and a s
|
||||
|
||||
test('fire perimeters uses digit 2 without colliding with wind or recent imagery', () => {
|
||||
const decoded = decodeLayerStateParams(new URLSearchParams('v=2&l=2.k.1'));
|
||||
assert.deepEqual(decoded.enabledLayerIds, ['fire-perimeters', 'recent-imagery', 'wind']);
|
||||
assert.equal(LAYER_STATE_REGISTRY.find(({ id }) => id === 'fire-perimeters').token, '2');
|
||||
assert.deepEqual(decodeLayerStateParams(new URLSearchParams(encode(decoded))), decoded);
|
||||
assert.deepEqual(decoded.enabledLayerIds, [
|
||||
'fire-perimeters',
|
||||
'recent-imagery',
|
||||
'wind',
|
||||
]);
|
||||
assert.equal(
|
||||
LAYER_STATE_REGISTRY.find(({ id }) => id === 'fire-perimeters').token,
|
||||
'2',
|
||||
);
|
||||
assert.deepEqual(
|
||||
decodeLayerStateParams(new URLSearchParams(encode(decoded))),
|
||||
decoded,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,419 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
import {
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
LEGACY_LAYER_STATE_TOKENS,
|
||||
LAYER_STATE_TOKEN_ALPHABET,
|
||||
LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
nextLayerStateToken,
|
||||
parseLayerStateTokenReservations,
|
||||
validateLayerStateAllocations,
|
||||
} from './layerState.js';
|
||||
import {
|
||||
PRE_LEDGER_LAYER_STATE_TOKENS,
|
||||
checkLayerStateTokens,
|
||||
readPublishedLayerStateReservations,
|
||||
} from '../../scripts/check-layer-state-tokens.mjs';
|
||||
|
||||
const LEDGER_PATH = 'src/data/layerStateTokenReservations.json';
|
||||
const SOURCE_PATH = 'src/data/layerState.js';
|
||||
const CODEC_SOURCE_PATH = fileURLToPath(
|
||||
new URL('./layerState.js', import.meta.url),
|
||||
);
|
||||
const CHECKER_PATH = fileURLToPath(
|
||||
new URL('../../scripts/check-layer-state-tokens.mjs', import.meta.url),
|
||||
);
|
||||
const NEXT_PATH = fileURLToPath(
|
||||
new URL('../../scripts/next-layer-state-token.mjs', import.meta.url),
|
||||
);
|
||||
const reservationRows = JSON.parse(
|
||||
readFileSync(
|
||||
new URL('./layerStateTokenReservations.json', import.meta.url),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
|
||||
test('pre-ledger published ownership is independent of the candidate mapping', () => {
|
||||
assert.notStrictEqual(
|
||||
PRE_LEDGER_LAYER_STATE_TOKENS,
|
||||
LEGACY_LAYER_STATE_TOKENS,
|
||||
);
|
||||
assert.deepEqual(PRE_LEDGER_LAYER_STATE_TOKENS, LEGACY_LAYER_STATE_TOKENS);
|
||||
assert.equal(Object.keys(PRE_LEDGER_LAYER_STATE_TOKENS).length, 28);
|
||||
assert.equal(PRE_LEDGER_LAYER_STATE_TOKENS['recent-imagery'], '1');
|
||||
assert.equal(PRE_LEDGER_LAYER_STATE_TOKENS['fire-perimeters'], '2');
|
||||
assert.throws(
|
||||
() =>
|
||||
validateLayerStateAllocations(PRE_LEDGER_LAYER_STATE_TOKENS, {
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
flights: '00',
|
||||
}),
|
||||
/Published layer-state token changed or removed: flights/,
|
||||
);
|
||||
});
|
||||
|
||||
function git(cwd, ...args) {
|
||||
return execFileSync('git', args, { cwd, encoding: 'utf8' }).trim();
|
||||
}
|
||||
|
||||
function withPublishedBase(
|
||||
{ rows, source = 'export const changed = true;\n' },
|
||||
check,
|
||||
) {
|
||||
const cwd = mkdtempSync(path.join(tmpdir(), 'gev-layer-token-ledger-'));
|
||||
try {
|
||||
git(cwd, 'init', '-q');
|
||||
mkdirSync(path.join(cwd, 'src/data'), { recursive: true });
|
||||
writeFileSync(path.join(cwd, SOURCE_PATH), source);
|
||||
if (rows !== undefined) {
|
||||
writeFileSync(path.join(cwd, LEDGER_PATH), JSON.stringify(rows));
|
||||
}
|
||||
git(cwd, 'add', 'src/data');
|
||||
git(
|
||||
cwd,
|
||||
'-c',
|
||||
'user.name=Layer Token Test',
|
||||
'-c',
|
||||
'[email protected]',
|
||||
'commit',
|
||||
'-q',
|
||||
'-m',
|
||||
'published base',
|
||||
);
|
||||
check(cwd);
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function writeCodecFixture(cwd, rows, entries) {
|
||||
const source = readFileSync(CODEC_SOURCE_PATH, 'utf8');
|
||||
const registryEnd = ']);\n\nexport const REGISTERED_LAYER_IDS';
|
||||
assert.equal(source.split(registryEnd).length, 2);
|
||||
const additions = entries
|
||||
.map(
|
||||
({ id, token }) =>
|
||||
` Object.freeze({ id: '${id}', token: '${token}', disposition: 'enabled-only' }),`,
|
||||
)
|
||||
.join('\n');
|
||||
mkdirSync(path.join(cwd, 'src/data'), { recursive: true });
|
||||
writeFileSync(path.join(cwd, 'package.json'), '{"type":"module"}');
|
||||
writeFileSync(
|
||||
path.join(cwd, SOURCE_PATH),
|
||||
source.replace(registryEnd, `${additions}\n${registryEnd}`),
|
||||
);
|
||||
writeFileSync(path.join(cwd, LEDGER_PATH), JSON.stringify(rows));
|
||||
return path.join(cwd, SOURCE_PATH);
|
||||
}
|
||||
|
||||
test('reservation ledger is complete, pinned, and rejects duplicate or malformed rows', () => {
|
||||
assert.deepEqual(
|
||||
{ ...parseLayerStateTokenReservations(reservationRows) },
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
|
||||
);
|
||||
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 28);
|
||||
assert.deepEqual(
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
|
||||
{ ...LEGACY_LAYER_STATE_TOKENS },
|
||||
);
|
||||
assert.throws(
|
||||
() => parseLayerStateTokenReservations(reservationRows.slice(1)),
|
||||
/Missing or changed legacy/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
parseLayerStateTokenReservations([
|
||||
...reservationRows,
|
||||
reservationRows[0],
|
||||
]),
|
||||
/Duplicate layer-state token reservation id/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
parseLayerStateTokenReservations([...reservationRows, ['future', 'a']]),
|
||||
/Duplicate layer-state token reservation/,
|
||||
);
|
||||
assert.equal(
|
||||
parseLayerStateTokenReservations([...reservationRows, ['future', '0']])
|
||||
.future,
|
||||
'0',
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
parseLayerStateTokenReservations([
|
||||
...reservationRows.filter(([id]) => id !== 'transit'),
|
||||
['future', 'j'],
|
||||
]),
|
||||
/Legacy layer-state token is immutable/,
|
||||
);
|
||||
assert.throws(
|
||||
() => parseLayerStateTokenReservations([...reservationRows, ['future']]),
|
||||
/Invalid layer-state token ledger row/,
|
||||
);
|
||||
});
|
||||
|
||||
test('published retired digits and competing PRs advance the merge-time allocation', () => {
|
||||
withPublishedBase(
|
||||
{ rows: [...reservationRows, ['retired-layer', '0']] },
|
||||
(cwd) => {
|
||||
const published = readPublishedLayerStateReservations('HEAD', cwd);
|
||||
assert.equal(nextLayerStateToken(published), '3');
|
||||
assert.equal(
|
||||
validateLayerStateAllocations(published, {
|
||||
...published,
|
||||
newcomer: '3',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
validateLayerStateAllocations(published, {
|
||||
...published,
|
||||
competing: '0',
|
||||
}),
|
||||
/next free token 3/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
validateLayerStateAllocations(published, {
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
newcomer: '0',
|
||||
}),
|
||||
/changed or removed: retired-layer/,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('PR B manually replaces provisional 0 with 3 after PR A publishes 0', () => {
|
||||
withPublishedBase({ rows: [...reservationRows, ['pr-a', '0']] }, (cwd) => {
|
||||
mkdirSync(path.join(cwd, 'scripts'));
|
||||
const fixtureChecker = path.join(
|
||||
cwd,
|
||||
'scripts/check-layer-state-tokens.mjs',
|
||||
);
|
||||
writeFileSync(fixtureChecker, readFileSync(CHECKER_PATH, 'utf8'));
|
||||
const fixtureNext = path.join(cwd, 'scripts/next-layer-state-token.mjs');
|
||||
writeFileSync(fixtureNext, readFileSync(NEXT_PATH, 'utf8'));
|
||||
const runCheck = () =>
|
||||
spawnSync(
|
||||
process.execPath,
|
||||
[realpathSync(fixtureChecker), '--base-ref', 'HEAD'],
|
||||
{
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
},
|
||||
);
|
||||
|
||||
writeCodecFixture(
|
||||
cwd,
|
||||
[...reservationRows, ['pr-b', '0']],
|
||||
[{ id: 'pr-b', token: '0' }],
|
||||
);
|
||||
const beforeRebase = runCheck();
|
||||
assert.equal(beforeRebase.status, 1);
|
||||
assert.match(beforeRebase.stderr, /changed or removed: pr-a/);
|
||||
|
||||
writeCodecFixture(
|
||||
cwd,
|
||||
[...reservationRows, ['pr-a', '0'], ['pr-b', '0']],
|
||||
[
|
||||
{ id: 'pr-a', token: '0' },
|
||||
{ id: 'pr-b', token: '0' },
|
||||
],
|
||||
);
|
||||
const staleRebase = runCheck();
|
||||
assert.equal(staleRebase.status, 1);
|
||||
assert.match(staleRebase.stderr, /Duplicate layer-state token reservation/);
|
||||
|
||||
const published = readPublishedLayerStateReservations('HEAD', cwd);
|
||||
assert.equal(nextLayerStateToken(published), '3');
|
||||
const validBaselineRows = [...reservationRows, ['pr-a', '0']];
|
||||
writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '0' }]);
|
||||
const helper = spawnSync(
|
||||
process.execPath,
|
||||
[realpathSync(fixtureNext), 'pr-b'],
|
||||
{ cwd, encoding: 'utf8' },
|
||||
);
|
||||
assert.equal(helper.status, 0, helper.stderr);
|
||||
assert.match(helper.stdout, /pr-b: 3/);
|
||||
assert.deepEqual(
|
||||
JSON.parse(readFileSync(path.join(cwd, LEDGER_PATH), 'utf8')),
|
||||
validBaselineRows,
|
||||
'the helper reports a token without rewriting the ledger',
|
||||
);
|
||||
writeCodecFixture(
|
||||
cwd,
|
||||
[...reservationRows, ['pr-a', '0'], ['pr-b', '3']],
|
||||
[
|
||||
{ id: 'pr-a', token: '0' },
|
||||
{ id: 'pr-b', token: '3' },
|
||||
],
|
||||
);
|
||||
const corrected = runCheck();
|
||||
assert.equal(corrected.status, 0, corrected.stderr);
|
||||
assert.match(corrected.stdout, /29 published, 1 new/);
|
||||
});
|
||||
});
|
||||
|
||||
test('allocation batches cross the last digit and base-36 pair boundaries in order', () => {
|
||||
const digitRows = [...'03456789'].map((digit) => [`retired-${digit}`, digit]);
|
||||
const allDigits = parseLayerStateTokenReservations([
|
||||
...reservationRows,
|
||||
...digitRows,
|
||||
]);
|
||||
const beforeNine = { ...allDigits };
|
||||
delete beforeNine['retired-9'];
|
||||
assert.equal(
|
||||
validateLayerStateAllocations(beforeNine, {
|
||||
...beforeNine,
|
||||
pair: '00',
|
||||
lastDigit: '9',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
const baseBefore0a = {
|
||||
...allDigits,
|
||||
...Object.fromEntries(
|
||||
[...'012345678'].map((second) => [`retired-0${second}`, `0${second}`]),
|
||||
),
|
||||
};
|
||||
assert.equal(nextLayerStateToken(baseBefore0a), '09');
|
||||
assert.equal(
|
||||
validateLayerStateAllocations(baseBefore0a, {
|
||||
...baseBefore0a,
|
||||
after: '0a',
|
||||
before: '09',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
const baseBefore10 = {
|
||||
...allDigits,
|
||||
...Object.fromEntries(
|
||||
[...LAYER_STATE_TOKEN_ALPHABET]
|
||||
.slice(0, -1)
|
||||
.map((second) => [`retired-0${second}`, `0${second}`]),
|
||||
),
|
||||
};
|
||||
assert.equal(nextLayerStateToken(baseBefore10), '0z');
|
||||
assert.equal(
|
||||
validateLayerStateAllocations(baseBefore10, {
|
||||
...baseBefore10,
|
||||
after: '10',
|
||||
before: '0z',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('an isolated valid two-character fixture round-trips an l field beyond the old 64-character cap', async () => {
|
||||
const cwd = mkdtempSync(path.join(tmpdir(), 'gev-layer-token-width-'));
|
||||
try {
|
||||
const priorDigits = [...'03456789'].map((digit) => [
|
||||
`qa-prior-digit-${digit}`,
|
||||
digit,
|
||||
]);
|
||||
const fixtureLayers = ['00', '01', '02', '03'].map((token) => ({
|
||||
id: `qa-layer-${token}`,
|
||||
token,
|
||||
}));
|
||||
const fixtureRows = [
|
||||
...reservationRows,
|
||||
...priorDigits,
|
||||
...fixtureLayers.map(({ id, token }) => [id, token]),
|
||||
];
|
||||
const fixtureModule = writeCodecFixture(cwd, fixtureRows, fixtureLayers);
|
||||
const codec = await import(pathToFileURL(fixtureModule));
|
||||
assert.equal(codec.validateLayerStateRegistry(), true);
|
||||
assert.equal(
|
||||
nextLayerStateToken(
|
||||
parseLayerStateTokenReservations([...reservationRows, ...priorDigits]),
|
||||
),
|
||||
'00',
|
||||
);
|
||||
assert.equal(
|
||||
validateLayerStateAllocations(
|
||||
LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
codec.LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
),
|
||||
true,
|
||||
);
|
||||
|
||||
const expectedLayerIds = codec.REGISTERED_LAYER_IDS;
|
||||
const state = codec.normalizeLayerState({
|
||||
enabledLayerIds: expectedLayerIds,
|
||||
});
|
||||
const params = new URLSearchParams([['v', '2']]);
|
||||
codec.encodeLayerStateParams(params, state);
|
||||
assert.ok(params.get('l').length > 64, 'fixture must cross the old cap');
|
||||
assert.equal(params.get('l').length, 67);
|
||||
const restored = codec.decodeLayerStateParams(params);
|
||||
assert.deepEqual(restored?.enabledLayerIds, expectedLayerIds);
|
||||
assert.deepEqual(restored?.options, state.options);
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('checker reads a complete future base ledger and rejects retired-token reuse', () => {
|
||||
withPublishedBase(
|
||||
{
|
||||
rows: [...reservationRows, ['retired-layer', '00']],
|
||||
source:
|
||||
'export const LAYER_STATE_TOKEN_RESERVATIONS = Object.freeze({ ["retired-layer"]: "00" });\n',
|
||||
},
|
||||
(cwd) => {
|
||||
const published = readPublishedLayerStateReservations('HEAD', cwd);
|
||||
assert.equal(Object.keys(published).length, 29);
|
||||
assert.equal(published['retired-layer'], '00');
|
||||
assert.throws(
|
||||
() =>
|
||||
validateLayerStateAllocations(published, {
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
newcomer: '00',
|
||||
}),
|
||||
/changed or removed/,
|
||||
);
|
||||
assert.throws(
|
||||
() => checkLayerStateTokens('HEAD', cwd),
|
||||
/changed or removed/,
|
||||
);
|
||||
const cli = spawnSync(
|
||||
process.execPath,
|
||||
[CHECKER_PATH, '--base-ref', 'HEAD'],
|
||||
{
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
},
|
||||
);
|
||||
assert.equal(cli.status, 1);
|
||||
assert.match(cli.stderr, /changed or removed/);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('checker fails closed on unknown or malformed published ledgers', () => {
|
||||
withPublishedBase({}, (cwd) => {
|
||||
assert.throws(
|
||||
() => readPublishedLayerStateReservations('HEAD', cwd),
|
||||
/no recognizable layer-state token ledger/,
|
||||
);
|
||||
});
|
||||
withPublishedBase({ rows: [...reservationRows, ['future', 'a']] }, (cwd) => {
|
||||
assert.throws(
|
||||
() => readPublishedLayerStateReservations('HEAD', cwd),
|
||||
/Duplicate layer-state token reservation/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
[
|
||||
["ais-live-vessels", "a"],
|
||||
["alpr-cameras", "p"],
|
||||
["bhote-koshi-2026", "h"],
|
||||
["bhote-koshi-locator", "z"],
|
||||
["bikeshare", "b"],
|
||||
["cctv", "c"],
|
||||
["directions", "n"],
|
||||
["earthquakes", "e"],
|
||||
["fire-perimeters", "2"],
|
||||
["flights", "f"],
|
||||
["local-dams", "q"],
|
||||
["local-datacenters", "d"],
|
||||
["local-firms", "w"],
|
||||
["military", "m"],
|
||||
["military-awareness", "g"],
|
||||
["military-installations", "i"],
|
||||
["radio", "r"],
|
||||
["recent-imagery", "1"],
|
||||
["rocket-launches", "x"],
|
||||
["satellites", "s"],
|
||||
["telegeography-submarine-cables", "u"],
|
||||
["traffic", "t"],
|
||||
["transit", "j"],
|
||||
["weather-cyclones", "y"],
|
||||
["weather-lightning", "l"],
|
||||
["weather-radar", "v"],
|
||||
["weather-satellite", "o"],
|
||||
["wind", "k"]
|
||||
]
|
||||
@@ -1,3 +1,5 @@
|
||||
import { cancelCameraArrival } from './cameraArrival.js';
|
||||
|
||||
function cloneLayerParams(value) {
|
||||
if (Array.isArray(value)) return value.map(cloneLayerParams);
|
||||
if (value && typeof value === 'object') {
|
||||
@@ -1074,6 +1076,7 @@ export class LayerLifecycle {
|
||||
const entry = this.layers.get(layerId);
|
||||
if (!entry) return { intentEpoch: null, promise: Promise.resolve() };
|
||||
const desiredState = Boolean(shouldEnable);
|
||||
if (!desiredState) cancelCameraArrival(this.viewer);
|
||||
if (entry.destroying) {
|
||||
return {
|
||||
intentEpoch: null,
|
||||
@@ -2095,6 +2098,7 @@ export class LayerLifecycle {
|
||||
async destroyLayer(layerId) {
|
||||
const entry = this.layers.get(layerId);
|
||||
if (!entry || entry.destroying) return false;
|
||||
cancelCameraArrival(this.viewer);
|
||||
entry.destroying = true;
|
||||
this._invalidateRefresh(layerId, entry, 'layer-destroyed');
|
||||
// Teardown becomes authoritative before the first await. Advancing the
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { createInfrastructureOverlayEntry } from './infrastructureOverlayEntry.js';
|
||||
import { isPointerFree } from './inputOwnership.js';
|
||||
import {
|
||||
selectInfraLod,
|
||||
@@ -14,10 +15,6 @@ const VISIBILITY_UPDATE_MS = 450;
|
||||
export const LOCAL_OVERLAY_COHORT_LIMIT = 160;
|
||||
const LOCAL_OVERLAY_COLLISION_CAPACITY = 96;
|
||||
const LOCAL_OVERLAY_CELL_SURPLUS = 2;
|
||||
const LOCAL_OVERLAY_MAX_DISTANCE_M = 14000000;
|
||||
const LOCAL_OVERLAY_FADE_START_M = 250000;
|
||||
const LOCAL_OVERLAY_FADE_START_RATIO =
|
||||
LOCAL_OVERLAY_FADE_START_M / LOCAL_OVERLAY_MAX_DISTANCE_M;
|
||||
// Stems are anchored at ellipsoid height 0, but high-elevation features
|
||||
// (e.g. dams in river canyons) sit hundreds of meters above the ellipsoid,
|
||||
// burying the short close-in stem inside the photoreal mesh. Once the
|
||||
@@ -182,33 +179,15 @@ export function createLocalInfrastructureOverlayEntry({
|
||||
accent,
|
||||
}) {
|
||||
const copy = localInfrastructureOverlayCopy(properties, layerId);
|
||||
return {
|
||||
id: String(id),
|
||||
return createInfrastructureOverlayEntry({
|
||||
id,
|
||||
source: layerId,
|
||||
position,
|
||||
variant: 'card',
|
||||
title: copy.title,
|
||||
details: copy.details,
|
||||
accent,
|
||||
priority,
|
||||
collisionGroup: 'ambient-card',
|
||||
zIndex: 30,
|
||||
interactive: false,
|
||||
minDistance: 0,
|
||||
maxDistance: LOCAL_OVERLAY_MAX_DISTANCE_M,
|
||||
distanceFadeStartRatio: LOCAL_OVERLAY_FADE_START_RATIO,
|
||||
distanceScale: {
|
||||
near: 250000,
|
||||
nearValue: 1,
|
||||
far: 9000000,
|
||||
farValue: 0.62,
|
||||
},
|
||||
edgeFade: 'keyhole',
|
||||
horizonCull: true,
|
||||
terrainOcclusion: false,
|
||||
gapPx: 15,
|
||||
placement: 'above',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -394,6 +373,7 @@ export function createLocalGeoJsonLayer(
|
||||
color,
|
||||
icon = '📍',
|
||||
source = 'Local JSONL',
|
||||
osmDerived = false,
|
||||
labels = true,
|
||||
labelMax = DEFAULT_LABEL_MAX,
|
||||
labelGridPx = DEFAULT_LABEL_GRID_PX,
|
||||
@@ -409,6 +389,8 @@ export function createLocalGeoJsonLayer(
|
||||
clearSelectedEntityContextForLayer,
|
||||
removeEntityContextsForLayer,
|
||||
governorRequestRender,
|
||||
showOsmCredit,
|
||||
hideOsmCredit,
|
||||
},
|
||||
) {
|
||||
let _dataSource = null;
|
||||
@@ -538,6 +520,7 @@ export function createLocalGeoJsonLayer(
|
||||
};
|
||||
|
||||
const disableLayer = (viewer) => {
|
||||
hideOsmCredit?.(viewer, id);
|
||||
_enabled = false;
|
||||
clearGroundRetryRender();
|
||||
_activeLodIds = new Set();
|
||||
@@ -942,6 +925,7 @@ export function createLocalGeoJsonLayer(
|
||||
}
|
||||
|
||||
if (_destroyed) return;
|
||||
if (_enabled && _count > 0 && osmDerived) showOsmCredit?.(viewer, id);
|
||||
// 3. Add an incredibly fast pre-render occluder to hide points behind the globe
|
||||
if (_enabled && !_preRenderRemover) {
|
||||
_preRenderRemover = viewer.scene.preRender.addEventListener(() => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Natural Earth physical regions pack
|
||||
# Natural Earth packs
|
||||
|
||||
Offline named-region polygons for the voice-annotation resolver
|
||||
(`src/data/naturalEarthRegions.js`) — "outline the Alps" resolves to the real
|
||||
@@ -8,6 +8,8 @@ range geometry with no network dependency.
|
||||
|------|----------------|----------|
|
||||
| `regions.json` | `ne_10m_geography_regions_polys` (ranges, deserts, plateaus, peninsulas, islands, …) | 1,046 named |
|
||||
| `marine.json` | `ne_10m_geography_marine_polys` (seas, gulfs, straits, bays, …) | 292 named |
|
||||
| `countries.json` | `ne_10m_admin_0_countries` (countries) | 256 |
|
||||
| `states_provinces.json` | `ne_10m_admin_1_states_provinces` (states, provinces and other first-level units) | 4,587 named |
|
||||
|
||||
**Source:** Natural Earth 10m physical vectors, via the canonical
|
||||
[nvkelso/natural-earth-vector](https://github.com/nvkelso/natural-earth-vector)
|
||||
@@ -34,3 +36,70 @@ See DATA_SOURCES.md.
|
||||
|
||||
Duplicate names exist upstream (two "Cordillera Oriental", a sliver + real
|
||||
"Canadian Shield", …); the lookup module resolves ties by largest area.
|
||||
|
||||
## States and provinces (`states_provinces.json`)
|
||||
|
||||
Worldwide first-level administrative units for area annotations ("outline
|
||||
Texas", "outline Bavaria"), read by `src/data/adminBoundaries.js`.
|
||||
|
||||
**Source:** `geojson/ne_10m_admin_1_states_provinces.geojson` from
|
||||
[nvkelso/natural-earth-vector](https://github.com/nvkelso/natural-earth-vector)
|
||||
at commit `ca96624a56bd078437bca8184e78163e5039ad19` (SHA-256
|
||||
`22d0e3ad85eb3e27f17cabf8ba2d50e554fbc27a87796ff891d958185da62fb5`), fetched
|
||||
2026-09-25. `geojson/ne_10m_populated_places_simple.geojson` from the same
|
||||
commit (SHA-256
|
||||
`fd3fa867a320cbd5c5b6bb5bc550afeec2939fb2cef688e508007282a55ac42f`) is read
|
||||
only to mark ambiguous names; none of it is bundled.
|
||||
|
||||
**License:** public domain, as above.
|
||||
|
||||
**Regenerate:** `node scripts/build-admin-packs.mjs --only admin1` downloads
|
||||
the pinned files (to the system temp directory, or `--cache <dir>`), checks
|
||||
their SHA-256 and rewrites the pack byte for byte. Parameters are in the
|
||||
script's `PARAMS` and in the pack's `meta.curation`.
|
||||
|
||||
**Schema:** `meta` (source, URLs, hashes, license, curation) and `features[]`
|
||||
with `name`, `nameEn` (when it differs), `alt[]` (Latin-script `name_alt`
|
||||
variants and abbreviations), `postal`, `type` (`type_en`), `rank`
|
||||
(`min_label`), `country` (`admin`), `iso2`, `iso` (ISO 3166-2), `amb[]`,
|
||||
`ambAbroad[]`, `cityState`, `label` ([lon, lat] label point) and
|
||||
`polygons`. `polygons` is a list of parts, largest first, each
|
||||
`[outer, ...holes]`; every ring is open and stored as integers in units of
|
||||
10^-3 degrees (10^-`d` when a feature carries `d`), the first vertex absolute
|
||||
and each later one as a `[dLon, dLat]` delta.
|
||||
|
||||
**Curation:**
|
||||
|
||||
- The 7 unnamed features and 2 degenerate Nauru districts are dropped
|
||||
(4,596 → 4,587).
|
||||
- Douglas-Peucker per ring at 0.004 × √(unit area in km²) km, clamped to
|
||||
0.005°–0.02° (Texas ≈ 0.02°, Bavaria ≈ 0.01°); coordinates rounded to
|
||||
3 decimals. The 75 units too small to survive (Maldives atolls, Malta's
|
||||
councils) keep 4 decimals at 0.001° (`d: 4`); 3 smaller still (Vatican
|
||||
City) keep their source rings at 5 decimals (`d: 5`).
|
||||
- Parts under 20 km² or under 0.03% of the unit's largest part are dropped;
|
||||
holes under 20 km² are dropped. Outers are counter-clockwise, holes
|
||||
clockwise.
|
||||
- `amb` lists names that are also a country (`admin`/`geonunit` of any
|
||||
unit), a populated place of 200,000+ (`pop_max`) inside the unit's box
|
||||
when the unit is larger than 1,000 km², or a populated place of 50,000+
|
||||
elsewhere in the same country; `ambAbroad` lists names shared only with a
|
||||
populated place of 50,000+ in another country. `cityState` marks a unit of
|
||||
at most 1,000 km² that contains its namesake city (Berlin, Vienna, Paris).
|
||||
548 units carry `amb`, 108 `ambAbroad`, 138 `cityState`.
|
||||
- Result: 3,648,916 bytes (1,260,545 with gzip -9); 6,492 parts, 80 holes,
|
||||
407,339 vertices.
|
||||
|
||||
## Countries (`countries.json`)
|
||||
|
||||
Natural Earth 10m admin-0 countries, public domain under the terms above.
|
||||
Source: `geojson/ne_10m_admin_0_countries.geojson` at the same pinned commit;
|
||||
SHA-256 `239eec57ac17f100a11e2536cffc56752c318b50ae765b0918ff7aab4ce8f255`.
|
||||
Regenerate with `node scripts/build-admin-packs.mjs --only countries`.
|
||||
Uses the state/province encoding and simplification, retaining parts of at least
|
||||
20 km² and 0.003% of the largest part (including Alaska and Hawaii); the largest
|
||||
part always stays. Holes under 20 km² are dropped.
|
||||
Includes England, Scotland, Wales and Northern Ireland from
|
||||
`ne_10m_admin_0_map_units.geojson` at the same commit (SHA-256
|
||||
`57da82be755f4afccd8f3b14251bb2752f5df1395f47d2d86f817470c4a48862`).
|
||||
Result: 945,843 bytes; 260 features, 2,088 parts, 122,788 vertices.
|
||||
@@ -0,0 +1 @@
|
||||
{"meta":{"title":"Natural Earth admin-0 countries","mapUnits":{"url":"https://raw.githubusercontent.com/nvkelso/natural-earth-vector/ca96624a56bd078437bca8184e78163e5039ad19/geojson/ne_10m_admin_0_map_units.geojson","sha256":"57da82be755f4afccd8f3b14251bb2752f5df1395f47d2d86f817470c4a48862"},"source":"ne_10m_admin_0_countries","url":"https://raw.githubusercontent.com/nvkelso/natural-earth-vector/ca96624a56bd078437bca8184e78163e5039ad19/geojson/ne_10m_admin_0_countries.geojson","sha256":"239eec57ac17f100a11e2536cffc56752c318b50ae765b0918ff7aab4ce8f255","commit":"ca96624a56bd078437bca8184e78163e5039ad19","license":"Public domain (https://www.naturalearthdata.com/about/terms-of-use/)","decimals":3,"curation":{"toleranceFactor":0.004,"minToleranceDeg":0.005,"maxToleranceDeg":0.02,"decimals":3,"minPartKm2":20,"minPartShare":0.00003,"minHoleKm2":20},"script":"scripts/build-admin-packs.mjs"},"features":[{"name":"Afghanistan","alt":["Islamic State of Afghanistan"],"country":"Afghanistan","iso":"AFG","iso2":"AF","type":"Country","polygons":[[[74383,37127,-15,40,89,10,43,55,123,-1,98,67,73,-84,98,17,-103,100,-129,63,-139,-18,-205,51,-12,-27,-80,3,-36,-65,-479,-121,-112,45,142,76,14,90,-268,53,-189,-16,-164,-81,-64,-69,-277,-95,-133,-191,-398,-62,-423,-268,-184,-12,-100,83,-122,297,63,241,36,453,60,55,8,82,-61,46,-196,-51,-83,33,101,258,-1,67,-407,222,-15,-40,-159,13,-112,-41,20,-18,-85,-40,-17,-72,-36,-12,-77,-142,-216,-148,8,-34,-90,7,-7,-56,82,-71,36,-114,-35,-82,-90,-82,-150,8,-89,68,-117,-35,-293,-3,-131,-142,32,-191,32,-22,-130,-107,-66,-13,-122,65,-139,151,-97,18,28,-59,-80,50,-26,-17,10,-61,-151,27,-42,-72,-223,-54,-12,-47,-104,-2,-10,-93,-92,8,-166,-92,-142,138,-105,46,8,79,-34,40,-114,25,-72,-34,-58,62,-244,-97,-136,97,-26,59,-91,43,-712,-53,-151,54,-63,56,-225,67,-94,70,-22,-49,-80,-19,-38,-80,3,-85,-87,-88,-474,-24,-284,-115,-23,-64,23,-116,-174,-277,-11,-237,-116,-152,-174,-60,-38,-60,-107,8,-102,-55,-12,-106,-155,31,-124,-53,-184,-18,-152,-88,-326,-15,-20,-30,28,-56,102,-71,-137,-74,24,-100,-21,-88,-162,-49,-224,-140,-159,-21,-103,54,-146,-140,-52,153,-227,145,-402,-8,-212,123,-41,74,-82,-10,20,-70,-95,-158,-8,-90,-85,-43,-6,-64,51,-91,-72,-169,-10,-118,-105,-92,-12,-85,-248,-122,190,-197,-246,-12,-151,-168,35,-298,-41,-130,25,-73,144,-78,190,-4,75,-42,-89,-30,-3,-68,-261,-265,-5,-93,267,-809,-15,-161,-40,-61,47,-532,865,-122,55,-52,1,-81,83,-225,-24,-168,-958,-989,1634,-450,1091,89,517,-110,87,97,305,86,558,-30,1160,295,145,122,-122,101,86,187,17,192,-54,164,98,322,184,54,113,106,34,113,246,119,74,-6,18,-73,311,-28,419,126,5,61,-159,16,-42,119,127,-9,147,102,123,15,81,50,92,137,116,-59,243,-41,52,29,-131,29,108,40,149,-48,29,-74,99,-98,236,70,259,265,-48,193,17,170,-35,162,193,192,-50,117,101,85,11,171,60,47,224,40,109,-26,114,38,53,67,77,5,169,120,-8,64,-131,124,19,125,-56,85,-160,36,-111,174,26,90,130,27,325,-87,194,-18,340,26,22,42,56,-6,123,53,45,111,14,192,-130,86,-35,89,109,26,15,115,122,75,86,127,181,75,41,59,-2,63,129,118,-108,111,91,109,-39,83,11,56,-100,61,22,91,-159,232,-185,94,52,98,335,203,-11,44,63,86,173,-61,-10,83,102,19,164,81,20,47,113,27,-19,48,61,24,240,32,176,75,238,-3,324,47,581,15,262,-76,59,15,36,67,106,4,158,92,63,10,80,-42,5,60]]]},{"name":"Akrotiri Sovereign Base Area","alt":["Akrotiri"],"country":"Akrotiri Sovereign Base Area","iso":"WSB","iso2":"-99","type":"Country","polygons":[[[32970,34632,-7,-7,-9,18,12,9,24,-6,-2,27,-33,4,-19,-19,-19,9,-3,-7,-48,19,-6,22,-28,0,-13,-13,-13,-2,-7,-15,-32,5,-7,-7,1,-16,73,18,80,-14,2,-18,29,-45,-4,-25,89,0,-21,28,7,37]]]},{"name":"Aland","alt":["Åland Islands"],"country":"Aland","iso":"ALD","iso2":"AX","type":"Country","polygons":[[[20236,60232,42,47,-76,14,-21,-7,11,26,-14,11,-52,11,-33,21,-21,-5,-48,11,13,-27,-41,22,-68,0,-22,46,-24,4,-44,-6,-61,-45,42,-8,39,-34,31,32,35,-31,10,-32,-28,-10,-13,35,-13,-48,-21,-14,-7,-28,-34,15,5,-24,-9,-17,-22,9,-27,49,32,51,-48,7,22,-21,-62,-17,-21,-31,3,19,-8,7,-36,2,24,-49,11,-5,13,-42,68,-96,26,22,18,4,17,-6,-1,-10,9,-5,13,0,23,7,5,10,14,5,23,0,14,-9,17,-32,-3,-10,10,-2,10,6,-9,30,57,10,39,-7,5,11,-19,17,13,61,-34,-20,-23,17,-43,13,-27,22,-2,10,40,7,-7,-21,68,-20,-1,18,16,15,-37,24,-18,38,27,-12,22,9,9,18,-10,19,37,-3,-4,-17,-33,-38,8,-19,29,-21,-3,-24,7,0,17,32,29,16,77,6,-16,-12,-49,-2,-21,-10,-13,-25,-2,-14,8,-6,42,0,25,-9,13,3,26,29,29,5,-21,21]],[[20025,60042,79,-14,17,-11,-16,-21,18,-7,48,10,13,-6,-9,-22,50,34,-32,61,-30,22,-62,7,-33,-22,-34,-9]],[[19634,60240,-26,17,-40,-2,-31,-23,0,-31,-24,-17,14,-35,38,-19,20,5,18,29,-11,33,55,-41,24,2,0,33,-32,30]],[[20284,59958,23,-4,15,9,3,14,74,4,48,14,-10,10,-98,15,-37,-21,-21,-22]],[[20256,60145,8,-27,-6,-10,-36,42,-24,-46,4,-16,64,-28,12,6,-9,20,29,36,-20,20]],[[20411,60260,-31,10,-21,-4,-12,-13,-2,-15,8,-9,30,12,5,-19,-7,-23,12,-2,48,48,-2,13,-11,8]],[[20583,60059,-38,-3,-44,-43,26,-8,23,14,37,-11,19,15,2,13,-7,3,17,3,-8,3]]]},{"name":"Albania","alt":["Republic of Albania"],"country":"Albania","iso":"ALB","iso2":"AL","type":"Country","polygons":[[[2Line truncated
|
||||
@@ -0,0 +1 @@
|
||||
{"meta":{"title":"Natural Earth admin-1 states and provinces","source":"ne_10m_admin_1_states_provinces","url":"https://raw.githubusercontent.com/nvkelso/natural-earth-vector/ca96624a56bd078437bca8184e78163e5039ad19/geojson/ne_10m_admin_1_states_provinces.geojson","sha256":"22d0e3ad85eb3e27f17cabf8ba2d50e554fbc27a87796ff891d958185da62fb5","ambiguitySource":"https://raw.githubusercontent.com/nvkelso/natural-earth-vector/ca96624a56bd078437bca8184e78163e5039ad19/geojson/ne_10m_populated_places_simple.geojson","ambiguitySha256":"fd3fa867a320cbd5c5b6bb5bc550afeec2939fb2cef688e508007282a55ac42f","commit":"ca96624a56bd078437bca8184e78163e5039ad19","license":"Public domain (https://www.naturalearthdata.com/about/terms-of-use/)","schema":"features[]: name, nameEn?, alt?[], postal?, type, rank? (min_label: the zoom at which Natural Earth labels the unit; lower is more prominent), country, iso2, iso (ISO 3166-2), amb?[] (normalized names that are also a country or a city — alone or with the country named), ambAbroad?[] (names shared only with a city in another country — ambiguous alone, settled by naming the country), cityState? (the unit is its own city), label? [lon, lat] label point, d? (decimals when not meta.decimals), polygons[][ring] (first ring outer, rest holes; each ring open and encoded as integers in 10^-d degrees, first vertex absolute then [dLon, dLat] deltas)","decimals":3,"curation":{"toleranceFactor":0.004,"minToleranceDeg":0.005,"maxToleranceDeg":0.02,"decimals":3,"minPartKm2":20,"minPartShare":0.0003,"minHoleKm2":20,"ambiguity":{"insideMinPopulation":200000,"elsewhereMinPopulation":50000,"cityStateMaxKm2":1000}},"script":"scripts/build-admin-packs.mjs"},"features":[{"name":"Badakhshan","alt":["Badahšan"],"postal":"BD","type":"Province","rank":6.7,"country":"Afghanistan","iso2":"AF","iso":"AF-BDS","label":[70.804,36.781],"polygons":[[[74892,37231,-29,14,-74,86,-129,63,-29,-13,-110,-5,-66,18,-77,0,-34,27,-23,-7,-5,13,-12,-27,-80,3,-16,-13,-2,-34,-18,-18,-352,-81,-38,-28,-52,2,-37,-14,-29,25,-62,-9,-16,8,-5,21,34,34,59,9,49,33,14,90,-78,3,-190,50,-44,-1,-63,-27,-82,12,-36,-15,-49,-42,-41,0,-38,-24,-64,-69,-72,-6,-94,-55,-111,-34,-133,-191,-149,-18,-34,-14,-69,11,-146,-41,-133,-94,-290,-174,-88,-20,-96,8,-42,18,-58,65,-25,86,-65,92,-3,63,-29,56,19,150,37,50,7,41,-7,101,24,77,-14,81,5,43,20,28,21,82,-13,41,8,18,52,37,8,82,-61,46,-36,2,-160,-53,-83,33,15,72,29,44,57,142,-1,67,-24,30,-116,45,-101,73,-67,10,-52,57,-47,7,-15,-40,-65,20,-28,-13,-66,6,-35,-27,-77,-14,20,-18,-44,-33,-41,-7,-17,-72,-36,-12,-77,-142,-55,-26,-131,-113,-30,-9,-4,-17,12,-17,-47,-10,-43,17,-11,-25,4,-31,31,-50,51,-21,28,-45,8,-69,-35,-82,-32,-6,-17,-38,-41,-38,-29,-9,-125,19,-5,-20,6,-123,37,-21,-24,-28,-14,-83,30,-88,-1,-53,91,-92,-28,-47,-40,-16,-19,-39,-30,-19,-3,-307,33,-100,1,-53,102,-57,84,-78,137,-23,80,0,31,-20,3,-134,-46,-112,33,-67,-161,-91,-28,-30,-19,-45,-23,-135,63,-9,90,37,95,-31,20,-45,56,-55,-19,-69,2,-39,6,-13,33,-4,58,24,16,45,36,26,43,-40,28,-8,106,-5,19,32,-17,84,12,26,82,16,54,57,-45,41,4,35,47,24,42,56,23,87,85,89,-4,15,42,42,15,37,79,38,17,38,84,30,76,70,79,27,-16,28,5,16,33,30,30,56,39,-5,87,-57,47,1,11,11,-19,40,-2,32,102,19,102,64,62,17,16,13,-5,26,9,8,113,27,-22,35,20,22,132,32,152,15,63,43,113,32,238,-3,78,22,45,-10,51,22,150,13,75,-10,65,15,177,-3,131,18,133,-5,92,-19,82,-42,88,-15,59,15,21,59,15,8,106,4,158,92,63,10,64,-46,16,4,10,54,-28,14,-42,53,-94,44,-17,21,2,19,89,10,43,55,90,11,33,-12,28,29,25,4,45,34,16,-2,57,-82]]]},{"name":"Badghis","alt":["Badghes","Badghisat","Badgis"],"postal":"BG","type":"Province","rank":6.7,"country":"Afghanistan","iso2":"AF","iso":"AF-BDG","label":[63.85,35.27],"polygons":[[[63125,35860,-21,-4,-15,-13,-5,-17,28,-56,72,-58,25,-8,5,-12,-34,-26,-77,-20,-26,-21,0,-66,24,-34,-21,-88,-13,-11,-82,-11,-67,-27,-95,-60,-39,-101,-84,-115,-28,-70,-20,-148,16,-51,49,-17,34,-1,69,-50,46,-14,47,-29,82,-6,31,-35,8,-45,77,-11,62,-24,71,34,116,-10,73,-22,99,-52,76,-13,42,10,77,-19,58,-30,227,53,67,45,45,10,26,39,52,24,107,0,57,-15,110,6,20,29,33,11,123,-4,57,-12,78,30,179,40,48,29,21,56,10,100,-46,76,-74,50,-57,58,-22,9,-32,-1,-49,25,-35,46,-116,-14,-102,12,-101,29,-5,17,6,79,13,53,-1,22,-8,7,-118,-20,-47,21,11,27,-15,22,-220,19,-24,15,-2,107,-12,37,-43,46,-51,18,-8,11,28,54,3,44,26,86,3,83,-11,30,-30,14,-16,-2,-124,-53,-184,-18,-71,-57,-81,-31,-87,-15]]]},{"name":"Baghlan","alt":["Baglan","Bughlan"],"postal":"BL","type":"Province","rank":6.7,"country":"Afghanistan","iso2":"AF","iso":"AF-BGL","amb":["baghlan"],"label":[68.983,35.859],"polygons":[[[69956,35887,-32,41,-3,19,-20,20,-32,67,-81,65,-10,115,-12,19,-54,8,-11,61,-20,48,-10,3,-56,-32,-92,-26,-79,-48,-99,-17,-23,9,-21,-11,-33,47,-35,8,-35,41,-117,31,-82,62,-84,16,-53,-20,-20,7,-43,28,-64,64,-12,22,-88,-13,-96,20,-217,7,-82,23,-38,-14,-19,-14,-10,-28,152,-201,46,-50,120,-67,31,-26,9,-18,-64,-80,-129,-85,-14,-26,-12,-108,-156,-177,-4,-35,42,-29,-5,-30,-36,-42,-10,-34,-54,-46,-3,-25,20,-60,-12,-32,7,-36,-40,-99,-31,-16,-20,-28,-5,Line truncated
|
||||
@@ -0,0 +1,23 @@
|
||||
# Military area names
|
||||
|
||||
This database is distributed under [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/), separately from the MIT code.
|
||||
© [OpenStreetMap contributors](https://www.openstreetmap.org/copyright).
|
||||
Distribution: [Overture Maps Foundation](https://docs.overturemaps.org/attribution/), base/land_use release **2026-09-23.1**.
|
||||
OSM snapshots: **2026-09-06**, with two named records from **2026-09-12**.
|
||||
|
||||
Selected named military areas; geometry simplified to interior label points,
|
||||
bounds and area, coordinates rounded and records re-encoded. Names are unchanged.
|
||||
Rows: `[typedOsmId, name, lon, lat, west, south, east, north, classIndex, areaM2]`.
|
||||
Labels use five decimal places; bounds use four. Area is measured on the WGS84
|
||||
ellipsoid. Class indices refer to `classes`. Output SHA-256: [`names.sha256`](names.sha256).
|
||||
|
||||
Rebuild (maintainer tool; requires Python and `duckdb==1.5.5`):
|
||||
|
||||
```sh
|
||||
python scripts/build-military-names.py --cache /tmp/military-names
|
||||
python scripts/build-military-names.py --cache /tmp/military-names --verify
|
||||
```
|
||||
|
||||
The pinned parquet file list and query are in `scripts/military-names-files.json`
|
||||
and `scripts/military-names.sql`. The first build downloads DuckDB extensions and
|
||||
scans source columns; subsequent builds reuse the cached selected rows.
|
||||
@@ -0,0 +1 @@
|
||||
{"release":"2026-09-23.1","snapshots":["2026-09-06","2026-09-12"],"classes":["military_land","airfield","naval_base","range","barracks","base","training_area"],"records":[["r122949","Scott Air Force Base",-89.85277,38.54384,-89.8799,38.5292,-89.8041,38.5588,1,12413875],["r130448","Fort Campbell",-87.61569,36.63134,-87.8047,36.5355,-87.4231,36.7268,0,419957267],["r133445","Fort George G Meade",-76.74626,39.10227,-76.7766,39.0732,-76.7092,39.131,0,18817854],["r133514","Joint Base Andrews",-76.87745,38.80571,-76.9034,38.7823,-76.8491,38.829,5,17812387],["r139794","Whiteman AFB",-93.56134,38.72992,-93.5959,38.7024,-93.5308,38.7575,1,19580209],["r139848","Fort Leonard Wood",-92.16933,37.70114,-92.2496,37.6052,-92.0411,37.798,0,248751778],["r147157","Camp du Valdahon",6.32589,47.18508,6.2731,47.151,6.375,47.2189,0,34786523],["r169792","Offutt Air Force Base",-95.90817,41.1172,-95.9664,41.0986,-95.8781,41.1364,1,11200098],["r170440","McGuire Air Force Base",-74.57822,40.03444,-74.6026,40.0267,-74.5593,40.0586,5,5388371],["r171293","Cannon AFB",-103.31633,34.38639,-103.3371,34.3653,-103.2955,34.4054,1,13976465],["r171528","White Sands",-106.49662,32.38378,-106.5139,32.3657,-106.4793,32.4031,0,8047394],["r175474","West Point",-74.00783,41.35584,-74.1043,41.3163,-73.9467,41.413,0,64474104],["r176921","Pope Field",-79.01404,35.17337,-79.0319,35.1587,-78.9888,35.1886,1,7327036],["r176940","Fort Bragg",-79.15415,35.14031,-79.3814,35.0306,-78.9447,35.2512,5,554150015],["r206824","Fort Belvoir",-77.14369,38.7108,-77.1739,38.6729,-77.1208,38.7486,0,23156083],["r237502","Joint Base Lewis-McChord",-122.50741,47.03083,-122.802,46.9015,-122.3698,47.1595,0,358289411],["r252043","Francis E. Warren Air Force Base",-104.86328,41.16587,-104.8859,41.1317,-104.8394,41.2012,1,23635348],["r253786","Naval Base Point Loma",-117.24939,32.6941,-117.2562,32.6648,-117.2341,32.7131,5,4741248],["r253814","Marine Corps Air Station Miramar",-117.08249,32.87638,-117.1986,32.8331,-116.9871,32.9208,0,87161025],["r308744","Schietveld",5.44866,51.04934,5.4361,51.0439,5.4672,51.0548,0,1200107],["r309689","Ādažu poligons",24.46976,57.15377,24.3461,57.0837,24.563,57.2226,6,133266211],["r309926","Roberts Army Heliport",-120.74307,35.81448,-120.7467,35.8091,-120.7404,35.8197,1,331999],["r317709","Naval Reservation San Clemente Island",-118.51541,32.92142,-118.6206,32.8004,-118.3487,33.0422,0,163093405],["r317710","Naval Air Warfare Center China Lake",-117.62946,35.75329,-117.8897,35.272,-116.9246,36.2318,0,4495435805],["r317712","Marine Corps Base Camp Pendleton",-117.42402,33.35453,-117.5963,33.2056,-117.2554,33.5036,0,507618719],["r317716","Camp Roberts",-120.77817,35.79079,-120.8594,35.6884,-120.6887,35.8934,6,173959948],["r317718","Camp San Luis Obispo",-120.69818,35.33796,-120.7635,35.3048,-120.6642,35.3716,0,23703563],["r379564","US Coast Guard - Base Portsmouth",-76.35878,36.88311,-76.3678,36.8801,-76.3518,36.8859,0,636553],["r383103","Patuxent River NAS (Trapnell Field)",-76.40977,38.28455,-76.4423,38.2641,-76.3817,38.3042,1,9064162],["r389913","Helmut-Schmidt-Universität – Universität der Bundeswehr Hamburg",10.11122,53.56877,10.1059,53.5663,10.1165,53.5709,4,255487],["r392208","Joint Expeditionary Base Little Creek - Fort Story East East",-76.01769,36.92062,-76.0464,36.9092,-75.9918,36.9323,0,6066899],["r392665","Naval Station Norfolk",-76.29734,36.94167,-76.3307,36.9193,-76.2646,36.964,0,13143152],["r416869","Naval Base Kitsap-Bremerton",-122.64202,47.5574,-122.6622,47.5511,-122.6257,47.5649,5,2487880],["r417668","Camp Murray",-122.56814,47.11741,-122.5809,47.111,-122.5577,47.1229,0,1105917],["r420625","Yakima Training Center",-120.18138,46.75205,-120.4667,46.5473,-119.9159,46.9563,0,1322423534],["r437075","Vojenský újezd Libavá",17.54728,49.67197,17.4068,49.5838,17.6984,49.7602,6,235507050],["r438525","Vojenský újezd Boletice",14.07264,48.8478,13.947,48.7872,14.2431,48.9085,6,165484736],["r439469","Vojenský újezd Hradiště",13.13,50.26224,13.0045,50.1629,13.2555,50.3615,6,280845474],["r440731","Vojenský újezd Březina",16.96557,49.40648,16.8378,49.313,17.0541,49.5,6,149643828],["r451539","Naval Magazine Indian Island",-122.72867,48.05303,-122.7481,48.0187,-122.7014,48.0872,0,10831143],["r451698","Naval Radio Station Jim Creek",-121.92018,48.2028,-121.9584,48.167,-121.8873,48.2249,0,19804554],["r454382","National Security Investigative Service",31.31037,30.05187,31.31,30.0508,31.3117,30.0528,0,14562],["r533411","Naval Outlying Landing Field Coupeville",-122.63203,48.18948,-122.6479,48.171,-122.6152,48.2067,1,2626295],["r533644","Eglin Air Force Base",-86.51396,30.5777,-86.9829,30.3814,-86.1073,30.7498,5,1873152593],["r534267","Saufley Field",-87.34183,30.46763,-87.3512,30.4582,-87.3321,30.4773,0,2666728],["r541791","Whidbey Island Naval Air Station Seaplane Base",-122.56089,48.28799,-122.6453,48.2676,-122.5559,48.3083,0,9647535],["r543980","U.S. Coast Guard - Base Seattle",-122.34035,47.58933,-122.343,47.5879,-122.3375,47.5908,Line truncated
|
||||
@@ -0,0 +1 @@
|
||||
74cc07f12c496a622f2f6c5a81922c293bca5eb44e0901c967bbf94e15ee514f names.json
|
||||
@@ -0,0 +1,45 @@
|
||||
# US Census Bureau counties pack
|
||||
|
||||
County and county-equivalent outlines for area annotations ("outline Travis
|
||||
County", "outline Orleans Parish"), read by `src/data/adminBoundaries.js`.
|
||||
|
||||
| File | Source dataset | Features |
|
||||
|------|----------------|----------|
|
||||
| `counties.json` | `cb_2025_us_county_5m` (cartographic boundary file, 1:5,000,000) | 3,235 |
|
||||
|
||||
**Source:** U.S. Census Bureau cartographic boundary files,
|
||||
<https://www2.census.gov/geo/tiger/GENZ2025/shp/cb_2025_us_county_5m.zip>
|
||||
(SHA-256 `faec522080681e79be5be435c981009a77891206ff8a7f1d142f3bf5da9ebd74`),
|
||||
fetched 2026-09-25. Covers the 50 states, the District of Columbia, Puerto
|
||||
Rico and the Island Areas (American Samoa, Guam, the Northern Mariana Islands,
|
||||
the US Virgin Islands).
|
||||
|
||||
**License:** public domain — a work of the U.S. Government (17 U.S.C. § 105).
|
||||
No attribution is required; the app credits the U.S. Census Bureau. See
|
||||
DATA_SOURCES.md.
|
||||
|
||||
**Regenerate:** `node scripts/build-admin-packs.mjs --only counties`
|
||||
downloads the pinned zip (to the system temp directory, or `--cache <dir>`),
|
||||
checks its SHA-256, reads the shapefile and rewrites the pack byte for byte.
|
||||
Parameters are in the script's `PARAMS` and in the pack's `meta.curation`.
|
||||
|
||||
**Schema:** `meta` (source, URL, hash, license, curation) and `features[]`
|
||||
with `name` (`NAME`), `full` (`NAMELSAD`, e.g. "Travis County"), `lsad` (the
|
||||
county word: County, Parish, Borough, Census Area, Municipality, city, …),
|
||||
`state` (`STATE_NAME`), `st` (`STUSPS`), `geoid` (`GEOID`) and `polygons`.
|
||||
`polygons` is a list of parts, largest first, each `[outer, ...holes]`; every
|
||||
ring is open and stored as integers in units of 10^-4 degrees, the first
|
||||
vertex absolute and each later one as a `[dLon, dLat]` delta.
|
||||
|
||||
**Curation:**
|
||||
|
||||
- Shapefile rings are grouped into parts (clockwise outers) and holes
|
||||
(counter-clockwise rings, assigned to the outer that contains them), so an
|
||||
independent city inside a county (Fairfax city in Fairfax County) is a hole.
|
||||
- Douglas-Peucker per ring at 0.003 × √(county area in km²) km, clamped to
|
||||
0.0005°–0.01° (Travis County ≈ 0.0014°); coordinates rounded to 4 decimals.
|
||||
- Parts under 1 km² or under 0.1% of the county's largest part are dropped;
|
||||
holes under 1 km² are dropped. Outers are counter-clockwise, holes
|
||||
clockwise.
|
||||
- Result: 1,541,464 bytes (533,326 with gzip -9); 3,524 parts, 18 holes,
|
||||
139,904 vertices.
|
||||
@@ -0,0 +1 @@
|
||||
{"meta":{"title":"US Census Bureau cartographic boundary counties","source":"cb_2025_us_county_5m","url":"https://www2.census.gov/geo/tiger/GENZ2025/shp/cb_2025_us_county_5m.zip","sha256":"faec522080681e79be5be435c981009a77891206ff8a7f1d142f3bf5da9ebd74","license":"Public domain (U.S. Government work, 17 U.S.C. § 105)","schema":"features[]: name, full (NAMELSAD), lsad? (County, Parish, Borough, …), state, st (USPS), geoid, d? (decimals when not meta.decimals), polygons[][ring] (first ring outer, rest holes; each ring open and encoded as integers in 10^-d degrees, first vertex absolute then [dLon, dLat] deltas)","decimals":4,"curation":{"toleranceFactor":0.003,"minToleranceDeg":0.0005,"maxToleranceDeg":0.01,"decimals":4,"minPartKm2":1,"minPartShare":0.001,"minHoleKm2":1},"script":"scripts/build-admin-packs.mjs"},"features":[{"name":"Autauga","full":"Autauga County","lsad":"County","state":"Alabama","st":"AL","geoid":"01001","polygons":[[[-869212,326575,103,-284,72,-457,50,-60,-44,-368,241,-533,189,-364,-66,-131,114,7,302,-128,-203,-103,16,-150,284,-296,80,-164,-135,-212,48,-232,178,-14,251,321,-90,296,8,222,314,-34,228,154,93,-17,-11,-307,77,-113,278,-74,279,227,22,211,354,85,45,-71,-13,-243,195,-130,134,137,340,-58,122,-288,156,-1,233,41,43,191,158,9,142,137,62,277,116,-60,335,101,-19,2975,-3011,-17,8,-440,-1032,-16,-1010,41]]]},{"name":"Baldwin","full":"Baldwin County","lsad":"County","state":"Alabama","st":"AL","geoid":"01003","polygons":[[[-880146,302224,523,71,1238,-23,379,22,651,113,786,90,988,247,398,60,-1,35,90,83,425,101,28,95,-216,-29,-72,29,-33,62,56,52,276,108,107,101,9,231,127,136,-20,107,111,150,69,36,192,1,78,74,245,129,46,61,-42,103,-291,41,-80,54,-68,9,-104,83,-45,50,-10,60,-50,39,50,106,-6,47,-85,108,-80,38,27,45,-16,122,112,220,75,50,19,60,73,17,20,45,-17,37,57,32,40,105,21,162,73,57,43,97,14,156,-26,195,-97,162,-11,85,-125,49,-109,86,-94,19,-116,93,-56,-23,-93,33,-81,58,-68,99,-211,51,-94,119,-209,100,-39,179,-97,106,11,66,-269,219,-99,119,-186,82,-53,107,-101,34,-14,56,-100,76,11,92,-87,88,54,142,87,74,-14,100,62,39,82,231,157,270,29,129,-96,330,-166,0,-5,2471,-163,-16,-442,191,-379,401,-509,-48,-64,-36,-240,90,-79,163,-55,-124,232,-237,3,-71,-160,-60,-167,88,-101,-33,-54,-162,-156,9,122,-127,-230,-119,-35,-93,151,-177,-114,-38,-99,112,-94,-178,-104,63,-166,-51,-74,-118,-330,55,53,-298,-316,-4,66,-144,262,-11,-65,-255,-296,-322,124,-212,151,-4,75,-197,-162,-188,69,-73,-143,-139,82,-184,-86,-220,216,-60,193,-264,-280,-105,-65,-209,-247,-204,-29,-117,54,-100,265,-125,96,-124,-18,-134,-404,-122,-35,-172,-261,-131,-55,-89,-41,-188,253,-551,-74,-133,272,-99,252,-162,193,-15,174,-213,68,-203,-25,-299,108,-199,25,-151,-94,-251,-223,-384,15,-63,119,-105,137,-612,413,-259,199,-61,79,-81,73,-155,206,-211,365,-210,173,-205,2,-139,-113,-149,-211,-82,-300,1,-424,-140,-192,-17,-212,29,-355,200,-130,-49,-178,-210,-572,-52,-53,-77]]]},{"name":"Barbour","full":"Barbour County","lsad":"County","state":"Alabama","st":"AL","geoid":"01005","polygons":[[[-857483,316180,1629,-4,1184,30,506,-11,4,872,1829,-36,236,74,35,313,406,182,111,139,256,-100,-78,251,53,263,-106,240,120,442,99,98,378,514,283,688,45,489,-1358,-7,-69,31,-5,112,-187,183,-179,84,-51,169,-256,189,21,98,-1525,-16,-172,-73,-24,-934,13,-310,-340,-6,-86,79,5,-81,-420,-1,2,-144,-749,-23,-55,-35,-120,-330,-155,-154,-285,-339,-90,-231,1,-82,-204,-243,98,-215,-58,-264,116,-235,-87,-237,-301,-199,-77,-293,33,-29,-122,-108,18,-375,-253,-409]]]},{"name":"Bibb","full":"Bibb County","lsad":"County","state":"Alabama","st":"AL","geoid":"01007","polygons":[[[-874212,328745,1017,6,3,-436,859,7,208,30,1933,18,1431,-7,-8,2136,-216,-1,-178,98,-177,45,0,147,-177,-1,8,199,-184,-2,-3,597,-560,77,0,133,43,123,-55,551,-498,3,-275,-253,-174,2,-4,141,-336,-60,-437,-332,-1,-659,-826,24,-297,-392,-69,-877,-1034,-28]]]},{"name":"Blount","full":"Blount County","lsad":"County","state":"Alabama","st":"AL","geoid":"01009","polygons":[[[-869634,338582,91,-133,6,-296,172,0,526,279,1248,-26,1138,-676,-25,-71,700,-6,3,367,557,-1,93,89,637,132,186,-45,898,712,-75,205,256,210,-33,79,-446,-3,309,317,66,146,3,604,97,257,192,269,-102,127,-953,932,-361,445,10,96,-418,11,-138,-112,-152,54,-85,-271,-160,-13,-126,-200,67,-199,-287,-207,-207,-300,-64,-164,-230,32,-293,-150,-34,-156,-298,-120,-18,130,-158,-111,200,-260,-27,-35,-91,96,-135,-139,78,-121,-189,-36,-23,-123,-263,-221,-109,-17,-39,-217,-277,-106,-3,-112,-281,51,-12,-160,-149,91,-319,-179,-181,3,2,-156,-393,-93,16,-359,-392,-46]]]},{"name":"Bullock","full":"Bullock County","lsad":"County","state":"Alabama","st":"AL","geoid":"01011","polygons":[[[-859969,320510,466,36,-43,72,83,-72,345,2,181,-74,10,-583,84,-218,943,0,-10,-869,1333,-1,-1,82,90,231,285,339,155,154,120,330,55,35,749,23,-2,144,420,1,-5,81,86,-79,340,6,-13,310,24,934,172,73,-172,-1,-61,880,-4215,-26,-211,143,-18,452,-185,137,-23,-302,-879,-27,1,-217,-127,-1]]]},{"name":"Butler","full":"Butler County","lsad":"County","state":"Alabama","st":"AL","geoid":"01013","polygonLine truncated
|
||||
@@ -1631,6 +1631,20 @@ test('installation summaries disclose viewport-scoped coverage', () => {
|
||||
assert.equal(retrying.reason, 'Overpass temporarily unavailable — retrying in 30s');
|
||||
});
|
||||
|
||||
test('installation summaries name a subject window instead of the viewport', () => {
|
||||
const stats = { coverage: { kind: 'subject', radiusM: 100_000 } };
|
||||
const empty = summarizeInstallationViewport([], { available: true, stale: false, stats });
|
||||
assert.equal(empty.count, 0);
|
||||
assert.match(empty.reason, /none mapped within 100 km/);
|
||||
assert.match(empty.reason, /not a complete 250 km survey/);
|
||||
const found = summarizeInstallationViewport(
|
||||
[{ id: 'ofm:installation:1', label: 'Military area', distanceM: 1200 }],
|
||||
{ available: true, stale: false, stats },
|
||||
);
|
||||
assert.equal(found.count, 1);
|
||||
assert.equal(found.reason, 'mapped matches within 100 km of the subject');
|
||||
});
|
||||
|
||||
test('compact Context snapshots retain installation coverage', () => {
|
||||
const snapshot = buildAwarenessContextSnapshot({
|
||||
subject: { layerId: 'flights', id: 'abc' },
|
||||
@@ -3060,3 +3074,27 @@ test('a moving camera refreshes Contacts more than once inside one parked interv
|
||||
restoreCollections();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
test('a selected installation card keeps every member name and escapes source text', () => {
|
||||
const runtime = installAwarenessRuntime();
|
||||
const restores = [];
|
||||
try {
|
||||
for (const layer of [aisLiveVesselsLayer, flightsLayer, militaryFlightsLayer, militaryInstallationsLayer])
|
||||
replaceMethod(layer, 'getNearby', () => [], restores);
|
||||
militaryAwarenessLayer.setParams({ passive: false });
|
||||
runtime.dispatch('gev:entity-selected', {
|
||||
layerId: 'military-installations', id: 'osm:military:w1', label: 'Parent base',
|
||||
latitude: 31.13, longitude: -97.78,
|
||||
properties: { memberNames: ['Area one', 'Area two', 'Area three', '<Area four>'] },
|
||||
});
|
||||
militaryAwarenessLayer.update();
|
||||
const html = runtime.panel().innerHTML;
|
||||
assert.match(html, /Named areas \(4\)/);
|
||||
for (const name of ['Area one', 'Area two', 'Area three', '<Area four>']) assert.ok(html.includes(name));
|
||||
assert.ok(!html.includes('<Area four>'));
|
||||
} finally {
|
||||
restores.reverse().forEach((restore) => restore());
|
||||
runtime.restore();
|
||||
}
|
||||
});
|
||||
@@ -7,6 +7,7 @@ const sourceSlot = createSourceSlot(
|
||||
createInstallationSource(),
|
||||
['getMappedSites', 'searchNearby'],
|
||||
'Installation source',
|
||||
{ destroy: () => {} },
|
||||
);
|
||||
export const configureInstallationSource = sourceSlot.configure;
|
||||
const layer = createApplicationInstallations({
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
installRenderGovernor,
|
||||
} from '../renderGovernor.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { registerEntityContext, selectEntityContext, getSelectedEntityContext } from './contextStore.js';
|
||||
import { registerEntityContext, selectEntityContext, getSelectedEntityContext, clearSelectedEntityContextForLayer } from './contextStore.js';
|
||||
|
||||
test('clicking a selected installation again or empty map clears it through refresh', async () => {
|
||||
const run = await runInstallationLoad({ elements: [{ type: 'node', id: 42,
|
||||
@@ -42,6 +42,23 @@ test('clicking a selected installation again or empty map clears it through refr
|
||||
} finally { run.restore(); }
|
||||
});
|
||||
|
||||
test('background installation paints neither reopen Contacts nor resurrect a cleared selection', async () => {
|
||||
const run = await runInstallationLoad({ elements: [{ type:'node',id:42,lat:30.2,lon:-97.7,tags:{military:'base',name:'Site'} }] });
|
||||
try {
|
||||
const selected=()=>run.contextLabels().length;
|
||||
run.click('osm:node:42');
|
||||
assert.equal(selected(),1);
|
||||
await militaryInstallationsLayer.update();
|
||||
assert.equal(getSelectedEntityContext()?.id,'osm:node:42');
|
||||
assert.equal(selected(),1,'data and floor refreshes must not publish a fresh selection');
|
||||
clearSelectedEntityContextForLayer('military-installations');
|
||||
await militaryInstallationsLayer.update();
|
||||
assert.equal(getSelectedEntityContext(),null);
|
||||
assert.equal(selected(),1,'an external clear must survive the next paint');
|
||||
assert.equal(run.entities()[0].point.pixelSize.getValue(),9);
|
||||
} finally { run.restore(); }
|
||||
});
|
||||
|
||||
test('clearing a stale installation highlight does not clear or reclaim another layer selection', async () => {
|
||||
const run = await runInstallationLoad({ elements: [{ type: 'node', id: 42,
|
||||
lat: 30.2, lon: -97.7, tags: { military: 'base', name: 'Site' } }] });
|
||||
@@ -798,7 +815,7 @@ test('the unavailable retry backs off 30s to a 240s ceiling and restarts clean',
|
||||
|
||||
test('the retry is wired to every lifecycle edge, not just declared', () => {
|
||||
assert.match(installationsSource,
|
||||
/setInstallationStatus\(\s*'unavailable',[^]*?\);\n\s*parts\.viewport\.scheduleUnavailableRetry\(\);/,
|
||||
/setInstallationStatus\(\s*'unavailable',[^]*?\);\n\s*if \(!isUnavailableCapability\(error\)\)\s*parts\.viewport\.scheduleUnavailableRetry\(error\?\.retryAfterMs\);/,
|
||||
'a failed load schedules the retry immediately after reporting unavailable');
|
||||
assert.match(installationsSource,
|
||||
/clearUnavailableRetry\(\);\n\s*setInstallationStatus\(\n?\s*layerState\.records\.length/,
|
||||
@@ -815,3 +832,60 @@ test('the retry is wired to every lifecycle edge, not just declared', () => {
|
||||
/layerState\.enabled && !layerState\.loading\)\s*parts\.ingestion\.loadInstallations\(\)/,
|
||||
'the fired retry re-checks enablement and never races an in-flight load');
|
||||
});
|
||||
|
||||
|
||||
test('a merged installation stays visible when only its second fragment intersects', () => {
|
||||
const far = [[-96.6,30.4],[-96.4,30.4],[-96.4,30.6],[-96.6,30.6]];
|
||||
const near = [[-97.5,30.4],[-97.4,30.4],[-97.4,30.6],[-97.5,30.6]];
|
||||
assert.equal(installationWithinViewport({latitude:30.5,longitude:-96.5,footprint:far,footprints:[[far],[near]]},VIEWPORT), true);
|
||||
});
|
||||
|
||||
test('a Contacts anchor replaces the viewport and ignores small subject moves', async () => {
|
||||
// Field report: a follow/Cockpit camera never fires moveEnd and often looks at
|
||||
// the horizon, so the Context row read "?" for the whole track.
|
||||
const run = await runInstallationLoad({ elements: [{ type: 'node', id: 7,
|
||||
lat: 30.3125, lon: -97.765, tags: { military: 'base', name: 'Camp Mabry' } }] });
|
||||
try {
|
||||
const box = (href) => {
|
||||
const url = new URL(href, 'https://example.test');
|
||||
return Object.fromEntries(['south', 'west', 'north', 'east']
|
||||
.map((key) => [key, Number(url.searchParams.get(key))]));
|
||||
};
|
||||
const centre = (href) => (box(href).south + box(href).north) / 2;
|
||||
assert.equal(run.stats().coverageLabel, 'CURRENT VIEWPORT ONLY');
|
||||
assert.equal(militaryInstallationsLayer.setContextAnchor({ latitude: 30.3125, longitude: -97.765 }), true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 650));
|
||||
const anchored = box(run.requests.at(-1));
|
||||
assert.ok(Math.abs(centre(run.requests.at(-1)) - 30.3125) < 0.01);
|
||||
assert.ok(anchored.north - anchored.south > 1.5, 'window reaches ~100 km each way');
|
||||
assert.equal(run.stats().coverageLabel, 'WITHIN 100 KM');
|
||||
assert.equal(run.stats().coverage.kind, 'subject');
|
||||
const count = run.requests.length;
|
||||
assert.equal(militaryInstallationsLayer.setContextAnchor({ latitude: 30.35, longitude: -97.765 }), false,
|
||||
'a 4 km move keeps the window');
|
||||
assert.equal(militaryInstallationsLayer.setContextAnchor({ latitude: 30.6, longitude: -97.765 }), true,
|
||||
'a 32 km move shifts the window');
|
||||
await new Promise((resolve) => setTimeout(resolve, 650));
|
||||
assert.equal(run.requests.length, count + 1);
|
||||
assert.ok(Math.abs(centre(run.requests.at(-1)) - 30.6) < 0.01);
|
||||
assert.equal(militaryInstallationsLayer.setContextAnchor(null), true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 650));
|
||||
assert.deepEqual(box(run.requests.at(-1)), { south: 30, west: -98, north: 31, east: -97 },
|
||||
'clearing the subject returns to the viewport');
|
||||
assert.equal(run.stats().coverageLabel, 'CURRENT VIEWPORT ONLY');
|
||||
} finally { run.restore(); }
|
||||
});
|
||||
|
||||
test('unchanged installations keep their entities across reloads', async () => {
|
||||
const run = await runInstallationLoad({ elements: [{ type: 'node', id: 42,
|
||||
lat: 30.2, lon: -97.7, tags: { military: 'base', name: 'Site' } }] });
|
||||
try {
|
||||
const before = run.entities().slice();
|
||||
assert.ok(before.length > 0);
|
||||
await militaryInstallationsLayer.update();
|
||||
const after = run.entities();
|
||||
assert.equal(after.length, before.length);
|
||||
assert.ok(after.every((entity, index) => entity === before[index]),
|
||||
'a reload with the same geometry must not recreate entities');
|
||||
} finally { run.restore(); }
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { militaryOsmKey } from '../sources/militaryTileGeometry.js';
|
||||
export { militaryOsmKey } from '../sources/militaryTileGeometry.js';
|
||||
import { requestWithDeadline } from '../sources/requestDeadline.js';
|
||||
import { loadBundledJson } from './bundledJson.js';
|
||||
import { createRetryableLoader } from './retryableLoad.js';
|
||||
|
||||
export const MILITARY_POINT_CAP = 512;
|
||||
export const MILITARY_LABEL_CAP = 24;
|
||||
|
||||
export const MILITARY_NAMES_TIMEOUT_MS = 10_000;
|
||||
|
||||
/** Share a bounded acquisition; failed assets become retryable after cooldown. */
|
||||
export function createMilitaryNamesLoader({
|
||||
loadPack = (signal) =>
|
||||
loadBundledJson(
|
||||
new URL('./local_data/osm_military_names/names.json', import.meta.url),
|
||||
() =>
|
||||
import('./local_data/osm_military_names/names.json', {
|
||||
with: { type: 'json' },
|
||||
}),
|
||||
{ signal },
|
||||
),
|
||||
timeoutMs = MILITARY_NAMES_TIMEOUT_MS,
|
||||
...retryOptions
|
||||
} = {}) {
|
||||
return createRetryableLoader(async () => {
|
||||
const pack = await requestWithDeadline(loadPack, { timeoutMs });
|
||||
const records = pack.records.map(
|
||||
([
|
||||
osmKey,
|
||||
name,
|
||||
longitude,
|
||||
latitude,
|
||||
west,
|
||||
south,
|
||||
east,
|
||||
north,
|
||||
kind,
|
||||
areaM2,
|
||||
]) => ({
|
||||
id: `osm:military:${osmKey}`,
|
||||
osmKey,
|
||||
name,
|
||||
longitude,
|
||||
latitude,
|
||||
bbox: [west, south, east, north],
|
||||
class: pack.classes[kind],
|
||||
areaM2,
|
||||
kind: 'installation',
|
||||
namedArea: true,
|
||||
pointOnly: true,
|
||||
validation: 'unreviewed',
|
||||
sources: [
|
||||
{ name: 'OpenStreetMap', id: osmKey },
|
||||
{ name: 'Overture Maps Foundation', id: osmKey },
|
||||
],
|
||||
}),
|
||||
);
|
||||
records.sort(
|
||||
(a, b) => b.areaM2 - a.areaM2 || a.osmKey.localeCompare(b.osmKey),
|
||||
);
|
||||
return { records, byId: new Map(records.map((r) => [r.osmKey, r])) };
|
||||
}, retryOptions);
|
||||
}
|
||||
|
||||
/** Load and index the separate data asset only when installation context needs it. */
|
||||
export const loadMilitaryNames = createMilitaryNamesLoader();
|
||||
|
||||
/** Join exact source identity before parcel merging; reject geographically inconsistent ids. */
|
||||
export function nameMilitaryFragment(fragment, names) {
|
||||
const osmKey = militaryOsmKey(fragment.featureKey);
|
||||
const named = names?.byId.get(osmKey);
|
||||
if (!named || !fragment.footprint?.length) return fragment;
|
||||
const [west, south, east, north] = named.bbox;
|
||||
const epsilon = (fragment.tileEpsilon || 0) * 2 + 0.0001;
|
||||
const xs = fragment.footprint.map((p) => p[0]);
|
||||
const ys = fragment.footprint.map((p) => p[1]);
|
||||
if (
|
||||
Math.max(...xs) < west - epsilon ||
|
||||
Math.min(...xs) > east + epsilon ||
|
||||
Math.max(...ys) < south - epsilon ||
|
||||
Math.min(...ys) > north + epsilon
|
||||
)
|
||||
return fragment;
|
||||
return {
|
||||
...fragment,
|
||||
name: named.name,
|
||||
class: named.class,
|
||||
osmKey,
|
||||
namedArea: true,
|
||||
areaM2: named.areaM2,
|
||||
nameRecord: named,
|
||||
};
|
||||
}
|
||||
|
||||
/** Area-ranked spatial thinning of visible label points, including dateline views. */
|
||||
export function militaryNamesInView(names, box, cap = MILITARY_POINT_CAP) {
|
||||
const width =
|
||||
box.east >= box.west ? box.east - box.west : 360 + box.east - box.west;
|
||||
const height = box.north - box.south;
|
||||
const cells = new Set(),
|
||||
records = [];
|
||||
let count = 0;
|
||||
for (const record of names?.records || []) {
|
||||
const x = (record.longitude - box.west + 360) % 360;
|
||||
if (x > width || record.latitude < box.south || record.latitude > box.north)
|
||||
continue;
|
||||
count++;
|
||||
const cell = `${Math.min(31, Math.floor((x / width) * 32))}:${Math.min(15, Math.floor(((record.latitude - box.south) / height) * 16))}`;
|
||||
if (cells.has(cell) || records.length >= cap) continue;
|
||||
cells.add(cell);
|
||||
records.push(record);
|
||||
}
|
||||
return { records, count };
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { gzipSync } from 'node:zlib';
|
||||
import {
|
||||
loadMilitaryNames,
|
||||
militaryOsmKey,
|
||||
nameMilitaryFragment,
|
||||
militaryNamesInView,
|
||||
MILITARY_POINT_CAP,
|
||||
} from './militaryNames.js';
|
||||
import { decodeOpenFreeMapMilitaryTile } from '../sources/openFreeMap.js';
|
||||
import { createControls } from '../layers/installations/controls.js';
|
||||
import {
|
||||
mergeMilitaryFragments,
|
||||
militaryNameInView,
|
||||
} from '../sources/militaryTileGeometry.js';
|
||||
|
||||
const sites = [
|
||||
['w27894251', 'Camp Mabry'],
|
||||
['r317712', 'Marine Corps Base Camp Pendleton'],
|
||||
['r253814', 'Marine Corps Air Station Miramar'],
|
||||
['w60546523', 'Ramstein Air Base'],
|
||||
['w102826331', 'RAF Lakenheath'],
|
||||
['r13529728', 'Fort Hood'],
|
||||
];
|
||||
|
||||
test('wide-view counts report visible points rather than retained off-screen candidates', () => {
|
||||
const state = {
|
||||
wide: true,
|
||||
records: Array.from({ length: 8 }, () => ({ kind: 'installation' })),
|
||||
lastUpdate: 1,
|
||||
status: 'ready',
|
||||
};
|
||||
const { methods } = createControls({
|
||||
state,
|
||||
services: {},
|
||||
source: {},
|
||||
parts: {
|
||||
namedMarkers: {
|
||||
stats: () => ({
|
||||
namedMarkers: 8,
|
||||
pointsOnScreen: 3,
|
||||
labelsOnScreen: 2,
|
||||
labelCap: 24,
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
const stats = methods.getStats();
|
||||
assert.equal(stats.count, 3);
|
||||
assert.equal(stats.namedMarkers, 8);
|
||||
assert.equal(stats.statusMessage, '3 mapped sites in view');
|
||||
});
|
||||
|
||||
test('the separately licensed pack has unique typed identities, areas, names and a pinned lean output', async () => {
|
||||
const raw = readFileSync(
|
||||
new URL('./local_data/osm_military_names/names.json', import.meta.url),
|
||||
);
|
||||
const hash = readFileSync(
|
||||
new URL('./local_data/osm_military_names/names.sha256', import.meta.url),
|
||||
'utf8',
|
||||
).split(' ')[0];
|
||||
assert.equal(createHash('sha256').update(raw).digest('hex'), hash);
|
||||
assert.ok(gzipSync(raw, { level: 9 }).length <= 2_000_000);
|
||||
const names = await loadMilitaryNames();
|
||||
assert.equal(names.records.length, 36466);
|
||||
assert.equal(names.byId.size, names.records.length);
|
||||
assert.ok(
|
||||
names.records.every(
|
||||
(r) => r.areaM2 > 0 && Number.isFinite(r.latitude + r.longitude),
|
||||
),
|
||||
);
|
||||
for (const [id, name] of sites) assert.equal(names.byId.get(id).name, name);
|
||||
});
|
||||
|
||||
test('checked-in OpenFreeMap fixtures retain typed OSM feature ids and geographical identity', async () => {
|
||||
const names = await loadMilitaryNames();
|
||||
const bytes = readFileSync(
|
||||
new URL('./fixtures/ofm-camp-mabry-12-935-1685.pbf', import.meta.url),
|
||||
);
|
||||
const tile = decodeOpenFreeMapMilitaryTile(bytes, 12, 935, 1685);
|
||||
const mabry = tile.military.find((r) => r.featureKey === '278942512');
|
||||
assert.ok(mabry);
|
||||
assert.equal(militaryOsmKey(mabry.featureKey), 'w27894251');
|
||||
assert.equal(nameMilitaryFragment(mabry, names).name, 'Camp Mabry');
|
||||
assert.equal(militaryOsmKey('3177123'), 'r317712');
|
||||
for (const value of [
|
||||
'tile:12/935/1685:0',
|
||||
'278942510',
|
||||
'3177127',
|
||||
'bad',
|
||||
1e20,
|
||||
])
|
||||
assert.equal(militaryOsmKey(value), null);
|
||||
assert.equal(
|
||||
nameMilitaryFragment({ ...mabry, featureKey: '3177123' }, names).name,
|
||||
'Military area',
|
||||
'a valid but distant id fails soft',
|
||||
);
|
||||
assert.equal(
|
||||
nameMilitaryFragment({ ...mabry, featureKey: '278942510' }, names).name,
|
||||
'Military area',
|
||||
);
|
||||
});
|
||||
|
||||
test('a merged title uses the largest named member and exposes all other member names', async () => {
|
||||
const names = await loadMilitaryNames();
|
||||
const bytes = readFileSync(
|
||||
new URL('./fixtures/ofm-camp-mabry-12-935-1685.pbf', import.meta.url),
|
||||
);
|
||||
const fragment = decodeOpenFreeMapMilitaryTile(bytes, 12, 935, 1685)
|
||||
.military[0];
|
||||
const parent = nameMilitaryFragment(fragment, names);
|
||||
const child = {
|
||||
...parent,
|
||||
featureKey: '12',
|
||||
osmKey: 'w1',
|
||||
nameRecord: {
|
||||
...parent.nameRecord,
|
||||
id: 'osm:military:w1',
|
||||
osmKey: 'w1',
|
||||
name: 'Small barracks',
|
||||
areaM2: 1,
|
||||
},
|
||||
};
|
||||
for (const records of [
|
||||
[child, parent],
|
||||
[parent, child],
|
||||
]) {
|
||||
const merged = mergeMilitaryFragments(records)[0];
|
||||
assert.equal(merged.name, 'Camp Mabry');
|
||||
assert.equal(merged.id, 'osm:military:w27894251');
|
||||
assert.deepEqual(
|
||||
[merged.longitude, merged.latitude],
|
||||
[parent.nameRecord.longitude, parent.nameRecord.latitude],
|
||||
);
|
||||
assert.deepEqual(merged.memberNames, ['Small barracks']);
|
||||
}
|
||||
});
|
||||
|
||||
test('wide views rank and thin names within a bounded viewport, including the dateline', async () => {
|
||||
const names = await loadMilitaryNames();
|
||||
for (const box of [
|
||||
{ west: -130, south: 20, east: -60, north: 60 },
|
||||
{ west: -15, south: 35, east: 35, north: 65 },
|
||||
{ west: 170, south: -60, east: -150, north: 70 },
|
||||
]) {
|
||||
const view = militaryNamesInView(names, box);
|
||||
assert.ok(view.records.length > 0);
|
||||
assert.ok(view.records.length <= MILITARY_POINT_CAP);
|
||||
assert.ok(view.count >= view.records.length);
|
||||
assert.equal(
|
||||
new Set(view.records.map((r) => r.id)).size,
|
||||
view.records.length,
|
||||
);
|
||||
for (const r of view.records) {
|
||||
assert.ok(r.latitude >= box.south && r.latitude <= box.north);
|
||||
assert.ok(
|
||||
box.east < box.west
|
||||
? r.longitude >= box.west || r.longitude <= box.east
|
||||
: r.longitude >= box.west && r.longitude <= box.east,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('a close view keeps the parent name inside its polygon when the usual label is off-screen', () => {
|
||||
const record = {
|
||||
id: 'osm:military:w1',
|
||||
namedArea: true,
|
||||
name: 'Parent base',
|
||||
longitude: 1,
|
||||
latitude: 1,
|
||||
footprints: [
|
||||
[
|
||||
[
|
||||
[0, 0],
|
||||
[10, 0],
|
||||
[10, 10],
|
||||
[0, 10],
|
||||
[0, 0],
|
||||
],
|
||||
],
|
||||
],
|
||||
};
|
||||
const visible = militaryNameInView(record, {
|
||||
west: 5,
|
||||
south: 5,
|
||||
east: 6,
|
||||
north: 6,
|
||||
});
|
||||
assert.equal(visible.id, record.id);
|
||||
assert.deepEqual([visible.longitude, visible.latitude], [5.5, 5.5]);
|
||||
assert.equal(
|
||||
militaryNameInView(record, { west: 0, south: 0, east: 2, north: 2 }),
|
||||
record,
|
||||
);
|
||||
const hole = {
|
||||
...record,
|
||||
footprints: [
|
||||
[
|
||||
record.footprints[0][0],
|
||||
[
|
||||
[4, 4],
|
||||
[7, 4],
|
||||
[7, 7],
|
||||
[4, 7],
|
||||
[4, 4],
|
||||
],
|
||||
],
|
||||
],
|
||||
};
|
||||
assert.equal(
|
||||
militaryNameInView(hole, { west: 5, south: 5, east: 6, north: 6 }),
|
||||
hole,
|
||||
);
|
||||
});
|
||||
@@ -325,3 +325,39 @@ export async function lookupNaturalRegionOutline(query, lat, lon) {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Resolve the smallest containing bundled physical region without a network geocoder. */
|
||||
export async function naturalRegionAtPoint(latitude, longitude) {
|
||||
if (
|
||||
![latitude, longitude].every(Number.isFinite) ||
|
||||
Math.abs(latitude) > 90 ||
|
||||
Math.abs(longitude) > 180
|
||||
)
|
||||
return null;
|
||||
await loadIndex();
|
||||
let best = null;
|
||||
for (const entry of _entries) {
|
||||
const [west, south, east, north] = entry.bbox;
|
||||
if (
|
||||
longitude < west ||
|
||||
longitude > east ||
|
||||
latitude < south ||
|
||||
latitude > north
|
||||
)
|
||||
continue;
|
||||
if (best && entry.areaKm2 >= best.areaKm2) continue;
|
||||
if (entry.polygons.some((ring) => pointInRing(ring, latitude, longitude)))
|
||||
best = entry;
|
||||
}
|
||||
return best
|
||||
? {
|
||||
label: best.name,
|
||||
locality: null,
|
||||
region: best.name,
|
||||
country: null,
|
||||
countryCode: null,
|
||||
source: 'Natural Earth',
|
||||
kind: best.kind,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as C from 'cesium';
|
||||
import { showOsmCredit, hideOsmCredit } from './dataCredits.js';
|
||||
import { createInfrastructureLayers } from './infrastructure.js';
|
||||
import { createBhoteKoshiLocatorLayer } from './bhoteKoshiLocator.js';
|
||||
import { createWorldAnnotationRenderer } from '../annotations/worldAnnotationRenderer.js';
|
||||
import * as annotations from '../annotations/worldAnnotationRenderer.js';
|
||||
import { createDirectionsLayer } from '../layers/directions/index.js';
|
||||
import { createLifecycle as createCctvLifecycle } from '../layers/cctv/lifecycle.js';
|
||||
import * as input from './inputOwnership.js';
|
||||
import { createOverpassAlprSource } from '../layers/alpr/source.js';
|
||||
|
||||
const noop = () => {};
|
||||
const overlayHost = { setEntries: noop, setVisible: noop, clearSource: noop };
|
||||
function viewer(t) {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
for (const name of ['HTMLCanvasElement', 'HTMLImageElement', 'ImageBitmap', 'OffscreenCanvas']) {
|
||||
const original = globalThis[name];
|
||||
globalThis[name] = class {};
|
||||
t.after(() => { if (original === undefined) delete globalThis[name]; else globalThis[name] = original; });
|
||||
}
|
||||
const before = globalThis.document;
|
||||
globalThis.document = { addEventListener: noop, removeEventListener: noop };
|
||||
t.after(() => { if (before === undefined) delete globalThis.document; else globalThis.document = before; });
|
||||
const credits = [];
|
||||
const v = { credits, creditDisplay: { addStaticCredit: (c) => credits.push(c), removeStaticCredit: c => { const i = credits.indexOf(c); if (i >= 0) credits.splice(i, 1); } },
|
||||
entities: new C.EntityCollection(), dataSources: new C.DataSourceCollection(),
|
||||
camera: { moveEnd: new C.Event(), positionCartographic: { height: 1000 } },
|
||||
scene: { requestRender: noop, canvas: { addEventListener: noop, removeEventListener: noop }, preRender: new C.Event(),
|
||||
primitives: { add: (p) => p, remove: noop }, screenSpaceCameraController: { enableInputs: true } },
|
||||
};
|
||||
return v;
|
||||
}
|
||||
function assertCredit(v) {
|
||||
assert.ok(v.credits.some((credit) => credit.showOnScreen && credit.html.includes('>© OpenStreetMap<')));
|
||||
}
|
||||
for (const index of [0, 1]) test(`${index ? 'dams' : 'datacenters'} introduce the shared OSM credit when data displays`, async (t) => {
|
||||
const v = viewer(t);
|
||||
t.mock.method(globalThis, 'fetch', async () => ({ ok: true, text: async () => JSON.stringify({ type: 'Feature', id: 'test', properties: { name: 'Site' }, geometry: { type: 'Polygon', coordinates: [[[0, 0], [0.01, 0], [0, 0.01], [0, 0]]] } }) }));
|
||||
const layer = createInfrastructureLayers({ overlayHost, registerEntityContext: noop, selectEntityContext: noop, clearSelectedEntityContextForLayer: noop, removeEntityContextsForLayer: noop, governorRequestRender: noop, showOsmCredit, hideOsmCredit })[index];
|
||||
await layer.enable(v);
|
||||
assert.equal(layer.getStats().count, 1);
|
||||
assertCredit(v);
|
||||
layer.destroy(v);
|
||||
assert.equal(v.credits.filter(c => c.showOnScreen).length, 0);
|
||||
});
|
||||
|
||||
test('Nepal locator introduces the shared OSM credit', async (t) => {
|
||||
const v = viewer(t);
|
||||
const layer = createBhoteKoshiLocatorLayer({ boundaryResolver: async () => ({ name: 'Nepal', ring: [[80, 26], [88, 26], [88, 30], [80, 30]] }), overlayHost, requestRender: noop, scheduleFrame: () => 1, cancelFrame: noop });
|
||||
await layer.init(v); await layer.enable(v, { origin: 'scene' });
|
||||
assertCredit(v); await layer.destroy(v);
|
||||
assert.equal(v.credits.filter(c => c.showOnScreen).length, 0);
|
||||
});
|
||||
|
||||
test('voice route geometry introduces the shared OSM credit', (t) => {
|
||||
const v = viewer(t), renderer = createWorldAnnotationRenderer(v);
|
||||
renderer.add({ id: 'route', type: 'route', color: 'primary', anchor: { lat: 0, lon: 0 }, path: [{ lat: 0, lon: 0 }, { lat: 0.01, lon: 0.01 }] });
|
||||
assertCredit(v); renderer.destroy();
|
||||
assert.equal(v.credits.filter(c => c.showOnScreen).length, 0);
|
||||
});
|
||||
|
||||
test('Directions introduces the shared OSM credit when the requested route draws', async (t) => {
|
||||
const v = viewer(t);
|
||||
t.mock.method(globalThis, 'fetch', async () => Response.json({ ok: true, geometry: [[0, 0], [0.01, 0.01]], steps: [] }));
|
||||
const layer = createDirectionsLayer({ services: {
|
||||
credits: { showOsmCredit, hideOsmCredit }, annotations, input, scenePick: {}, camera: {},
|
||||
overlays: { setOverlayEntries: noop, setOverlaySourceVisible: noop, clearOverlaySource: noop },
|
||||
render: { governorRequestRender: noop, holdContinuousRender: noop, releaseContinuousRender: noop },
|
||||
sprites: { registerSpriteCollection: noop, unregisterSpriteCollection: noop, restoreSpriteOrder: noop },
|
||||
picking: { registerPickOwner: noop, unregisterPickOwner: noop },
|
||||
ground: { warmGroundFloor: noop },
|
||||
} });
|
||||
layer.init(v); layer.enable(v);
|
||||
layer.placeEndpoint('a', { lat: 0, lon: 0 }); layer.placeEndpoint('b', { lat: 0.01, lon: 0.01 });
|
||||
for (let i = 0; i < 30; i++) await Promise.resolve();
|
||||
assert.equal(layer.getStats().error, null);
|
||||
assertCredit(v); await layer.destroy(v);
|
||||
assert.equal(v.credits.filter(c => c.showOnScreen).length, 0);
|
||||
});
|
||||
|
||||
test('Warendorf webcam display introduces the shared OSM credit', (t) => {
|
||||
const v = viewer(t), empty = new Proxy({}, { get: () => noop });
|
||||
const lifecycle = createCctvLifecycle({ state: { _viewer: v, _records: [{ camera: { id: 'warendorf-marktplatz-rathaus', cityId: 'warendorf' } }], _recordById: new Map(), _cctvOverlayHost: overlayHost },
|
||||
services: { credits: { showOsmCredit, hideOsmCredit }, sprites: empty, activation: empty, picking: empty, focus: empty, render: empty },
|
||||
parts: { selection: empty, geometryQueue: empty, rendering: empty, projection: empty, cards: empty, presentation: empty }, source: {} });
|
||||
lifecycle.methods.enable(); assertCredit(v); lifecycle.methods.disable();
|
||||
assert.equal(v.credits.filter(c => c.showOnScreen).length, 0);
|
||||
});
|
||||
|
||||
test('optional Overpass ALPR adapter uses the shared contributor wording', () => {
|
||||
assert.equal(createOverpassAlprSource().attribution.text, '© OpenStreetMap contributors');
|
||||
});
|
||||
@@ -219,11 +219,7 @@ export function getActiveTrackedReadoutId() {
|
||||
}
|
||||
|
||||
/** Static-context layers whose click selection publishes the readout card. */
|
||||
const READOUT_CONTEXT_LAYERS = new Set([
|
||||
'military-installations',
|
||||
'alpr-cameras',
|
||||
'local-adsb',
|
||||
]);
|
||||
const READOUT_CONTEXT_LAYERS = new Set(['alpr-cameras', 'local-adsb']);
|
||||
|
||||
/**
|
||||
* Initialize the model bridge and selection listeners. No render listener is
|
||||
|
||||
@@ -191,7 +191,7 @@ test('tracked entity publishes a protected host entry backed by the frame cache'
|
||||
}
|
||||
});
|
||||
|
||||
test('selection lifecycle ignores vessels, accepts installations, and clears without stale cards', () => {
|
||||
test('selection lifecycle leaves installations to their own entry and retains ALPR cards', () => {
|
||||
const originalWindow = globalThis.window;
|
||||
const fakeWindow = new EventTarget();
|
||||
const changed = makeCesiumEvent();
|
||||
@@ -215,8 +215,8 @@ test('selection lifecycle ignores vessels, accepts installations, and clears wit
|
||||
fakeWindow.dispatchEvent(new CustomEvent('gev:entity-selected', {
|
||||
detail: { layerId: 'military-installations', entity: installation },
|
||||
}));
|
||||
assert.equal(getActiveTrackedReadoutId(), 'installations:fort-test');
|
||||
assert.equal(recorder.calls.filter(({ op }) => op === 'set').at(-1).entries[0].title, 'FORT TEST');
|
||||
assert.equal(getActiveTrackedReadoutId(), null);
|
||||
assert.equal(recorder.calls.filter(({ op }) => op === 'set').length, setsBefore, 'installation selection cannot create a second overlay');
|
||||
|
||||
// ALPR cameras are static context too: a click publishes the same card.
|
||||
const camera = {
|
||||
|
||||
@@ -76,11 +76,11 @@ test('a healthy keyed layer reports live flow with its real coverage', () => {
|
||||
assert.deepEqual(idle, {
|
||||
mode: 'live',
|
||||
error: null,
|
||||
loadingLabel: 'LIVE · TomTom flow · 87% cov',
|
||||
loadingLabel: 'LIVE · Roads: OpenStreetMap · Flow: TomTom · 87% cov · Unmatched: simulated',
|
||||
});
|
||||
assert.equal(
|
||||
trafficFeedPresentation({ liveMode: true, fetching: true }).loadingLabel,
|
||||
'syncing LIVE traffic flow',
|
||||
'Syncing flow · Roads: OpenStreetMap · Flow: TomTom · Unmatched: simulated',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -159,3 +159,11 @@ test('traffic can be destroyed before its first enable and destroyed repeatedly'
|
||||
assert.doesNotThrow(() => traffic.destroy(viewer));
|
||||
assert.equal(traffic.getStats().count, 0);
|
||||
});
|
||||
|
||||
test('zero matched dots never claims live coverage', () => {
|
||||
const feed = trafficFeedPresentation({ liveMode: true, coveragePct: 0 });
|
||||
assert.equal(feed.mode, 'live');
|
||||
assert.match(feed.loadingLabel, /^SIMULATED/);
|
||||
assert.match(feed.loadingLabel, /TomTom \(no matches\)/);
|
||||
assert.ok(!LIVE_CLAIM.test(feed.loadingLabel));
|
||||
});
|
||||
@@ -366,7 +366,7 @@ test('the timed parser stays source-equivalent to the production parser', () =>
|
||||
|
||||
assert.doesNotMatch(SOURCE, /function fetchRoadsTimed\s*\(/);
|
||||
assert.doesNotMatch(SOURCE, /function renderRoadsForAltitudeTimed\s*\(/);
|
||||
const fetchBody = canonicalSemanticBody(functionBody('fetchRoads'));
|
||||
const fetchBody = canonicalSemanticBody(functionBody('readRoads'));
|
||||
assert.equal(fetchBody.includes('response.text()'), false);
|
||||
assert.equal(fetchBody.includes('JSON.parse('), false);
|
||||
const okCheck = fetchBody.indexOf('if(!response.ok)');
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/** One cancellable consumer of the shared terrain service, coalesced by floor cell. */
|
||||
export function createAlprFloorResolver(ground) {
|
||||
const pending = new Map();
|
||||
const queued = new Map();
|
||||
let owner = null;
|
||||
let running = false;
|
||||
|
||||
async function pump(controller) {
|
||||
try {
|
||||
while (queued.size && !controller.signal.aborted) {
|
||||
const batch = [...queued.values()].slice(0, 64);
|
||||
for (const cell of batch) queued.delete(cell.key);
|
||||
try {
|
||||
await ground.resolveGroundFloorCells(
|
||||
batch.map(({ lat, lon }) => ({ lat, lon })),
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
} catch {
|
||||
// Missing floors remain clamped; cancellation must not publish later.
|
||||
}
|
||||
for (const cell of batch) {
|
||||
if (pending.get(cell.key) === cell) pending.delete(cell.key);
|
||||
cell.finish();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (owner === controller) running = false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolve(points) {
|
||||
if (typeof ground.resolveGroundFloorCells !== 'function')
|
||||
return Promise.resolve();
|
||||
owner ??= new AbortController();
|
||||
const waits = [];
|
||||
for (const point of points) {
|
||||
const lat = Number(point.lat.toFixed(3));
|
||||
const lon = Number(point.lon.toFixed(3));
|
||||
const key = `${lat},${lon}`;
|
||||
let cell = pending.get(key);
|
||||
if (!cell) {
|
||||
let finish;
|
||||
const promise = new Promise((done) => {
|
||||
finish = done;
|
||||
});
|
||||
cell = { key, lat, lon, promise, finish };
|
||||
pending.set(key, cell);
|
||||
queued.set(key, cell);
|
||||
}
|
||||
waits.push(cell.promise);
|
||||
}
|
||||
if (!running && queued.size) {
|
||||
running = true;
|
||||
void pump(owner);
|
||||
}
|
||||
return Promise.all(waits);
|
||||
}
|
||||
|
||||
async function prepare(points) {
|
||||
let timer;
|
||||
try {
|
||||
await Promise.race([
|
||||
resolve(points),
|
||||
new Promise((done) => {
|
||||
timer = setTimeout(done, ground.FLOOR_RESOLVE_DEADLINE_MS ?? 1200);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
owner?.abort();
|
||||
owner = null;
|
||||
running = false;
|
||||
for (const cell of pending.values()) cell.finish();
|
||||
pending.clear();
|
||||
queued.clear();
|
||||
}
|
||||
return { resolve, prepare, cancel };
|
||||
}
|
||||
+107
-33
@@ -1,3 +1,4 @@
|
||||
import { isUnavailableCapability } from '../../sources/capability.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
LAYER_ID,
|
||||
@@ -21,7 +22,9 @@ import { createAlprPresentation } from './presentation.js';
|
||||
/**
|
||||
* Own one layer's requests, records, display and viewer subscriptions.
|
||||
* A source fetches a bounded box with an AbortSignal and resolves
|
||||
* { records, stale, saturated }. Records use stable string ids and latitude /
|
||||
* { records, stale, saturated }, optionally with `coverage` (the bounds its
|
||||
* records fully cover, reused for later views inside it) and `zoom` (their
|
||||
* tile precision; `source.detailZoom(box)` names the zoom a view needs). Records use stable string ids and latitude /
|
||||
* longitude in degrees; optional directionDeg is a compass bearing [0, 360).
|
||||
* Manufacturer, operator and cameraType are optional display fields.
|
||||
* Source label and attribution { name, description, text, href } identify the
|
||||
@@ -67,6 +70,12 @@ export function createAlprCamerasLayer({ source, services } = {}) {
|
||||
debounceTimer: null,
|
||||
/** Snapped box of the last successful query; a view still inside it reuses its records. */
|
||||
lastQueryBox: null,
|
||||
/** Extract zoom of the accepted records (null for sources without tiles). */
|
||||
lastQueryZoom: null,
|
||||
/** Increments whenever the displayed camera set changes (QA stability probe). */
|
||||
renderRevision: 0,
|
||||
/** Cameras in view beyond the render cap. */
|
||||
renderSaturated: false,
|
||||
};
|
||||
const {
|
||||
initOverlay,
|
||||
@@ -81,6 +90,7 @@ export function createAlprCamerasLayer({ source, services } = {}) {
|
||||
clearSelection,
|
||||
updateSelectedAnchor,
|
||||
installInteraction,
|
||||
prepareFloors,
|
||||
} = createAlprPresentation({ state, services, source });
|
||||
|
||||
function setAlprStatus(status, error = null) {
|
||||
@@ -109,10 +119,34 @@ export function createAlprCamerasLayer({ source, services } = {}) {
|
||||
if (resetBackoff) state.retryDelayMs = 0;
|
||||
}
|
||||
|
||||
/** Whether accepted records already answer `box` without a request. */
|
||||
function canReuse(box) {
|
||||
const detailZoom = source.detailZoom?.(box) ?? null;
|
||||
return Boolean(
|
||||
state.lastQueryBox &&
|
||||
boxContains(state.lastQueryBox, box) &&
|
||||
(state.lastQueryZoom == null ||
|
||||
detailZoom == null ||
|
||||
state.lastQueryZoom >= detailZoom - 1) &&
|
||||
state.lastUpdate &&
|
||||
Date.now() - state.lastUpdate < QUERY_REUSE_MS &&
|
||||
!state.stale &&
|
||||
state.status !== 'unavailable',
|
||||
);
|
||||
}
|
||||
|
||||
function scheduleLoad() {
|
||||
if (!state.enabled) return;
|
||||
clearUnavailableRetry({ resetBackoff: false });
|
||||
clearTimeout(state.debounceTimer);
|
||||
// A view the accepted tiles already cover is a local filter: show it now.
|
||||
// Only a view that needs a request waits out the debounce.
|
||||
const box = viewportBox(state.viewer);
|
||||
if (box && canReuse(box)) {
|
||||
state.debounceTimer = null;
|
||||
loadCameras();
|
||||
return;
|
||||
}
|
||||
state.debounceTimer = setTimeout(() => {
|
||||
state.debounceTimer = null;
|
||||
loadCameras();
|
||||
@@ -142,14 +176,9 @@ export function createAlprCamerasLayer({ source, services } = {}) {
|
||||
// request still in flight was for a viewport the user has since left, so it
|
||||
// is aborted rather than allowed to replace the records that already cover
|
||||
// this view. Failed queries never replace the last successful box.
|
||||
if (
|
||||
state.lastQueryBox &&
|
||||
boxContains(state.lastQueryBox, box) &&
|
||||
state.lastUpdate &&
|
||||
Date.now() - state.lastUpdate < QUERY_REUSE_MS &&
|
||||
!state.stale &&
|
||||
state.status !== 'unavailable'
|
||||
) {
|
||||
// Reuse also needs the accepted records to be precise enough for this view:
|
||||
// one zoom coarser than the view would pick is a few metres, never more.
|
||||
if (canReuse(box)) {
|
||||
if (state.abort) {
|
||||
state.abort.abort();
|
||||
state.abort = null;
|
||||
@@ -165,7 +194,8 @@ export function createAlprCamerasLayer({ source, services } = {}) {
|
||||
}
|
||||
// A move inside an in-flight query must not abort and restart that query.
|
||||
if (state.abort && boxContains(state.pendingQueryBox, box)) {
|
||||
renderRecords();
|
||||
// The accepted records may cover only part of the new view. Re-filtering
|
||||
// them during floor preparation creates an intermediate marker set.
|
||||
return;
|
||||
}
|
||||
const queryBox = snapAlprBox(box);
|
||||
@@ -176,7 +206,8 @@ export function createAlprCamerasLayer({ source, services } = {}) {
|
||||
state.loading = true;
|
||||
state.retrying = state.status === 'unavailable' || state.retryDelayMs > 0;
|
||||
setAlprStatus('loading');
|
||||
renderRecords();
|
||||
// Keep the current markers until the new view's records land, so a still
|
||||
// camera sees one update instead of old records re-filtered, then new ones.
|
||||
try {
|
||||
const snapshot = await source.fetch(queryBox, requestAbort.signal);
|
||||
if (
|
||||
@@ -185,16 +216,43 @@ export function createAlprCamerasLayer({ source, services } = {}) {
|
||||
!state.enabled
|
||||
)
|
||||
return;
|
||||
if (snapshot.zoomIn) {
|
||||
state.records = [];
|
||||
state.recordById.clear();
|
||||
state.noCoverage = false;
|
||||
state.lastQueryBox = null;
|
||||
state.lastQueryZoom = null;
|
||||
clearUnavailableRetry();
|
||||
renderRecords();
|
||||
setAlprStatus('zoom-in');
|
||||
return;
|
||||
}
|
||||
const { records, stale, saturated } = validateAlprSnapshot(snapshot);
|
||||
// Resolve marker floors (bounded wait, warm cells answer at once) so new
|
||||
// markers are placed on the floor instead of clamped then moved.
|
||||
await prepareFloors(records);
|
||||
if (
|
||||
requestAbort.signal.aborted ||
|
||||
state.abort !== requestAbort ||
|
||||
!state.enabled
|
||||
)
|
||||
return;
|
||||
state.noCoverage = snapshot.noCoverage === true;
|
||||
state.records = records;
|
||||
state.recordById = new Map(records.map((r) => [r.id, r]));
|
||||
state.lastUpdate = Date.now();
|
||||
state.stale = stale;
|
||||
// Saturated when the QUERY truncated OR the render cap would hide cameras:
|
||||
// either way the view holds more than the screen shows, and the user must
|
||||
// be told rather than left with a count that disagrees with the map.
|
||||
state.saturated = saturated || records.length > MAX_RENDERED;
|
||||
state.lastQueryBox = queryBox;
|
||||
// Saturated when the QUERY truncated; the render cap is judged against
|
||||
// the cameras in view (renderSaturated). Either way the row says so.
|
||||
state.saturated = saturated;
|
||||
// The fetched tiles cover more than the view: pans inside them are free.
|
||||
state.lastQueryBox =
|
||||
snapshot.coverage && boxContains(snapshot.coverage, queryBox)
|
||||
? snapshot.coverage
|
||||
: queryBox;
|
||||
state.lastQueryZoom = Number.isInteger(snapshot.zoom)
|
||||
? snapshot.zoom
|
||||
: null;
|
||||
clearUnavailableRetry();
|
||||
// Stale and saturated are independent facts; a cached response that also
|
||||
// hit the cap must still warn about coverage, not just about freshness.
|
||||
@@ -219,7 +277,7 @@ export function createAlprCamerasLayer({ source, services } = {}) {
|
||||
'unavailable',
|
||||
error?.message || 'Camera source temporarily unavailable',
|
||||
);
|
||||
scheduleUnavailableRetry();
|
||||
if (!isUnavailableCapability(error)) scheduleUnavailableRetry();
|
||||
} finally {
|
||||
if (state.abort === requestAbort) {
|
||||
state.abort = null;
|
||||
@@ -282,6 +340,7 @@ export function createAlprCamerasLayer({ source, services } = {}) {
|
||||
return loadCameras();
|
||||
},
|
||||
destroy(viewer = state.viewer) {
|
||||
source.destroy?.();
|
||||
this.disable();
|
||||
destroyOverlay();
|
||||
state.moveEndRemove?.();
|
||||
@@ -299,6 +358,7 @@ export function createAlprCamerasLayer({ source, services } = {}) {
|
||||
state.records = [];
|
||||
state.recordById = new Map();
|
||||
state.lastQueryBox = null;
|
||||
state.lastQueryZoom = null;
|
||||
state.lastUpdate = null;
|
||||
state.error = null;
|
||||
state.status = 'idle';
|
||||
@@ -335,12 +395,21 @@ export function createAlprCamerasLayer({ source, services } = {}) {
|
||||
getStats() {
|
||||
return {
|
||||
count: state.dataSource?.entities.values.length || 0,
|
||||
countLabel: state.enabled
|
||||
? `${state.dataSource?.entities.values.length || 0} nearby`
|
||||
: '',
|
||||
countLabel:
|
||||
state.enabled && !state.noCoverage && state.status !== 'zoom-in'
|
||||
? `${state.dataSource?.entities.values.length || 0} nearby${
|
||||
Number.isInteger(state.onScreen)
|
||||
? ` · ${state.onScreen} on screen`
|
||||
: ''
|
||||
}`
|
||||
: '',
|
||||
noCoverage: Boolean(state.noCoverage),
|
||||
lastUpdate: state.lastUpdate,
|
||||
stale: state.stale,
|
||||
saturated: state.saturated,
|
||||
saturated: state.saturated || state.renderSaturated,
|
||||
renderRevision: state.renderRevision,
|
||||
onScreen: state.onScreen ?? null,
|
||||
shown: state.dataSource?.entities.values.length || 0,
|
||||
error: state.error,
|
||||
status: state.status,
|
||||
loading: state.loading,
|
||||
@@ -353,17 +422,22 @@ export function createAlprCamerasLayer({ source, services } = {}) {
|
||||
? state.retrying
|
||||
? 'retrying mapped ALPR cameras'
|
||||
: 'loading mapped ALPR cameras'
|
||||
: state.status === 'zoom-in'
|
||||
? 'Zoom in to load mapped cameras'
|
||||
: [
|
||||
state.stale ? 'Showing cached locations' : '',
|
||||
state.saturated ? 'Coverage limited — zoom in' : '',
|
||||
state.status === 'empty'
|
||||
? 'No mapped cameras returned — coverage is incomplete'
|
||||
: '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · '),
|
||||
: state.noCoverage
|
||||
? 'No ALPR data for this area — US and Canada only'
|
||||
: state.status === 'zoom-in'
|
||||
? 'Zoom in to load mapped cameras'
|
||||
: [
|
||||
state.stale ? 'Showing cached locations' : '',
|
||||
state.saturated || state.renderSaturated
|
||||
? 'Coverage limited — zoom in'
|
||||
: '',
|
||||
state.status === 'empty' ? 'No ALPR data for this area' : '',
|
||||
state.status === 'ready' && state.onScreen === 0
|
||||
? 'None on screen — nearby cameras are outside the view'
|
||||
: '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · '),
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -384,4 +458,4 @@ export {
|
||||
QUERY_SNAP_DEGREES,
|
||||
QUERY_REUSE_MS,
|
||||
} from './policy.js';
|
||||
export { createOverpassAlprSource } from './source.js';
|
||||
export { createOverpassAlprSource, createAlprTileSource } from './source.js';
|
||||
+178
-32
@@ -3,18 +3,28 @@ import {
|
||||
MAX_CANVAS_FRUSTUMS,
|
||||
MARKER_ICON_SIZE,
|
||||
SELECTED_MARKER_ICON_SIZE,
|
||||
MARKER_FLOOR_LIFT_M,
|
||||
ANCHOR_SAMPLES_PER_PAINT,
|
||||
SURFACE_BELOW_FLOOR_M,
|
||||
SURFACE_ABOVE_FLOOR_M,
|
||||
SURFACE_SAMPLES_PER_PAINT,
|
||||
} from './policy.js';
|
||||
import {
|
||||
MARKER_IMAGE,
|
||||
SELECTED_IMAGE,
|
||||
BRACKETS_IMAGE,
|
||||
directionWedgePositions,
|
||||
markerScale,
|
||||
paintDirectionWedge,
|
||||
validAlprGroundHeight,
|
||||
} from './visuals.js';
|
||||
|
||||
/** Per-layer presentation on a caller-owned overlay; Cesium remains the fallback. */
|
||||
export function createAlprOverlay({ state, services }) {
|
||||
export function createAlprOverlay({
|
||||
state,
|
||||
services,
|
||||
onSurfaceChange = () => {},
|
||||
}) {
|
||||
let lane,
|
||||
removeMapListener,
|
||||
retryTimer,
|
||||
@@ -22,14 +32,21 @@ export function createAlprOverlay({ state, services }) {
|
||||
let records = [],
|
||||
hits = [],
|
||||
images = [],
|
||||
destroyed = false;
|
||||
let surfaceRegime;
|
||||
destroyed = false,
|
||||
lastCamera = null;
|
||||
const requestPaint = () => {
|
||||
if (!destroyed && state.enabled) lane?.requestPaint();
|
||||
};
|
||||
|
||||
function nativeVisible(entity, visible) {
|
||||
if (!entity) return;
|
||||
canvasOwned(entity, !visible);
|
||||
setShown(entity, visible);
|
||||
}
|
||||
|
||||
/** Swap a marker between its native look and a faint canvas pick target. */
|
||||
function canvasOwned(entity, owned) {
|
||||
const visible = !owned;
|
||||
const billboard = entity.billboard;
|
||||
// Keep a faint native pick target under the canvas badge so sibling layer
|
||||
// handlers recognize ALPR ownership instead of treating the click as empty.
|
||||
@@ -55,7 +72,15 @@ export function createAlprOverlay({ state, services }) {
|
||||
entity.gevAlprNativeAppearance = null;
|
||||
entity.gevAlprPickPosition = null;
|
||||
}
|
||||
billboard.show = visible;
|
||||
}
|
||||
|
||||
// Assign only on change: each Entity property write mints a new property
|
||||
// and raises definitionChanged, which paint used to do for every overlay
|
||||
// camera on every frame.
|
||||
function setShown(entity, visible) {
|
||||
if (entity.gevAlprShown === visible) return;
|
||||
entity.gevAlprShown = visible;
|
||||
entity.billboard.show = visible;
|
||||
if (entity.polyline) entity.polyline.show = visible;
|
||||
if (entity.polygon) entity.polygon.show = visible;
|
||||
}
|
||||
@@ -72,15 +97,25 @@ export function createAlprOverlay({ state, services }) {
|
||||
requestPaint();
|
||||
}
|
||||
|
||||
function anchorFor(record, entity) {
|
||||
function anchorFor(record, entity, budget) {
|
||||
if (entity.gevAlprCanvasPosition) return entity.gevAlprCanvasPosition;
|
||||
const scene = state.viewer.scene;
|
||||
const location = Cesium.Cartographic.fromDegrees(
|
||||
record.longitude,
|
||||
record.latitude,
|
||||
);
|
||||
// A floor-placed marker anchors where its native badge sits; only a
|
||||
// marker still waiting for its floor samples the rendered surface, and
|
||||
// each paint samples a few at most (each sample is a depth render).
|
||||
let height;
|
||||
if (scene.sampleHeightSupported) {
|
||||
const floor = services.groundFloor.cachedGroundFloor(
|
||||
record.latitude,
|
||||
record.longitude,
|
||||
);
|
||||
if (validAlprGroundHeight(floor)) height = floor + MARKER_FLOOR_LIFT_M;
|
||||
else if (scene.sampleHeightSupported) {
|
||||
if (budget.samples <= 0) return null;
|
||||
budget.samples -= 1;
|
||||
try {
|
||||
height = scene.sampleHeight(location, [entity]);
|
||||
} catch {
|
||||
@@ -89,19 +124,107 @@ export function createAlprOverlay({ state, services }) {
|
||||
}
|
||||
if (!validAlprGroundHeight(height) && scene.globe.show)
|
||||
height = scene.globe.getHeight?.(location);
|
||||
if (!validAlprGroundHeight(height))
|
||||
height = services.groundFloor.cachedGroundFloor(
|
||||
record.latitude,
|
||||
record.longitude,
|
||||
);
|
||||
if (!validAlprGroundHeight(height)) return null;
|
||||
setAnchor(record, entity, height);
|
||||
return entity.gevAlprCanvasPosition;
|
||||
}
|
||||
|
||||
function floorHeightAt(latitude, longitude) {
|
||||
const floor = services.groundFloor.cachedGroundFloor(latitude, longitude);
|
||||
return validAlprGroundHeight(floor) ? floor + MARKER_FLOOR_LIFT_M : null;
|
||||
}
|
||||
|
||||
function setAnchor(record, entity, height) {
|
||||
entity.gevAlprCanvasPosition = Cesium.Cartesian3.fromDegrees(
|
||||
record.longitude,
|
||||
record.latitude,
|
||||
height,
|
||||
);
|
||||
entity.gevAlprWedge = directionWedgePositions(record, height);
|
||||
return entity.gevAlprCanvasPosition;
|
||||
entity.gevAlprWedge = directionWedgePositions(
|
||||
record,
|
||||
height,
|
||||
floorHeightAt,
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether the visible photoreal tiles (or globe) have finished streaming. */
|
||||
function surfaceSettled(scene) {
|
||||
if (scene.globe?.show) return scene.globe.tilesLoaded !== false;
|
||||
for (let i = 0; i < (scene.primitives?.length || 0); i++) {
|
||||
const primitive = scene.primitives.get(i);
|
||||
if (primitive?.show && primitive.tilesLoaded === false) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace floor anchors with the rendered surface under each overlay camera.
|
||||
* Floors come from ~111 m cells, so on a slope a badge could float or sink
|
||||
* by tens of metres. Runs only while the camera is still and tiles have
|
||||
* settled, a few depth samples per paint, and each anchor at most once;
|
||||
* samples far outside the floor prior (a roof or a streaming tile) are
|
||||
* rejected. Returns whether anchors remain to check.
|
||||
*/
|
||||
function refineAnchors(budget) {
|
||||
const camera = state.viewer.camera;
|
||||
const scene = state.viewer.scene;
|
||||
const still =
|
||||
lastCamera &&
|
||||
camera.positionWC &&
|
||||
Cesium.Cartesian3.equalsEpsilon(
|
||||
camera.positionWC,
|
||||
lastCamera.position,
|
||||
0,
|
||||
0.05,
|
||||
) &&
|
||||
Cesium.Cartesian3.equalsEpsilon(
|
||||
camera.directionWC,
|
||||
lastCamera.direction,
|
||||
0,
|
||||
1e-6,
|
||||
);
|
||||
if (camera.positionWC && camera.directionWC)
|
||||
lastCamera = {
|
||||
position: Cesium.Cartesian3.clone(camera.positionWC),
|
||||
direction: Cesium.Cartesian3.clone(camera.directionWC),
|
||||
};
|
||||
if (!scene.sampleHeightSupported) return false;
|
||||
let pending = false;
|
||||
for (const record of records) {
|
||||
const entity = state.dataSource.entities.getById(record.id);
|
||||
const anchor = entity?.gevAlprCanvasPosition;
|
||||
if (!anchor || entity.gevAlprSurfaceCheckedFor === anchor) continue;
|
||||
if (!still || !surfaceSettled(scene) || budget.samples <= 0) {
|
||||
pending = true;
|
||||
continue;
|
||||
}
|
||||
budget.samples -= 1;
|
||||
let sample;
|
||||
try {
|
||||
sample = scene.sampleHeight(
|
||||
Cesium.Cartographic.fromDegrees(record.longitude, record.latitude),
|
||||
[entity],
|
||||
);
|
||||
} catch {
|
||||
/* streaming tiles */
|
||||
}
|
||||
const current = Cesium.Cartographic.fromCartesian(anchor).height;
|
||||
const floor = services.groundFloor.cachedGroundFloor(
|
||||
record.latitude,
|
||||
record.longitude,
|
||||
);
|
||||
const accepted =
|
||||
validAlprGroundHeight(sample) &&
|
||||
(!validAlprGroundHeight(floor) ||
|
||||
(sample >= floor - SURFACE_BELOW_FLOOR_M &&
|
||||
sample <= floor + SURFACE_ABOVE_FLOOR_M));
|
||||
if (accepted && Math.abs(sample + MARKER_FLOOR_LIFT_M - current) > 1)
|
||||
setAnchor(record, entity, sample + MARKER_FLOOR_LIFT_M);
|
||||
entity.gevAlprSurfaceCheckedFor = entity.gevAlprCanvasPosition;
|
||||
if (entity.id === state.selectedId)
|
||||
entity.gevAlprDisplayPosition = entity.gevAlprCanvasPosition;
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
|
||||
function paint({ ctx, width, height, keyhole, occluder }) {
|
||||
@@ -109,6 +232,7 @@ export function createAlprOverlay({ state, services }) {
|
||||
if (!state.enabled || destroyed) return;
|
||||
let unresolved = false;
|
||||
const painted = [];
|
||||
const budget = { samples: ANCHOR_SAMPLES_PER_PAINT };
|
||||
for (const record of records) {
|
||||
const entity = state.dataSource.entities.getById(record.id);
|
||||
if (!entity) continue;
|
||||
@@ -122,23 +246,32 @@ export function createAlprOverlay({ state, services }) {
|
||||
nativeVisible(entity, true);
|
||||
continue;
|
||||
}
|
||||
const anchor = anchorFor(record, entity);
|
||||
const anchor = anchorFor(record, entity, budget);
|
||||
if (!anchor) {
|
||||
nativeVisible(entity, true);
|
||||
unresolved = true;
|
||||
continue;
|
||||
}
|
||||
nativeVisible(entity, false);
|
||||
if (occluder && !occluder.isPointVisible(anchor)) continue;
|
||||
canvasOwned(entity, true);
|
||||
if (occluder && !occluder.isPointVisible(anchor)) {
|
||||
setShown(entity, false);
|
||||
continue;
|
||||
}
|
||||
const scene = state.viewer.scene;
|
||||
const origin = Cesium.SceneTransforms.worldToWindowCoordinates(
|
||||
scene,
|
||||
anchor,
|
||||
);
|
||||
if (!origin) continue;
|
||||
if (!origin) {
|
||||
setShown(entity, false);
|
||||
continue;
|
||||
}
|
||||
const alpha =
|
||||
services.overlays.keyholeAlpha?.(origin.x, origin.y, keyhole) ?? 1;
|
||||
if (!(alpha > 0)) continue;
|
||||
if (!(alpha > 0)) {
|
||||
setShown(entity, false);
|
||||
continue;
|
||||
}
|
||||
const wedge = entity.gevAlprWedge;
|
||||
if (wedge) {
|
||||
const left = Cesium.SceneTransforms.worldToWindowCoordinates(
|
||||
@@ -161,15 +294,27 @@ export function createAlprOverlay({ state, services }) {
|
||||
origin.x > width + 60 ||
|
||||
origin.y < -60 ||
|
||||
origin.y > height + 60
|
||||
)
|
||||
) {
|
||||
setShown(entity, false);
|
||||
continue;
|
||||
entity.billboard.show = true;
|
||||
painted.push({ record, origin, selected, image, alpha });
|
||||
}
|
||||
setShown(entity, true);
|
||||
painted.push({
|
||||
record,
|
||||
origin,
|
||||
selected,
|
||||
image,
|
||||
alpha,
|
||||
scale: markerScale(
|
||||
Cesium.Cartesian3.distance(state.viewer.camera.positionWC, anchor),
|
||||
),
|
||||
});
|
||||
if (selected) entity.gevAlprDisplayPosition = anchor;
|
||||
}
|
||||
// Paint all glyphs after the wedges so one camera's cone cannot wash out another.
|
||||
for (const { record, origin, selected, image, alpha } of painted) {
|
||||
const size = selected ? SELECTED_MARKER_ICON_SIZE : MARKER_ICON_SIZE;
|
||||
for (const { record, origin, selected, image, alpha, scale } of painted) {
|
||||
const size =
|
||||
(selected ? SELECTED_MARKER_ICON_SIZE : MARKER_ICON_SIZE) * scale;
|
||||
ctx.save();
|
||||
ctx.globalAlpha *= alpha;
|
||||
ctx.drawImage(
|
||||
@@ -190,7 +335,10 @@ export function createAlprOverlay({ state, services }) {
|
||||
ctx.restore();
|
||||
hits.push({ id: record.id, x: origin.x, y: origin.y, radius: size / 2 });
|
||||
}
|
||||
if (unresolved && !retryTimer && Date.now() < retryUntil) {
|
||||
const refining = refineAnchors({
|
||||
samples: Math.min(budget.samples, SURFACE_SAMPLES_PER_PAINT),
|
||||
});
|
||||
if ((unresolved || refining) && !retryTimer && Date.now() < retryUntil) {
|
||||
retryTimer = setTimeout(() => {
|
||||
retryTimer = null;
|
||||
requestPaint();
|
||||
@@ -201,6 +349,12 @@ export function createAlprOverlay({ state, services }) {
|
||||
return {
|
||||
init() {
|
||||
destroyed = false;
|
||||
removeMapListener = services.overlays?.subscribeMapStack?.((event) => {
|
||||
if (event.detail?.status !== 'ready' || !state.enabled) return;
|
||||
// Same-regime provider changes also invalidate absolute placements.
|
||||
onSurfaceChange();
|
||||
resetAnchors();
|
||||
});
|
||||
if (!services.overlays?.registerPaintLane || typeof Image === 'undefined')
|
||||
return;
|
||||
lane = services.overlays.registerPaintLane('selected', paint, {
|
||||
@@ -214,14 +368,6 @@ export function createAlprOverlay({ state, services }) {
|
||||
image.src = src;
|
||||
return image;
|
||||
});
|
||||
surfaceRegime = state.viewer.scene.globe.show;
|
||||
removeMapListener = services.overlays.subscribeMapStack?.((event) => {
|
||||
if (event.detail?.status !== 'ready') return;
|
||||
const next = state.viewer.scene.globe.show;
|
||||
if (next === surfaceRegime) return;
|
||||
surfaceRegime = next;
|
||||
resetAnchors();
|
||||
});
|
||||
},
|
||||
sync(visible) {
|
||||
if (!lane) return;
|
||||
|
||||
@@ -2,9 +2,13 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createAlprOverlay } from './overlay.js';
|
||||
import { directionWedgePositions, alprLabelDetails } from './visuals.js';
|
||||
import {
|
||||
directionWedgePositions,
|
||||
alprLabelDetails,
|
||||
markerScale,
|
||||
} from './visuals.js';
|
||||
|
||||
function harness(t, count = 2) {
|
||||
function harness(t, count = 2, { floor = null } = {}) {
|
||||
const previousImage = globalThis.Image;
|
||||
const project = Cesium.SceneTransforms.worldToWindowCoordinates;
|
||||
globalThis.Image = class {
|
||||
@@ -43,7 +47,7 @@ function harness(t, count = 2) {
|
||||
const overlay = createAlprOverlay({
|
||||
state,
|
||||
services: {
|
||||
groundFloor: { cachedGroundFloor: () => null },
|
||||
groundFloor: { cachedGroundFloor: () => floor },
|
||||
overlays: {
|
||||
registerPaintLane(_lane, painter) {
|
||||
paint = painter;
|
||||
@@ -190,6 +194,20 @@ test('an unresolved surface preserves the native marker; the horizon removes ove
|
||||
assert.equal(h.draw.length, 0);
|
||||
});
|
||||
|
||||
test('repeated paints of a still view never rewrite marker properties', (t) => {
|
||||
// Each Entity write raises definitionChanged; paint used to hide then show
|
||||
// every overlay camera's native marker on every frame.
|
||||
const h = harness(t, 3);
|
||||
h.overlay.sync(h.records);
|
||||
h.paint();
|
||||
let changes = 0;
|
||||
for (const entity of h.entities.values)
|
||||
entity.definitionChanged.addEventListener(() => changes++);
|
||||
for (let i = 0; i < 5; i++) h.paint();
|
||||
assert.equal(changes, 0);
|
||||
assert.equal(h.entities.getById('camera:0').billboard.show.getValue(), true);
|
||||
});
|
||||
|
||||
test('adapter labels never claim a custom source is public OSM data', () => {
|
||||
assert.deepEqual(
|
||||
alprLabelDetails(
|
||||
@@ -199,3 +217,49 @@ test('adapter labels never claim a custom source is public OSM data', () => {
|
||||
['Source: Custom directory', 'VENDOR'],
|
||||
);
|
||||
});
|
||||
|
||||
test('overlay glyphs and native badges shrink alike with distance', () => {
|
||||
assert.equal(markerScale(100), 1.15);
|
||||
assert.equal(markerScale(40_000), 0.45);
|
||||
assert.equal(markerScale(90_000), 0.45);
|
||||
assert.ok(Math.abs(markerScale(20_250) - 0.8) < 1e-9);
|
||||
});
|
||||
|
||||
test('a still view replaces floor anchors with the rendered surface, within the floor window', (t) => {
|
||||
// Floors come from ~111 m cells; on a slope a badge floated above the mesh.
|
||||
const h = harness(t, 2, { floor: 150 });
|
||||
const scene = h.state.viewer.scene;
|
||||
const camera = h.state.viewer.camera;
|
||||
camera.directionWC = Cesium.Cartesian3.UNIT_X;
|
||||
scene.sampleHeightSupported = true;
|
||||
scene.primitives = { length: 0, get: () => null };
|
||||
let samples = 0;
|
||||
scene.sampleHeight = (carto) => {
|
||||
samples++;
|
||||
return Cesium.Math.toDegrees(carto.latitude) > 30.00005 ? 400 : 172;
|
||||
};
|
||||
h.overlay.sync(h.records);
|
||||
h.paint();
|
||||
const height = (id) =>
|
||||
Cesium.Cartographic.fromCartesian(
|
||||
h.entities.getById(id).gevAlprCanvasPosition,
|
||||
).height;
|
||||
assert.equal(
|
||||
samples,
|
||||
0,
|
||||
'no depth samples while the camera may still be moving',
|
||||
);
|
||||
assert.ok(Math.abs(height('camera:0') - 151.5) < 0.01, 'floor anchor first');
|
||||
h.paint();
|
||||
assert.ok(
|
||||
Math.abs(height('camera:0') - 173.5) < 0.01,
|
||||
'rendered surface once still',
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(height('camera:1') - 151.5) < 0.01,
|
||||
'a rooftop-height sample is rejected',
|
||||
);
|
||||
const taken = samples;
|
||||
h.paint();
|
||||
assert.equal(samples, taken, 'each anchor is checked once');
|
||||
});
|
||||
+42
-21
@@ -1,33 +1,37 @@
|
||||
/**
|
||||
* @file Community-mapped automatic license plate reader (ALPR) camera layer.
|
||||
*
|
||||
* Data source: OpenStreetMap contributors, including the DeFlock community
|
||||
* (`man_made=surveillance` + `surveillance:type=ALPR`) — see
|
||||
* https://wiki.openstreetmap.org/wiki/Tag:surveillance:type=ALPR and
|
||||
* https://deflock.org. OSM data remains ODbL 1.0, separate from the MIT code.
|
||||
* Fetched viewport-bounded through the existing generic
|
||||
* `/api/overpass` proxy (same one `traffic.js` and `militaryInstallations.js`
|
||||
* use) — no new server route needed for a single narrow tag pair.
|
||||
*
|
||||
* This is mapped surveillance infrastructure, not a live camera feed: no
|
||||
* plate records, no vendor accounts, nothing beyond what a contributor chose
|
||||
* to publish to OSM. Coverage and tag accuracy are not guaranteed.
|
||||
*
|
||||
* @module data/alprCameras
|
||||
* Community-mapped OpenStreetMap ALPR locations, via hourly US/Canada detail
|
||||
* tiles. OSM data remains ODbL, separate from the application code. These are
|
||||
* mapped locations, not footage, plate records or evidence of current activity.
|
||||
*/
|
||||
|
||||
export const LAYER_ID = 'alpr-cameras';
|
||||
|
||||
export const OVERPASS_URL = '/api/overpass';
|
||||
|
||||
export const REQUEST_DEBOUNCE_MS = 500;
|
||||
export const REQUEST_DEBOUNCE_MS = 300;
|
||||
|
||||
/** Keep public Overpass queries city-scale, never globe-wide. */
|
||||
/** Keep camera acquisition city-scale, never globe-wide. */
|
||||
export const MAX_VIEWPORT_DEGREES = 3;
|
||||
|
||||
/** Overpass `out body N;` cap — also detects possibly truncated coverage. */
|
||||
/** Camera record cap; reaching it reports limited coverage. */
|
||||
export const QUERY_LIMIT = 1500;
|
||||
|
||||
/**
|
||||
* The extract carries full camera attributes from z9 (z0-8 are geometry-only
|
||||
* heat tiles). Each view uses the finest zoom from z12 down to z9 that fits
|
||||
* DETAIL_MAX_TILES, so a whole city loads from a few z9/z10 tiles and a
|
||||
* neighbourhood from z12 (about 2 m position precision).
|
||||
*/
|
||||
export const DETAIL_MIN_ZOOM = 9;
|
||||
export const DETAIL_MAX_ZOOM = 12;
|
||||
export const DETAIL_MAX_TILES = 16;
|
||||
|
||||
/** Decoded records kept per accepted tile coverage (memory bound, not a render cap). */
|
||||
export const SOURCE_RECORD_LIMIT = 6000;
|
||||
|
||||
/** Best-precision coordinates remembered per camera across zoom changes. */
|
||||
export const PRECISION_CACHE_LIMIT = 20000;
|
||||
|
||||
/** Render cap. Kept at or above QUERY_LIMIT on purpose: if it ever sat below
|
||||
* it, cameras between the two would be silently dropped while `saturated`
|
||||
* stayed false and `count` still reported them — a lie about coverage. */
|
||||
@@ -40,20 +44,37 @@ export const DIRECTION_CONE_HALF_ANGLE_DEG = 20;
|
||||
export const EARTH_MEAN_RADIUS_M = 6371008.8;
|
||||
|
||||
/** Query boxes are snapped outward to this grid so nearby camera moves reuse
|
||||
* one Overpass request body (the shared proxy keys its cache and in-flight
|
||||
* dedupe by the exact body) instead of each becoming a distinct upstream fetch. */
|
||||
* one accepted viewport snapshot rather than rebuilding records on every pan. */
|
||||
export const QUERY_SNAP_DEGREES = 0.05;
|
||||
|
||||
/** A view still fully inside the last snapped query box reuses those records
|
||||
* for this long before asking Overpass again. */
|
||||
* for this long before selecting tiles again. */
|
||||
export const QUERY_REUSE_MS = 10 * 60 * 1000;
|
||||
|
||||
/** Vendor-neutral camera badge palette, with coral selection. */
|
||||
export const ALPR_COLOR = '#52d4ff';
|
||||
export const ALPR_SELECTED_COLOR = '#ff6474';
|
||||
export const MARKER_ICON_SIZE = 38;
|
||||
/** Badge scale by camera distance (near m, scale, far m, scale), shared by
|
||||
* native badges and overlay glyphs so both shrink alike in city-wide views. */
|
||||
export const MARKER_SCALE_BY_DISTANCE = Object.freeze([
|
||||
500, 1.15, 40_000, 0.45,
|
||||
]);
|
||||
export const SELECTED_MARKER_ICON_SIZE = 60;
|
||||
export const MAX_CANVAS_FRUSTUMS = 64;
|
||||
|
||||
/** Metres a floor-placed marker sits above its resolved ground cell. */
|
||||
export const MARKER_FLOOR_LIFT_M = 1.5;
|
||||
|
||||
/** Rendered-surface samples one overlay paint may take (each is a depth render). */
|
||||
export const ANCHOR_SAMPLES_PER_PAINT = 4;
|
||||
|
||||
/** Accepted rendered-surface window around a floor cell (a cut or an embankment
|
||||
* sits within it; a rooftop or an unstreamed tile usually does not). */
|
||||
export const SURFACE_BELOW_FLOOR_M = 30;
|
||||
/** Rendered-surface refinements per paint while the camera is still. */
|
||||
export const SURFACE_SAMPLES_PER_PAINT = 2;
|
||||
export const SURFACE_ABOVE_FLOOR_M = 40;
|
||||
|
||||
/** OSM attribution may collapse after five seconds; full credit stays in Data attribution. */
|
||||
export const CREDIT_DISPLAY_MS = 5000;
|
||||
+244
-51
@@ -9,15 +9,17 @@ import {
|
||||
MARKER_ICON_SIZE,
|
||||
SELECTED_MARKER_ICON_SIZE,
|
||||
CREDIT_DISPLAY_MS,
|
||||
MARKER_FLOOR_LIFT_M,
|
||||
MARKER_SCALE_BY_DISTANCE,
|
||||
} from './policy.js';
|
||||
import { boxContains } from './model.js';
|
||||
import { createAlprFloorResolver } from './floors.js';
|
||||
import { createAlprOverlay } from './overlay.js';
|
||||
import {
|
||||
MARKER_IMAGE,
|
||||
SELECTED_IMAGE,
|
||||
alprDisplayId,
|
||||
alprLabelDetails,
|
||||
directionWedgePositions,
|
||||
validAlprGroundHeight,
|
||||
} from './visuals.js';
|
||||
|
||||
@@ -32,9 +34,198 @@ export function createAlprPresentation({ state, services, source }) {
|
||||
} = services.context;
|
||||
|
||||
const { cachedGroundFloor } = services.groundFloor;
|
||||
const overlay = createAlprOverlay({ state, services });
|
||||
const overlay = createAlprOverlay({
|
||||
state,
|
||||
services,
|
||||
onSurfaceChange: refreshMarkerPlacements,
|
||||
});
|
||||
let visibleRecords = [];
|
||||
let selectionStartedAt = 0;
|
||||
/** Markers still clamped while their floor cell resolves (id -> entity). */
|
||||
const clampedMarkers = new Map();
|
||||
let floorResolve = null;
|
||||
const floors = createAlprFloorResolver(services.groundFloor);
|
||||
|
||||
/**
|
||||
* Marker placement from the cached ground floor (validated mesh or DEM, the
|
||||
* ellipsoidal datum every layer shares). Ground clamping on photoreal tiles
|
||||
* re-samples every clamped marker whenever a tile streams in, which made a
|
||||
* whole-city view stutter while zooming, so it is only the placeholder for
|
||||
* a cell that has not resolved yet.
|
||||
* @param {{latitude:number, longitude:number}} record Camera.
|
||||
* @returns {{position: Cesium.Cartesian3, clamped: boolean}} Placement.
|
||||
*/
|
||||
function markerPlacement(record) {
|
||||
const floor = cachedGroundFloor(record.latitude, record.longitude);
|
||||
const clamped = !validAlprGroundHeight(floor);
|
||||
return {
|
||||
clamped,
|
||||
position: Cesium.Cartesian3.fromDegrees(
|
||||
record.longitude,
|
||||
record.latitude,
|
||||
clamped ? 0 : floor + MARKER_FLOOR_LIFT_M,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** Records the current view would display (viewport box, nearest first when capped). */
|
||||
function viewRecords(records) {
|
||||
const box = viewportBox(state.viewer);
|
||||
if (!box) return { box, visible: [], capped: false };
|
||||
let visible = records.filter((record) =>
|
||||
boxContains(box, {
|
||||
south: record.latitude,
|
||||
north: record.latitude,
|
||||
west: record.longitude,
|
||||
east: record.longitude,
|
||||
}),
|
||||
);
|
||||
const capped = visible.length > MAX_RENDERED;
|
||||
if (capped) {
|
||||
const lat = (box.south + box.north) / 2;
|
||||
const lon = (box.west + box.east) / 2;
|
||||
const scale = Math.cos((lat * Math.PI) / 180);
|
||||
const distance = (record) =>
|
||||
(record.latitude - lat) ** 2 + ((record.longitude - lon) * scale) ** 2;
|
||||
visible = visible
|
||||
.map((record) => ({ record, d: distance(record) }))
|
||||
.sort((a, b) => a.d - b.d)
|
||||
.slice(0, MAX_RENDERED)
|
||||
.map((item) => item.record);
|
||||
}
|
||||
return { box, visible, capped };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the floor cells of the records this view will display, waiting at
|
||||
* most the ground service's bounded deadline. Never throws.
|
||||
* @param {Array<object>} records Candidate records.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function prepareFloors(records) {
|
||||
const resolve = services.groundFloor.resolveGroundFloorCells;
|
||||
if (typeof resolve !== 'function') return;
|
||||
const cold = viewRecords(records)
|
||||
.visible.filter(
|
||||
(record) =>
|
||||
!validAlprGroundHeight(
|
||||
cachedGroundFloor(record.latitude, record.longitude),
|
||||
),
|
||||
)
|
||||
.map((record) => ({ lat: record.latitude, lon: record.longitude }));
|
||||
if (!cold.length) return;
|
||||
try {
|
||||
await floors.prepare(cold);
|
||||
} catch {
|
||||
/* markers fall back to clamping until their floors resolve */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loaded cameras that project inside the canvas and in front of the globe.
|
||||
* "Nearby" spans twice the camera-to-ground range, so a view can hold
|
||||
* cameras while none is on screen; the row then says so rather than
|
||||
* leaving the user to wait for markers. Runs once per render pass.
|
||||
* @returns {number|null} Count, or null without a scene to project into.
|
||||
*/
|
||||
function countOnScreen() {
|
||||
const scene = state.viewer?.scene;
|
||||
const canvas = scene?.canvas;
|
||||
const width = canvas?.clientWidth || canvas?.width;
|
||||
const height = canvas?.clientHeight || canvas?.height;
|
||||
const camera = state.viewer?.camera;
|
||||
if (!scene || !width || !height || !camera?.positionWC) return null;
|
||||
const occluder = new Cesium.EllipsoidalOccluder(
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
camera.positionWC,
|
||||
);
|
||||
let count = 0;
|
||||
try {
|
||||
for (const entity of state.dataSource.entities.values) {
|
||||
const position = entity.position?.getValue?.(Cesium.JulianDate.now());
|
||||
if (!position || !occluder.isPointVisible(position)) continue;
|
||||
const point = Cesium.SceneTransforms.worldToWindowCoordinates(
|
||||
scene,
|
||||
position,
|
||||
);
|
||||
if (
|
||||
point &&
|
||||
point.x >= 0 &&
|
||||
point.x <= width &&
|
||||
point.y >= 0 &&
|
||||
point.y <= height
|
||||
)
|
||||
count += 1;
|
||||
}
|
||||
} catch {
|
||||
// A scene without a usable frame (startup, teardown) cannot answer.
|
||||
return null;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/** Write a marker position, respecting the overlay's pick-target swap. */
|
||||
function setMarkerPosition(entity, position, clamped) {
|
||||
const heightReference = clamped
|
||||
? Cesium.HeightReference.CLAMP_TO_GROUND
|
||||
: Cesium.HeightReference.NONE;
|
||||
const saved = entity.gevAlprNativeAppearance;
|
||||
if (saved) {
|
||||
saved.position = position;
|
||||
saved.heightReference = heightReference;
|
||||
} else {
|
||||
entity.position = position;
|
||||
entity.billboard.heightReference = heightReference;
|
||||
}
|
||||
entity.gevAlprClamped = clamped;
|
||||
}
|
||||
|
||||
/** Refresh all native placements, including the appearances held by the canvas. */
|
||||
function refreshMarkerPlacements() {
|
||||
if (!state.enabled || !state.dataSource) return;
|
||||
clampedMarkers.clear();
|
||||
for (const entity of state.dataSource.entities.values) {
|
||||
const { position, clamped } = markerPlacement(entity.gevAlprRecord);
|
||||
setMarkerPosition(entity, position, clamped);
|
||||
if (clamped) clampedMarkers.set(entity.id, entity);
|
||||
}
|
||||
resolveClampedFloors();
|
||||
governorRequestRender('alpr-surface');
|
||||
}
|
||||
|
||||
/** Resolve floors for clamped markers once, then place them in place. */
|
||||
function resolveClampedFloors() {
|
||||
const resolve = services.groundFloor.resolveGroundFloorCells;
|
||||
if (floorResolve || !clampedMarkers.size || typeof resolve !== 'function')
|
||||
return;
|
||||
const batch = [...clampedMarkers.values()];
|
||||
const request = Promise.resolve(
|
||||
floors.resolve(
|
||||
batch.map((entity) => ({
|
||||
lat: entity.gevAlprRecord.latitude,
|
||||
lon: entity.gevAlprRecord.longitude,
|
||||
})),
|
||||
),
|
||||
)
|
||||
.catch(() => {})
|
||||
.then(() => {
|
||||
if (floorResolve !== request) return;
|
||||
floorResolve = null;
|
||||
if (!state.enabled || !state.dataSource) return;
|
||||
let moved = false;
|
||||
for (const entity of batch) {
|
||||
if (clampedMarkers.get(entity.id) !== entity) continue;
|
||||
clampedMarkers.delete(entity.id);
|
||||
const { position, clamped } = markerPlacement(entity.gevAlprRecord);
|
||||
if (clamped) continue;
|
||||
setMarkerPosition(entity, position, false);
|
||||
moved = true;
|
||||
}
|
||||
if (moved) governorRequestRender('alpr-floor');
|
||||
// Cells that never resolved stay clamped; later renders retry them.
|
||||
});
|
||||
floorResolve = request;
|
||||
}
|
||||
|
||||
function updateAppearance(entity, selected) {
|
||||
const color = Cesium.Color.fromCssColorString(
|
||||
@@ -44,12 +235,6 @@ export function createAlprPresentation({ state, services, source }) {
|
||||
entity.billboard.width = entity.billboard.height = selected
|
||||
? SELECTED_MARKER_ICON_SIZE
|
||||
: MARKER_ICON_SIZE;
|
||||
if (entity.polyline) {
|
||||
entity.polyline.width = selected ? 3 : 1.75;
|
||||
entity.polyline.material = color.withAlpha(selected ? 0.98 : 0.82);
|
||||
}
|
||||
if (entity.polygon)
|
||||
entity.polygon.material = color.withAlpha(selected ? 0.8 : 0.2);
|
||||
if (entity.gevLabelModel) {
|
||||
entity.gevLabelModel.accent = color.toCssColorString();
|
||||
entity.gevLabelModel.leaderAnimationStartedAt = selected
|
||||
@@ -118,13 +303,18 @@ export function createAlprPresentation({ state, services, source }) {
|
||||
}
|
||||
|
||||
function clearRendered() {
|
||||
hideOnMapCredit();
|
||||
floors.cancel();
|
||||
floorResolve = null;
|
||||
overlay.clear();
|
||||
visibleRecords = [];
|
||||
clampedMarkers.clear();
|
||||
if (state.dataSource?.entities) state.dataSource.entities.removeAll();
|
||||
removeEntityContextsForLayer(LAYER_ID);
|
||||
}
|
||||
|
||||
function hideOnMapCredit() {
|
||||
services.credits?.hideOsmCredit?.(state.viewer, LAYER_ID);
|
||||
clearTimeout(state.creditTimer);
|
||||
state.creditTimer = null;
|
||||
if (state.credit)
|
||||
@@ -133,6 +323,14 @@ export function createAlprPresentation({ state, services, source }) {
|
||||
}
|
||||
|
||||
function presentOnMapCredit() {
|
||||
if (
|
||||
state.enabled &&
|
||||
source.attribution?.name === 'OpenStreetMap' &&
|
||||
services.credits?.showOsmCredit
|
||||
) {
|
||||
services.credits.showOsmCredit(state.viewer, LAYER_ID);
|
||||
return;
|
||||
}
|
||||
if (!state.enabled || state.creditPresented || !state.credit) return;
|
||||
state.creditPresented = true;
|
||||
state.viewer.creditDisplay?.addStaticCredit(state.credit);
|
||||
@@ -142,19 +340,10 @@ export function createAlprPresentation({ state, services, source }) {
|
||||
|
||||
function renderRecords() {
|
||||
const selectedContext = getSelectedEntityContext();
|
||||
const box = viewportBox(state.viewer);
|
||||
const visible = box
|
||||
? state.records
|
||||
.filter((record) =>
|
||||
boxContains(box, {
|
||||
south: record.latitude,
|
||||
north: record.latitude,
|
||||
west: record.longitude,
|
||||
east: record.longitude,
|
||||
}),
|
||||
)
|
||||
.slice(0, MAX_RENDERED)
|
||||
: [];
|
||||
// Over the render cap, keep the cameras nearest the view centre and say
|
||||
// coverage is limited rather than dropping an arbitrary subset.
|
||||
const { visible, capped } = viewRecords(state.records);
|
||||
state.renderSaturated = capped;
|
||||
visibleRecords = visible;
|
||||
// A refresh may retain its own selection, never reclaim one cleared or
|
||||
// replaced by an aircraft, another layer, or a voice action.
|
||||
@@ -168,8 +357,14 @@ export function createAlprPresentation({ state, services, source }) {
|
||||
}
|
||||
governorRequestRender('alpr-render');
|
||||
const visibleIds = new Set(visible.map((record) => record.id));
|
||||
let changed = false;
|
||||
state.dataSource.entities.suspendEvents();
|
||||
for (const entity of [...state.dataSource.entities.values]) {
|
||||
if (!visibleIds.has(entity.id)) state.dataSource.entities.remove(entity);
|
||||
if (!visibleIds.has(entity.id)) {
|
||||
state.dataSource.entities.remove(entity);
|
||||
clampedMarkers.delete(entity.id);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
removeEntityContextsForLayer(LAYER_ID, { retainIds: visibleIds });
|
||||
for (const record of visible) {
|
||||
@@ -180,10 +375,11 @@ export function createAlprPresentation({ state, services, source }) {
|
||||
}
|
||||
const color = markerColor();
|
||||
const selected = record.id === state.selectedId;
|
||||
const position = Cesium.Cartesian3.fromDegrees(
|
||||
record.longitude,
|
||||
record.latitude,
|
||||
);
|
||||
const { position, clamped } = markerPlacement(record);
|
||||
// Direction wedges are painted by the overlay for the nearest cameras.
|
||||
// Native ground-clamped wedges (a ground polyline plus a classification
|
||||
// polygon per camera) cost ~40 ms per frame for 200 downtown cameras on
|
||||
// photoreal tiles, so markers stay billboards only.
|
||||
const entityDef = {
|
||||
id: record.id,
|
||||
position,
|
||||
@@ -191,38 +387,30 @@ export function createAlprPresentation({ state, services, source }) {
|
||||
image: selected ? SELECTED_IMAGE : MARKER_IMAGE,
|
||||
width: selected ? SELECTED_MARKER_ICON_SIZE : MARKER_ICON_SIZE,
|
||||
height: selected ? SELECTED_MARKER_ICON_SIZE : MARKER_ICON_SIZE,
|
||||
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
|
||||
heightReference: clamped
|
||||
? Cesium.HeightReference.CLAMP_TO_GROUND
|
||||
: Cesium.HeightReference.NONE,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
scaleByDistance: new Cesium.NearFarScalar(350, 1.15, 4_000_000, 0.48),
|
||||
// City-wide views hold hundreds of badges; let them shrink.
|
||||
scaleByDistance: new Cesium.NearFarScalar(
|
||||
...MARKER_SCALE_BY_DISTANCE,
|
||||
),
|
||||
},
|
||||
};
|
||||
const wedge = directionWedgePositions(record);
|
||||
if (wedge) {
|
||||
entityDef.polyline = {
|
||||
positions: [wedge[1], position, wedge[2]],
|
||||
width: selected ? 3 : 1.75,
|
||||
material: color.withAlpha(0.82),
|
||||
clampToGround: true,
|
||||
};
|
||||
entityDef.polygon = {
|
||||
hierarchy: wedge,
|
||||
material: color.withAlpha(0.2),
|
||||
height: 0,
|
||||
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
|
||||
classificationType: Cesium.ClassificationType.BOTH,
|
||||
};
|
||||
}
|
||||
let entity = existing;
|
||||
// Keep the Cesium entity and its ground-clamping subscription while its
|
||||
// geometry is unchanged. Updating metadata must not rebuild the marker.
|
||||
const previous = entity?.gevAlprRecord;
|
||||
if (!entity) entity = state.dataSource.entities.add(entityDef);
|
||||
else {
|
||||
if (!entity) {
|
||||
entity = state.dataSource.entities.add(entityDef);
|
||||
entity.gevAlprClamped = clamped;
|
||||
changed = true;
|
||||
} else {
|
||||
if (
|
||||
previous.latitude !== record.latitude ||
|
||||
previous.longitude !== record.longitude
|
||||
) {
|
||||
entity.position = position;
|
||||
setMarkerPosition(entity, position, clamped);
|
||||
entity.gevAlprCanvasPosition = null;
|
||||
entity.gevAlprDisplayPosition = null;
|
||||
}
|
||||
@@ -231,8 +419,6 @@ export function createAlprPresentation({ state, services, source }) {
|
||||
previous.longitude !== record.longitude ||
|
||||
previous.directionDeg !== record.directionDeg
|
||||
) {
|
||||
entity.polyline = entityDef.polyline;
|
||||
entity.polygon = entityDef.polygon;
|
||||
entity.gevAlprCanvasPosition = null;
|
||||
entity.gevAlprWedge = null;
|
||||
}
|
||||
@@ -281,6 +467,12 @@ export function createAlprPresentation({ state, services, source }) {
|
||||
},
|
||||
});
|
||||
}
|
||||
state.dataSource.entities.resumeEvents();
|
||||
if (changed) state.renderRevision += 1;
|
||||
state.onScreen = countOnScreen();
|
||||
for (const entity of state.dataSource.entities.values)
|
||||
if (entity.gevAlprClamped) clampedMarkers.set(entity.id, entity);
|
||||
resolveClampedFloors();
|
||||
const selectedEntity = state.selectedId
|
||||
? state.dataSource.entities.getById(state.selectedId)
|
||||
: null;
|
||||
@@ -288,9 +480,9 @@ export function createAlprPresentation({ state, services, source }) {
|
||||
updateSelectedAnchor();
|
||||
overlay.sync(visible);
|
||||
if (selectedEntity) services.overlays?.refreshReadout?.(selectedEntity);
|
||||
// Start only when mapped data is actually displayed, not while an upstream
|
||||
// request or a zoom-in prompt could consume the five-second introduction.
|
||||
// Keep the on-map credit tied to displayed data, not loading or zoom prompts.
|
||||
if (visible.length) presentOnMapCredit();
|
||||
else hideOnMapCredit();
|
||||
}
|
||||
|
||||
function focusNearest() {
|
||||
@@ -458,5 +650,6 @@ export function createAlprPresentation({ state, services, source }) {
|
||||
clearSelection,
|
||||
updateSelectedAnchor,
|
||||
installInteraction,
|
||||
prepareFloors,
|
||||
};
|
||||
}
|
||||
+191
-2
@@ -1,10 +1,199 @@
|
||||
import { tilesForBounds, tileToBBox } from '../../data/tomtomTiles.js';
|
||||
import {
|
||||
OVERPASS_URL,
|
||||
QUERY_LIMIT,
|
||||
MAX_VIEWPORT_DEGREES,
|
||||
QUERY_SNAP_DEGREES,
|
||||
QUERY_LIMIT,
|
||||
DETAIL_MIN_ZOOM,
|
||||
DETAIL_MAX_ZOOM,
|
||||
DETAIL_MAX_TILES,
|
||||
SOURCE_RECORD_LIMIT,
|
||||
PRECISION_CACHE_LIMIT,
|
||||
} from './policy.js';
|
||||
import {
|
||||
createVectorTileSource,
|
||||
validTileBounds,
|
||||
} from '../../sources/vectorTiles.js';
|
||||
import { buildOverpassQuery, normalizeAlprNode } from './records.js';
|
||||
import { decodeAlprTile } from './tileRecords.js';
|
||||
|
||||
/**
|
||||
* Finest attributed extract zoom whose tiles for `box` fit the per-view cap,
|
||||
* or null when even z9 would not (the layer asks the user to zoom in).
|
||||
* @param {{south:number, west:number, north:number, east:number}} box View box.
|
||||
* @returns {number|null} Tile zoom.
|
||||
*/
|
||||
export function alprDetailZoom(box) {
|
||||
for (let zoom = DETAIL_MAX_ZOOM; zoom >= DETAIL_MIN_ZOOM; zoom--)
|
||||
if (
|
||||
tilesForBounds(box, zoom, { maxTiles: DETAIL_MAX_TILES + 1 }).length <=
|
||||
DETAIL_MAX_TILES
|
||||
)
|
||||
return zoom;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tile coordinates are quantized per zoom (about 19 m at z9, 2 m at z12).
|
||||
* Remember each camera's finest position so zooming out never nudges a marker
|
||||
* that a closer view already placed precisely. Bounded, oldest first.
|
||||
* @param {number} [limit] Cameras remembered.
|
||||
* @returns {{apply: (record: object, zoom: number) => object, clear: () => void,
|
||||
* size: () => number}} Cache.
|
||||
*/
|
||||
export function createAlprPrecisionCache(limit = PRECISION_CACHE_LIMIT) {
|
||||
const known = new Map();
|
||||
return {
|
||||
apply(record, zoom) {
|
||||
const finer = known.get(record.id);
|
||||
if (finer && finer.zoom > zoom)
|
||||
return finer.latitude === record.latitude &&
|
||||
finer.longitude === record.longitude
|
||||
? record
|
||||
: { ...record, latitude: finer.latitude, longitude: finer.longitude };
|
||||
known.delete(record.id);
|
||||
known.set(record.id, {
|
||||
zoom,
|
||||
latitude: record.latitude,
|
||||
longitude: record.longitude,
|
||||
});
|
||||
while (known.size > limit) known.delete(known.keys().next().value);
|
||||
return record;
|
||||
},
|
||||
clear: () => known.clear(),
|
||||
size: () => known.size,
|
||||
};
|
||||
}
|
||||
|
||||
/** Construct the viewport-bounded OpenStreetMap hourly extract adapter. */
|
||||
export function createAlprTileSource({
|
||||
tileFetchImpl = (...args) => globalThis.fetch(...args),
|
||||
} = {}) {
|
||||
const sources = ['us', 'ca'].map((country) =>
|
||||
createVectorTileSource({
|
||||
tileJsonUrl: `https://tiles.dontgetflocked.com/cameras-${country}-hourly.json`,
|
||||
allowedOrigin: 'https://tiles.dontgetflocked.com',
|
||||
decode: decodeAlprTile,
|
||||
fetchImpl: tileFetchImpl,
|
||||
maxTiles: DETAIL_MAX_TILES,
|
||||
ttlMs: 60 * 60 * 1000,
|
||||
}),
|
||||
);
|
||||
const precision = createAlprPrecisionCache();
|
||||
const precise = precision.apply;
|
||||
return {
|
||||
/**
|
||||
* Read the camera records for every extract tile covering `box`.
|
||||
* @param {{south:number, west:number, north:number, east:number}} box View box.
|
||||
* @param {AbortSignal} [signal] Cancellation.
|
||||
* @returns {Promise<{records: Array<object>, stale: boolean, saturated: boolean,
|
||||
* noCoverage?: boolean, zoomIn?: boolean, coverage?: object, zoom?: number}>}
|
||||
* Records inside the fetched tiles; `coverage` is those tiles' bounds.
|
||||
*/
|
||||
async fetch(box, signal) {
|
||||
if (
|
||||
!validTileBounds(box) ||
|
||||
box.north - box.south >
|
||||
MAX_VIEWPORT_DEGREES + 2 * QUERY_SNAP_DEGREES + 1e-9 ||
|
||||
box.east - box.west >
|
||||
MAX_VIEWPORT_DEGREES + 2 * QUERY_SNAP_DEGREES + 1e-9
|
||||
)
|
||||
throw new TypeError('ALPR requires a bounded city viewport');
|
||||
signal?.throwIfAborted();
|
||||
// Fast geographic rejection; the exact extract extents below refine coverage.
|
||||
if (box.east < -180 || box.west > -50 || box.north < 17 || box.south > 84)
|
||||
return {
|
||||
records: [],
|
||||
stale: false,
|
||||
saturated: false,
|
||||
noCoverage: true,
|
||||
};
|
||||
const zoom = alprDetailZoom(box);
|
||||
if (zoom === null)
|
||||
return { records: [], stale: false, saturated: false, zoomIn: true };
|
||||
const tiles = tilesForBounds(box, zoom, {
|
||||
maxTiles: DETAIL_MAX_TILES + 1,
|
||||
});
|
||||
const tileBounds = tiles.map((tile) => tileToBBox(zoom, tile.x, tile.y));
|
||||
const coverage = {
|
||||
south: Math.min(...tileBounds.map((b) => b.south)),
|
||||
west: Math.min(...tileBounds.map((b) => b.west)),
|
||||
north: Math.max(...tileBounds.map((b) => b.north)),
|
||||
east: Math.max(...tileBounds.map((b) => b.east)),
|
||||
};
|
||||
const records = new Map();
|
||||
let covered = false,
|
||||
partial = false;
|
||||
for (const [index, source] of sources.entries()) {
|
||||
if (
|
||||
index === 1 &&
|
||||
(box.north < 41 ||
|
||||
box.south > 84 ||
|
||||
box.east < -142 ||
|
||||
box.west > -52)
|
||||
)
|
||||
continue;
|
||||
const metadata = await source.getMetadata(signal);
|
||||
const [west, south, east, north] = metadata.bounds || [];
|
||||
if (![west, south, east, north].every(Number.isFinite))
|
||||
throw new Error('Camera coverage unavailable');
|
||||
if (
|
||||
box.east < west ||
|
||||
box.west > east ||
|
||||
box.north < south ||
|
||||
box.south > north
|
||||
)
|
||||
continue;
|
||||
covered = true;
|
||||
const result = await source.fetchBounds(box, { zoom, signal });
|
||||
partial ||= result.partial;
|
||||
for (const record of result.tiles.flat())
|
||||
records.set(record.id, precise(record, zoom));
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
let list = [...records.values()];
|
||||
const truncated = list.length > SOURCE_RECORD_LIMIT;
|
||||
if (truncated) {
|
||||
// Keep the cameras nearest the view centre when a dense metro overflows.
|
||||
const lat = (box.south + box.north) / 2;
|
||||
const lon = (box.west + box.east) / 2;
|
||||
const scale = Math.cos((lat * Math.PI) / 180);
|
||||
list = list
|
||||
.map((record) => ({
|
||||
record,
|
||||
d:
|
||||
(record.latitude - lat) ** 2 +
|
||||
((record.longitude - lon) * scale) ** 2,
|
||||
}))
|
||||
.sort((a, b) => a.d - b.d)
|
||||
.slice(0, SOURCE_RECORD_LIMIT)
|
||||
.map((item) => item.record);
|
||||
}
|
||||
return {
|
||||
records: list,
|
||||
stale: false,
|
||||
saturated: partial || truncated,
|
||||
noCoverage: !covered,
|
||||
// A partial or truncated read does not cover its tiles; reuse only the box.
|
||||
coverage: partial || truncated ? { ...box } : coverage,
|
||||
zoom,
|
||||
};
|
||||
},
|
||||
detailZoom: alprDetailZoom,
|
||||
destroy() {
|
||||
for (const source of sources) source.clear();
|
||||
precision.clear();
|
||||
},
|
||||
label: 'OpenStreetMap · community mapped',
|
||||
attribution: {
|
||||
name: 'OpenStreetMap',
|
||||
description: '© OpenStreetMap contributors, ODbL',
|
||||
text: '© OpenStreetMap contributors, ODbL',
|
||||
href: 'https://www.openstreetmap.org/copyright',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Construct the bounded OSM request adapter without starting a request. */
|
||||
export function createOverpassAlprSource({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
@@ -75,7 +264,7 @@ export function createOverpassAlprSource({
|
||||
attribution: {
|
||||
name: 'OpenStreetMap',
|
||||
description: 'OpenStreetMap contributors (ODbL 1.0; community mapped)',
|
||||
text: '© OpenStreetMap',
|
||||
text: '© OpenStreetMap contributors',
|
||||
href: 'https://www.openstreetmap.org/copyright',
|
||||
},
|
||||
};
|
||||
|
||||
+211
-38
@@ -1,12 +1,19 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createOverpassAlprSource } from './source.js';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import {
|
||||
alprDetailZoom,
|
||||
createAlprPrecisionCache,
|
||||
createAlprTileSource,
|
||||
createOverpassAlprSource,
|
||||
buildOverpassQuery,
|
||||
} from './source.js';
|
||||
import { validateAlprSnapshot, alprCreditMarkup } from './model.js';
|
||||
const box = { south: 30, west: -98, north: 30.1, east: -97.9 };
|
||||
test('the source rejects invalid and unbounded queries before fetching', async () => {
|
||||
let calls = 0;
|
||||
const source = createOverpassAlprSource({
|
||||
fetchImpl() {
|
||||
const source = createAlprTileSource({
|
||||
tileFetchImpl() {
|
||||
calls++;
|
||||
},
|
||||
});
|
||||
@@ -21,55 +28,77 @@ test('the source rejects invalid and unbounded queries before fetching', async (
|
||||
}
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
test('cancellation during body parsing rejects even when the transport ignores it', async () => {
|
||||
const fixture = readFileSync(
|
||||
new URL(
|
||||
'../../data/fixtures/osm-alpr-austin-11-467-843.pbf',
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
const metadata = (country) => ({
|
||||
tiles: [
|
||||
`https://tiles.dontgetflocked.com/cameras-${country}-hourly/{z}/{x}/{y}.mvt`,
|
||||
],
|
||||
bounds: country === 'ca' ? [-124, 42, -63, 54] : [-160, 17, -64, 59],
|
||||
});
|
||||
const austin = { south: 30.2, north: 30.35, west: -97.85, east: -97.65 };
|
||||
|
||||
test('cancellation while reading TileJSON is preserved even if fetch ignores it', async () => {
|
||||
const abort = new AbortController();
|
||||
const source = createOverpassAlprSource({
|
||||
fetchImpl: async () => ({
|
||||
const source = createAlprTileSource({
|
||||
tileFetchImpl: async () => ({
|
||||
ok: true,
|
||||
headers: new Headers(),
|
||||
json: async () => {
|
||||
async text() {
|
||||
abort.abort();
|
||||
return { elements: [] };
|
||||
return JSON.stringify(metadata('us'));
|
||||
},
|
||||
}),
|
||||
});
|
||||
await assert.rejects(source.fetch(box, abort.signal), { name: 'AbortError' });
|
||||
await assert.rejects(source.fetch(austin, abort.signal), {
|
||||
name: 'AbortError',
|
||||
});
|
||||
});
|
||||
|
||||
test('Overpass normalization returns camera records and preserves limited/stale coverage', async () => {
|
||||
const source = createOverpassAlprSource({
|
||||
fetchImpl: async () => ({
|
||||
ok: true,
|
||||
headers: new Headers({ 'x-overpass-cache': 'STALE' }),
|
||||
json: async () => ({
|
||||
elements: [
|
||||
{
|
||||
type: 'node',
|
||||
id: 12,
|
||||
lat: 30,
|
||||
lon: -98,
|
||||
tags: {
|
||||
'surveillance:type': 'camera;ALPR',
|
||||
'camera:direction': '90',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
test('extract detail tiles map OSM records and repeated pans reuse decoded tiles', async () => {
|
||||
const calls = [];
|
||||
const source = createAlprTileSource({
|
||||
tileFetchImpl: async (url) => {
|
||||
calls.push(url);
|
||||
return url.endsWith('.json')
|
||||
? Response.json(metadata(url.includes('-ca-') ? 'ca' : 'us'))
|
||||
: new Response(fixture);
|
||||
},
|
||||
});
|
||||
const snapshot = await source.fetch(box);
|
||||
assert.equal(snapshot.records[0].id, 'alpr:12');
|
||||
assert.equal(snapshot.records[0].latitude, 30);
|
||||
assert.equal(snapshot.records[0].directionDeg, 90);
|
||||
assert.equal(snapshot.stale, true);
|
||||
assert.equal(snapshot.saturated, false);
|
||||
const snapshot = await source.fetch(austin);
|
||||
assert.ok(snapshot.records.length > 0);
|
||||
const record = snapshot.records[0];
|
||||
assert.match(record.id, /^alpr:/);
|
||||
assert.equal(record.manufacturer, 'Flock Safety');
|
||||
assert.equal(record.lastVerified, null);
|
||||
assert.ok(record.osmTimestamp);
|
||||
assert.equal(snapshot.stale, false);
|
||||
assert.equal('elements' in snapshot, false);
|
||||
const fetched = calls.length;
|
||||
await source.fetch(austin);
|
||||
assert.equal(calls.length, fetched);
|
||||
assert.ok(calls.every((url) => !url.includes('overpass')));
|
||||
assert.doesNotMatch(JSON.stringify(source.attribution), /flock/i);
|
||||
});
|
||||
|
||||
test('outside extract coverage is an explicit no-data state without Overpass', async () => {
|
||||
const source = createAlprTileSource({
|
||||
tileFetchImpl: () => assert.fail('no network expected in Europe'),
|
||||
});
|
||||
assert.deepEqual(
|
||||
await source.fetch({ south: 51, north: 51.1, west: 0, east: 0.1 }),
|
||||
{ records: [], stale: false, saturated: false, noCoverage: true },
|
||||
);
|
||||
});
|
||||
|
||||
test('an unsuccessful source response releases its body before reporting an error', async () => {
|
||||
let cancelled = 0;
|
||||
const source = createOverpassAlprSource({
|
||||
fetchImpl: async () => ({
|
||||
const source = createAlprTileSource({
|
||||
tileFetchImpl: async () => ({
|
||||
ok: false,
|
||||
status: 429,
|
||||
body: {
|
||||
@@ -79,7 +108,7 @@ test('an unsuccessful source response releases its body before reporting an erro
|
||||
},
|
||||
}),
|
||||
});
|
||||
await assert.rejects(source.fetch(box), /rate-limited/);
|
||||
await assert.rejects(source.fetch(box), /unavailable/);
|
||||
assert.equal(cancelled, 1);
|
||||
});
|
||||
|
||||
@@ -115,3 +144,147 @@ test('provider attribution escapes markup and rejects executable links', () => {
|
||||
assert.ok(html.includes('?a=1&b=2'));
|
||||
assert.ok(!html.includes('<img'));
|
||||
});
|
||||
|
||||
test('wide detail view returns zoom guidance before any fetch and can retry a smaller view', async () => {
|
||||
let calls = 0;
|
||||
const source = createAlprTileSource({
|
||||
tileFetchImpl: async () => {
|
||||
calls++;
|
||||
return Response.json({
|
||||
tiles: ['https://tiles.dontgetflocked.com/{z}/{x}/{y}.pbf'],
|
||||
bounds: [-180, 17, -50, 84],
|
||||
});
|
||||
},
|
||||
});
|
||||
const wide = await source.fetch({
|
||||
south: 29,
|
||||
north: 32,
|
||||
west: -99,
|
||||
east: -96,
|
||||
});
|
||||
assert.equal(wide.zoomIn, true);
|
||||
assert.equal(calls, 0);
|
||||
await source
|
||||
.fetch({ south: 30.267, north: 30.268, west: -97.744, east: -97.743 })
|
||||
.catch(() => {});
|
||||
assert.ok(calls > 0);
|
||||
});
|
||||
|
||||
test('each view reads the finest attributed extract zoom that fits 16 tiles', () => {
|
||||
// Field report: a z11-only read refused whole-city views. Attributed detail
|
||||
// exists from z9, so a city reads a few z9/z10 tiles and a street z12.
|
||||
assert.equal(
|
||||
alprDetailZoom({ south: 30.29, north: 30.31, west: -97.78, east: -97.75 }),
|
||||
12,
|
||||
);
|
||||
assert.equal(
|
||||
alprDetailZoom({ south: 30.17, north: 30.36, west: -97.85, east: -97.63 }),
|
||||
12,
|
||||
);
|
||||
assert.equal(
|
||||
alprDetailZoom({ south: 29.7, north: 30.7, west: -98.25, east: -97.25 }),
|
||||
10,
|
||||
);
|
||||
assert.equal(
|
||||
alprDetailZoom({ south: 29.2, north: 31.2, west: -98.75, east: -96.75 }),
|
||||
9,
|
||||
);
|
||||
assert.equal(
|
||||
alprDetailZoom({ south: 29, north: 32, west: -99, east: -96 }),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('a whole-city view loads cameras and reports the tiles it covers', async () => {
|
||||
const urls = [];
|
||||
const source = createAlprTileSource({
|
||||
tileFetchImpl: async (url) => {
|
||||
urls.push(url);
|
||||
return url.endsWith('.json')
|
||||
? Response.json(metadata(url.includes('-ca-') ? 'ca' : 'us'))
|
||||
: new Response(fixture);
|
||||
},
|
||||
});
|
||||
const city = { south: 29.95, north: 30.55, west: -98.05, east: -97.45 };
|
||||
const snapshot = await source.fetch(city);
|
||||
assert.equal(snapshot.zoomIn, undefined);
|
||||
assert.equal(snapshot.zoom, 10);
|
||||
assert.ok(snapshot.records.length > 0);
|
||||
assert.ok(urls.some((url) => url.includes('/10/')));
|
||||
for (const key of ['south', 'west'])
|
||||
assert.ok(snapshot.coverage[key] <= city[key]);
|
||||
for (const key of ['north', 'east'])
|
||||
assert.ok(snapshot.coverage[key] >= city[key]);
|
||||
});
|
||||
|
||||
test('coarser tiles never move a camera a finer tile already placed', () => {
|
||||
const cache = createAlprPrecisionCache(2);
|
||||
const fine = {
|
||||
id: 'alpr:1',
|
||||
latitude: 30.30001,
|
||||
longitude: -97.76001,
|
||||
operator: 'A',
|
||||
};
|
||||
assert.equal(cache.apply(fine, 12), fine);
|
||||
const coarse = {
|
||||
...fine,
|
||||
latitude: 30.3001,
|
||||
longitude: -97.7602,
|
||||
operator: 'B',
|
||||
};
|
||||
const kept = cache.apply(coarse, 9);
|
||||
assert.equal(kept.latitude, fine.latitude);
|
||||
assert.equal(kept.longitude, fine.longitude);
|
||||
assert.equal(kept.operator, 'B', 'attributes still refresh');
|
||||
const finer = { ...fine, latitude: 30.300012 };
|
||||
assert.equal(cache.apply(finer, 13), finer);
|
||||
cache.apply({ id: 'alpr:2', latitude: 1, longitude: 1 }, 12);
|
||||
cache.apply({ id: 'alpr:3', latitude: 1, longitude: 1 }, 12);
|
||||
assert.equal(cache.size(), 2, 'bounded');
|
||||
});
|
||||
|
||||
test('the explicit Overpass factory posts the unchanged query to its API transport', async () => {
|
||||
const calls = [];
|
||||
const source = createOverpassAlprSource({
|
||||
fetchImpl: async (url, options) => {
|
||||
calls.push({ url, options });
|
||||
return Response.json({
|
||||
elements: [
|
||||
{
|
||||
type: 'node',
|
||||
id: 42,
|
||||
lat: 30.05,
|
||||
lon: -97.95,
|
||||
tags: { 'surveillance:type': 'ALPR', operator: 'Test' },
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
});
|
||||
const result = await source.fetch(box);
|
||||
assert.equal(result.records.length, 1);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].url, '/api/overpass');
|
||||
assert.equal(calls[0].options.method, 'POST');
|
||||
assert.equal(
|
||||
new URLSearchParams(calls[0].options.body).get('data'),
|
||||
buildOverpassQuery(box.south, box.west, box.north, box.east),
|
||||
);
|
||||
});
|
||||
|
||||
test('the tile adapter never passes public URLs through an injected API transport', async (t) => {
|
||||
const urls = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
urls.push(url);
|
||||
return url.endsWith('.json')
|
||||
? Response.json(metadata('us'))
|
||||
: new Response(fixture);
|
||||
});
|
||||
const source = createAlprTileSource({
|
||||
fetchImpl: () => assert.fail('API transport must not receive tile URLs'),
|
||||
});
|
||||
const result = await source.fetch(austin);
|
||||
assert.ok(result.records.length);
|
||||
assert.ok(urls.length > 1);
|
||||
assert.ok(urls.every((url) => url.startsWith('https://tiles.')));
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { PbfReader } from 'pbf';
|
||||
import { VectorTile } from '@mapbox/vector-tile';
|
||||
import { normalizeAlprNode } from './records.js';
|
||||
|
||||
/** Map extract attributes without treating an OSM edit date as a field verification. */
|
||||
export function normalizeAlprTileFeature(feature) {
|
||||
const p = feature?.properties || {};
|
||||
if (feature?.geometry?.type !== 'Point' || p.osmType !== 'node') return null;
|
||||
const [lon, lat] = feature.geometry.coordinates;
|
||||
const record = normalizeAlprNode({
|
||||
type: 'node',
|
||||
id: Number(p.osmId),
|
||||
lat,
|
||||
lon,
|
||||
tags: {
|
||||
'surveillance:type': 'ALPR',
|
||||
operator: p.operator,
|
||||
manufacturer: p.brand,
|
||||
'camera:type': p.cameraType,
|
||||
'camera:direction': p.direction,
|
||||
'surveillance:zone': p.surveillanceZone,
|
||||
ref: p.ref,
|
||||
check_date: p.check_date,
|
||||
},
|
||||
});
|
||||
return record ? { ...record, osmTimestamp: p.osmTimestamp || null } : null;
|
||||
}
|
||||
|
||||
/** Decode detail-level camera points; geometry-only heatmap tiles are not records. */
|
||||
export function decodeAlprTile(bytes, z, x, y) {
|
||||
const layer = new VectorTile(new PbfReader(bytes)).layers.cameras;
|
||||
if (!layer) return [];
|
||||
if (layer.length > 40_000)
|
||||
throw new Error('Camera tile feature limit exceeded');
|
||||
const records = [];
|
||||
for (let i = 0; i < layer.length; i++) {
|
||||
const record = normalizeAlprTileFeature(
|
||||
layer.feature(i).toGeoJSON(x, y, z),
|
||||
);
|
||||
if (record) records.push(record);
|
||||
}
|
||||
return records;
|
||||
}
|
||||
@@ -1,6 +1,25 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { destinationPointDeg } from './model.js';
|
||||
import { DIRECTION_CONE_M, DIRECTION_CONE_HALF_ANGLE_DEG } from './policy.js';
|
||||
import {
|
||||
DIRECTION_CONE_M,
|
||||
DIRECTION_CONE_HALF_ANGLE_DEG,
|
||||
MARKER_SCALE_BY_DISTANCE,
|
||||
} from './policy.js';
|
||||
|
||||
/**
|
||||
* Badge scale at a camera distance, matching Cesium's NearFarScalar for the
|
||||
* native badges (linear between near and far, clamped outside).
|
||||
* @param {number} distanceM Camera-to-marker distance.
|
||||
* @returns {number} Scale factor.
|
||||
*/
|
||||
export function markerScale(distanceM) {
|
||||
const [near, nearScale, far, farScale] = MARKER_SCALE_BY_DISTANCE;
|
||||
if (!Number.isFinite(distanceM) || distanceM <= near) return nearScale;
|
||||
if (distanceM >= far) return farScale;
|
||||
return (
|
||||
nearScale + ((distanceM - near) / (far - near)) * (farScale - nearScale)
|
||||
);
|
||||
}
|
||||
|
||||
// Camera artwork and visual treatment contributed by Manjunath (@manjunath22466).
|
||||
// Module-relative assets also resolve when the layer is consumed by another app.
|
||||
@@ -48,8 +67,16 @@ export function alprLabelDetails(record, source) {
|
||||
return details;
|
||||
}
|
||||
|
||||
/** Illustrative bearing wedge, not measured field of view or operating range. */
|
||||
export function directionWedgePositions(record, heightM = 0) {
|
||||
/**
|
||||
* Illustrative bearing wedge, not measured field of view or operating range.
|
||||
* @param {object} record Camera record.
|
||||
* @param {number} [heightM] Apex height.
|
||||
* @param {function(number, number): (number|null)} [heightAt] Optional height
|
||||
* for the two far corners (degrees lat, lon), so the wedge follows a slope
|
||||
* instead of floating level from the apex; falls back to the apex height.
|
||||
* @returns {Array<Cesium.Cartesian3>|null} Apex, left and right corners.
|
||||
*/
|
||||
export function directionWedgePositions(record, heightM = 0, heightAt = null) {
|
||||
if (!Number.isFinite(record.directionDeg)) return null;
|
||||
const left = destinationPointDeg(
|
||||
record.latitude,
|
||||
@@ -63,10 +90,18 @@ export function directionWedgePositions(record, heightM = 0) {
|
||||
record.directionDeg + DIRECTION_CONE_HALF_ANGLE_DEG,
|
||||
DIRECTION_CONE_M,
|
||||
);
|
||||
const corner = (point) => {
|
||||
const height = heightAt?.(point.latitude, point.longitude);
|
||||
return Number.isFinite(height) ? height : heightM;
|
||||
};
|
||||
return [
|
||||
Cesium.Cartesian3.fromDegrees(record.longitude, record.latitude, heightM),
|
||||
Cesium.Cartesian3.fromDegrees(left.longitude, left.latitude, heightM),
|
||||
Cesium.Cartesian3.fromDegrees(right.longitude, right.latitude, heightM),
|
||||
Cesium.Cartesian3.fromDegrees(left.longitude, left.latitude, corner(left)),
|
||||
Cesium.Cartesian3.fromDegrees(
|
||||
right.longitude,
|
||||
right.latitude,
|
||||
corner(right),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createControls } from './controls.js';
|
||||
import { createSubject } from './subject.js';
|
||||
|
||||
test('passive Contacts releases its installation anchor and subject refresh cannot reclaim it', (t) => {
|
||||
const prior = globalThis.document;
|
||||
globalThis.document = { body: { classList: { contains: () => false } } };
|
||||
t.after(() => {
|
||||
globalThis.document = prior;
|
||||
});
|
||||
const anchors = [];
|
||||
const position = Cesium.Cartesian3.fromDegrees(-117.14, 32.87);
|
||||
const state = {
|
||||
enabled: true,
|
||||
passive: false,
|
||||
subject: {
|
||||
id: 'site',
|
||||
layerId: 'military-installations',
|
||||
label: 'Site',
|
||||
position,
|
||||
},
|
||||
sourceRevision: 'current',
|
||||
lastEvaluatedPosition: position,
|
||||
results: {},
|
||||
};
|
||||
const services = {
|
||||
installations: { setContextAnchor: (value) => anchors.push(value) },
|
||||
};
|
||||
let activations = 0;
|
||||
const parts = {
|
||||
dependencies: { activateOperationalContext: () => activations++ },
|
||||
queries: {
|
||||
collectSourceStates: () => ({}),
|
||||
sourceRevision: () => 'current',
|
||||
},
|
||||
navigation: { isFlightLayer: () => false },
|
||||
model: { awarenessRefreshRequired: () => false },
|
||||
rendering: { renderVisual() {}, scheduleDirectionOverlayUpdate() {} },
|
||||
};
|
||||
const controls = createControls({ state, services, parts }).methods;
|
||||
const subject = createSubject({ state, services, parts });
|
||||
subject.refreshSelectedSubject();
|
||||
assert.equal(anchors.length, 1);
|
||||
assert.ok(anchors[0] instanceof Cesium.Cartesian3);
|
||||
controls.setParams({ passive: true });
|
||||
assert.equal(anchors.at(-1), null);
|
||||
const released = anchors.length;
|
||||
subject.refreshSelectedSubject();
|
||||
subject.refreshSelectedSubject();
|
||||
assert.equal(
|
||||
anchors.length,
|
||||
released,
|
||||
'passive refresh must not reclaim the viewport',
|
||||
);
|
||||
controls.setParams({ passive: false });
|
||||
assert.equal(activations, 1);
|
||||
subject.refreshSelectedSubject();
|
||||
assert.ok(anchors.at(-1) instanceof Cesium.Cartesian3);
|
||||
});
|
||||
Vendored
+1
@@ -29,6 +29,7 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
if (typeof params.passive !== 'boolean') return;
|
||||
const wasPassive = layerState.passive;
|
||||
layerState.passive = params.passive;
|
||||
if (layerState.passive) services.installations?.setContextAnchor?.(null);
|
||||
if (layerState.enabled && wasPassive && !layerState.passive)
|
||||
parts.dependencies.activateOperationalContext();
|
||||
},
|
||||
|
||||
@@ -206,6 +206,7 @@ export function createLifecycle({
|
||||
layerState.autoFocusAttempted = false;
|
||||
layerState.autoFocusRetryPending = false;
|
||||
layerState.subject = null;
|
||||
services.installations?.setContextAnchor?.(null);
|
||||
layerState.results = null;
|
||||
layerState.lastSubjectRefreshMs = 0;
|
||||
layerState.lastEvaluatedPosition = null;
|
||||
|
||||
@@ -72,7 +72,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
return `<li><span class="military-awareness-target unavailable" aria-label="Unavailable">${escapeHtml(label)} <span>${formatAwarenessDistance(item.distanceM)}</span></span></li>`;
|
||||
}
|
||||
const accessibleLabel = label === '—' ? 'Unavailable' : label;
|
||||
return `<li><button type="button" class="military-awareness-target" data-awareness-layer="${escapeHtml(cohort.id)}" data-awareness-id="${escapeHtml(targetId)}" aria-label="Focus ${escapeHtml(accessibleLabel)}">${escapeHtml(label)} <span>${formatAwarenessDistance(item.distanceM)}</span></button></li>`;
|
||||
return `<li><button type="button" class="military-awareness-target" data-awareness-layer="${escapeHtml(cohort.id)}" data-awareness-id="${escapeHtml(targetId)}" aria-label="Focus ${escapeHtml(accessibleLabel)}">${escapeHtml(label)} <span>${formatAwarenessDistance(item.distanceM)}</span></button>${namedAreasHtml(item.memberNames)}</li>`;
|
||||
})
|
||||
.join('');
|
||||
const pageCount = Math.max(
|
||||
@@ -164,11 +164,18 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
);
|
||||
}
|
||||
|
||||
function namedAreasHtml(names) {
|
||||
return names?.length
|
||||
? `<details class="military-awareness-names"><summary>Named areas (${names.length})</summary><div>${names.map((name) => escapeHtml(name)).join('<br>')}</div></details>`
|
||||
: '';
|
||||
}
|
||||
|
||||
function renderResults() {
|
||||
if (!layerState.enabled || !layerState.results) return hidePanel();
|
||||
const panel = ensurePanel();
|
||||
const { subject, cohorts } = layerState.results;
|
||||
const markup = `<div class="military-awareness-subject">${escapeHtml(subject.label)} · ${formatAwarenessDistance(AWARENESS_RADIUS_M)} FLIGHT / VESSEL WINDOW</div>
|
||||
${namedAreasHtml(subject.memberNames)}
|
||||
${navigationControlsHtml()}
|
||||
${cohorts.map(rowHtml).join('')}
|
||||
<p class="military-awareness-note" tabindex="-1" data-awareness-focus-continuation>Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.</p>`;
|
||||
|
||||
@@ -94,14 +94,33 @@ export function createQueries({ state: layerState, services, parts, source }) {
|
||||
*/
|
||||
|
||||
function summarizeInstallationViewport(items, source) {
|
||||
const coverage = source.stats?.coverage;
|
||||
// getNearby measures these distances from the current subject, independent
|
||||
// of the retained square used to fetch installation tiles.
|
||||
const withinRadius =
|
||||
coverage?.kind === 'subject' && Number.isFinite(coverage.radiusM)
|
||||
? items.filter(
|
||||
(item) => (item.distanceM ?? item.distance) <= coverage.radiusM,
|
||||
)
|
||||
: items;
|
||||
const summary = parts.navigation.summarizeAwarenessCohortForNavigation(
|
||||
items,
|
||||
withinRadius,
|
||||
source,
|
||||
);
|
||||
if (summary.count === null)
|
||||
return source.stats?.statusMessage
|
||||
? { ...summary, reason: source.stats.statusMessage }
|
||||
: summary;
|
||||
// A subject window is its own bounded area: name it instead of the viewport.
|
||||
if (coverage?.kind === 'subject' && Number.isFinite(coverage.radiusM)) {
|
||||
const km = Math.round(coverage.radiusM / 1000);
|
||||
return {
|
||||
...summary,
|
||||
reason: summary.count
|
||||
? `mapped matches within ${km} km of the subject`
|
||||
: `none mapped within ${km} km; not a complete 250 km survey`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...summary,
|
||||
reason: summary.count
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createQueries } from './queries.js';
|
||||
import { createSubject } from './subject.js';
|
||||
import { createControls } from '../installations/controls.js';
|
||||
import { createModel } from '../installations/model.js';
|
||||
import { summarizeAwarenessCohort } from '../../data/militaryAwarenessEngine.js';
|
||||
|
||||
test('Contacts excludes square corners and measures from the current moving subject', () => {
|
||||
const records = [
|
||||
{ id: 'corner', kind: 'installation', latitude: 90 / 111.195, longitude: 90 / 111.195 },
|
||||
{ id: 'east', kind: 'installation', latitude: 0, longitude: 90 / 111.195 },
|
||||
{ id: 'west', kind: 'installation', latitude: 0, longitude: -90 / 111.195 },
|
||||
];
|
||||
const installationState = { records, contextAnchor: { latitude: 0, longitude: 0 },
|
||||
lastUpdate: 1, status: 'ready', coverage: { kind: 'subject', radiusM: 100000 },
|
||||
distanceEndpointScratch: new Cesium.Cartographic(),
|
||||
distanceGeodesicScratch: new Cesium.EllipsoidGeodesic(),
|
||||
};
|
||||
const installationParts = { rendering: { installationSurfaceHeightM: () => 0 } };
|
||||
installationParts.model = createModel({ state: installationState, parts: installationParts });
|
||||
const installations = createControls({ state: installationState, parts: installationParts }).methods;
|
||||
const services = { installations, flights: { getNearby: () => [] },
|
||||
military: { getNearby: () => [] }, vessels: { getNearby: () => [] } };
|
||||
const parts = { navigation: { summarizeAwarenessCohortForNavigation: (items, source) =>
|
||||
summarizeAwarenessCohort(items, source) } };
|
||||
parts.queries = createQueries({ state: {}, services, parts });
|
||||
const subjectOwner = createSubject({ state: {}, services, parts });
|
||||
const states = Object.fromEntries(['flights', 'military', 'ais-live-vessels', 'military-installations']
|
||||
.map((id) => [id, { available: true, stats: {} }]));
|
||||
states['military-installations'].stats.coverage = { kind: 'subject', radiusM: 100000 };
|
||||
const evaluate = (lon) => subjectOwner.evaluateSubject({
|
||||
id: 'moving', layerId: 'flights', position: Cesium.Cartesian3.fromDegrees(lon, 0, 10000),
|
||||
}, states).cohorts[3].summary;
|
||||
installations.setContextAnchor({ latitude: 0, longitude: 0 });
|
||||
const first = evaluate(0);
|
||||
assert.equal(first.count, 2);
|
||||
assert.ok(!first.nearest.some((item) => item.id === 'corner'), '90 km N + 90 km E is outside the disk');
|
||||
assert.equal(installations.getStats().count, 2);
|
||||
assert.equal(installations.getStats().statusMessage, '2 mapped sites within 100 km of the contact');
|
||||
installations.setContextAnchor({ latitude: 0, longitude: 19 / 111.195 });
|
||||
const moved = evaluate(19 / 111.195);
|
||||
assert.equal(installations.getStats().count, 1);
|
||||
assert.equal(installations.getStats().statusMessage, '1 mapped site within 100 km of the contact');
|
||||
assert.equal(moved.count, 1, 'west site leaves the radius before the fetch anchor refreshes');
|
||||
assert.equal(moved.nearest[0].id, 'east');
|
||||
assert.equal(moved.reason, 'mapped matches within 100 km of the subject');
|
||||
assert.deepEqual(installationState.contextAnchor, { latitude: 0, longitude: 0 });
|
||||
});
|
||||
@@ -48,8 +48,13 @@ export function createSubject({ state: layerState, services, parts, source }) {
|
||||
(item) =>
|
||||
!parts.queries.isSame(subject, item, 'ais-live-vessels', 'mmsi'),
|
||||
);
|
||||
const coverage = installationsState.stats?.coverage;
|
||||
const installationRadius =
|
||||
coverage?.kind === 'subject' && Number.isFinite(coverage.radiusM)
|
||||
? coverage.radiusM
|
||||
: AWARENESS_RADIUS_M;
|
||||
const installations = militaryInstallationsLayer
|
||||
.getNearby(position, AWARENESS_RADIUS_M, AWARENESS_QUERY_LIMIT)
|
||||
.getNearby(position, installationRadius, AWARENESS_QUERY_LIMIT)
|
||||
.filter(
|
||||
(item) =>
|
||||
!parts.queries.isSame(subject, item, 'military-installations', 'id'),
|
||||
@@ -92,7 +97,8 @@ export function createSubject({ state: layerState, services, parts, source }) {
|
||||
source:
|
||||
installationsState.stats.source ||
|
||||
SOURCE_LABEL['military-installations'],
|
||||
coverage: 'CURRENT VIEWPORT ONLY',
|
||||
coverage:
|
||||
installationsState.stats.coverageLabel || 'CURRENT VIEWPORT ONLY',
|
||||
summary: parts.queries.summarizeInstallationViewport(
|
||||
installations,
|
||||
installationsState,
|
||||
@@ -326,6 +332,11 @@ export function createSubject({ state: layerState, services, parts, source }) {
|
||||
});
|
||||
if (!resolved) return;
|
||||
const { position, presence } = resolved;
|
||||
// Mapped installations load around the subject, not the (follow/Cockpit)
|
||||
// camera view. The installation layer moves its window only after the
|
||||
// subject travels far enough, so this per-refresh call is cheap.
|
||||
if (!layerState.passive)
|
||||
militaryInstallationsLayer.setContextAnchor?.(position);
|
||||
// UNCHECKED leaves the verdict alone: this tick simply did not look.
|
||||
if (presence === SUBJECT_PRESENCE.LIVE) layerState.subjectMissing = false;
|
||||
else if (presence === SUBJECT_PRESENCE.MISSING)
|
||||
@@ -392,6 +403,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
|
||||
layerId: record.layerId,
|
||||
id: record.properties?.mmsi || record.id,
|
||||
label: record.label || record.id,
|
||||
memberNames: record.properties?.memberNames || [],
|
||||
position: Cesium.Cartesian3.fromDegrees(longitude, latitude, 0),
|
||||
};
|
||||
}
|
||||
@@ -411,6 +423,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
|
||||
}
|
||||
|
||||
function clearAwarenessSubject() {
|
||||
militaryInstallationsLayer.setContextAnchor?.(null);
|
||||
layerState.autoFocusRetryPending = false;
|
||||
layerState.subject = null;
|
||||
layerState.subjectMissing = false;
|
||||
|
||||
@@ -339,6 +339,12 @@ export function createLifecycle({
|
||||
*/
|
||||
enable() {
|
||||
layerState._enabled = true;
|
||||
if (
|
||||
layerState._records.some(
|
||||
(record) => record.camera.cityId === 'warendorf',
|
||||
)
|
||||
)
|
||||
services.credits?.showOsmCredit?.(layerState._viewer, 'cctv');
|
||||
layerState._lastUpdate = Date.now();
|
||||
// Pick-ownership (H2): camera billboards use the camera id directly;
|
||||
// coverage polyline entities use `cctv-<cameraId>-<role>` entity ids.
|
||||
@@ -385,6 +391,7 @@ export function createLifecycle({
|
||||
|
||||
/** Disables the layer: hides entities, stops the projection loop and load queue. */
|
||||
disable() {
|
||||
services.credits?.hideOsmCredit?.(layerState._viewer, 'cctv');
|
||||
layerState._enabled = false;
|
||||
unregisterPickOwner('cctv');
|
||||
// ADJUST mode does not survive a layer toggle — predictable re-entry.
|
||||
@@ -408,6 +415,7 @@ export function createLifecycle({
|
||||
* @param {Cesium.Viewer} [viewer] - Viewer instance (falls back to stored ref).
|
||||
*/
|
||||
destroy(viewer) {
|
||||
services.credits?.hideOsmCredit?.(layerState._viewer, 'cctv');
|
||||
layerState._sourceAbort?.abort();
|
||||
if (typeof document !== 'undefined')
|
||||
document.removeEventListener(
|
||||
|
||||
@@ -695,6 +695,7 @@ export function createDirectionsLayer({ services }) {
|
||||
}
|
||||
|
||||
function clearRouteGraphics() {
|
||||
services.credits?.hideOsmCredit?.(_viewer, 'directions');
|
||||
_clearStepSelection();
|
||||
stopStepAnchoring();
|
||||
removeEntity(_routeEntity);
|
||||
@@ -800,6 +801,7 @@ export function createDirectionsLayer({ services }) {
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
});
|
||||
}
|
||||
services.credits?.showOsmCredit?.(_viewer, 'directions');
|
||||
services.ground.warmGroundFloor(cells);
|
||||
_anchorAttempts = 0;
|
||||
_anchorStartedAt = Date.now();
|
||||
|
||||
Vendored
+93
-2
@@ -1,6 +1,10 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { installationFeedback } from '../../data/installationFeedback.js';
|
||||
import { LAYER_ID, DISTANCE_PREFILTER_MARGIN_M } from './policy.js';
|
||||
import {
|
||||
LAYER_ID,
|
||||
DISTANCE_PREFILTER_MARGIN_M,
|
||||
ANCHOR_REFRESH_M,
|
||||
} from './policy.js';
|
||||
|
||||
export function createControls({ state: layerState, services, parts, source }) {
|
||||
const methods = {
|
||||
@@ -16,12 +20,71 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
|
||||
statsRefreshInterval: 1000,
|
||||
|
||||
visitNamedMarkers(visitor) {
|
||||
parts.namedMarkers.visit(visitor);
|
||||
},
|
||||
|
||||
/** Request a one-shot Google Maps Places search around the current map view. */
|
||||
searchNearby() {
|
||||
layerState.googleSearchRequested = true;
|
||||
return parts.ingestion.loadInstallations();
|
||||
},
|
||||
|
||||
/**
|
||||
* Load sites around a Contacts subject instead of the camera viewport.
|
||||
*
|
||||
* A follow or Cockpit camera moves every frame, so `moveEnd` never fires and
|
||||
* its view rectangle often reaches the horizon; a subject window is the
|
||||
* only area that answers "what is near this contact". The window moves
|
||||
* only after the subject travels ANCHOR_REFRESH_M, and cached tiles make
|
||||
* that cheap. Passing null returns the layer to viewport loading.
|
||||
* @param {Cesium.Cartesian3|{latitude:number, longitude:number}|null} position
|
||||
* Subject position.
|
||||
* @returns {boolean} Whether a new load was scheduled.
|
||||
*/
|
||||
setContextAnchor(position) {
|
||||
let next = null;
|
||||
if (position) {
|
||||
if (
|
||||
Number.isFinite(position.latitude) &&
|
||||
Number.isFinite(position.longitude)
|
||||
)
|
||||
next = { latitude: position.latitude, longitude: position.longitude };
|
||||
else {
|
||||
const carto = Cesium.Cartographic.fromCartesian(position);
|
||||
if (carto)
|
||||
next = {
|
||||
latitude: Cesium.Math.toDegrees(carto.latitude),
|
||||
longitude: Cesium.Math.toDegrees(carto.longitude),
|
||||
};
|
||||
}
|
||||
}
|
||||
// Keep the live centre even when the coarse fetch anchor has not moved.
|
||||
layerState.contextPosition = next
|
||||
? Cesium.Cartesian3.fromDegrees(next.longitude, next.latitude)
|
||||
: null;
|
||||
const previous = layerState.contextAnchor;
|
||||
if (!next) {
|
||||
if (!previous) return false;
|
||||
layerState.contextAnchor = null;
|
||||
parts.viewport.scheduleLoad();
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
previous &&
|
||||
parts.model.approximateSurfaceDistanceM(
|
||||
Cesium.Math.toRadians(previous.latitude),
|
||||
Cesium.Math.toRadians(previous.longitude),
|
||||
next.latitude,
|
||||
next.longitude,
|
||||
) < ANCHOR_REFRESH_M
|
||||
)
|
||||
return false;
|
||||
layerState.contextAnchor = next;
|
||||
parts.viewport.scheduleLoad();
|
||||
return true;
|
||||
},
|
||||
|
||||
getNearby(center, rangeM, maxCount = 50) {
|
||||
if (!center) return [];
|
||||
const range = Number.isFinite(rangeM) ? rangeM : Infinity;
|
||||
@@ -100,8 +163,30 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
},
|
||||
|
||||
getStats() {
|
||||
const namedMarkers = parts.namedMarkers?.stats();
|
||||
const subjectWindow =
|
||||
layerState.coverage?.kind === 'subject' &&
|
||||
layerState.contextPosition &&
|
||||
Number.isFinite(layerState.coverage.radiusM);
|
||||
const mappedCount = subjectWindow
|
||||
? methods.getNearby(
|
||||
layerState.contextPosition,
|
||||
layerState.coverage.radiusM,
|
||||
layerState.records.length || 1,
|
||||
).length
|
||||
: layerState.wide && namedMarkers
|
||||
? namedMarkers.pointsOnScreen
|
||||
: layerState.records.filter(
|
||||
(record) => record.kind === 'installation',
|
||||
).length;
|
||||
return {
|
||||
count: layerState.records.length,
|
||||
count:
|
||||
subjectWindow || layerState.wide
|
||||
? mappedCount
|
||||
: layerState.records.length,
|
||||
namedInView: layerState.namedInView || 0,
|
||||
wide: Boolean(layerState.wide),
|
||||
...namedMarkers,
|
||||
lastUpdate: layerState.lastUpdate,
|
||||
stale: layerState.stale,
|
||||
saturated: layerState.saturated,
|
||||
@@ -113,11 +198,17 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
failureReason: layerState.failureReason,
|
||||
statusMessage: installationFeedback({
|
||||
...layerState,
|
||||
count: layerState.lastUpdate ? mappedCount : undefined,
|
||||
retrying: layerState.loading && Boolean(layerState.failureReason),
|
||||
}),
|
||||
loadingLabel: layerState.loading
|
||||
? 'loading mapped installation context'
|
||||
: '',
|
||||
coverage: layerState.coverage,
|
||||
coverageLabel:
|
||||
layerState.coverage?.kind === 'subject'
|
||||
? `WITHIN ${Math.round(layerState.coverage.radiusM / 1000)} KM`
|
||||
: 'CURRENT VIEWPORT ONLY',
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createNamedMarkers } from './namedMarkers.js';
|
||||
import { createModel } from './model.js';
|
||||
import { createRendering } from './rendering.js';
|
||||
import { createIngestion } from './ingestion.js';
|
||||
@@ -8,7 +9,7 @@ import { createLifecycle } from './lifecycle.js';
|
||||
import { createState } from './state.js';
|
||||
|
||||
/** Construct one layer with its own scene state and supplied application services. */
|
||||
export function createInstallationsLayer({ services, source }) {
|
||||
export function createInstallationsLayer({ services, source, overlayHost }) {
|
||||
if (
|
||||
typeof source?.getMappedSites !== 'function' ||
|
||||
typeof source?.searchNearby !== 'function'
|
||||
@@ -16,9 +17,10 @@ export function createInstallationsLayer({ services, source }) {
|
||||
throw new TypeError('An installation source is required');
|
||||
const state = createState({ services });
|
||||
const parts = {};
|
||||
const context = { state, services, parts, source };
|
||||
const context = { state, services, parts, source, overlayHost };
|
||||
parts.model = createModel(context);
|
||||
parts.rendering = createRendering(context);
|
||||
parts.namedMarkers = createNamedMarkers(context);
|
||||
parts.ingestion = createIngestion(context);
|
||||
parts.viewport = createViewport(context);
|
||||
parts.selection = createSelection(context);
|
||||
|
||||
@@ -1,3 +1,50 @@
|
||||
import { militaryOsmKey } from '../../data/militaryNames.js';
|
||||
import { isUnavailableCapability } from '../../sources/capability.js';
|
||||
/** Keep named point coverage until its matching polygon takes over. */
|
||||
export function retainNamedHandoff(previous, records, box) {
|
||||
const named = new Map(
|
||||
previous.filter((r) => r.namedArea).map((r) => [r.id, r]),
|
||||
);
|
||||
const represented = new Set();
|
||||
const next = records.map((record) => {
|
||||
represented.add(record.id);
|
||||
for (const id of record.aliasIds || []) represented.add(id);
|
||||
for (const member of record.namedMembers || []) represented.add(member.id);
|
||||
for (const source of record.sources || []) {
|
||||
const key = militaryOsmKey(source.id);
|
||||
if (key) represented.add(`osm:military:${key}`);
|
||||
}
|
||||
const prior =
|
||||
named.get(record.id) ||
|
||||
(record.aliasIds || [])
|
||||
.map((id) => named.get(id))
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => b.areaM2 - a.areaM2)[0];
|
||||
return !record.namedArea && prior
|
||||
? {
|
||||
...record,
|
||||
name: prior.name,
|
||||
namedArea: true,
|
||||
areaM2: prior.areaM2,
|
||||
class: prior.class,
|
||||
}
|
||||
: record;
|
||||
});
|
||||
for (const record of previous) {
|
||||
if (
|
||||
record.pointOnly &&
|
||||
record.namedArea &&
|
||||
!represented.has(record.id) &&
|
||||
record.longitude >= box.west &&
|
||||
record.longitude <= box.east &&
|
||||
record.latitude >= box.south &&
|
||||
record.latitude <= box.north
|
||||
)
|
||||
next.push(record);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function createIngestion({
|
||||
state: layerState,
|
||||
services,
|
||||
@@ -26,7 +73,7 @@ export function createIngestion({
|
||||
|
||||
async function loadInstallations() {
|
||||
if (!layerState.enabled || !layerState.viewer) return;
|
||||
const box = parts.viewport.viewportBox(layerState.viewer);
|
||||
const { box, coverage } = parts.viewport.loadArea(layerState.viewer);
|
||||
// Guidance, not a fault: the layer chose not to query because the view is
|
||||
// unbounded (a global view, or Cockpit looking at the horizon). Keep it out
|
||||
// of `error` — the manager derives refresh failures and the global status
|
||||
@@ -43,6 +90,7 @@ export function createIngestion({
|
||||
}
|
||||
layerState.abort?.abort();
|
||||
const requestAbort = new AbortController();
|
||||
const keyOwner = (layerState.cameraLoadOwner ??= {});
|
||||
layerState.abort = requestAbort;
|
||||
layerState.loading = true;
|
||||
parts.viewport.clearUnavailableRetry({ resetBackoff: false });
|
||||
@@ -53,19 +101,34 @@ export function createIngestion({
|
||||
source.getMappedSites(box, { exact, signal: requestAbort.signal });
|
||||
|
||||
let payload = await fetchInstallations(false);
|
||||
// Map tiles carry military areas only at z9 and finer: a view too wide
|
||||
// for that is guidance, exactly like an unbounded view, never an empty
|
||||
// all-clear. Previously loaded sites stay on the map.
|
||||
if (payload.zoomIn) {
|
||||
if (
|
||||
requestAbort.signal.aborted ||
|
||||
layerState.abort !== requestAbort ||
|
||||
!layerState.enabled
|
||||
)
|
||||
return;
|
||||
parts.viewport.clearUnavailableRetry();
|
||||
setInstallationStatus('zoom-in');
|
||||
return;
|
||||
}
|
||||
// A SATURATED snapped tile was truncated upstream, so features from the
|
||||
// snap's extra ring may have crowded out sites actually on screen. Re-ask
|
||||
// for the exact viewport (separately keyed and cached) before rendering.
|
||||
let saturated = payload.saturated === true;
|
||||
if (saturated) {
|
||||
if (saturated && !payload.tileSource) {
|
||||
payload = await fetchInstallations(true);
|
||||
saturated = payload.saturated === true;
|
||||
}
|
||||
// The proxy answers a bbox at least as large as the viewport; keep only what
|
||||
// was actually asked for so nothing off-screen reaches the map or the
|
||||
// "current viewport only" context claim.
|
||||
const records = payload.records.filter((record) =>
|
||||
parts.model.installationWithinViewport(record, box),
|
||||
const records = payload.records.filter(
|
||||
(record) =>
|
||||
payload.wide || parts.model.installationWithinViewport(record, box),
|
||||
);
|
||||
let placesError = null;
|
||||
if (layerState.googleSearchRequested) {
|
||||
@@ -139,43 +202,84 @@ export function createIngestion({
|
||||
'Google Places search unavailable; showing mapped sites';
|
||||
}
|
||||
}
|
||||
await resolveGroundFloorCellsBounded(
|
||||
records.map((record) => ({
|
||||
lat: record.latitude,
|
||||
lon: record.longitude,
|
||||
})),
|
||||
);
|
||||
let floorTimer;
|
||||
await Promise.race([
|
||||
resolveGroundFloorCellsBounded(
|
||||
records
|
||||
.filter((record) => !record.pointOnly)
|
||||
.map((record) => ({
|
||||
lat: record.latitude,
|
||||
lon: record.longitude,
|
||||
})),
|
||||
),
|
||||
new Promise((resolve) => {
|
||||
floorTimer = setTimeout(resolve, 120);
|
||||
}),
|
||||
]).finally(() => clearTimeout(floorTimer));
|
||||
if (
|
||||
requestAbort.signal.aborted ||
|
||||
layerState.abort !== requestAbort ||
|
||||
!layerState.enabled
|
||||
)
|
||||
return;
|
||||
layerState.records = records;
|
||||
layerState.recordById = new Map(
|
||||
layerState.records.map((record) => [record.id, record]),
|
||||
);
|
||||
layerState.lastUpdate = Date.now();
|
||||
layerState.stale = payload.status === 'stale';
|
||||
// Even the exact-viewport retry can saturate in a dense area. Say so rather
|
||||
// than implying the view is completely surveyed.
|
||||
layerState.saturated = saturated;
|
||||
layerState.failureReason = null;
|
||||
parts.viewport.clearUnavailableRetry();
|
||||
setInstallationStatus(
|
||||
layerState.records.length
|
||||
? layerState.stale
|
||||
? 'stale'
|
||||
: 'ready'
|
||||
: 'empty',
|
||||
payload.status === 'stale'
|
||||
? 'Serving cached mapped context'
|
||||
: saturated
|
||||
? 'Too many mapped sites in view to list them all'
|
||||
: placesError,
|
||||
);
|
||||
parts.rendering.renderRecords();
|
||||
parts.rendering.warmInstallationFloors(layerState.records);
|
||||
const publish = (payload, records) => {
|
||||
if (
|
||||
requestAbort.signal.aborted ||
|
||||
layerState.abort !== requestAbort ||
|
||||
!layerState.enabled
|
||||
)
|
||||
return;
|
||||
if (!payload.wide)
|
||||
records = retainNamedHandoff(layerState.records, records, box);
|
||||
layerState.namedInView =
|
||||
payload.namedInView ?? records.filter((r) => r.namedArea).length;
|
||||
layerState.wide = Boolean(payload.wide);
|
||||
layerState.records = records;
|
||||
layerState.recordById = new Map(
|
||||
layerState.records.map((record) => [record.id, record]),
|
||||
);
|
||||
layerState.lastUpdate = Date.now();
|
||||
layerState.coverage = coverage;
|
||||
layerState.stale = payload.status === 'stale';
|
||||
// Even the exact-viewport retry can saturate in a dense area. Say so rather
|
||||
// than implying the view is completely surveyed.
|
||||
layerState.saturated = saturated;
|
||||
layerState.failureReason = null;
|
||||
parts.viewport.clearUnavailableRetry();
|
||||
setInstallationStatus(
|
||||
layerState.records.length
|
||||
? layerState.stale
|
||||
? 'stale'
|
||||
: 'ready'
|
||||
: 'empty',
|
||||
payload.status === 'stale'
|
||||
? `Serving cached mapped context · ${payload.records[0]?.retrievedAt || 'date unknown'}`
|
||||
: saturated
|
||||
? 'Too many mapped sites in view to list them all'
|
||||
: placesError,
|
||||
);
|
||||
parts.rendering.renderRecords();
|
||||
parts.rendering.warmInstallationFloors(
|
||||
layerState.records.filter((r) => !r.pointOnly),
|
||||
);
|
||||
};
|
||||
publish(payload, records);
|
||||
payload.enrichment?.then((enriched) => {
|
||||
if (
|
||||
!enriched ||
|
||||
requestAbort.signal.aborted ||
|
||||
layerState.abort !== requestAbort ||
|
||||
!layerState.enabled
|
||||
)
|
||||
return;
|
||||
const named = enriched.records.filter((record) =>
|
||||
parts.model.installationWithinViewport(record, box),
|
||||
);
|
||||
publish(enriched, [
|
||||
...named,
|
||||
...records.filter((record) => record.id.startsWith('google:')),
|
||||
]);
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
requestAbort.signal.aborted ||
|
||||
@@ -189,11 +293,17 @@ export function createIngestion({
|
||||
'unavailable',
|
||||
error?.message || 'Installation context unavailable',
|
||||
);
|
||||
parts.viewport.scheduleUnavailableRetry();
|
||||
if (!isUnavailableCapability(error))
|
||||
parts.viewport.scheduleUnavailableRetry(error?.retryAfterMs);
|
||||
} finally {
|
||||
// An older aborted request must not clear a newer request's busy state.
|
||||
if (layerState.abort === requestAbort) {
|
||||
layerState.abort = null;
|
||||
if (
|
||||
requestAbort.signal.aborted &&
|
||||
layerState.cameraLoadOwner === keyOwner
|
||||
)
|
||||
layerState.cameraLoadKey = null;
|
||||
// Keep ownership until superseded so late names cannot enrich an old view.
|
||||
layerState.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { LAYER_ID } from './policy.js';
|
||||
import {
|
||||
claimCameraSensitivity,
|
||||
releaseCameraSensitivity,
|
||||
} from '../../data/cameraSensitivity.js';
|
||||
|
||||
export function createLifecycle({
|
||||
state: layerState,
|
||||
@@ -17,14 +21,37 @@ export function createLifecycle({
|
||||
'military-installations',
|
||||
);
|
||||
viewer.dataSources.add(layerState.dataSource);
|
||||
layerState.moveEndRemove = viewer.camera.moveEnd.addEventListener(
|
||||
parts.viewport.scheduleLoad,
|
||||
);
|
||||
const cameraChanged = () => {
|
||||
if (!layerState.contextAnchor) parts.viewport.scheduleLoad();
|
||||
};
|
||||
layerState.moveEndRemove =
|
||||
viewer.camera.moveEnd.addEventListener(cameraChanged);
|
||||
layerState.changedRemove =
|
||||
viewer.camera.changed?.addEventListener(cameraChanged);
|
||||
parts.selection.installInteraction(viewer);
|
||||
},
|
||||
|
||||
enable() {
|
||||
layerState.enabled = true;
|
||||
layerState.surfaceChangedRemove ||= services.maps?.subscribeMapStack(
|
||||
parts.rendering.syncSelectionFill,
|
||||
);
|
||||
// Cached markers and geometry become visible before the next fetch.
|
||||
if (
|
||||
layerState.records?.some((r) =>
|
||||
r.sources?.some((s) => s.name === 'OpenStreetMap'),
|
||||
)
|
||||
)
|
||||
services.credits?.showOsmCredit?.(layerState.viewer, LAYER_ID, {
|
||||
openMapTiles: layerState.records.some((r) =>
|
||||
r.sources?.some(
|
||||
(s) => s.name === 'OpenStreetMap' && s.id?.startsWith('tile:'),
|
||||
),
|
||||
),
|
||||
});
|
||||
parts.namedMarkers?.enable();
|
||||
parts.namedMarkers?.sync(layerState.records || []);
|
||||
claimCameraSensitivity(layerState.viewer.camera, LAYER_ID, 0.01);
|
||||
registerPickOwner(LAYER_ID, (id) => layerState.recordById.has(id));
|
||||
layerState.dataSource.show = true;
|
||||
// DataLayerManager invokes update() immediately after enable(), which owns
|
||||
@@ -33,6 +60,14 @@ export function createLifecycle({
|
||||
|
||||
disable() {
|
||||
layerState.enabled = false;
|
||||
services.credits?.hideOsmCredit?.(layerState.viewer, LAYER_ID);
|
||||
parts.namedMarkers?.hide();
|
||||
releaseCameraSensitivity(layerState.viewer?.camera, LAYER_ID);
|
||||
layerState.contextAnchor = null;
|
||||
layerState.contextPosition = null;
|
||||
layerState.coverage = { kind: 'viewport' };
|
||||
layerState.cameraLoadKey = null;
|
||||
layerState.cameraLoadOwner = null;
|
||||
unregisterPickOwner(LAYER_ID);
|
||||
parts.viewport.clearUnavailableRetry();
|
||||
clearTimeout(layerState.timer);
|
||||
@@ -42,15 +77,25 @@ export function createLifecycle({
|
||||
if (layerState.dataSource) layerState.dataSource.show = false;
|
||||
clearSelectedEntityContextForLayer(LAYER_ID);
|
||||
layerState.selectedId = null;
|
||||
parts.rendering.clearSelectionFill?.();
|
||||
layerState.surfaceChangedRemove?.();
|
||||
layerState.surfaceChangedRemove = null;
|
||||
layerState.failureReason = null;
|
||||
parts.ingestion.setInstallationStatus('idle');
|
||||
},
|
||||
|
||||
destroy(viewer) {
|
||||
source.destroy?.();
|
||||
this.disable();
|
||||
layerState.contextAnchor = null;
|
||||
layerState.contextPosition = null;
|
||||
layerState.coverage = { kind: 'viewport' };
|
||||
layerState.moveEndRemove?.();
|
||||
layerState.changedRemove?.();
|
||||
parts.selection.destroy?.();
|
||||
layerState.clickHandler?.destroy();
|
||||
layerState.clickHandler = null;
|
||||
parts.namedMarkers?.destroy();
|
||||
parts.rendering.clearRendered();
|
||||
if (layerState.dataSource && viewer)
|
||||
viewer.dataSources.remove(layerState.dataSource, true);
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as credits from '../../data/dataCredits.js';
|
||||
import { createLifecycle } from './lifecycle.js';
|
||||
import { LAYER_ID } from './policy.js';
|
||||
|
||||
for (const source of [
|
||||
{ name: 'OpenStreetMap', id: 'tile:base' },
|
||||
{ name: 'OpenStreetMap', id: 'way:123' },
|
||||
{ name: 'Google Places', id: 'place:base' },
|
||||
null,
|
||||
]) {
|
||||
test(`enable restores cached installation credits before a refresh: ${source?.id || 'empty'}`, () => {
|
||||
const visibleCredits = new Set();
|
||||
const noop = () => {};
|
||||
const viewer = {
|
||||
camera: {},
|
||||
creditDisplay: {
|
||||
addStaticCredit: (credit) => visibleCredits.add(credit),
|
||||
removeStaticCredit: (credit) => visibleCredits.delete(credit),
|
||||
},
|
||||
};
|
||||
const state = {
|
||||
viewer,
|
||||
records: source ? [{ id: 'base', sources: [source] }] : [],
|
||||
recordById: new Map(),
|
||||
dataSource: { show: false },
|
||||
};
|
||||
let markersVisible = false;
|
||||
const parts = {
|
||||
namedMarkers: {
|
||||
enable: () => {
|
||||
markersVisible = true;
|
||||
},
|
||||
sync: noop,
|
||||
hide: () => {
|
||||
markersVisible = false;
|
||||
},
|
||||
},
|
||||
rendering: {},
|
||||
viewport: { clearUnavailableRetry: noop },
|
||||
ingestion: { setInstallationStatus: noop },
|
||||
};
|
||||
const lifecycle = createLifecycle({
|
||||
state,
|
||||
services: {
|
||||
credits,
|
||||
picking: { registerPickOwner: noop, unregisterPickOwner: noop },
|
||||
context: { clearSelectedEntityContextForLayer: noop },
|
||||
},
|
||||
parts,
|
||||
source: {},
|
||||
}).methods;
|
||||
const expected =
|
||||
source?.name === 'OpenStreetMap'
|
||||
? source.id.startsWith('tile:')
|
||||
? 2
|
||||
: 1
|
||||
: 0;
|
||||
try {
|
||||
for (let cycle = 0; cycle < 2; cycle++) {
|
||||
lifecycle.enable();
|
||||
assert.equal(state.dataSource.show, true);
|
||||
assert.equal(markersVisible, true);
|
||||
// No renderRecords or successful fetch occurs between toggles.
|
||||
assert.equal(visibleCredits.size, expected);
|
||||
lifecycle.disable();
|
||||
assert.equal(state.dataSource.show, false);
|
||||
assert.equal(markersVisible, false);
|
||||
assert.equal(visibleCredits.size, 0);
|
||||
}
|
||||
credits.showOsmCredit(viewer, 'another-layer');
|
||||
lifecycle.enable();
|
||||
lifecycle.disable();
|
||||
assert.equal(visibleCredits.size, 1, 'another owner retains its credit');
|
||||
assert.equal(credits.hideOsmCredit(viewer, LAYER_ID), false);
|
||||
} finally {
|
||||
lifecycle.disable();
|
||||
credits.hideOsmCredit(viewer, 'another-layer');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -116,24 +116,28 @@ export function createModel({ state: layerState, services, parts, source }) {
|
||||
longitude >= box.west &&
|
||||
longitude <= box.east;
|
||||
if (centreInside) return true;
|
||||
if (Array.isArray(footprint) && footprint.length) {
|
||||
let minLat = Infinity;
|
||||
let maxLat = -Infinity;
|
||||
let minLon = Infinity;
|
||||
let maxLon = -Infinity;
|
||||
for (const [lon, lat] of footprint) {
|
||||
if (lat < minLat) minLat = lat;
|
||||
if (lat > maxLat) maxLat = lat;
|
||||
if (lon < minLon) minLon = lon;
|
||||
if (lon > maxLon) maxLon = lon;
|
||||
}
|
||||
return (
|
||||
maxLat >= box.south &&
|
||||
minLat <= box.north &&
|
||||
maxLon >= box.west &&
|
||||
minLon <= box.east
|
||||
);
|
||||
}
|
||||
const footprints =
|
||||
record.footprints?.map((rings) => rings[0]) ||
|
||||
(footprint?.length ? [footprint] : []);
|
||||
if (footprints.length)
|
||||
return footprints.some((footprint) => {
|
||||
let minLat = Infinity;
|
||||
let maxLat = -Infinity;
|
||||
let minLon = Infinity;
|
||||
let maxLon = -Infinity;
|
||||
for (const [lon, lat] of footprint) {
|
||||
if (lat < minLat) minLat = lat;
|
||||
if (lat > maxLat) maxLat = lat;
|
||||
if (lon < minLon) minLon = lon;
|
||||
if (lon > maxLon) maxLon = lon;
|
||||
}
|
||||
return (
|
||||
maxLat >= box.south &&
|
||||
minLat <= box.north &&
|
||||
maxLon >= box.west &&
|
||||
minLon <= box.east
|
||||
);
|
||||
});
|
||||
// Unknown extent: inclusive. Only a point feature may be excluded on centre.
|
||||
return record.osmType !== 'node';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
MILITARY_LABEL_CAP,
|
||||
MILITARY_POINT_CAP,
|
||||
} from '../../data/militaryNames.js';
|
||||
import { LAYER_ID, COLOR_BY_CLASS } from './policy.js';
|
||||
import { createInfrastructureOverlayEntry } from '../../data/infrastructureOverlayEntry.js';
|
||||
|
||||
/** Bounded point markers; the shared overlay owns text projection and decluttering. */
|
||||
export function createNamedMarkers({ state, services, parts, overlayHost }) {
|
||||
let points = null;
|
||||
let entries = [];
|
||||
const markers = new Map();
|
||||
const screen = new Cesium.Cartesian2();
|
||||
const occluder = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84);
|
||||
let shownPoints = 0;
|
||||
let ordered = [];
|
||||
const viewMatrix = new Cesium.Matrix4();
|
||||
let width = 0,
|
||||
height = 0;
|
||||
let removeChanged = null,
|
||||
removeEnd = null,
|
||||
removeFrame = null;
|
||||
|
||||
function refresh() {
|
||||
const viewer = state.viewer;
|
||||
if (!state.enabled || !points) return;
|
||||
width = viewer.scene.canvas.clientWidth;
|
||||
height = viewer.scene.canvas.clientHeight;
|
||||
if (viewer.camera.viewMatrix)
|
||||
Cesium.Matrix4.clone(viewer.camera.viewMatrix, viewMatrix);
|
||||
occluder.cameraPosition = viewer.camera.positionWC;
|
||||
shownPoints = 0;
|
||||
for (let index = 0; index < ordered.length; index++) {
|
||||
const marker = ordered[index];
|
||||
const point = marker.point;
|
||||
point.show = marker.named && occluder.isPointVisible(point.position);
|
||||
if (!point.show) continue;
|
||||
const projected = Cesium.SceneTransforms.worldToWindowCoordinates(
|
||||
viewer.scene,
|
||||
point.position,
|
||||
screen,
|
||||
);
|
||||
if (
|
||||
projected &&
|
||||
screen.x >= 0 &&
|
||||
screen.y >= 0 &&
|
||||
screen.x < width &&
|
||||
screen.y < height
|
||||
)
|
||||
shownPoints++;
|
||||
}
|
||||
}
|
||||
|
||||
function publishLabels() {
|
||||
overlayHost.setEntries(LAYER_ID, entries, {
|
||||
// Keep the bounded point cohort available while Contacts owns the window.
|
||||
// The arbiter spends at most 24 slots on currently visible, separated titles.
|
||||
cohortLimit: MILITARY_POINT_CAP,
|
||||
collisionCapacity: MILITARY_LABEL_CAP,
|
||||
maxVisible: MILITARY_LABEL_CAP - (state.selectedId ? 1 : 0),
|
||||
moving: false,
|
||||
visible: state.enabled,
|
||||
});
|
||||
}
|
||||
|
||||
function cameraChanged() {
|
||||
refresh();
|
||||
services.render.governorRequestRender('installation-names');
|
||||
}
|
||||
function frame() {
|
||||
const viewer = state.viewer;
|
||||
if (
|
||||
viewer?.camera.viewMatrix &&
|
||||
(!Cesium.Matrix4.equals(viewMatrix, viewer.camera.viewMatrix) ||
|
||||
width !== viewer.scene.canvas.clientWidth ||
|
||||
height !== viewer.scene.canvas.clientHeight)
|
||||
)
|
||||
refresh();
|
||||
}
|
||||
function enable() {
|
||||
const viewer = state.viewer;
|
||||
if (!viewer || removeChanged || removeEnd || removeFrame) return;
|
||||
removeChanged = viewer.camera.changed?.addEventListener(cameraChanged);
|
||||
removeEnd = viewer.camera.moveEnd?.addEventListener(cameraChanged);
|
||||
removeFrame = viewer.scene.preRender?.addEventListener(frame);
|
||||
if (points) points.show = true;
|
||||
publishLabels();
|
||||
overlayHost.setVisible(LAYER_ID, true);
|
||||
cameraChanged();
|
||||
}
|
||||
function hide() {
|
||||
removeChanged?.();
|
||||
removeEnd?.();
|
||||
removeFrame?.();
|
||||
removeChanged = removeEnd = removeFrame = null;
|
||||
if (points) points.show = false;
|
||||
overlayHost.clearSource(LAYER_ID);
|
||||
overlayHost.setVisible(LAYER_ID, false);
|
||||
shownPoints = 0;
|
||||
}
|
||||
|
||||
function sync(records) {
|
||||
const viewer = state.viewer;
|
||||
if (!viewer?.scene?.primitives) return;
|
||||
if (!points) {
|
||||
points = viewer.scene.primitives.add(
|
||||
new Cesium.PointPrimitiveCollection(),
|
||||
);
|
||||
}
|
||||
points.show = state.enabled;
|
||||
const named = records
|
||||
.filter((r) => r.namedArea || r.id === state.selectedId)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
Number(b.id === state.selectedId) -
|
||||
Number(a.id === state.selectedId) || b.areaM2 - a.areaM2,
|
||||
)
|
||||
.slice(0, MILITARY_POINT_CAP);
|
||||
const keep = new Set(named.map((r) => r.id));
|
||||
for (const record of named) {
|
||||
if (markers.has(record.id)) continue;
|
||||
const alias = record.aliasIds?.find(
|
||||
(id) => !keep.has(id) && markers.has(id),
|
||||
);
|
||||
if (!alias) continue;
|
||||
const marker = markers.get(alias);
|
||||
markers.delete(alias);
|
||||
marker.point.id = { installationId: record.id };
|
||||
marker.entry.id = record.id;
|
||||
markers.set(record.id, marker);
|
||||
}
|
||||
for (const [id, marker] of markers)
|
||||
if (!keep.has(id)) {
|
||||
points.remove(marker.point);
|
||||
markers.delete(id);
|
||||
}
|
||||
ordered = [];
|
||||
entries = [];
|
||||
for (const record of named) {
|
||||
const position = Cesium.Cartesian3.fromDegrees(
|
||||
record.longitude,
|
||||
record.latitude,
|
||||
record.pointOnly
|
||||
? 0
|
||||
: parts.rendering.installationSurfaceHeightM(record),
|
||||
);
|
||||
let marker = markers.get(record.id);
|
||||
if (!marker) {
|
||||
const id = { installationId: record.id };
|
||||
marker = {
|
||||
point: points.add({
|
||||
id,
|
||||
position,
|
||||
pixelSize: 7,
|
||||
color: parts.model.colorFor(record),
|
||||
outlineColor: Cesium.Color.BLACK,
|
||||
outlineWidth: 1,
|
||||
disableDepthTestDistance: Infinity,
|
||||
}),
|
||||
entry: createInfrastructureOverlayEntry({
|
||||
id: record.id,
|
||||
source: LAYER_ID,
|
||||
position,
|
||||
title: record.name,
|
||||
priority: record.areaM2 || 0,
|
||||
accent: COLOR_BY_CLASS[record.class] || '#9ca6b0',
|
||||
}),
|
||||
};
|
||||
markers.set(record.id, marker);
|
||||
} else {
|
||||
marker.point.position = position;
|
||||
}
|
||||
marker.named = record.namedArea;
|
||||
marker.entry.variant =
|
||||
record.id === state.selectedId ? 'selected' : 'card';
|
||||
marker.entry.selected = record.id === state.selectedId;
|
||||
marker.entry.details = marker.entry.selected
|
||||
? [
|
||||
String(record.class || 'installation')
|
||||
.replaceAll('_', ' ')
|
||||
.toUpperCase(),
|
||||
...(record.memberNames || []).slice(0, 3),
|
||||
]
|
||||
: [];
|
||||
marker.entry.position = position;
|
||||
marker.entry.title = record.name;
|
||||
marker.entry.priority = record.areaM2 || 0;
|
||||
entries.push(marker.entry);
|
||||
ordered.push(marker);
|
||||
}
|
||||
if (state.enabled) publishLabels();
|
||||
refresh();
|
||||
services.render.governorRequestRender('installation-names');
|
||||
}
|
||||
return {
|
||||
sync,
|
||||
visit(visitor) {
|
||||
for (let i = 0; i < ordered.length; i++) {
|
||||
const marker = ordered[i];
|
||||
visitor(marker.point.id.installationId, marker.point);
|
||||
}
|
||||
},
|
||||
refresh,
|
||||
enable,
|
||||
hide,
|
||||
stats: () => ({
|
||||
namedMarkers: markers.size,
|
||||
pointsOnScreen: shownPoints,
|
||||
labelsOnScreen: state.enabled
|
||||
? overlayHost.getDiagnostics?.().paintedBySource[LAYER_ID] || 0
|
||||
: 0,
|
||||
labelCap: MILITARY_LABEL_CAP,
|
||||
}),
|
||||
destroy() {
|
||||
hide();
|
||||
ordered = [];
|
||||
if (points) state.viewer?.scene.primitives.remove(points);
|
||||
points = null;
|
||||
entries = [];
|
||||
markers.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as C from 'cesium';
|
||||
import { createNamedMarkers } from './namedMarkers.js';
|
||||
import { createLifecycle } from './lifecycle.js';
|
||||
import { createLocalInfrastructureOverlayEntry } from '../../data/localGeojsonCore.js';
|
||||
|
||||
function setup(t) {
|
||||
let labels = [], options, publications = 0, visible = false;
|
||||
const overlayHost = {
|
||||
setEntries(id, entries, policy) {
|
||||
assert.equal(id, 'military-installations');
|
||||
labels = entries; options = policy; publications++;
|
||||
},
|
||||
clearSource() { labels = []; },
|
||||
setVisible(_id, value) { visible = value; },
|
||||
};
|
||||
let x = -100;
|
||||
const projections = new Set();
|
||||
t.mock.method(C.SceneTransforms, 'worldToWindowCoordinates', (_scene, _point, scratch) => { projections.add(scratch); scratch.x = x; scratch.y = 300; return scratch; });
|
||||
const camera = { changed: new C.Event(), moveEnd: new C.Event(), positionWC: C.Cartesian3.fromDegrees(0, 0, 1e6), viewMatrix: C.Matrix4.clone(C.Matrix4.IDENTITY) };
|
||||
const state = { viewer: { camera, dataSources: { add() {}, remove() {} }, scene: { canvas: { clientWidth: 1280, clientHeight: 800 }, preRender: new C.Event(), primitives: { add: (p) => p, remove() {} } } }, enabled: true, contextAnchor: { latitude: 0, longitude: 0 } };
|
||||
const services = { render: { governorRequestRender() {} }, picking: { registerPickOwner() {}, unregisterPickOwner() {} }, context: { clearSelectedEntityContextForLayer() {} } };
|
||||
let loads = 0;
|
||||
const parts = { model: { colorFor: () => C.Color.ORANGE }, rendering: { installationSurfaceHeightM: () => 0, clearRendered() {} }, viewport: { scheduleLoad: () => loads++, clearUnavailableRetry() {} }, selection: { installInteraction() {} }, ingestion: { setInstallationStatus() {} } };
|
||||
const ctx = { state, services, parts, source: {}, overlayHost };
|
||||
parts.namedMarkers = createNamedMarkers(ctx);
|
||||
const lifecycle = createLifecycle(ctx).methods;
|
||||
lifecycle.init(state.viewer); lifecycle.enable();
|
||||
const records = [1, 2].map((n) => ({ id: `osm:military:w${n}`, name: `Site ${n}`, namedArea: true, areaM2: 100 / n, latitude: 0, longitude: 0, pointOnly: true }));
|
||||
return { state, camera, get labels() { return labels; }, get options() { return options; }, get visible() { return visible; }, projections, records, markers: parts.namedMarkers, lifecycle, setX: (value) => { x = value; }, publications: () => publications, loads: () => loads };
|
||||
}
|
||||
|
||||
test('Contacts camera motion reprojects and declutters existing labels without source loads or primitive replacement', (t) => {
|
||||
const h = setup(t);
|
||||
h.markers.sync(h.records);
|
||||
assert.equal(h.markers.stats().pointsOnScreen, 0);
|
||||
h.setX(600); h.camera.changed.raiseEvent(); h.camera.moveEnd.raiseEvent();
|
||||
assert.equal(h.markers.stats().pointsOnScreen, 2);
|
||||
assert.equal(h.labels.length, 2);
|
||||
assert.equal(h.options.collisionCapacity, 24);
|
||||
assert.equal(h.options.cohortLimit, 512);
|
||||
assert.equal(h.visible, true);
|
||||
assert.equal(h.loads(), 0);
|
||||
assert.equal(h.publications(), 3);
|
||||
assert.equal(h.projections.size, 1);
|
||||
h.setX(1400); h.camera.viewMatrix[12] = 1; h.state.viewer.scene.preRender.raiseEvent();
|
||||
assert.equal(h.markers.stats().pointsOnScreen, 0, 'small pose changes refresh before render');
|
||||
h.lifecycle.disable();
|
||||
assert.equal(h.camera.changed.numberOfListeners, 1, 'only the viewport listener remains');
|
||||
assert.equal(h.state.viewer.scene.preRender.numberOfListeners, 0);
|
||||
assert.equal(h.labels.length, 0);
|
||||
assert.equal(h.visible, false);
|
||||
h.state.records = h.records;
|
||||
h.setX(600); h.lifecycle.enable();
|
||||
assert.equal(h.labels.length, 2);
|
||||
assert.equal(h.publications(), 5);
|
||||
h.lifecycle.destroy(h.state.viewer);
|
||||
assert.equal(h.camera.changed.numberOfListeners, 0);
|
||||
assert.equal(h.camera.moveEnd.numberOfListeners, 0);
|
||||
assert.equal(h.state.viewer.scene.preRender.numberOfListeners, 0);
|
||||
});
|
||||
|
||||
test('wide named markers retain primitives until close polygons publish with the same identity', async (t) => {
|
||||
const { retainNamedHandoff } = await import('./ingestion.js');
|
||||
const h = setup(t);
|
||||
h.setX(600); h.markers.sync(h.records);
|
||||
const first = h.labels[0];
|
||||
const box = { west: -1, east: 1, south: -1, north: 1 };
|
||||
h.markers.sync(retainNamedHandoff(h.records, [], box));
|
||||
assert.equal(h.labels[0], first);
|
||||
assert.equal(first.title, 'Site 1');
|
||||
const polygon = { ...h.records[0], namedArea: false, name: 'Military area', pointOnly: false, footprint: [[0, 0], [0.1, 0], [0, 0.1], [0, 0]] };
|
||||
const handed = retainNamedHandoff(h.records, [polygon], box);
|
||||
assert.equal(handed.length, 2);
|
||||
assert.equal(handed[0].name, 'Site 1');
|
||||
assert.equal(handed[0].pointOnly, false);
|
||||
h.markers.sync(handed);
|
||||
assert.equal(h.labels[0], first);
|
||||
assert.equal(first.title, 'Site 1');
|
||||
assert.equal(h.labels.length, 2, 'no duplicate title at handoff');
|
||||
assert.equal(retainNamedHandoff(h.records, [], { west: 1, east: 2, south: 1, north: 2 }).length, 0);
|
||||
h.lifecycle.destroy(h.state.viewer);
|
||||
});
|
||||
|
||||
test('canonical aliases transfer named marker ownership without replacing primitives', (t) => {
|
||||
const h = setup(t);
|
||||
h.setX(600);
|
||||
const parent = h.records[1];
|
||||
h.markers.sync([parent]);
|
||||
let originalPoint;
|
||||
h.markers.visit((_id, point) => { originalPoint = point; });
|
||||
const label = h.labels[0];
|
||||
for (const record of [
|
||||
{ ...parent, id: h.records[0].id, aliasIds: [parent.id], pointOnly: false },
|
||||
{ ...parent, aliasIds: [h.records[0].id], pointOnly: false },
|
||||
parent,
|
||||
]) {
|
||||
h.markers.sync([record]);
|
||||
h.markers.visit((id, point) => {
|
||||
const currentLabel = h.labels[0];
|
||||
assert.equal(id, record.id);
|
||||
assert.equal(point, originalPoint);
|
||||
assert.equal(currentLabel, label);
|
||||
assert.equal(point.id.installationId, record.id);
|
||||
assert.equal(currentLabel.id, record.id);
|
||||
});
|
||||
}
|
||||
assert.equal(h.labels.length, 1);
|
||||
h.lifecycle.destroy(h.state.viewer);
|
||||
});
|
||||
|
||||
|
||||
test('the bounded host cohort ranks wide points and polygon titles by area', (t) => {
|
||||
const h = setup(t);
|
||||
h.markers.sync(Array.from({ length: 600 }, (_, n) => ({
|
||||
...h.records[0], id: `site:${n}`, name: `Site ${n}`, areaM2: n,
|
||||
pointOnly: n % 2 === 0,
|
||||
})));
|
||||
assert.equal(h.labels.length, 512);
|
||||
assert.equal(h.labels[0].id, 'site:599');
|
||||
assert.equal(h.labels.at(-1).id, 'site:88');
|
||||
assert.ok(h.labels.every((entry) => !entry.stateless && entry.horizonCull && entry.collisionGroup === 'ambient-card'));
|
||||
const first = h.labels[0];
|
||||
const publications = h.publications();
|
||||
for (let i = 0; i < 100; i++) {
|
||||
h.camera.viewMatrix[12] += 0.01;
|
||||
h.state.viewer.scene.preRender.raiseEvent();
|
||||
}
|
||||
assert.equal(h.labels[0], first);
|
||||
assert.equal(h.publications(), publications, 'camera frames never rebuild host entries');
|
||||
assert.equal(h.projections.size, 1, 'point projection reuses scratch storage');
|
||||
h.lifecycle.destroy(h.state.viewer);
|
||||
assert.equal(h.labels.length, 0);
|
||||
});
|
||||
|
||||
|
||||
test('wide and polygon titles use the Data Centers entry contract and persistent shared domain', (t) => {
|
||||
const h = setup(t);
|
||||
h.markers.sync([h.records[0], { ...h.records[1], pointOnly: false }]);
|
||||
assert.equal(h.options.moving, false);
|
||||
assert.equal(h.options.solveIntervalMs, undefined);
|
||||
assert.equal(h.options.maxVisible, 24);
|
||||
for (const entry of h.labels) {
|
||||
const reference = createLocalInfrastructureOverlayEntry({ id: entry.id, layerId: 'military-installations', position: entry.position, properties: { name: entry.title }, priority: entry.priority, accent: entry.accent });
|
||||
assert.deepEqual(entry, { ...reference, selected: false });
|
||||
}
|
||||
h.lifecycle.destroy(h.state.viewer);
|
||||
});
|
||||
|
||||
|
||||
test('selection promotes exactly one entry and deselection restores that same label', (t) => {
|
||||
const h = setup(t);
|
||||
h.markers.sync(h.records);
|
||||
const original = h.labels[0];
|
||||
h.state.selectedId = h.records[0].id;
|
||||
h.markers.sync(h.records);
|
||||
assert.equal(h.labels.length, 2);
|
||||
assert.equal(h.labels[0], original);
|
||||
assert.equal(original.variant, 'selected');
|
||||
assert.equal(h.labels.filter(e => e.selected).length, 1);
|
||||
assert.equal(h.options.maxVisible, 23);
|
||||
h.state.selectedId = null;
|
||||
h.markers.sync(h.records);
|
||||
assert.equal(h.labels[0], original);
|
||||
assert.equal(original.variant, 'card');
|
||||
assert.equal(original.selected, false);
|
||||
assert.deepEqual(original.details, []);
|
||||
h.lifecycle.destroy(h.state.viewer);
|
||||
});
|
||||
|
||||
test('Contacts can select a point outside the normal title cap or an unnamed polygon', (t) => {
|
||||
const h = setup(t);
|
||||
const records = Array.from({ length: 520 }, (_, i) => ({ ...h.records[0], id: `site:${i}`, areaM2: 1000 - i }));
|
||||
records.push({ ...h.records[0], id: 'unnamed', name: 'Military area', namedArea: false, pointOnly: false });
|
||||
for (const id of ['site:519', 'unnamed']) {
|
||||
h.state.selectedId = id;
|
||||
h.markers.sync(records);
|
||||
assert.equal(h.labels[0].id, id);
|
||||
assert.equal(h.labels[0].variant, 'selected');
|
||||
assert.equal(h.labels.filter(e => e.selected).length, 1);
|
||||
assert.equal(h.labels.length, 512);
|
||||
}
|
||||
h.lifecycle.destroy(h.state.viewer);
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createInstallationSource } from './source.js';
|
||||
import { createIngestion } from './ingestion.js';
|
||||
import { decodeOpenFreeMapMilitaryTile } from '../../sources/openFreeMap.js';
|
||||
import { createMilitaryNamesLoader, loadMilitaryNames } from '../../data/militaryNames.js';
|
||||
import { loadBundledJson } from '../../data/bundledJson.js';
|
||||
|
||||
const box = { south: 30.3, west: -97.8, north: 30.33, east: -97.74 };
|
||||
const wide = { south: 20, west: -130, north: 60, east: -60 };
|
||||
const tile = decodeOpenFreeMapMilitaryTile(readFileSync(new URL('../../data/fixtures/ofm-camp-mabry-12-935-1685.pbf', import.meta.url)), 12, 935, 1685);
|
||||
const flush = async () => { for (let i = 0; i < 30; i++) await Promise.resolve(); };
|
||||
function deferred() { let resolve; const promise = new Promise((r) => { resolve = r; }); return { promise, resolve }; }
|
||||
function sourceWith(job) { return createInstallationSource({
|
||||
loadNames: () => job.promise,
|
||||
fetchImpl: async () => Response.json({ unavailable: true }),
|
||||
mapTiles: { fetchBounds: async () => ({ tiles: [tile] }), clear() {} },
|
||||
}); }
|
||||
|
||||
test('close polygons publish immediately while a wide consumer can cancel its independent names wait', async () => {
|
||||
const job = deferred(), source = sourceWith(job), abort = new AbortController();
|
||||
const waiting = source.getMappedSites(wide, { signal: abort.signal });
|
||||
const rejection = assert.rejects(waiting, { name: 'AbortError' });
|
||||
const payload = await source.getMappedSites(box);
|
||||
assert.ok(payload.records.length);
|
||||
assert.ok(payload.records.every((r) => r.name === 'Military area'));
|
||||
abort.abort();
|
||||
await rejection;
|
||||
job.resolve(await loadMilitaryNames());
|
||||
const enriched = await payload.enrichment;
|
||||
assert.ok(enriched.records.some((r) => r.name === 'Camp Mabry'));
|
||||
});
|
||||
|
||||
for (const stale of [false, true]) test(`late names ${stale ? 'cannot publish to a superseded view' : 'enrich the current published polygons'}`, async () => {
|
||||
const job = deferred(), source = sourceWith(job), publications = [];
|
||||
let currentBox = box;
|
||||
const state = { enabled: true, viewer: {}, records: [], googleSearchRequested: false };
|
||||
const ingestion = createIngestion({ state, source,
|
||||
services: { render: { governorRequestRender() {} }, ground: { resolveGroundFloorCellsBounded: async () => {} } },
|
||||
parts: {
|
||||
viewport: { loadArea: () => ({ box: currentBox, coverage: {} }), clearUnavailableRetry() {}, scheduleUnavailableRetry() {} },
|
||||
model: { installationWithinViewport: () => true },
|
||||
rendering: { renderRecords: () => publications.push(state.records), warmInstallationFloors() {} },
|
||||
},
|
||||
});
|
||||
await ingestion.loadInstallations();
|
||||
assert.equal(state.loading, false);
|
||||
assert.ok(publications[0].length);
|
||||
if (stale) { currentBox = wide; state.enabled = false; state.abort.abort(); }
|
||||
job.resolve(await loadMilitaryNames());
|
||||
await flush();
|
||||
assert.equal(publications.length, stale ? 1 : 2);
|
||||
if (!stale) assert.ok(state.records.some((r) => r.name === 'Camp Mabry'));
|
||||
state.abort.abort();
|
||||
});
|
||||
|
||||
for (const stage of ['response', 'body']) test(`shared names ${stage} deadline aborts and releases the retryable loader`, async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
|
||||
let calls = 0, signal;
|
||||
t.mock.method(globalThis, 'fetch', async (_url, options) => {
|
||||
calls++; signal = options.signal;
|
||||
if (calls > 1) return Response.json({ records: [], classes: [] });
|
||||
if (stage === 'response') return new Promise(() => {});
|
||||
return { ok: true, json: () => new Promise(() => {}) };
|
||||
});
|
||||
const load = createMilitaryNamesLoader({ timeoutMs: 50, cooldownMs: 100,
|
||||
loadPack: (signal) => loadBundledJson(new URL('https://example.test/names.json'), null, { signal }) });
|
||||
const first = load();
|
||||
assert.equal(load(), first, 'shared acquisition');
|
||||
const rejected = assert.rejects(first, { name: 'TimeoutError' });
|
||||
await flush(); t.mock.timers.tick(51); await rejected;
|
||||
assert.equal(signal.aborted, true);
|
||||
await assert.rejects(load(), { name: 'TimeoutError' });
|
||||
assert.equal(calls, 1);
|
||||
t.mock.timers.tick(101);
|
||||
assert.deepEqual((await load()).records, []);
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createViewport } from './viewport.js';
|
||||
import { createLifecycle } from './lifecycle.js';
|
||||
|
||||
test('camera changes debounce, deduplicate moveEnd, cancel superseded loads and recover from wide views', (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
let rectangle = Cesium.Rectangle.fromDegrees(-117.2, 32.7, -117.1, 32.8);
|
||||
const camera = {
|
||||
changed: new Cesium.Event(),
|
||||
moveEnd: new Cesium.Event(),
|
||||
percentageChanged: 0.5,
|
||||
computeViewRectangle: () => rectangle,
|
||||
};
|
||||
const viewer = {
|
||||
camera,
|
||||
scene: { globe: { ellipsoid: Cesium.Ellipsoid.WGS84 } },
|
||||
dataSources: { add() {}, remove() {} },
|
||||
};
|
||||
const state = { enabled: false, renderedKeys: new Map() };
|
||||
const loads = [];
|
||||
const parts = {
|
||||
selection: { installInteraction() {} },
|
||||
rendering: { clearRendered() {} },
|
||||
ingestion: {
|
||||
loadInstallations() {
|
||||
state.abort = new AbortController();
|
||||
loads.push({
|
||||
...parts.viewport.loadArea(viewer),
|
||||
signal: state.abort.signal,
|
||||
});
|
||||
},
|
||||
setInstallationStatus() {},
|
||||
},
|
||||
};
|
||||
const services = {
|
||||
picking: { registerPickOwner() {}, unregisterPickOwner() {} },
|
||||
context: { clearSelectedEntityContextForLayer() {} },
|
||||
};
|
||||
const ctx = { state, parts, services, source: {} };
|
||||
parts.viewport = createViewport(ctx);
|
||||
const life = createLifecycle(ctx).methods;
|
||||
life.init(viewer);
|
||||
life.enable();
|
||||
for (let i = 0; i < 8; i++) {
|
||||
camera.changed.raiseEvent();
|
||||
t.mock.timers.tick(15);
|
||||
}
|
||||
t.mock.timers.tick(200);
|
||||
assert.equal(
|
||||
loads.length,
|
||||
1,
|
||||
'changed alone must load, even without moveEnd',
|
||||
);
|
||||
camera.moveEnd.raiseEvent();
|
||||
t.mock.timers.tick(200);
|
||||
assert.equal(loads.length, 1);
|
||||
assert.equal(
|
||||
loads[0].signal.aborted,
|
||||
false,
|
||||
'duplicate settle must not abort its own load',
|
||||
);
|
||||
rectangle = Cesium.Rectangle.fromDegrees(-117.5, 33.2, -117.3, 33.4);
|
||||
camera.changed.raiseEvent();
|
||||
assert.equal(loads[0].signal.aborted, true);
|
||||
t.mock.timers.tick(200);
|
||||
assert.equal(loads.length, 2);
|
||||
rectangle = Cesium.Rectangle.fromDegrees(-150, 10, -80, 60);
|
||||
camera.moveEnd.raiseEvent();
|
||||
t.mock.timers.tick(200);
|
||||
assert.equal(
|
||||
loads.at(-1).box.west,
|
||||
-150,
|
||||
'wide views reach the named-point source',
|
||||
);
|
||||
rectangle = Cesium.Rectangle.fromDegrees(-117.2, 32.7, -117.1, 32.8);
|
||||
camera.changed.raiseEvent();
|
||||
t.mock.timers.tick(200);
|
||||
assert.ok(loads.at(-1).box);
|
||||
state.contextAnchor = { latitude: 32.8, longitude: -117.1 };
|
||||
const count = loads.length;
|
||||
camera.moveEnd.raiseEvent();
|
||||
t.mock.timers.tick(200);
|
||||
assert.equal(loads.length, count, 'Contacts owns the anchor while active');
|
||||
life.disable();
|
||||
assert.equal(state.contextAnchor, null);
|
||||
life.enable();
|
||||
rectangle = Cesium.Rectangle.fromDegrees(-117.5, 33.2, -117.3, 33.4);
|
||||
camera.changed.raiseEvent();
|
||||
t.mock.timers.tick(200);
|
||||
assert.equal(loads.at(-1).coverage.kind, 'viewport');
|
||||
life.destroy(viewer);
|
||||
assert.equal(camera.changed.numberOfListeners, 0);
|
||||
assert.equal(camera.moveEnd.numberOfListeners, 0);
|
||||
assert.equal(camera.percentageChanged, 0.5);
|
||||
});
|
||||
|
||||
test('an aborting real ingestion cannot clear the next scheduled view key', async (t) => {
|
||||
const { createIngestion } = await import('./ingestion.js');
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
let rectangle = Cesium.Rectangle.fromDegrees(-117.2, 32.7, -117.1, 32.8);
|
||||
const viewer = {
|
||||
camera: { computeViewRectangle: () => rectangle },
|
||||
scene: { globe: { ellipsoid: Cesium.Ellipsoid.WGS84 } },
|
||||
};
|
||||
const state = { enabled: true, viewer };
|
||||
const pending = [];
|
||||
const parts = {
|
||||
rendering: { renderRecords() {}, warmInstallationFloors() {} },
|
||||
model: { installationWithinViewport: () => true },
|
||||
};
|
||||
const ctx = {
|
||||
state,
|
||||
parts,
|
||||
services: {
|
||||
render: { governorRequestRender() {} },
|
||||
ground: { resolveGroundFloorCellsBounded: async () => {} },
|
||||
},
|
||||
source: {
|
||||
getMappedSites(box, { signal }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
signal.addEventListener('abort', () => reject(signal.reason), {
|
||||
once: true,
|
||||
});
|
||||
pending.push({ box, signal, resolve });
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
parts.viewport = createViewport(ctx);
|
||||
parts.ingestion = createIngestion(ctx);
|
||||
const flush = async () => {
|
||||
for (let i = 0; i < 20; i++) await Promise.resolve();
|
||||
};
|
||||
parts.viewport.scheduleLoad();
|
||||
t.mock.timers.tick(200);
|
||||
assert.equal(pending.length, 1);
|
||||
rectangle = Cesium.Rectangle.fromDegrees(-117.5, 33.2, -117.3, 33.4);
|
||||
parts.viewport.scheduleLoad();
|
||||
const nextKey = state.cameraLoadKey;
|
||||
assert.equal(pending[0].signal.aborted, true);
|
||||
await flush();
|
||||
assert.equal(
|
||||
state.cameraLoadKey,
|
||||
nextKey,
|
||||
'A finally must not clear B schedule ownership',
|
||||
);
|
||||
t.mock.timers.tick(200);
|
||||
assert.equal(pending.length, 2);
|
||||
parts.viewport.scheduleLoad();
|
||||
t.mock.timers.tick(600);
|
||||
assert.equal(pending.length, 2);
|
||||
assert.equal(pending[1].signal.aborted, false);
|
||||
pending[1].resolve({ records: [], status: 'ready' });
|
||||
await flush();
|
||||
assert.equal(state.loading, false);
|
||||
assert.equal(state.cameraLoadKey, nextKey);
|
||||
});
|
||||
|
||||
test('wide views refresh screen decluttering when the globe rectangle stays unchanged', (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const camera = {
|
||||
positionWC: new Cesium.Cartesian3(10000000, 0, 0),
|
||||
directionWC: new Cesium.Cartesian3(-1, 0, 0),
|
||||
computeViewRectangle: () => Cesium.Rectangle.MAX_VALUE,
|
||||
};
|
||||
const state = {
|
||||
enabled: true,
|
||||
viewer: { camera, scene: { globe: { ellipsoid: Cesium.Ellipsoid.WGS84 } } },
|
||||
};
|
||||
let loads = 0;
|
||||
const parts = {
|
||||
ingestion: {
|
||||
loadInstallations() {
|
||||
loads++;
|
||||
},
|
||||
},
|
||||
};
|
||||
const viewport = createViewport({ state, parts, services: {}, source: {} });
|
||||
viewport.scheduleLoad();
|
||||
t.mock.timers.tick(200);
|
||||
viewport.scheduleLoad();
|
||||
t.mock.timers.tick(200);
|
||||
assert.equal(loads, 1, 'the same settled camera remains deduplicated');
|
||||
camera.positionWC = new Cesium.Cartesian3(0, 10000000, 0);
|
||||
camera.directionWC = new Cesium.Cartesian3(0, -1, 0);
|
||||
viewport.scheduleLoad();
|
||||
t.mock.timers.tick(200);
|
||||
assert.equal(loads, 2, 'a different continent needs fresh projected labels');
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
export const LAYER_ID = 'military-installations';
|
||||
|
||||
export const REQUEST_DEBOUNCE_MS = 500;
|
||||
export const REQUEST_DEBOUNCE_MS = 180;
|
||||
|
||||
export const MAX_VIEWPORT_DEGREES = 10;
|
||||
|
||||
@@ -19,3 +19,6 @@ export const COLOR_BY_CLASS = {
|
||||
export const EARTH_MEAN_RADIUS_M = 6371008.8;
|
||||
|
||||
export const DISTANCE_PREFILTER_MARGIN_M = 5000;
|
||||
|
||||
/** A Contacts subject must move this far before its installation window moves. */
|
||||
export const ANCHOR_REFRESH_M = 20_000;
|
||||
Loaded 100 of 211 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user