mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
Merge branch 'main' into fix/cctv-tool-routing
This commit is contained in:
commit
13b474d302
11 files changed
+754
-36
No files matched your search
@@ -0,0 +1,3 @@
|
|||||||
|
- Require a geocoded containment anchor for ambiguous analyst region names,
|
||||||
|
preserve boundary provenance and cancellation safety, and retain exact-name
|
||||||
|
precedence for natural outlines (manjunath22466, #1235).
|
||||||
@@ -1,5 +1,14 @@
|
|||||||
# God's Eye View Current State
|
# God's Eye View Current State
|
||||||
|
|
||||||
|
Analyst region queries resolve unique Natural Earth names offline without
|
||||||
|
geocoding, so Gold Coast and Ivory Coast name the West African natural regions.
|
||||||
|
Duplicate names require a geocoded containment anchor; when several entries for
|
||||||
|
the same name contain that anchor, the lookup stays unresolved rather than
|
||||||
|
selecting the largest entry. Natural outlines try exact names before aliases and
|
||||||
|
broader suffix variants. Follow-ups retain boundary provenance, and cancelled
|
||||||
|
lookups preserve the previous analyst cohort and boundary source. Navigation
|
||||||
|
search keeps a successful geocode, with no search or view-based region recovery.
|
||||||
|
|
||||||
Shared distance checks use `src/geoDistance.js` with `{ lat, lon }` points and
|
Shared distance checks use `src/geoDistance.js` with `{ lat, lon }` points and
|
||||||
the IUGG mean spherical Earth radius, 6371.0088 km. `greatCircleKm` is
|
the IUGG mean spherical Earth radius, 6371.0088 km. `greatCircleKm` is
|
||||||
canonical; `greatCircleMeters` is its exact metre conversion. Missing,
|
canonical; `greatCircleMeters` is its exact metre conversion. Missing,
|
||||||
|
|||||||
@@ -1745,8 +1745,9 @@ async function runBehaviorLayer() {
|
|||||||
'behavior: analyst follow-up re-filters the remembered set',
|
'behavior: analyst follow-up re-filters the remembered set',
|
||||||
`count=${r?.count} followUp=${r?.coverage?.followUp}`,
|
`count=${r?.count} followUp=${r?.coverage?.followUp}`,
|
||||||
);
|
);
|
||||||
// A marine region resolves from the bundled Natural Earth pack in the page,
|
// Require the bundled boundary source and the existing whole-query limit.
|
||||||
// without the slower geocode fallback.
|
// This elapsed time includes record acquisition and filtering; the region
|
||||||
|
// resolver has its own separate fallback budget.
|
||||||
const gulfStarted = Date.now();
|
const gulfStarted = Date.now();
|
||||||
r = await run('analyst_query', {
|
r = await run('analyst_query', {
|
||||||
layers: ['flights'],
|
layers: ['flights'],
|
||||||
@@ -1757,9 +1758,10 @@ async function runBehaviorLayer() {
|
|||||||
report(
|
report(
|
||||||
r?.ok === true &&
|
r?.ok === true &&
|
||||||
String(r?.coverage?.scope || '').includes('Gulf of Mexico') &&
|
String(r?.coverage?.scope || '').includes('Gulf of Mexico') &&
|
||||||
|
r?.coverage?.scopeSource === 'natural-earth' &&
|
||||||
gulfMs < 3000,
|
gulfMs < 3000,
|
||||||
'behavior: analyst resolves the Gulf of Mexico from the bundled pack',
|
'behavior: analyst resolves the Gulf of Mexico from the bundled pack',
|
||||||
`scope=${r?.coverage?.scope} ms=${gulfMs} err=${r?.error || ''}`,
|
`scope=${r?.coverage?.scope} source=${r?.coverage?.scopeSource} ms=${gulfMs} err=${r?.error || ''}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// (5) zoom_to_globe is ABSOLUTE full-earth (>12,000 km band)
|
// (5) zoom_to_globe is ABSOLUTE full-earth (>12,000 km band)
|
||||||
|
|||||||
@@ -120,6 +120,93 @@ test('Georgia resolves from camera geography without a geocoder', async () => {
|
|||||||
assert.deepEqual(geocodes, []);
|
assert.deepEqual(geocodes, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Gold Coast in Australia cannot acquire its West African namesake outline', async () => {
|
||||||
|
for (const types of [['locality', 'political'], ['natural_feature']]) {
|
||||||
|
const { resolve, geocodes } = harness({
|
||||||
|
lat: -28.0167,
|
||||||
|
lng: 153.4,
|
||||||
|
label: 'Gold Coast, Australia',
|
||||||
|
name: 'Gold Coast',
|
||||||
|
types,
|
||||||
|
});
|
||||||
|
const resolved = await resolve('Gold Coast', viewerAt(-28.0167, 153.4));
|
||||||
|
assert.equal(resolved?.source, 'geocode');
|
||||||
|
assert.equal(resolved.lat, -28.0167);
|
||||||
|
assert.equal(resolved.lon, 153.4);
|
||||||
|
assert.equal(resolved.naturalRegion, undefined);
|
||||||
|
const outline = await resolved.resolveOutline();
|
||||||
|
assert.equal(outline?.naturalRegion, undefined);
|
||||||
|
assert.deepEqual(geocodes, ['Gold Coast']);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('natural annotation outlines require a geocoded anchor', async () => {
|
||||||
|
for (const viewer of [AUSTIN, {}, viewerAt(46.5, 10, 248_000)]) {
|
||||||
|
const miss = harness();
|
||||||
|
assert.equal(await miss.resolve('the Alps', viewer), null);
|
||||||
|
assert.ok(miss.geocodes.includes('the Alps'));
|
||||||
|
}
|
||||||
|
const anchored = harness({
|
||||||
|
lat: 46.5,
|
||||||
|
lng: 10,
|
||||||
|
label: 'Alps',
|
||||||
|
name: 'Alps',
|
||||||
|
types: ['natural_feature'],
|
||||||
|
});
|
||||||
|
const resolved = await anchored.resolve('the Alps', {});
|
||||||
|
assert.equal(resolved.source, 'geocode');
|
||||||
|
const outline = await resolved.resolveOutline();
|
||||||
|
assert.equal(outline.naturalRegion, 'Alps');
|
||||||
|
assert.ok(outline.ring.length >= 8);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('overlapping natural candidates continue through geocoding without choosing a ring', async () => {
|
||||||
|
const { resolve, geocodes } = harness({
|
||||||
|
lat: 45.6336,
|
||||||
|
lng: -74.86493,
|
||||||
|
label: 'Canadian Shield',
|
||||||
|
name: 'Canadian Shield',
|
||||||
|
types: ['natural_feature'],
|
||||||
|
});
|
||||||
|
const resolved = await resolve(
|
||||||
|
'Canadian Shield',
|
||||||
|
viewerAt(45.6336, -74.86493),
|
||||||
|
);
|
||||||
|
assert.equal(resolved?.source, 'geocode');
|
||||||
|
assert.equal((await resolved.resolveOutline())?.naturalRegion, undefined);
|
||||||
|
assert.deepEqual(geocodes, ['Canadian Shield']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('geocoded natural outlines respect district and around intent', async () => {
|
||||||
|
for (const extra of [
|
||||||
|
{ entityKind: 'district' },
|
||||||
|
{ intent: 'around_the_thing' },
|
||||||
|
]) {
|
||||||
|
const { resolve, geocodes } = harness({
|
||||||
|
lat: 46.5,
|
||||||
|
lng: 10,
|
||||||
|
label: 'Alps',
|
||||||
|
name: 'Alps',
|
||||||
|
types:
|
||||||
|
extra.entityKind === 'district' ? ['locality'] : ['natural_feature'],
|
||||||
|
});
|
||||||
|
const resolved = await resolve('the Alps', viewerAt(46.5, 10), extra);
|
||||||
|
assert.ok(geocodes.includes('the Alps'));
|
||||||
|
assert.equal((await resolved.resolveOutline())?.naturalRegion, undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cancellation while a bundled admin lookup is pending cannot publish an annotation', async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const { resolve, geocodes } = harness();
|
||||||
|
const pending = resolve('the Alps', viewerAt(46.5, 10), {
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
controller.abort();
|
||||||
|
await assert.rejects(pending, { name: 'AbortError' });
|
||||||
|
assert.deepEqual(geocodes, []);
|
||||||
|
});
|
||||||
|
|
||||||
test('country asks and geocoded country scopes use the bundled outline', async () => {
|
test('country asks and geocoded country scopes use the bundled outline', async () => {
|
||||||
const direct = harness();
|
const direct = harness();
|
||||||
for (const name of ['Switzerland', 'Iran', 'France', 'Japan']) {
|
for (const name of ['Switzerland', 'Iran', 'France', 'Japan']) {
|
||||||
|
|||||||
@@ -44,10 +44,115 @@ test('region ring: Natural Earth names resolve without a geocoder', async () =>
|
|||||||
for (const name of ['Gulf of Mexico', 'the Alps']) {
|
for (const name of ['Gulf of Mexico', 'the Alps']) {
|
||||||
const region = await resolveRegionRingForQuery(name);
|
const region = await resolveRegionRingForQuery(name);
|
||||||
assert.ok(region?.ring?.length >= 3, `${name} resolves to a ring`);
|
assert.ok(region?.ring?.length >= 3, `${name} resolves to a ring`);
|
||||||
|
assert.equal(region.source, 'natural-earth');
|
||||||
assert.equal(region.error, undefined);
|
assert.equal(region.error, undefined);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('region ring: duplicate natural names need a containing geocoded anchor', async () => {
|
||||||
|
const { resolveRegionRingForQuery } = createAnnotationResolver({
|
||||||
|
featureSource: featureSource(),
|
||||||
|
});
|
||||||
|
const miss = geocoderFor(null);
|
||||||
|
assert.equal(
|
||||||
|
await resolveRegionRingForQuery('Cordillera Oriental', undefined, miss),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
assert.deepEqual(miss.calls, ['Cordillera Oriental']);
|
||||||
|
|
||||||
|
const inside = geocoderFor({
|
||||||
|
lat: 5,
|
||||||
|
lng: -73,
|
||||||
|
label: 'Cordillera Oriental',
|
||||||
|
name: 'Cordillera Oriental',
|
||||||
|
types: ['natural_feature'],
|
||||||
|
});
|
||||||
|
const region = await resolveRegionRingForQuery(
|
||||||
|
'Cordillera Oriental',
|
||||||
|
undefined,
|
||||||
|
inside,
|
||||||
|
);
|
||||||
|
assert.equal(region?.source, 'natural-earth');
|
||||||
|
assert.equal(region.name, 'Cordillera Oriental');
|
||||||
|
assert.ok(region.ring.length >= 8);
|
||||||
|
assert.deepEqual(inside.calls, ['Cordillera Oriental']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('region ring: outside and overlapping geocoded natural anchors stay unresolved', async () => {
|
||||||
|
const { resolveRegionRingForQuery } = createAnnotationResolver({
|
||||||
|
featureSource: featureSource(),
|
||||||
|
});
|
||||||
|
for (const [name, lat, lng] of [
|
||||||
|
['Cordillera Oriental', 30.27, -97.74],
|
||||||
|
['Canadian Shield', 45.6336, -74.86493],
|
||||||
|
]) {
|
||||||
|
const geocoder = geocoderFor({
|
||||||
|
lat,
|
||||||
|
lng,
|
||||||
|
label: name,
|
||||||
|
name,
|
||||||
|
types: ['natural_feature'],
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
await resolveRegionRingForQuery(name, undefined, geocoder),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
assert.deepEqual(geocoder.calls, [name]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('region ring: geocoded administrative scope wins over duplicate natural geography', async () => {
|
||||||
|
const lookups = [];
|
||||||
|
const { resolveRegionRingForQuery } = createAnnotationResolver({
|
||||||
|
featureSource: featureSource({
|
||||||
|
getAdministrativeAreas: async (point) => {
|
||||||
|
lookups.push(point);
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const geo = geocoderFor({
|
||||||
|
lat: 5,
|
||||||
|
lng: -73,
|
||||||
|
label: 'Cordillera Oriental',
|
||||||
|
name: 'Cordillera Oriental',
|
||||||
|
types: ['locality'],
|
||||||
|
});
|
||||||
|
const resolved = await resolveRegionRingForQuery(
|
||||||
|
'Cordillera Oriental',
|
||||||
|
undefined,
|
||||||
|
geo,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
resolved,
|
||||||
|
null,
|
||||||
|
'an unavailable city boundary is not a natural-region outline',
|
||||||
|
);
|
||||||
|
assert.deepEqual(lookups, [{ lat: 5, lon: -73 }]);
|
||||||
|
assert.deepEqual(geo.calls, ['Cordillera Oriental']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('region ring: cancellation during bundled lookup and geocode never returns a region', async () => {
|
||||||
|
const { resolveRegionRingForQuery } = createAnnotationResolver({
|
||||||
|
featureSource: featureSource(),
|
||||||
|
});
|
||||||
|
const controller = new AbortController();
|
||||||
|
const pending = resolveRegionRingForQuery('the Alps', controller.signal);
|
||||||
|
controller.abort();
|
||||||
|
await assert.rejects(pending, { name: 'AbortError' });
|
||||||
|
|
||||||
|
const later = new AbortController();
|
||||||
|
await assert.rejects(
|
||||||
|
resolveRegionRingForQuery('Cordillera Oriental', later.signal, {
|
||||||
|
async geocode() {
|
||||||
|
later.abort();
|
||||||
|
return { place: null };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ name: 'AbortError' },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('region ring: a slow admin-boundary lookup returns region-timeout within the budget', async () => {
|
test('region ring: a slow admin-boundary lookup returns region-timeout within the budget', async () => {
|
||||||
let adminSignal;
|
let adminSignal;
|
||||||
let release;
|
let release;
|
||||||
@@ -119,6 +224,7 @@ test('region ring: bundled states and counties resolve without a geocoder', asyn
|
|||||||
]) {
|
]) {
|
||||||
const region = await resolveRegionRingForQuery(query, undefined, geocoder);
|
const region = await resolveRegionRingForQuery(query, undefined, geocoder);
|
||||||
assert.equal(region?.name, name, query);
|
assert.equal(region?.name, name, query);
|
||||||
|
assert.equal(region.source, 'bundled-admin');
|
||||||
assert.ok(region.ring.length >= 8, `${query} has a real ring`);
|
assert.ok(region.ring.length >= 8, `${query} has a real ring`);
|
||||||
}
|
}
|
||||||
assert.deepEqual(geocoder.calls, [], 'no geocoding');
|
assert.deepEqual(geocoder.calls, [], 'no geocoding');
|
||||||
|
|||||||
@@ -444,6 +444,7 @@ export function createAnnotationResolver({
|
|||||||
* AFTER the anchor mark is drawn and upgrade the mark in place.
|
* AFTER the anchor mark is drawn and upgrade the mark in place.
|
||||||
*/
|
*/
|
||||||
const resolveOutline = async () => {
|
const resolveOutline = async () => {
|
||||||
|
signal?.throwIfAborted();
|
||||||
let scope = baseScope;
|
let scope = baseScope;
|
||||||
const isAdmin =
|
const isAdmin =
|
||||||
scope === 'country' ||
|
scope === 'country' ||
|
||||||
@@ -469,6 +470,7 @@ export function createAnnotationResolver({
|
|||||||
const ne = await lookupNaturalRegionOutline(target, lat, lon).catch(
|
const ne = await lookupNaturalRegionOutline(target, lat, lon).catch(
|
||||||
() => null,
|
() => null,
|
||||||
);
|
);
|
||||||
|
signal?.throwIfAborted();
|
||||||
if (ne) {
|
if (ne) {
|
||||||
registerDynamicCredit(viewer, NATURAL_EARTH_CREDIT);
|
registerDynamicCredit(viewer, NATURAL_EARTH_CREDIT);
|
||||||
const neCentroid = ringCentroid(ne.ring);
|
const neCentroid = ringCentroid(ne.ring);
|
||||||
@@ -2004,8 +2006,8 @@ export function createAnnotationResolver({
|
|||||||
/**
|
/**
|
||||||
* Region ring for ANALYST queries ("how many flights over Texas / the Alps") —
|
* Region ring for ANALYST queries ("how many flights over Texas / the Alps") —
|
||||||
* a name-only entry point that reuses this module's boundary machinery
|
* a name-only entry point that reuses this module's boundary machinery
|
||||||
* without the annotation pipeline. Natural Earth pack first (offline,
|
* without the annotation pipeline. Unique Natural Earth names resolve first
|
||||||
* instant; largest-area match is correct for a global name-only ask), then
|
* (offline); duplicate names require a geocoded containment anchor, then
|
||||||
* geocode + admin boundary for states/countries/counties (Tier A disk-cached
|
* geocode + admin boundary for states/countries/counties (Tier A disk-cached
|
||||||
* Overpass, so repeat asks are instant). Returns null when the name doesn't
|
* Overpass, so repeat asks are instant). Returns null when the name doesn't
|
||||||
* resolve to a region-like boundary — the analyst engine reports that
|
* resolve to a region-like boundary — the analyst engine reports that
|
||||||
@@ -2020,7 +2022,7 @@ export function createAnnotationResolver({
|
|||||||
* @param {AbortSignal} [signal]
|
* @param {AbortSignal} [signal]
|
||||||
* @param {object} [placeSearch]
|
* @param {object} [placeSearch]
|
||||||
* @param {{budgetMs?: number}} [options] `Infinity` waits for the lookup.
|
* @param {{budgetMs?: number}} [options] `Infinity` waits for the lookup.
|
||||||
* @returns {Promise<{name:string, ring:Array<[number,number]>}|{name:string, ring:null, error:'region-timeout'}|null>}
|
* @returns {Promise<{name:string, ring:Array<[number,number]>, source:string}|{name:string, ring:null, error:'region-timeout'}|null>}
|
||||||
*/
|
*/
|
||||||
async function resolveRegionRingForQuery(
|
async function resolveRegionRingForQuery(
|
||||||
name,
|
name,
|
||||||
@@ -2037,16 +2039,24 @@ export function createAnnotationResolver({
|
|||||||
const q = String(name || '').trim();
|
const q = String(name || '').trim();
|
||||||
if (!q) return null;
|
if (!q) return null;
|
||||||
const ne = await findNaturalRegion(q).catch(() => null);
|
const ne = await findNaturalRegion(q).catch(() => null);
|
||||||
if (ne?.polygons?.length) {
|
signal?.throwIfAborted();
|
||||||
|
if (ne?.candidates === 1 && ne.polygons?.length) {
|
||||||
// Largest ring carries the query scope; multi-ring regions (Andes) keep
|
// Largest ring carries the query scope; multi-ring regions (Andes) keep
|
||||||
// their main cordillera — good enough for containment counting.
|
// their main cordillera — good enough for containment counting.
|
||||||
const ring = [...ne.polygons].sort((a, b) => b.length - a.length)[0];
|
const ring = [...ne.polygons].sort((a, b) => b.length - a.length)[0];
|
||||||
if (ring?.length >= 3) return { name: ne.name, ring };
|
if (ring?.length >= 3)
|
||||||
|
return { name: ne.name, ring, source: 'natural-earth' };
|
||||||
}
|
}
|
||||||
// Bundled states/provinces/counties by name (offline); the main part only,
|
// Bundled states/provinces/counties by name (offline); the main part only,
|
||||||
// like the Natural Earth rung above.
|
// like the Natural Earth rung above.
|
||||||
const admin = await findAdminArea(q).catch(() => null);
|
const admin = await findAdminArea(q).catch(() => null);
|
||||||
if (admin) return { name: admin.name, ring: [...admin.ring] };
|
signal?.throwIfAborted();
|
||||||
|
if (admin)
|
||||||
|
return {
|
||||||
|
name: admin.name,
|
||||||
|
ring: [...admin.ring],
|
||||||
|
source: 'bundled-admin',
|
||||||
|
};
|
||||||
const lookup = resolveAdminRegionRing(q, signal, placeSearch);
|
const lookup = resolveAdminRegionRing(q, signal, placeSearch);
|
||||||
if (!Number.isFinite(budgetMs)) return lookup;
|
if (!Number.isFinite(budgetMs)) return lookup;
|
||||||
let timer;
|
let timer;
|
||||||
@@ -2067,8 +2077,26 @@ export function createAnnotationResolver({
|
|||||||
const geo = await geocodePlace(q, null, signal, placeSearch).catch(
|
const geo = await geocodePlace(q, null, signal, placeSearch).catch(
|
||||||
() => null,
|
() => null,
|
||||||
);
|
);
|
||||||
|
signal?.throwIfAborted();
|
||||||
if (!geo) return null;
|
if (!geo) return null;
|
||||||
const scope = scopeFromTypes(geo.types);
|
const scope = scopeFromTypes(geo.types);
|
||||||
|
if (
|
||||||
|
scope === 'auto' ||
|
||||||
|
(scope === 'compound' && geo.types?.includes('natural_feature'))
|
||||||
|
) {
|
||||||
|
const natural = await lookupNaturalRegionOutline(
|
||||||
|
q,
|
||||||
|
geo.lat,
|
||||||
|
geo.lon,
|
||||||
|
).catch(() => null);
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
if (natural)
|
||||||
|
return {
|
||||||
|
name: natural.name,
|
||||||
|
ring: natural.ring,
|
||||||
|
source: 'natural-earth',
|
||||||
|
};
|
||||||
|
}
|
||||||
if (!['country', 'state', 'county', 'city'].includes(scope)) return null;
|
if (!['country', 'state', 'county', 'city'].includes(scope)) return null;
|
||||||
if (scope === 'state' || scope === 'county' || scope === 'country') {
|
if (scope === 'state' || scope === 'county' || scope === 'country') {
|
||||||
const admin = await findAdminAreaAt(
|
const admin = await findAdminAreaAt(
|
||||||
@@ -2077,12 +2105,16 @@ export function createAnnotationResolver({
|
|||||||
geo.lon,
|
geo.lon,
|
||||||
scope,
|
scope,
|
||||||
).catch(() => null);
|
).catch(() => null);
|
||||||
if (admin) return { name: q, ring: [...admin.ring] };
|
signal?.throwIfAborted();
|
||||||
|
if (admin)
|
||||||
|
return { name: q, ring: [...admin.ring], source: 'bundled-admin' };
|
||||||
}
|
}
|
||||||
const fp = await fetchAdminArea(geo.lat, geo.lon, q, scope, signal).catch(
|
const fp = await fetchAdminArea(geo.lat, geo.lon, q, scope, signal).catch(
|
||||||
() => null,
|
() => null,
|
||||||
);
|
);
|
||||||
if (fp?.ring?.length >= 3) return { name: q, ring: fp.ring };
|
signal?.throwIfAborted();
|
||||||
|
if (fp?.ring?.length >= 3)
|
||||||
|
return { name: q, ring: fp.ring, source: 'remote-boundary' };
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -881,6 +881,7 @@ export function createAnalystEngine(providers) {
|
|||||||
followUp && !spec.scope ? lastResult.distanceOf || null : null;
|
followUp && !spec.scope ? lastResult.distanceOf || null : null;
|
||||||
let resolvedScope = null;
|
let resolvedScope = null;
|
||||||
let scopeNote = rememberedScope?.coverage || 'anywhere';
|
let scopeNote = rememberedScope?.coverage || 'anywhere';
|
||||||
|
let scopeSource = rememberedScope?.source || null;
|
||||||
// Human phrasing for the same scope, so every spoken count can name what it
|
// Human phrasing for the same scope, so every spoken count can name what it
|
||||||
// measured ("8 in view", "about 30 within 250 km of Austin") instead of
|
// measured ("8 in view", "about 30 within 250 km of Austin") instead of
|
||||||
// arriving as a bare number that contradicts the panel.
|
// arriving as a bare number that contradicts the panel.
|
||||||
@@ -890,8 +891,16 @@ export function createAnalystEngine(providers) {
|
|||||||
// further when a new scope is given.
|
// further when a new scope is given.
|
||||||
const scope = spec.scope || { kind: followUp ? 'anywhere' : 'view' };
|
const scope = spec.scope || { kind: followUp ? 'anywhere' : 'view' };
|
||||||
if (scope.kind === 'region') {
|
if (scope.kind === 'region') {
|
||||||
const region = await providers.resolveRegionRing(scope.name, signal);
|
let region;
|
||||||
if (!isCurrent()) return cancelled();
|
try {
|
||||||
|
region = await providers.resolveRegionRing(scope.name, signal);
|
||||||
|
} catch (error) {
|
||||||
|
// Cancellation belongs to the query owner, not an error's name: a
|
||||||
|
// resource-local AbortError from a current lookup still propagates.
|
||||||
|
if (signal?.aborted || !isCurrent()) return cancelled();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (signal?.aborted || !isCurrent()) return cancelled();
|
||||||
if (region?.error === 'region-timeout') {
|
if (region?.error === 'region-timeout') {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -910,6 +919,7 @@ export function createAnalystEngine(providers) {
|
|||||||
}
|
}
|
||||||
resolvedScope = region;
|
resolvedScope = region;
|
||||||
scopeNote = `region:${region.name}`;
|
scopeNote = `region:${region.name}`;
|
||||||
|
scopeSource = region.source || null;
|
||||||
scopeLabel = `over ${region.name}`;
|
scopeLabel = `over ${region.name}`;
|
||||||
scopeDetail = scopeLabel;
|
scopeDetail = scopeLabel;
|
||||||
} else if (scope.kind === 'radius') {
|
} else if (scope.kind === 'radius') {
|
||||||
@@ -1049,6 +1059,7 @@ export function createAnalystEngine(providers) {
|
|||||||
layersQueried,
|
layersQueried,
|
||||||
records: totals,
|
records: totals,
|
||||||
scope: scopeNote,
|
scope: scopeNote,
|
||||||
|
...(scopeSource ? { scopeSource } : {}),
|
||||||
...(rememberedScope?.note ? { note: rememberedScope.note } : {}),
|
...(rememberedScope?.note ? { note: rememberedScope.note } : {}),
|
||||||
...(queriedSnapshots.length
|
...(queriedSnapshots.length
|
||||||
? { feedProvenance: feedProvenanceEnvelope(queriedSnapshots) }
|
? { feedProvenance: feedProvenanceEnvelope(queriedSnapshots) }
|
||||||
@@ -1081,6 +1092,7 @@ export function createAnalystEngine(providers) {
|
|||||||
label: memoryOverride?.scopeLabel || result.scopeLabel,
|
label: memoryOverride?.scopeLabel || result.scopeLabel,
|
||||||
detail: memoryOverride?.scopeDetail || result.display.scope,
|
detail: memoryOverride?.scopeDetail || result.display.scope,
|
||||||
coverage: committedCoverage.scope,
|
coverage: committedCoverage.scope,
|
||||||
|
source: committedCoverage.scopeSource || null,
|
||||||
note: committedCoverage.note || null,
|
note: committedCoverage.note || null,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { createAnalystEngine, applyScope, haversineKm } from './analystEngine.js';
|
import { createAnalystEngine, applyScope, haversineKm } from './analystEngine.js';
|
||||||
|
import { createAnnotationResolver } from '../annotations/resolver.js';
|
||||||
|
import { FEATURE_SOURCE_METHODS } from '../sources/featureSource.js';
|
||||||
|
|
||||||
// Stub world: a square "Texland" region, flights + ships + fires around it.
|
// Stub world: a square "Texland" region, flights + ships + fires around it.
|
||||||
const TEXLAND = { name: 'Texland', ring: [[-100, 28], [-94, 28], [-94, 33], [-100, 33]] };
|
const TEXLAND = { name: 'Texland', ring: [[-100, 28], [-94, 28], [-94, 33], [-100, 33]] };
|
||||||
@@ -131,6 +133,175 @@ test('analyst: count flights over a region', async () => {
|
|||||||
assert.ok(r.coverage.scope.includes('Texland'));
|
assert.ok(r.coverage.scope.includes('Texland'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('analyst: region coverage identifies the source of the boundary used for counting', async () => {
|
||||||
|
for (const source of ['natural-earth', 'bundled-admin', 'remote-boundary']) {
|
||||||
|
const engine = createAnalystEngine({
|
||||||
|
getRecords: () => FLIGHTS,
|
||||||
|
resolveRegionRing: async () => ({ ...TEXLAND, source }),
|
||||||
|
});
|
||||||
|
const result = await engine.query({
|
||||||
|
layers: ['flights'],
|
||||||
|
scope: { kind: 'region', name: 'Texland' },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.equal(result.count, 3, 'the boundary excludes the Oregon flight');
|
||||||
|
assert.equal(result.coverage.scope, 'region:Texland');
|
||||||
|
assert.equal(result.coverage.scopeSource, source);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('analyst: filter-only follow-ups retain the region source without resolving again', async () => {
|
||||||
|
const lookups = [];
|
||||||
|
const engine = createAnalystEngine({
|
||||||
|
getRecords: () => FLIGHTS,
|
||||||
|
resolveRegionRing: async (name) => {
|
||||||
|
lookups.push(name);
|
||||||
|
return { ...TEXLAND, source: 'natural-earth' };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const first = await engine.query({
|
||||||
|
layers: ['flights'],
|
||||||
|
scope: { kind: 'region', name: 'Texland' },
|
||||||
|
});
|
||||||
|
const airborne = await engine.query({
|
||||||
|
followUp: true,
|
||||||
|
filters: [{ field: 'onGround', op: 'eq', value: false }],
|
||||||
|
});
|
||||||
|
const military = await engine.query({
|
||||||
|
followUp: true,
|
||||||
|
filters: [{ field: 'military', op: 'eq', value: true }],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(first.count, 3);
|
||||||
|
assert.equal(airborne.count, 2);
|
||||||
|
assert.deepEqual(military.items.map((item) => item.id), ['RCH01']);
|
||||||
|
for (const result of [first, airborne, military]) {
|
||||||
|
assert.equal(result.coverage.scope, 'region:Texland');
|
||||||
|
assert.equal(result.scopeLabel, 'over Texland');
|
||||||
|
assert.equal(result.coverage.scopeSource, 'natural-earth');
|
||||||
|
}
|
||||||
|
assert.deepEqual(lookups, ['Texland']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('analyst: an explicit replacement region replaces retained provenance', async () => {
|
||||||
|
const engine = createAnalystEngine({
|
||||||
|
getRecords: () => FLIGHTS,
|
||||||
|
resolveRegionRing: async (name) => name === 'Texland'
|
||||||
|
? { ...TEXLAND, source: 'natural-earth' }
|
||||||
|
: {
|
||||||
|
name,
|
||||||
|
ring: [[-98, 30.5], [-96, 30.5], [-96, 31.5], [-98, 31.5]],
|
||||||
|
source: 'remote-boundary',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await engine.query({
|
||||||
|
layers: ['flights'],
|
||||||
|
scope: { kind: 'region', name: 'Texland' },
|
||||||
|
});
|
||||||
|
const replacement = await engine.query({
|
||||||
|
followUp: true,
|
||||||
|
scope: { kind: 'region', name: 'Northern Texland' },
|
||||||
|
});
|
||||||
|
const followUp = await engine.query({ followUp: true });
|
||||||
|
|
||||||
|
for (const result of [replacement, followUp]) {
|
||||||
|
assert.deepEqual(result.items.map((item) => item.id), ['RCH01']);
|
||||||
|
assert.equal(result.coverage.scope, 'region:Northern Texland');
|
||||||
|
assert.equal(result.coverage.scopeSource, 'remote-boundary');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('analyst: an explicit scope without provenance clears the retained region source', async () => {
|
||||||
|
for (const scope of [
|
||||||
|
{ kind: 'anywhere' },
|
||||||
|
{ kind: 'view' },
|
||||||
|
{ kind: 'radius', km: 150 },
|
||||||
|
{ kind: 'region', name: 'Uncredited Texland' },
|
||||||
|
]) {
|
||||||
|
const engine = createAnalystEngine({
|
||||||
|
getRecords: () => FLIGHTS,
|
||||||
|
resolveRegionRing: async (name) => ({
|
||||||
|
...TEXLAND,
|
||||||
|
name,
|
||||||
|
...(name === 'Texland' ? { source: 'natural-earth' } : {}),
|
||||||
|
}),
|
||||||
|
getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }),
|
||||||
|
});
|
||||||
|
const first = await engine.query({
|
||||||
|
layers: ['flights'],
|
||||||
|
scope: { kind: 'region', name: 'Texland' },
|
||||||
|
});
|
||||||
|
const replacement = await engine.query({ followUp: true, scope });
|
||||||
|
const followUp = await engine.query({ followUp: true });
|
||||||
|
|
||||||
|
assert.equal(first.coverage.scopeSource, 'natural-earth');
|
||||||
|
for (const result of [replacement, followUp]) {
|
||||||
|
assert.equal(result.ok, true, scope.kind);
|
||||||
|
assert.equal(result.count, 3, scope.kind);
|
||||||
|
assert.equal(Object.hasOwn(result.coverage, 'scopeSource'), false, scope.kind);
|
||||||
|
assert.notEqual(result.coverage.scope, first.coverage.scope, scope.kind);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [label, committedScope, scopeSource] of [
|
||||||
|
['replacement boundary source', 'region:Presented Texland', 'remote-boundary'],
|
||||||
|
['window without a boundary source', 'window:presented', null],
|
||||||
|
]) {
|
||||||
|
test(`analyst: pre-commit displayed cohort retains ${label} and its feed provenance`, async () => {
|
||||||
|
const committedSnapshot = {
|
||||||
|
id: 'flights', enabled: true, feedState: 'stale',
|
||||||
|
source: 'displayed-cohort-feed', lastUpdate: 1_800_000_000_000,
|
||||||
|
};
|
||||||
|
let reads = 0;
|
||||||
|
let lookups = 0;
|
||||||
|
const engine = createAnalystEngine({
|
||||||
|
getRecords: () => { reads++; return FLIGHTS; },
|
||||||
|
getLayerSnapshot: () => ({
|
||||||
|
id: 'flights', enabled: true, feedState: 'nominal', source: 'current-feed',
|
||||||
|
}),
|
||||||
|
resolveRegionRing: async () => {
|
||||||
|
lookups++;
|
||||||
|
return { ...TEXLAND, source: 'natural-earth' };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await engine.query(
|
||||||
|
{ layers: ['flights'], scope: { kind: 'region', name: 'Texland' } },
|
||||||
|
{
|
||||||
|
beforeCommit: async (result) => {
|
||||||
|
assert.equal(result.count, 3);
|
||||||
|
assert.equal(result.coverage.scopeSource, 'natural-earth');
|
||||||
|
const { scopeSource: _originalSource, ...coverage } = result.coverage;
|
||||||
|
return {
|
||||||
|
matched: [{ key: 'flights', rows: [FLIGHTS[1]] }],
|
||||||
|
scopeLabel: 'the presented cohort',
|
||||||
|
scopeDetail: 'the presented cohort window',
|
||||||
|
coverage: {
|
||||||
|
...coverage,
|
||||||
|
scope: committedScope,
|
||||||
|
...(scopeSource ? { scopeSource } : {}),
|
||||||
|
feedProvenance: { layers: [committedSnapshot] },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const followUp = await engine.query({ followUp: true });
|
||||||
|
assert.equal(followUp.count, 1);
|
||||||
|
assert.deepEqual(followUp.items.map((item) => item.id), ['RCH01']);
|
||||||
|
assert.equal(followUp.scopeLabel, 'the presented cohort');
|
||||||
|
assert.equal(followUp.display.scope, 'the presented cohort window');
|
||||||
|
assert.equal(followUp.coverage.scope, committedScope);
|
||||||
|
if (scopeSource) assert.equal(followUp.coverage.scopeSource, scopeSource);
|
||||||
|
else assert.equal(Object.hasOwn(followUp.coverage, 'scopeSource'), false);
|
||||||
|
assert.equal(followUp.coverage.feedProvenance.overall, 'stale');
|
||||||
|
assert.deepEqual(followUp.coverage.feedProvenance.layers, [committedSnapshot]);
|
||||||
|
assert.equal(reads, 1, 'displayed cohort is not replaced by another feed read');
|
||||||
|
assert.equal(lookups, 1, 'follow-up retains the committed scope without resolving again');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
test('analyst: attribute filter — above 40,000 ft (~12,192 m)', async () => {
|
test('analyst: attribute filter — above 40,000 ft (~12,192 m)', async () => {
|
||||||
const r = await makeEngine().query({
|
const r = await makeEngine().query({
|
||||||
layers: ['flights'], scope: { kind: 'anywhere' },
|
layers: ['flights'], scope: { kind: 'anywhere' },
|
||||||
@@ -317,6 +488,138 @@ test('analyst: region lookup receives and obeys the owning action signal', async
|
|||||||
assert.equal(result.cancelled, true);
|
assert.equal(result.cancelled, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (const [label, reason, trackAuthority] of [
|
||||||
|
['default abort reason', undefined, false],
|
||||||
|
['custom error reason', new Error('operator stopped'), false],
|
||||||
|
['primitive reason and revoked authority', 'operator stopped', true],
|
||||||
|
]) {
|
||||||
|
test(`analyst: real region resolver cancellation preserves memory (${label})`, async () => {
|
||||||
|
const started = Promise.withResolvers();
|
||||||
|
const geocode = Promise.withResolvers();
|
||||||
|
const controller = new AbortController();
|
||||||
|
const lookups = [];
|
||||||
|
const { resolveRegionRingForQuery } = createAnnotationResolver({
|
||||||
|
featureSource: Object.fromEntries(FEATURE_SOURCE_METHODS.map((method) => [
|
||||||
|
method,
|
||||||
|
async () => { throw new Error('offline region query reached feature source'); },
|
||||||
|
])),
|
||||||
|
});
|
||||||
|
const placeSearch = {
|
||||||
|
geocode(name, { signal }) {
|
||||||
|
lookups.push(name);
|
||||||
|
assert.equal(signal, controller.signal);
|
||||||
|
started.resolve();
|
||||||
|
return geocode.promise;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const engine = createAnalystEngine({
|
||||||
|
getRecords: () => FLIGHTS,
|
||||||
|
resolveRegionRing: (name, signal) => resolveRegionRingForQuery(
|
||||||
|
name, signal, placeSearch, { budgetMs: Infinity },
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const first = await engine.query({
|
||||||
|
layers: ['flights'], scope: { kind: 'region', name: 'Texas' },
|
||||||
|
});
|
||||||
|
assert.equal(first.ok, true);
|
||||||
|
assert.equal(first.coverage.scopeSource, 'bundled-admin');
|
||||||
|
assert.deepEqual(first.items.map((item) => item.id), ['SWA1', 'RCH01', 'GND1']);
|
||||||
|
let commits = 0;
|
||||||
|
const pending = engine.query(
|
||||||
|
{ layers: ['flights'], scope: { kind: 'region', name: 'Cordillera Oriental' } },
|
||||||
|
{
|
||||||
|
signal: controller.signal,
|
||||||
|
...(trackAuthority ? { isCurrent: () => !controller.signal.aborted } : {}),
|
||||||
|
beforeCommit: async () => { commits++; },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await started.promise;
|
||||||
|
controller.abort(reason);
|
||||||
|
// Let an actual late geocode result cross the resolver's post-await check.
|
||||||
|
geocode.resolve({ place: {
|
||||||
|
lat: 5, lng: -73, label: 'Cordillera Oriental',
|
||||||
|
name: 'Cordillera Oriental', types: ['natural_feature'],
|
||||||
|
} });
|
||||||
|
const result = await pending;
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
assert.equal(result.code, 'CANCELLED');
|
||||||
|
assert.equal(result.cancelled, true);
|
||||||
|
assert.equal(commits, 0);
|
||||||
|
const followUp = await engine.query({ followUp: true });
|
||||||
|
assert.deepEqual(followUp.items.map((item) => item.id), first.items.map((item) => item.id));
|
||||||
|
assert.equal(followUp.coverage.scope, first.coverage.scope);
|
||||||
|
assert.equal(followUp.coverage.scopeSource, 'bundled-admin');
|
||||||
|
assert.equal(followUp.scopeLabel, first.scopeLabel);
|
||||||
|
assert.deepEqual(lookups, ['Cordillera Oriental'], 'follow-up does not resolve again');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('analyst: a rejected superseded region lookup returns cancellation and retains its prior cohort', async () => {
|
||||||
|
const lookup = Promise.withResolvers();
|
||||||
|
let current = true;
|
||||||
|
const engine = createAnalystEngine({
|
||||||
|
getRecords: () => FLIGHTS,
|
||||||
|
resolveRegionRing: (name) => name === 'Texland'
|
||||||
|
? { ...TEXLAND, source: 'natural-earth' }
|
||||||
|
: lookup.promise,
|
||||||
|
});
|
||||||
|
const first = await engine.query({ layers: ['flights'], scope: { kind: 'region', name: 'Texland' } });
|
||||||
|
const pending = engine.query(
|
||||||
|
{ layers: ['flights'], scope: { kind: 'region', name: 'Replacement' } },
|
||||||
|
{ isCurrent: () => current },
|
||||||
|
);
|
||||||
|
current = false;
|
||||||
|
lookup.reject(new Error('late lookup failed'));
|
||||||
|
assert.equal((await pending).code, 'CANCELLED');
|
||||||
|
const followUp = await engine.query({ followUp: true });
|
||||||
|
assert.deepEqual(followUp.items.map((item) => item.id), first.items.map((item) => item.id));
|
||||||
|
assert.equal(followUp.coverage.scopeSource, 'natural-earth');
|
||||||
|
assert.equal(followUp.coverage.scope, first.coverage.scope);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('analyst: an aborted owner cannot commit a region provider that returns a late success', async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const lookup = Promise.withResolvers();
|
||||||
|
const engine = createAnalystEngine({
|
||||||
|
getRecords: () => FLIGHTS,
|
||||||
|
resolveRegionRing: () => lookup.promise,
|
||||||
|
});
|
||||||
|
const pending = engine.query(
|
||||||
|
{ layers: ['flights'], scope: { kind: 'region', name: 'Texland' } },
|
||||||
|
{ signal: controller.signal },
|
||||||
|
);
|
||||||
|
controller.abort();
|
||||||
|
lookup.resolve({ ...TEXLAND, source: 'natural-earth' });
|
||||||
|
assert.equal((await pending).code, 'CANCELLED');
|
||||||
|
assert.equal(engine.hasMemory(), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const error of [new Error('live lookup failed'), new DOMException('resource stopped', 'AbortError')]) {
|
||||||
|
test(`analyst: live region ${error.name} propagates without changing memory`, async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const engine = createAnalystEngine({
|
||||||
|
getRecords: () => FLIGHTS,
|
||||||
|
resolveRegionRing: async (name) => {
|
||||||
|
if (name === 'Texland') return { ...TEXLAND, source: 'natural-earth' };
|
||||||
|
throw error;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const first = await engine.query({ layers: ['flights'], scope: { kind: 'region', name: 'Texland' } });
|
||||||
|
await assert.rejects(
|
||||||
|
engine.query(
|
||||||
|
{ layers: ['flights'], scope: { kind: 'region', name: 'Replacement' } },
|
||||||
|
{ signal: controller.signal },
|
||||||
|
),
|
||||||
|
(thrown) => thrown === error,
|
||||||
|
);
|
||||||
|
assert.equal(controller.signal.aborted, false);
|
||||||
|
const followUp = await engine.query({ followUp: true });
|
||||||
|
assert.deepEqual(followUp.items.map((item) => item.id), first.items.map((item) => item.id));
|
||||||
|
assert.equal(followUp.coverage.scope, first.coverage.scope);
|
||||||
|
assert.equal(followUp.coverage.scopeSource, 'natural-earth');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
test('analyst: route fields queryable from cached enrichment only', async () => {
|
test('analyst: route fields queryable from cached enrichment only', async () => {
|
||||||
const r = await makeEngine().query({
|
const r = await makeEngine().query({
|
||||||
layers: ['flights'], scope: { kind: 'anywhere' },
|
layers: ['flights'], scope: { kind: 'anywhere' },
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ const loadIndex = createRetryableLoader(async () => {
|
|||||||
return index;
|
return index;
|
||||||
});
|
});
|
||||||
|
|
||||||
function toResult(entry) {
|
function toResult(entry, candidates = 1) {
|
||||||
return {
|
return {
|
||||||
name: entry.name,
|
name: entry.name,
|
||||||
featurecla: entry.featurecla,
|
featurecla: entry.featurecla,
|
||||||
@@ -197,6 +197,7 @@ function toResult(entry) {
|
|||||||
polygons: entry.polygons,
|
polygons: entry.polygons,
|
||||||
bboxDiagonalKm: entry.bboxDiagonalKm,
|
bboxDiagonalKm: entry.bboxDiagonalKm,
|
||||||
areaKm2: entry.areaKm2,
|
areaKm2: entry.areaKm2,
|
||||||
|
candidates,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,7 +210,9 @@ function toResult(entry) {
|
|||||||
* @param {string} query e.g. "the Alps", "Rockies", "Gulf of Mexico"
|
* @param {string} query e.g. "the Alps", "Rockies", "Gulf of Mexico"
|
||||||
* @returns {Promise<{name:string, featurecla:string, kind:'natural'|'marine',
|
* @returns {Promise<{name:string, featurecla:string, kind:'natural'|'marine',
|
||||||
* polygons:Array<Array<[number,number]>>, bboxDiagonalKm:number,
|
* polygons:Array<Array<[number,number]>>, bboxDiagonalKm:number,
|
||||||
* areaKm2:number}|null>} largest-area match, or null when nothing matches.
|
* areaKm2:number, candidates:number}|null>} largest-area match plus the
|
||||||
|
* number of same-name entries, or null when nothing matches. Uniqueness in
|
||||||
|
* this pack alone does not establish the user's intended place.
|
||||||
*/
|
*/
|
||||||
export async function findNaturalRegion(query) {
|
export async function findNaturalRegion(query) {
|
||||||
const norm = normalizeName(query);
|
const norm = normalizeName(query);
|
||||||
@@ -223,7 +226,7 @@ export async function findNaturalRegion(query) {
|
|||||||
for (const key of candidates) {
|
for (const key of candidates) {
|
||||||
if (!key) continue;
|
if (!key) continue;
|
||||||
const list = index.get(key);
|
const list = index.get(key);
|
||||||
if (list && list.length) return toResult(list[0]);
|
if (list && list.length) return toResult(list[0], list.length);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -270,9 +273,10 @@ export function pointInRing(ring, lat, lon) {
|
|||||||
/**
|
/**
|
||||||
* Resolve a natural-region OUTLINE ring for the annotation resolver's first
|
* Resolve a natural-region OUTLINE ring for the annotation resolver's first
|
||||||
* rung. Stricter than `findNaturalRegion`: walks ALL entries sharing the
|
* rung. Stricter than `findNaturalRegion`: walks ALL entries sharing the
|
||||||
* matched name (duplicate names included — the US and Spanish "Sierra
|
* matched name (duplicate names included), trying exact names, aliases and
|
||||||
* Nevada" both exist upstream) and returns the single ring that CONTAINS the
|
* broader suffix variants in that order. Returns a ring only when exactly one
|
||||||
* geocoded anchor. Containment is simultaneously the disambiguator and the
|
* entry under the matched name CONTAINS the anchor. Containment is the
|
||||||
|
* disambiguator and the
|
||||||
* wrong-place guard: when no ring contains the anchor this returns null and
|
* wrong-place guard: when no ring contains the anchor this returns null and
|
||||||
* the resolver's normal ladder continues unchanged.
|
* the resolver's normal ladder continues unchanged.
|
||||||
*
|
*
|
||||||
@@ -291,23 +295,26 @@ export async function lookupNaturalRegionOutline(query, lat, lon) {
|
|||||||
ALIASES[norm],
|
ALIASES[norm],
|
||||||
...suffixVariants(ALIASES[norm] || norm),
|
...suffixVariants(ALIASES[norm] || norm),
|
||||||
];
|
];
|
||||||
const seen = new Set();
|
for (const key of new Set(candidates.filter(Boolean))) {
|
||||||
for (const key of candidates) {
|
const containing = [];
|
||||||
if (!key || seen.has(key)) continue;
|
|
||||||
seen.add(key);
|
|
||||||
for (const entry of index.get(key) || []) {
|
for (const entry of index.get(key) || []) {
|
||||||
for (const ring of entry.polygons) {
|
const ring = entry.polygons.find((candidate) =>
|
||||||
if (pointInRing(ring, lat, lon)) {
|
pointInRing(candidate, lat, lon),
|
||||||
return {
|
);
|
||||||
name: entry.name,
|
if (ring) containing.push({ entry, ring });
|
||||||
kind: entry.kind,
|
|
||||||
featurecla: entry.featurecla,
|
|
||||||
ring,
|
|
||||||
areaKm2: entry.areaKm2,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
// Specific names win over aliases and broader rewrites. Ambiguity belongs
|
||||||
|
// to this name, not the union of every lower-priority candidate name.
|
||||||
|
if (containing.length > 1) return null;
|
||||||
|
if (containing.length === 0) continue;
|
||||||
|
const [{ entry, ring }] = containing;
|
||||||
|
return {
|
||||||
|
name: entry.name,
|
||||||
|
kind: entry.kind,
|
||||||
|
featurecla: entry.featurecla,
|
||||||
|
ring,
|
||||||
|
areaKm2: entry.areaKm2,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,10 @@ test('marquee ranges resolve with sane areas (owner acceptance: Alps + Rockies)'
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('aliases and articles: "the Alps", "Rockies", "Sahara Desert", "Himalaya"', async () => {
|
test('aliases and articles: "the Alps", "Rockies", "Sahara Desert", "Himalaya"', async () => {
|
||||||
assert.equal((await findNaturalRegion('the Alps'))?.name, 'Alps');
|
const alps = await findNaturalRegion('the Alps');
|
||||||
|
assert.equal(alps?.name, 'Alps');
|
||||||
|
assert.equal(alps?.candidates, 1);
|
||||||
|
assert.equal((await findNaturalRegion('Alps mountains'))?.name, 'Alps');
|
||||||
assert.equal((await findNaturalRegion('Rockies'))?.name, 'Rocky Mountains');
|
assert.equal((await findNaturalRegion('Rockies'))?.name, 'Rocky Mountains');
|
||||||
assert.equal((await findNaturalRegion('the Rockies'))?.name, 'Rocky Mountains');
|
assert.equal((await findNaturalRegion('the Rockies'))?.name, 'Rocky Mountains');
|
||||||
assert.equal((await findNaturalRegion('Sahara Desert'))?.name, 'Sahara');
|
assert.equal((await findNaturalRegion('Sahara Desert'))?.name, 'Sahara');
|
||||||
@@ -41,6 +44,11 @@ test('aliases and articles: "the Alps", "Rockies", "Sahara Desert", "Himalaya"',
|
|||||||
assert.equal((await findNaturalRegion(' THE ALPS '))?.name, 'Alps');
|
assert.equal((await findNaturalRegion(' THE ALPS '))?.name, 'Alps');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('duplicate natural-region names expose ambiguity instead of claiming uniqueness', async () => {
|
||||||
|
assert.ok((await findNaturalRegion('Cordillera Oriental'))?.candidates > 1);
|
||||||
|
assert.ok((await findNaturalRegion('Canadian Shield'))?.candidates > 1);
|
||||||
|
});
|
||||||
|
|
||||||
test('major deserts/ranges resolve with sane areas', async () => {
|
test('major deserts/ranges resolve with sane areas', async () => {
|
||||||
const sahara = await findNaturalRegion('Sahara');
|
const sahara = await findNaturalRegion('Sahara');
|
||||||
assert.ok(sahara && sahara.areaKm2 > 8_000_000 && sahara.areaKm2 < 12_000_000,
|
assert.ok(sahara && sahara.areaKm2 > 8_000_000 && sahara.areaKm2 < 12_000_000,
|
||||||
@@ -122,24 +130,55 @@ test('listRegions() enumerates both kinds for diagnostics', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── lookupNaturalRegionOutline (resolver first-rung contract) ──────
|
// ── lookupNaturalRegionOutline (resolver first-rung contract) ──────
|
||||||
test('outline lookup: Alps ring via containment anchor', async () => {
|
test('outline lookup: containing exact names win over broader suffix variants', async () => {
|
||||||
const r = await lookupNaturalRegionOutline('the Alps', 46.5, 10.0);
|
const r = await lookupNaturalRegionOutline('the Alps', 46.5, 10.0);
|
||||||
assert.ok(r, 'Alps must resolve with an inside anchor');
|
assert.ok(r, 'Alps must resolve with an inside anchor');
|
||||||
assert.equal(r.name, 'Alps');
|
assert.equal(r.name, 'Alps');
|
||||||
assert.ok(r.ring.length >= 8, 'range-scale ring');
|
assert.ok(r.ring.length >= 8, 'range-scale ring');
|
||||||
assert.ok(r.areaKm2 > 100000 && r.areaKm2 < 400000, `sane Alps area, got ${r.areaKm2}`);
|
assert.ok(r.areaKm2 > 100000 && r.areaKm2 < 400000, `sane Alps area, got ${r.areaKm2}`);
|
||||||
|
const range = await lookupNaturalRegionOutline('Alaska Range', 63.14, -148.71);
|
||||||
|
assert.equal(range?.name, 'Alaska Range');
|
||||||
|
assert.ok(pointInRing(range.ring, 63.14, -148.71));
|
||||||
|
assert.equal((await lookupNaturalRegionOutline('Alaska', 63.14, -148.71))?.name, 'Alaska');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('outline lookup: duplicate names disambiguate by anchor containment', async () => {
|
test('outline lookup: duplicate names disambiguate by anchor containment', async () => {
|
||||||
const us = await lookupNaturalRegionOutline('Sierra Nevada', 37.2, -119.0);
|
const us = await lookupNaturalRegionOutline('Sierra Nevada', 37.2, -119.0);
|
||||||
assert.ok(us, 'US anchor must match a Sierra Nevada');
|
assert.ok(us, 'US anchor must match a Sierra Nevada');
|
||||||
assert.ok(pointInRing(us.ring, 37.2, -119.0), 'returned ring contains the US anchor');
|
assert.ok(pointInRing(us.ring, 37.2, -119.0), 'returned ring contains the US anchor');
|
||||||
|
const colombia = await lookupNaturalRegionOutline('Cordillera Oriental', 5, -73);
|
||||||
|
assert.ok(colombia, 'a single containing candidate resolves a duplicate name');
|
||||||
|
assert.ok(pointInRing(colombia.ring, 5, -73));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('outline lookup: overlapping duplicate candidates remain unresolved', async () => {
|
||||||
|
assert.equal(await lookupNaturalRegionOutline('Canadian Shield', 45.6336, -74.86493), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('outline lookup: anchor outside every ring → null (wrong-place guard)', async () => {
|
test('outline lookup: anchor outside every ring → null (wrong-place guard)', async () => {
|
||||||
assert.equal(await lookupNaturalRegionOutline('the Alps', 30.26, -97.77), null);
|
assert.equal(await lookupNaturalRegionOutline('the Alps', 30.26, -97.77), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('outline lookup: a unique physical-region name does not override an outside contemporary place', async () => {
|
||||||
|
for (const [name, lat, lon] of [
|
||||||
|
['Gold Coast', -28, 153.4],
|
||||||
|
['Santa Cruz', 37, -122],
|
||||||
|
['Isabela', 17, 122],
|
||||||
|
['India', 28, 95], // Northeast India lies outside the physical peninsula.
|
||||||
|
['Korea', 33.5, 126.5], // Jeju lies outside the physical peninsula.
|
||||||
|
]) {
|
||||||
|
assert.equal((await findNaturalRegion(name))?.candidates, 1, `${name} has a unique physical namesake`);
|
||||||
|
assert.equal(await lookupNaturalRegionOutline(name, lat, lon), null, `${name} requires containment`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('outline lookup: aliases retain containment checks', async () => {
|
||||||
|
const alps = await lookupNaturalRegionOutline('Alps mountains', 46.5, 10);
|
||||||
|
assert.equal(alps?.name, 'Alps');
|
||||||
|
assert.ok(pointInRing(alps.ring, 46.5, 10));
|
||||||
|
assert.equal(await lookupNaturalRegionOutline('Alps mountains', -28, 153.4), null);
|
||||||
|
});
|
||||||
|
|
||||||
test('outline lookup: non-region names never match', async () => {
|
test('outline lookup: non-region names never match', async () => {
|
||||||
assert.equal(await lookupNaturalRegionOutline('Zilker Park', 30.26, -97.77), null);
|
assert.equal(await lookupNaturalRegionOutline('Zilker Park', 30.26, -97.77), null);
|
||||||
assert.equal(await lookupNaturalRegionOutline('Texas', 31.0, -99.0), null);
|
assert.equal(await lookupNaturalRegionOutline('Texas', 31.0, -99.0), null);
|
||||||
|
|||||||
@@ -152,6 +152,124 @@ test('regionFramingPlan: E-W elongated region looks along the east axis', () =>
|
|||||||
assert.equal(plan.headingDeg, 90);
|
assert.equal(plan.headingDeg, 90);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Compatibility guard for inherited geocode authority, not a newly fixed bug.
|
||||||
|
test('search retains main geocode authority for Gold Coast, Australia', async () => {
|
||||||
|
const viewer = stubViewer();
|
||||||
|
const result = await runSearch(viewer, {}, {
|
||||||
|
query: 'Gold Coast',
|
||||||
|
result: {
|
||||||
|
formatted_address: 'Gold Coast, Queensland, Australia',
|
||||||
|
types: ['locality', 'political'],
|
||||||
|
geometry: {
|
||||||
|
location: { lat: -28, lng: 153.4 },
|
||||||
|
viewport: boxOf(-28.2, 153.2, -27.8, 153.6),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(result.label, 'Gold Coast, Queensland, Australia');
|
||||||
|
assert.equal(result.navigationMode, 'city-overview');
|
||||||
|
assert.equal(viewer.flights.length, 1);
|
||||||
|
const flown = flownRectangleDegrees(viewer);
|
||||||
|
assert.ok(Math.abs((flown.south + flown.north) / 2 + 28) < 0.000001);
|
||||||
|
assert.ok(Math.abs((flown.west + flown.east) / 2 - 153.4) < 0.000001);
|
||||||
|
});
|
||||||
|
|
||||||
|
function alpsViewer() {
|
||||||
|
const viewer = stubViewer();
|
||||||
|
Object.assign(viewer.camera.positionCartographic, {
|
||||||
|
latitude: Cesium.Math.toRadians(46.5),
|
||||||
|
longitude: Cesium.Math.toRadians(10),
|
||||||
|
height: 248_000,
|
||||||
|
});
|
||||||
|
return viewer;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a successful nearby recovery resolves after a geocoder miss', async () => {
|
||||||
|
const viewer = alpsViewer();
|
||||||
|
const result = await searchAndFlyTo(viewer, 'the Alps', {
|
||||||
|
placeSearch: { async geocode() { return { place: null, answered: true }; } },
|
||||||
|
recoverNearView: async () => ({
|
||||||
|
lat: 46.6,
|
||||||
|
lon: 10.1,
|
||||||
|
label: 'The Alps local landmark',
|
||||||
|
types: ['locality', 'political'],
|
||||||
|
viewport: { low: { latitude: 46.5, longitude: 10 }, high: { latitude: 46.7, longitude: 10.2 } },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(result.label, 'The Alps local landmark');
|
||||||
|
assert.equal(result.navigationMode, 'city-overview');
|
||||||
|
assert.equal(viewer.flights.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a geocoder miss returns null even over a namesake region', async () => {
|
||||||
|
for (const answered of [true, false]) {
|
||||||
|
const viewer = alpsViewer();
|
||||||
|
const result = await searchAndFlyTo(viewer, 'the Alps', {
|
||||||
|
placeSearch: { async geocode() { return { place: null, answered }; } },
|
||||||
|
recoverNearView: async () => null,
|
||||||
|
});
|
||||||
|
assert.equal(result, null);
|
||||||
|
assert.equal(viewer.flights.length, 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a geocoder rejection propagates without nearby recovery or a flight', async () => {
|
||||||
|
const viewer = alpsViewer();
|
||||||
|
const failure = new Error('geocoder failed');
|
||||||
|
let recoveries = 0;
|
||||||
|
await assert.rejects(searchAndFlyTo(viewer, 'the Alps', {
|
||||||
|
placeSearch: { async geocode() { throw failure; } },
|
||||||
|
recoverNearView: async () => { recoveries += 1; return null; },
|
||||||
|
}), (error) => error === failure);
|
||||||
|
assert.equal(recoveries, 0);
|
||||||
|
assert.equal(viewer.flights.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('search cancellation before or during geocoding cannot issue a flight', async () => {
|
||||||
|
for (const preAborted of [true, false]) {
|
||||||
|
const viewer = alpsViewer();
|
||||||
|
const controller = new AbortController();
|
||||||
|
if (preAborted) controller.abort();
|
||||||
|
let geocodes = 0;
|
||||||
|
let recoveries = 0;
|
||||||
|
await assert.rejects(searchAndFlyTo(viewer, 'the Alps', {
|
||||||
|
signal: controller.signal,
|
||||||
|
placeSearch: {
|
||||||
|
async geocode(_query, { signal }) {
|
||||||
|
geocodes += 1;
|
||||||
|
assert.equal(signal, controller.signal);
|
||||||
|
controller.abort();
|
||||||
|
return { place: null, answered: true };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
recoverNearView: async () => { recoveries += 1; return null; },
|
||||||
|
}), { name: 'AbortError' });
|
||||||
|
assert.equal(geocodes, preAborted ? 0 : 1);
|
||||||
|
assert.equal(recoveries, 0);
|
||||||
|
assert.equal(viewer.flights.length, 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cancellation during nearby recovery cannot issue a flight', async () => {
|
||||||
|
const viewer = alpsViewer();
|
||||||
|
const controller = new AbortController();
|
||||||
|
let authorityChecks = 0;
|
||||||
|
await assert.rejects(searchAndFlyTo(viewer, 'the Alps', {
|
||||||
|
signal: controller.signal,
|
||||||
|
placeSearch: { async geocode() { return { place: null, answered: true }; } },
|
||||||
|
recoverNearView: async () => {
|
||||||
|
controller.abort();
|
||||||
|
return {
|
||||||
|
lat: 46.6, lon: 10.1, label: 'Alpine landmark',
|
||||||
|
types: ['locality'],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
beforeFly: () => { authorityChecks += 1; return true; },
|
||||||
|
}), { name: 'AbortError' });
|
||||||
|
assert.equal(authorityChecks, 0);
|
||||||
|
assert.equal(viewer.flights.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
test('regionFramingPlan: park- and lake-scale viewports keep full framing', () => {
|
test('regionFramingPlan: park- and lake-scale viewports keep full framing', () => {
|
||||||
// Zilker Park (~3 km).
|
// Zilker Park (~3 km).
|
||||||
assert.equal(regionFramingPlan({
|
assert.equal(regionFramingPlan({
|
||||||
|
|||||||
Reference in new issue
Block a user