mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
Merge feat/voice-geometry onto main
Both stack parents (#837, #842) are on main, so the voice geometry branch now builds on main directly. Conflict resolutions keep main's behaviour and add the geometry work on top: osm_query gating on every provider, restored admin identity for Nominatim outlines, merged analyst follow-up memory, and the cancelled Globe View reset instruction ahead of the generic cancellation.
This commit is contained in:
commit
ecaddd838b
102 files changed
+12925
-1006
No files matched your search
@@ -796,6 +796,35 @@ export async function findAdminArea(query, { near = null } = {}) {
|
||||
return toResult(ranked[0], candidates.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every prominent unit an ask's words could mean — countries, and states or
|
||||
* provinces that would answer a bare name (see `stateCandidates`) — for a
|
||||
* caller that asks the user which one ("Georgia", "Punjab") instead of
|
||||
* letting the camera decide. Qualifiers narrow the list as in
|
||||
* `findAdminArea`; minor units ("Kent", "Santa Barbara") are left out.
|
||||
*
|
||||
* @param {string} query
|
||||
* @returns {Promise<AdminArea[]>} Largest first; empty when none.
|
||||
*/
|
||||
export async function findAdminCandidates(query) {
|
||||
const parsed = parseAdminQuery(query);
|
||||
if (!parsed?.name || parsed.kind === 'county') return [];
|
||||
const prominent = ({ entry }) =>
|
||||
entry.kind === 'country' ||
|
||||
(entry.feature.rank ?? Infinity) <= BARE_NAME_MAX_RANK ||
|
||||
Boolean(entry.feature.cityState);
|
||||
const candidates = [
|
||||
...(await countryCandidates(parsed)),
|
||||
...(parsed.kind === 'country'
|
||||
? []
|
||||
: await stateCandidates(parsed, { allowAmbiguous: true })),
|
||||
].filter(prominent);
|
||||
const seen = new Set();
|
||||
return rank(candidates, null)
|
||||
.filter(({ entry }) => !seen.has(entry) && seen.add(entry))
|
||||
.map((scored) => toResult(scored, 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
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
decodeRing,
|
||||
findAdminArea,
|
||||
findAdminAreaAt,
|
||||
findAdminCandidates,
|
||||
normalizeAdminName,
|
||||
packLoads,
|
||||
parseAdminQuery,
|
||||
@@ -247,6 +248,7 @@ test('geocoder-confirmed lookup: the named unit that contains the point', async
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.deepEqual(await findAdminCandidates('Bagmati Province'), []);
|
||||
// A county the geocoder typed, named without the county word.
|
||||
const travis = await findAdminAreaAt(
|
||||
['Travis', 'Travis County'],
|
||||
|
||||
+197
-46
@@ -18,7 +18,11 @@
|
||||
*
|
||||
* Providers (injected — keeps the engine pure and node-testable):
|
||||
* getRecords(layerKey) → Array<record> (layer accessor snapshot)
|
||||
* resolveRegionRing(name, signal?) → Promise<{ring, name}|{error:'region-timeout'}|null>
|
||||
* resolveRegionRing(name, {signal}?) → Promise<{name, geometry|ring, prepared?, source?}
|
||||
* |{error:'region-timeout'}|{needsClarification, candidates}|null>
|
||||
* (geometry = MultiPolygon coordinates; islands and holes all count)
|
||||
* resolveAreaScope(scope) → Promise<{ok:true, area:{areaId, name, prepared,
|
||||
* source, approximate}}|{ok:false, code, error}> (area/drawn/annotation)
|
||||
* getViewContext() → {lat, lon, viewRadiusKm, bounds?} (camera-derived)
|
||||
*
|
||||
* @module data/analystEngine
|
||||
@@ -26,6 +30,7 @@
|
||||
|
||||
import { feedProvenanceEnvelope } from './layerSnapshot.js';
|
||||
import { pointInRing } from './naturalEarthRegions.js';
|
||||
import { pointInPreparedArea, prepareArea } from './areaGeometry.js';
|
||||
import { VOICE_LAYER_MANIFEST, voiceLayer } from '../voice/layerManifest.js';
|
||||
import { greatCircleKm } from '../geoDistance.js';
|
||||
|
||||
@@ -67,6 +72,9 @@ const ANALYST_SCOPE_KINDS = Object.freeze([
|
||||
'region',
|
||||
'radius',
|
||||
'anywhere',
|
||||
'area',
|
||||
'drawn',
|
||||
'annotation',
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -104,9 +112,14 @@ export function haversineKm(lat1, lon1, lat2, lon2) {
|
||||
return greatCircleKm({ lat: lat1, lon: lon1 }, { lat: lat2, lon: lon2 });
|
||||
}
|
||||
|
||||
/** Scope records spatially. scope: {kind:'view'|'region'|'radius'|'anywhere', …}. */
|
||||
/** Scope records spatially. scope: {kind:'view'|'region'|'radius'|'anywhere'|'area', …}. */
|
||||
export function applyScope(records, scope, resolved) {
|
||||
if (!scope || scope.kind === 'anywhere') return records;
|
||||
if (resolved?.prepared) {
|
||||
return records.filter((r) =>
|
||||
pointInPreparedArea(resolved.prepared, r.lat, r.lon),
|
||||
);
|
||||
}
|
||||
if (scope.kind === 'region' && resolved?.ring) {
|
||||
return records.filter(
|
||||
(r) =>
|
||||
@@ -320,6 +333,24 @@ function normalizeSpec(spec, layerKeys) {
|
||||
return {
|
||||
refusal: refusal('BAD_SCOPE', 'A region scope needs a place name.'),
|
||||
};
|
||||
if (
|
||||
kind === 'area' &&
|
||||
(typeof scope.areaId !== 'string' || !scope.areaId.trim())
|
||||
)
|
||||
return {
|
||||
refusal: refusal(
|
||||
'BAD_SCOPE',
|
||||
'An area scope needs the areaId resolve_area returned.',
|
||||
),
|
||||
};
|
||||
if (
|
||||
kind === 'annotation' &&
|
||||
scope.id !== undefined &&
|
||||
(typeof scope.id !== 'string' || !scope.id.trim())
|
||||
)
|
||||
return {
|
||||
refusal: refusal('BAD_SCOPE', 'scope.id must be an annotation id.'),
|
||||
};
|
||||
if (scope.center !== undefined && scope.center !== null) {
|
||||
if (!validLat(scope.center.lat) || !validLon(scope.center.lon))
|
||||
return {
|
||||
@@ -650,9 +681,8 @@ export function createAnalystEngine(providers) {
|
||||
/**
|
||||
* Run one query.
|
||||
* @param {object} spec Query arguments (see the analyst_query tool).
|
||||
* @param {{isCurrent?: () => boolean, signal?: AbortSignal,
|
||||
* beforeCommit?: (result: object) => Promise<object|void>}} [options]
|
||||
* A query that is no longer current
|
||||
* @param {{signal?: AbortSignal, isCurrent?: () => boolean, beforeCommit?: (result: object) =>
|
||||
* Promise<object|void>}} [options] A query that is no longer current
|
||||
* (cancelled or superseded while awaiting a boundary or between scan
|
||||
* slices) returns CANCELLED and never replaces the follow-up memory.
|
||||
* `beforeCommit` lets a caller finish required async projection work and
|
||||
@@ -662,7 +692,7 @@ export function createAnalystEngine(providers) {
|
||||
*/
|
||||
async function query(
|
||||
spec = {},
|
||||
{ isCurrent = () => true, signal = undefined, beforeCommit = null } = {},
|
||||
{ signal = null, isCurrent = () => true, beforeCommit = null } = {},
|
||||
) {
|
||||
const cancelled = () =>
|
||||
refusal('CANCELLED', 'This query was superseded.', { cancelled: true });
|
||||
@@ -673,6 +703,7 @@ export function createAnalystEngine(providers) {
|
||||
{ coverage: { layersQueried: [], scope: 'none' } },
|
||||
);
|
||||
const followUp = Boolean(spec.followUp);
|
||||
const inheritsRememberedScope = followUp && spec.scope == null;
|
||||
// A follow-up re-filters the remembered set. Layers named explicitly
|
||||
// must be that set: "those hospitals" after an aircraft answer is a new
|
||||
// question, never a filter over the old aircraft.
|
||||
@@ -815,25 +846,37 @@ export function createAnalystEngine(providers) {
|
||||
}
|
||||
|
||||
// 2) Spatial scope
|
||||
const rememberedScope =
|
||||
followUp && !spec.scope ? lastResult.scopePresentation || null : null;
|
||||
const rememberedDistanceOf =
|
||||
followUp && !spec.scope ? lastResult.distanceOf || null : null;
|
||||
const rememberedPresentation = inheritsRememberedScope
|
||||
? lastResult.presentation || null
|
||||
: null;
|
||||
const rememberedCoverageNote =
|
||||
rememberedPresentation?.coverageNote ||
|
||||
(inheritsRememberedScope ? lastResult.coverage?.note : null);
|
||||
const rememberedDistanceOf = inheritsRememberedScope
|
||||
? lastResult.distanceOf || null
|
||||
: null;
|
||||
let resolvedScope = null;
|
||||
let scopeNote = rememberedScope?.coverage || 'anywhere';
|
||||
let scopeSource = rememberedScope?.source || null;
|
||||
let scopeNote =
|
||||
rememberedPresentation?.coverageScope ||
|
||||
(inheritsRememberedScope ? lastResult.coverage?.scope : null) ||
|
||||
'anywhere';
|
||||
let scopeSource =
|
||||
rememberedPresentation?.coverageScopeSource ||
|
||||
(inheritsRememberedScope ? lastResult.coverage?.scopeSource : null) ||
|
||||
null;
|
||||
// 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
|
||||
// arriving as a bare number that contradicts the panel.
|
||||
let scopeLabel = rememberedScope?.label || 'anywhere in the loaded data';
|
||||
let scopeDetail = rememberedScope?.detail || scopeLabel;
|
||||
let scopeLabel =
|
||||
rememberedPresentation?.scopeLabel || 'anywhere in the loaded data';
|
||||
let scopeDetail = rememberedPresentation?.display?.scope || scopeLabel;
|
||||
// A follow-up re-filters a set that was already scoped, so it only narrows
|
||||
// further when a new scope is given.
|
||||
const scope = spec.scope || { kind: followUp ? 'anywhere' : 'view' };
|
||||
if (scope.kind === 'region') {
|
||||
let region;
|
||||
try {
|
||||
region = await providers.resolveRegionRing(scope.name, signal);
|
||||
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.
|
||||
@@ -849,7 +892,30 @@ export function createAnalystEngine(providers) {
|
||||
coverage: { layersQueried, scope: `region:${scope.name}:timeout` },
|
||||
};
|
||||
}
|
||||
if (!region?.ring) {
|
||||
if (region?.needsClarification) {
|
||||
return refusal(
|
||||
'NEEDS_CLARIFICATION',
|
||||
region.error ||
|
||||
`"${scope.name}" names more than one place — ask which one.`,
|
||||
{
|
||||
needsClarification: true,
|
||||
query: scope.name,
|
||||
candidates: region.candidates || [],
|
||||
coverage: {
|
||||
layersQueried,
|
||||
scope: `region:${scope.name}:ambiguous`,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
const prepared =
|
||||
region?.prepared ||
|
||||
(region?.geometry
|
||||
? prepareArea(region.geometry)
|
||||
: region?.ring
|
||||
? prepareArea([[region.ring]])
|
||||
: null);
|
||||
if (!prepared) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'REGION_UNRESOLVED',
|
||||
@@ -857,11 +923,39 @@ export function createAnalystEngine(providers) {
|
||||
coverage: { layersQueried, scope: `region:${scope.name}:unresolved` },
|
||||
};
|
||||
}
|
||||
resolvedScope = region;
|
||||
resolvedScope = { ...region, prepared };
|
||||
scopeNote = `region:${region.name}`;
|
||||
scopeSource = region.source || null;
|
||||
scopeLabel = `over ${region.name}`;
|
||||
scopeDetail = scopeLabel;
|
||||
const regionSource = region.sourceLabel || region.source;
|
||||
scopeDetail = `${scopeLabel}${region.approximate ? ' (approximate outline)' : ''}${regionSource ? ` · ${regionSource}` : ''}`;
|
||||
} else if (
|
||||
scope.kind === 'area' ||
|
||||
scope.kind === 'drawn' ||
|
||||
scope.kind === 'annotation'
|
||||
) {
|
||||
const found = providers.resolveAreaScope
|
||||
? await providers.resolveAreaScope(scope)
|
||||
: {
|
||||
ok: false,
|
||||
code: 'AREA_UNAVAILABLE',
|
||||
error: 'Area scopes are not available here.',
|
||||
};
|
||||
if (!isCurrent()) return cancelled();
|
||||
if (!found?.ok || !found.area?.prepared) {
|
||||
return refusal(
|
||||
found?.code || 'AREA_UNKNOWN',
|
||||
found?.error || 'That area is not available.',
|
||||
{ coverage: { layersQueried, scope: `${scope.kind}:unresolved` } },
|
||||
);
|
||||
}
|
||||
const area = found.area;
|
||||
resolvedScope = { prepared: area.prepared, name: area.name };
|
||||
scopeNote = `area:${area.areaId || scope.kind}`;
|
||||
scopeLabel = `inside ${area.name}`;
|
||||
const sourceLabel = area.sourceLabel || area.source;
|
||||
scopeDetail = `${scopeLabel}${area.approximate ? ' (approximate outline)' : ''}${sourceLabel ? ` · ${sourceLabel}` : ''}`;
|
||||
if (area.areaId) resolvedScope.areaId = area.areaId;
|
||||
} else if (scope.kind === 'radius') {
|
||||
// An explicit center always wins. Otherwise, when Contacts is active its
|
||||
// SUBJECT is the centre the operator is actually reasoning about: the
|
||||
@@ -906,25 +1000,21 @@ export function createAnalystEngine(providers) {
|
||||
scopeLabel = 'in view';
|
||||
scopeDetail = `within ${Math.round(view.viewRadiusKm)} km of the view centre`;
|
||||
}
|
||||
const inScope =
|
||||
scope.kind === 'region' && resolvedScope?.ring
|
||||
const inScope = resolvedScope?.prepared
|
||||
? (row) => pointInPreparedArea(resolvedScope.prepared, row.lat, row.lon)
|
||||
: (scope.kind === 'radius' || scope.kind === 'view') &&
|
||||
resolvedScope?.center &&
|
||||
Number.isFinite(resolvedScope.km)
|
||||
? (row) =>
|
||||
Number.isFinite(row.lat) &&
|
||||
Number.isFinite(row.lon) &&
|
||||
pointInRing(resolvedScope.ring, row.lat, row.lon)
|
||||
: (scope.kind === 'radius' || scope.kind === 'view') &&
|
||||
resolvedScope?.center &&
|
||||
Number.isFinite(resolvedScope.km)
|
||||
? (row) =>
|
||||
Number.isFinite(row.lat) &&
|
||||
Number.isFinite(row.lon) &&
|
||||
haversineKm(
|
||||
resolvedScope.center.lat,
|
||||
resolvedScope.center.lon,
|
||||
row.lat,
|
||||
row.lon,
|
||||
) <= resolvedScope.km
|
||||
: null;
|
||||
haversineKm(
|
||||
resolvedScope.center.lat,
|
||||
resolvedScope.center.lon,
|
||||
row.lat,
|
||||
row.lon,
|
||||
) <= resolvedScope.km
|
||||
: null;
|
||||
|
||||
// 3) Filter, count and rank in one pass, yielding between slices.
|
||||
const selection = await selectAnalystRows(groups, {
|
||||
@@ -992,15 +1082,25 @@ export function createAnalystEngine(providers) {
|
||||
// For on-screen text; the model speaks the count and scopeLabel only.
|
||||
display: {
|
||||
scope: scopeDetail,
|
||||
...(windows.length ? { window: windows.join('; ') } : {}),
|
||||
...(caveats.length ? { caveat: caveats.join('; ') } : {}),
|
||||
...(rememberedPresentation?.display?.window || windows.length
|
||||
? {
|
||||
window:
|
||||
rememberedPresentation?.display?.window || windows.join('; '),
|
||||
}
|
||||
: {}),
|
||||
...(rememberedPresentation?.display?.caveat || caveats.length
|
||||
? {
|
||||
caveat:
|
||||
rememberedPresentation?.display?.caveat || caveats.join('; '),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
coverage: {
|
||||
layersQueried,
|
||||
records: totals,
|
||||
scope: scopeNote,
|
||||
...(scopeSource ? { scopeSource } : {}),
|
||||
...(rememberedScope?.note ? { note: rememberedScope.note } : {}),
|
||||
...(rememberedCoverageNote ? { note: rememberedCoverageNote } : {}),
|
||||
...(queriedSnapshots.length
|
||||
? { feedProvenance: feedProvenanceEnvelope(queriedSnapshots) }
|
||||
: {}),
|
||||
@@ -1010,37 +1110,88 @@ export function createAnalystEngine(providers) {
|
||||
// than implying a view-centred answer.
|
||||
...(resolvedScope?.centeredOn
|
||||
? { centeredOn: resolvedScope.centeredOn }
|
||||
: rememberedPresentation?.centeredOn
|
||||
? { centeredOn: rememberedPresentation.centeredOn }
|
||||
: {}),
|
||||
...(rememberedPresentation?.window
|
||||
? { window: structuredClone(rememberedPresentation.window) }
|
||||
: {}),
|
||||
...(resolvedScope?.areaId
|
||||
? { areaId: resolvedScope.areaId }
|
||||
: rememberedPresentation?.areaId
|
||||
? { areaId: rememberedPresentation.areaId }
|
||||
: {}),
|
||||
};
|
||||
const memoryOverride =
|
||||
typeof beforeCommit === 'function'
|
||||
? (await beforeCommit(result)) || null
|
||||
: null;
|
||||
if (!isCurrent()) return cancelled();
|
||||
const committedCoverage = memoryOverride?.coverage || result.coverage;
|
||||
lastResult = {
|
||||
matched: memoryOverride?.matched || selection.matched,
|
||||
distanceOf: memoryOverride?.distanceOf || rememberedDistanceOf,
|
||||
layerKeys: [...(memoryOverride?.layerKeys || layers)],
|
||||
coverage: committedCoverage,
|
||||
coverage: memoryOverride?.coverage || result.coverage,
|
||||
unanswered: [
|
||||
...(memoryOverride?.unanswered === undefined
|
||||
? unanswered
|
||||
: memoryOverride.unanswered),
|
||||
],
|
||||
scopePresentation: {
|
||||
label: memoryOverride?.scopeLabel || result.scopeLabel,
|
||||
detail: memoryOverride?.scopeDetail || result.display.scope,
|
||||
coverage: committedCoverage.scope,
|
||||
source: committedCoverage.scopeSource || null,
|
||||
note: committedCoverage.note || null,
|
||||
},
|
||||
presentation: structuredClone(
|
||||
memoryOverride?.presentation || {
|
||||
scopeLabel: result.scopeLabel,
|
||||
display: result.display,
|
||||
coverageScope: result.coverage?.scope,
|
||||
...(result.coverage?.scopeSource
|
||||
? { coverageScopeSource: result.coverage.scopeSource }
|
||||
: {}),
|
||||
...(result.coverage?.note
|
||||
? { coverageNote: result.coverage.note }
|
||||
: {}),
|
||||
...(result.centeredOn ? { centeredOn: result.centeredOn } : {}),
|
||||
...(result.window ? { window: result.window } : {}),
|
||||
...(result.areaId ? { areaId: result.areaId } : {}),
|
||||
},
|
||||
),
|
||||
};
|
||||
return result;
|
||||
}
|
||||
|
||||
return {
|
||||
query,
|
||||
/**
|
||||
* Make another tool's result set "the last answer" (osm_query's places),
|
||||
* so a follow-up filters exactly what the user just heard about.
|
||||
* @param {string} layerKey Queryable layer the rows belong to.
|
||||
* @param {Array<object>} rows Records in that layer's analyst shape.
|
||||
* @param {{scope?: string}} [options]
|
||||
*/
|
||||
remember(layerKey, rows, { scope = 'handoff' } = {}) {
|
||||
if (!ANALYST_LAYERS[layerKey] || !Array.isArray(rows)) return false;
|
||||
lastResult = {
|
||||
matched: [{ key: layerKey, rows: rows.slice() }],
|
||||
layerKeys: [layerKey],
|
||||
coverage: {
|
||||
layersQueried: [
|
||||
{
|
||||
layerKey,
|
||||
status: rows.length ? 'ok' : 'empty',
|
||||
returned: rows.length,
|
||||
total: rows.length,
|
||||
truncated: false,
|
||||
},
|
||||
],
|
||||
records: {
|
||||
returned: rows.length,
|
||||
total: rows.length,
|
||||
truncated: false,
|
||||
},
|
||||
scope,
|
||||
followUp: false,
|
||||
},
|
||||
};
|
||||
return true;
|
||||
},
|
||||
reset() {
|
||||
lastResult = null;
|
||||
},
|
||||
|
||||
@@ -275,14 +275,18 @@ for (const [label, committedScope, scopeSource] of [
|
||||
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] },
|
||||
},
|
||||
presentation: {
|
||||
scopeLabel: 'the presented cohort',
|
||||
display: { scope: 'the presented cohort window' },
|
||||
coverageScope: committedScope,
|
||||
...(scopeSource ? { coverageScopeSource: scopeSource } : {}),
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
@@ -436,6 +440,87 @@ test('analyst: failed pre-commit work cannot replace follow-up memory', async ()
|
||||
assert.deepEqual(followUp.items.map((item) => item.id), ['SWA1']);
|
||||
});
|
||||
|
||||
test('analyst: follow-ups preserve partial and unanswered coverage', async () => {
|
||||
const eng = createAnalystEngine({
|
||||
getRecords: (key) => (key === 'flights' ? FLIGHTS : []),
|
||||
getLayerSnapshot: (key) => ({
|
||||
enabled: key === 'flights',
|
||||
feedState: key === 'flights' ? 'nominal' : 'off',
|
||||
}),
|
||||
getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }),
|
||||
});
|
||||
const first = await eng.query({
|
||||
layers: ['flights', 'military'],
|
||||
scope: { kind: 'anywhere' },
|
||||
});
|
||||
assert.equal(first.partial, true);
|
||||
assert.deepEqual(first.unanswered, ['military']);
|
||||
|
||||
const followUp = await eng.query({ followUp: true, limit: 1 });
|
||||
assert.equal(followUp.partial, true);
|
||||
assert.deepEqual(followUp.unanswered, ['military']);
|
||||
assert.deepEqual(
|
||||
followUp.coverage.layersQueried.map(({ layerKey, status }) => [
|
||||
layerKey,
|
||||
status,
|
||||
]),
|
||||
first.coverage.layersQueried.map(({ layerKey, status }) => [
|
||||
layerKey,
|
||||
status,
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test('analyst: cancelled pre-commit work cannot replace follow-up memory', async () => {
|
||||
const eng = makeEngine();
|
||||
await eng.query({
|
||||
layers: ['flights'],
|
||||
scope: { kind: 'anywhere' },
|
||||
filters: [{ field: 'id', op: 'eq', value: 'SWA1' }],
|
||||
});
|
||||
let current = true;
|
||||
const stale = await eng.query(
|
||||
{ layers: ['local-firms'], scope: { kind: 'anywhere' } },
|
||||
{
|
||||
isCurrent: () => current,
|
||||
beforeCommit: async () => {
|
||||
current = false;
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.equal(stale.code, 'CANCELLED');
|
||||
const followUp = await eng.query({ followUp: true });
|
||||
assert.deepEqual(
|
||||
followUp.items.map((item) => item.id),
|
||||
['SWA1'],
|
||||
);
|
||||
});
|
||||
|
||||
test('analyst: failed pre-commit work cannot replace follow-up memory', async () => {
|
||||
const eng = makeEngine();
|
||||
await eng.query({
|
||||
layers: ['flights'],
|
||||
scope: { kind: 'anywhere' },
|
||||
filters: [{ field: 'id', op: 'eq', value: 'SWA1' }],
|
||||
});
|
||||
await assert.rejects(
|
||||
eng.query(
|
||||
{ layers: ['local-firms'], scope: { kind: 'anywhere' } },
|
||||
{
|
||||
beforeCommit: async () => {
|
||||
throw new Error('projection failed');
|
||||
},
|
||||
},
|
||||
),
|
||||
/projection failed/,
|
||||
);
|
||||
const followUp = await eng.query({ followUp: true });
|
||||
assert.deepEqual(
|
||||
followUp.items.map((item) => item.id),
|
||||
['SWA1'],
|
||||
);
|
||||
});
|
||||
|
||||
test('analyst: unresolved region is an honest failure, not empty success', async () => {
|
||||
const r = await makeEngine().query({ layers: ['flights'], scope: { kind: 'region', name: 'Atlantis' } });
|
||||
assert.equal(r.ok, false);
|
||||
@@ -460,7 +545,7 @@ test('analyst: region lookup receives and obeys the owning action signal', async
|
||||
let receivedSignal = null;
|
||||
const eng = createAnalystEngine({
|
||||
getRecords: () => FLIGHTS,
|
||||
resolveRegionRing: async (_name, signal) => {
|
||||
resolveRegionRing: async (_name, { signal }) => {
|
||||
receivedSignal = signal;
|
||||
await new Promise((resolve) =>
|
||||
signal.addEventListener('abort', resolve, { once: true }),
|
||||
@@ -514,7 +599,7 @@ for (const [label, reason, trackAuthority] of [
|
||||
};
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: () => FLIGHTS,
|
||||
resolveRegionRing: (name, signal) => resolveRegionRingForQuery(
|
||||
resolveRegionRing: (name, { signal } = {}) => resolveRegionRingForQuery(
|
||||
name, signal, placeSearch, { budgetMs: Infinity },
|
||||
),
|
||||
});
|
||||
@@ -720,7 +805,9 @@ test('unknown fields, operators, scopes and wrong-typed values are refused with
|
||||
assert.equal(num.code, 'BAD_VALUE');
|
||||
const scope = await engine.query({ layers: ['flights'], scope: { kind: 'polygon' } });
|
||||
assert.equal(scope.code, 'UNKNOWN_SCOPE');
|
||||
assert.deepEqual(scope.allowed, ['view', 'region', 'radius', 'anywhere']);
|
||||
assert.deepEqual(scope.allowed, ['view', 'region', 'radius', 'anywhere', 'area', 'drawn', 'annotation']);
|
||||
const noId = await engine.query({ layers: ['flights'], scope: { kind: 'area' } });
|
||||
assert.equal(noId.code, 'BAD_SCOPE', 'an area scope needs its areaId');
|
||||
const sort = await engine.query({ layers: ['flights'], sortBy: 'loudness' });
|
||||
assert.equal(sort.code, 'UNKNOWN_FIELD');
|
||||
assert.equal(engine.hasMemory(), false, 'refusals never become follow-up memory');
|
||||
@@ -963,3 +1050,129 @@ test('analyst: a follow-up naming other layers than the last answer is refused',
|
||||
assert.equal(r.code, 'FOLLOW_UP_MISMATCH');
|
||||
assert.equal(engine.hasMemory(), true, 'the earlier answer is still the last answer');
|
||||
});
|
||||
|
||||
// ── Area handles: region multipolygons and the area / drawn / annotation scopes ──
|
||||
|
||||
const box = (w, s, e, n) => [[w, s], [e, s], [e, n], [w, n], [w, s]];
|
||||
|
||||
function makeAreaEngine(overrides = {}) {
|
||||
return createAnalystEngine({
|
||||
getRecords: (key) => ({ flights: FLIGHTS, 'local-firms': FIRES }[key] || []),
|
||||
resolveRegionRing: async () => null,
|
||||
getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }),
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
test('analyst: a region counts every part of a multipolygon and skips its holes', async () => {
|
||||
// Main part around Austin with a hole over the airport ground traffic, and a
|
||||
// separate island over Oregon: the old largest-ring shortcut dropped N123.
|
||||
const engine = makeAreaEngine({
|
||||
resolveRegionRing: async () => ({
|
||||
name: 'Archipelago',
|
||||
geometry: [
|
||||
[box(-100, 28, -94, 33), box(-97.7, 30.1, -97.6, 30.25)],
|
||||
[box(-123, 44, -121, 46)],
|
||||
],
|
||||
source: 'osm',
|
||||
}),
|
||||
});
|
||||
const r = await engine.query({ layers: ['flights'], scope: { kind: 'region', name: 'Archipelago' }, limit: 10 });
|
||||
assert.equal(r.ok, true);
|
||||
assert.deepEqual(r.items.map((i) => i.id).sort(), ['N123', 'RCH01']);
|
||||
assert.equal(r.count, 2, 'SWA1 and GND1 sit in the hole');
|
||||
assert.match(r.display.scope, /over Archipelago · osm/);
|
||||
});
|
||||
|
||||
test('analyst: an ambiguous region asks instead of counting', async () => {
|
||||
const engine = makeAreaEngine({
|
||||
resolveRegionRing: async () => ({
|
||||
ok: false,
|
||||
needsClarification: true,
|
||||
candidates: [{ candidateId: 'osm:r1', name: 'Georgia', level: 'country' }],
|
||||
error: '"Georgia" matches 2 places — ask which one.',
|
||||
}),
|
||||
});
|
||||
const r = await engine.query({ layers: ['flights'], scope: { kind: 'region', name: 'Georgia' } });
|
||||
assert.equal(r.ok, false);
|
||||
assert.equal(r.code, 'NEEDS_CLARIFICATION');
|
||||
assert.equal(r.candidates.length, 1);
|
||||
assert.equal(engine.hasMemory(), false);
|
||||
});
|
||||
|
||||
test('analyst: region lookup receives the caller lifetime signal', async () => {
|
||||
const controller = new AbortController();
|
||||
let received;
|
||||
const engine = makeAreaEngine({
|
||||
resolveRegionRing: async (_name, options) => {
|
||||
received = options?.signal;
|
||||
return { error: 'region-timeout' };
|
||||
},
|
||||
});
|
||||
const result = await engine.query(
|
||||
{
|
||||
layers: ['flights'],
|
||||
scope: { kind: 'region', name: 'Signal Region' },
|
||||
},
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
assert.equal(received, controller.signal);
|
||||
assert.equal(result.code, 'region-timeout');
|
||||
});
|
||||
|
||||
test('analyst: an area scope counts inside the stored handle and names it', async () => {
|
||||
const { prepareArea } = await import('./areaGeometry.js');
|
||||
const prepared = prepareArea([[box(-99, 30, -97.5, 31)]]);
|
||||
const seen = [];
|
||||
const engine = makeAreaEngine({
|
||||
resolveAreaScope: async (scope) => {
|
||||
seen.push(scope);
|
||||
if (scope.kind === 'area' && scope.areaId === 'osm:r42')
|
||||
return { ok: true, area: { areaId: 'osm:r42', name: 'Hill Country', prepared, source: 'osm' } };
|
||||
if (scope.kind === 'drawn')
|
||||
return { ok: true, area: { areaId: 'anno:anno-7', name: 'the drawn area', prepared, source: 'drawn' } };
|
||||
return { ok: false, code: 'AREA_UNKNOWN', error: 'No such area' };
|
||||
},
|
||||
});
|
||||
const fires = await engine.query({ layers: ['local-firms'], scope: { kind: 'area', areaId: 'osm:r42' } });
|
||||
assert.equal(fires.ok, true);
|
||||
assert.equal(fires.count, 2);
|
||||
assert.equal(fires.scopeLabel, 'inside Hill Country');
|
||||
assert.equal(fires.areaId, 'osm:r42');
|
||||
assert.equal(fires.coverage.scope, 'area:osm:r42');
|
||||
const followUp = await engine.query({ followUp: true, limit: 1 });
|
||||
assert.equal(followUp.scopeLabel, 'inside Hill Country');
|
||||
assert.match(followUp.display.scope, /inside Hill Country/);
|
||||
assert.equal(followUp.coverage.scope, 'area:osm:r42');
|
||||
assert.equal(followUp.areaId, 'osm:r42');
|
||||
const drawn = await engine.query({ layers: ['flights'], scope: { kind: 'drawn' } });
|
||||
assert.equal(drawn.ok, true);
|
||||
assert.equal(drawn.count, 2, 'SWA1 and GND1 sit inside the drawn box');
|
||||
const missing = await engine.query({ layers: ['flights'], scope: { kind: 'area', areaId: 'osm:r0' } });
|
||||
assert.equal(missing.ok, false);
|
||||
assert.equal(missing.code, 'AREA_UNKNOWN');
|
||||
assert.deepEqual(seen.map((s) => s.kind), ['area', 'drawn', 'area']);
|
||||
});
|
||||
|
||||
test('analyst: an area scope superseded while resolving is cancelled and leaves no memory', async () => {
|
||||
const { prepareArea } = await import('./areaGeometry.js');
|
||||
let current = true;
|
||||
const engine = makeAreaEngine({
|
||||
resolveAreaScope: async () => {
|
||||
current = false; // a newer turn arrived while the area was looked up
|
||||
return { ok: true, area: { name: 'X', prepared: prepareArea([[box(-100, 28, -94, 33)]]) } };
|
||||
},
|
||||
});
|
||||
const r = await engine.query(
|
||||
{ layers: ['flights'], scope: { kind: 'annotation' } },
|
||||
{ isCurrent: () => current },
|
||||
);
|
||||
assert.equal(r.code, 'CANCELLED');
|
||||
assert.equal(engine.hasMemory(), false);
|
||||
});
|
||||
|
||||
test('analyst: without an area provider the area scopes refuse plainly', async () => {
|
||||
const r = await makeAreaEngine().query({ layers: ['flights'], scope: { kind: 'drawn' } });
|
||||
assert.equal(r.ok, false);
|
||||
assert.equal(r.code, 'AREA_UNAVAILABLE');
|
||||
});
|
||||
@@ -0,0 +1,397 @@
|
||||
/**
|
||||
* Area geometry for voice area handles: normalization, containment, size and
|
||||
* display simplification over full MultiPolygons (islands and holes kept).
|
||||
*
|
||||
* Coordinates follow GeoJSON: a MultiPolygon is `Array<Polygon>`, a Polygon is
|
||||
* `Array<Ring>` with the outer ring first and holes after it, a Ring is
|
||||
* `Array<[lon, lat]>`.
|
||||
*
|
||||
* Counting uses the geometry exactly as resolved. Display simplification
|
||||
* (`displayParts`) makes a separate copy for drawing and never feeds back into
|
||||
* containment.
|
||||
*
|
||||
* Antimeridian: a ring whose consecutive longitudes jump by more than 180° is
|
||||
* unwrapped into one continuous span (e.g. 170..190) when the jumps cancel
|
||||
* out. Containment then tests the point at lon, lon + 360 and lon − 360. A ring
|
||||
* that circles a pole (jumps that do not cancel) is used as given.
|
||||
*
|
||||
* Pure: no Cesium, DOM or network.
|
||||
* @module data/areaGeometry
|
||||
*/
|
||||
|
||||
const EARTH_RADIUS_KM = 6371;
|
||||
const toRad = (degrees) => (degrees * Math.PI) / 180;
|
||||
|
||||
const validPair = (p) =>
|
||||
Array.isArray(p) &&
|
||||
Number.isFinite(p[0]) &&
|
||||
Number.isFinite(p[1]) &&
|
||||
Math.abs(p[0]) <= 540 &&
|
||||
Math.abs(p[1]) <= 90;
|
||||
|
||||
const samePoint = (a, b, eps = 1e-9) =>
|
||||
Math.abs(a[0] - b[0]) <= eps && Math.abs(a[1] - b[1]) <= eps;
|
||||
|
||||
/** A ring as closed [lon, lat] pairs, or null with fewer than 3 distinct points. */
|
||||
function cleanRing(ring) {
|
||||
if (!Array.isArray(ring)) return null;
|
||||
const out = [];
|
||||
for (const p of ring) {
|
||||
if (!validPair(p)) continue;
|
||||
const pair = [Number(p[0]), Number(p[1])];
|
||||
if (out.length && samePoint(out[out.length - 1], pair)) continue;
|
||||
out.push(pair);
|
||||
}
|
||||
if (out.length && samePoint(out[0], out[out.length - 1])) out.pop();
|
||||
if (out.length < 3) return null;
|
||||
out.push([out[0][0], out[0][1]]);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* GeoJSON Polygon/MultiPolygon geometry (or bare coordinates) → MultiPolygon
|
||||
* coordinates with closed rings; null when nothing usable remains. Degenerate
|
||||
* holes are dropped; a polygon whose outer ring is degenerate is dropped whole.
|
||||
* @param {object|Array} geometry
|
||||
* @returns {Array|null}
|
||||
*/
|
||||
export function normalizeMultiPolygon(geometry) {
|
||||
let polygons;
|
||||
if (Array.isArray(geometry)) {
|
||||
// Bare coordinates: a ring, a polygon or a multipolygon, by nesting depth.
|
||||
const depth = (value) => (Array.isArray(value) ? 1 + depth(value[0]) : 0);
|
||||
const d = depth(geometry);
|
||||
polygons = d === 2 ? [[geometry]] : d === 3 ? [geometry] : geometry;
|
||||
} else if (geometry?.type === 'Polygon') polygons = [geometry.coordinates];
|
||||
else if (geometry?.type === 'MultiPolygon') polygons = geometry.coordinates;
|
||||
else return null;
|
||||
const out = [];
|
||||
for (const polygon of Array.isArray(polygons) ? polygons : []) {
|
||||
if (!Array.isArray(polygon)) continue;
|
||||
const outer = cleanRing(polygon[0]);
|
||||
if (!outer) continue;
|
||||
const holes = polygon.slice(1).map(cleanRing).filter(Boolean);
|
||||
out.push([outer, ...holes]);
|
||||
}
|
||||
return out.length ? out : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Longitudes made continuous across the antimeridian. Returns the ring as
|
||||
* given when it has no jump, or when its jumps do not cancel (a ring that
|
||||
* circles a pole).
|
||||
*/
|
||||
export function unwrapRing(ring) {
|
||||
let shift = 0;
|
||||
let jumped = false;
|
||||
const out = [[ring[0][0], ring[0][1]]];
|
||||
for (let i = 1; i < ring.length; i += 1) {
|
||||
const delta = ring[i][0] - ring[i - 1][0];
|
||||
if (delta > 180) {
|
||||
shift -= 360;
|
||||
jumped = true;
|
||||
} else if (delta < -180) {
|
||||
shift += 360;
|
||||
jumped = true;
|
||||
}
|
||||
out.push([ring[i][0] + shift, ring[i][1]]);
|
||||
}
|
||||
if (!jumped) return ring;
|
||||
return shift === 0 ? out : ring;
|
||||
}
|
||||
|
||||
function ringBbox(ring) {
|
||||
let w = Infinity;
|
||||
let s = Infinity;
|
||||
let e = -Infinity;
|
||||
let n = -Infinity;
|
||||
for (const [lon, lat] of ring) {
|
||||
if (lon < w) w = lon;
|
||||
if (lon > e) e = lon;
|
||||
if (lat < s) s = lat;
|
||||
if (lat > n) n = lat;
|
||||
}
|
||||
return [w, s, e, n];
|
||||
}
|
||||
|
||||
/** Even-odd ray cast. */
|
||||
function pointInRingXY(ring, lon, lat) {
|
||||
let inside = false;
|
||||
for (let i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) {
|
||||
const xi = ring[i][0];
|
||||
const yi = ring[i][1];
|
||||
const xj = ring[j][0];
|
||||
const yj = ring[j][1];
|
||||
if (
|
||||
yi > lat !== yj > lat &&
|
||||
lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi
|
||||
)
|
||||
inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
const wrap180 = (lon) => ((((lon + 180) % 360) + 360) % 360) - 180;
|
||||
const wrap360 = (lon) => {
|
||||
const wrapped = lon % 360;
|
||||
return wrapped < 0 ? wrapped + 360 : wrapped;
|
||||
};
|
||||
|
||||
/** The shortest circular longitude interval containing every outer-ring vertex. */
|
||||
function longitudeEnvelope(parts) {
|
||||
const values = parts
|
||||
.flatMap(({ outer }) => outer.map(([lon]) => wrap360(lon)))
|
||||
.sort((a, b) => a - b);
|
||||
if (values.length === 0) return null;
|
||||
let gapIndex = values.length - 1;
|
||||
let largestGap = values[0] + 360 - values.at(-1);
|
||||
for (let i = 0; i < values.length - 1; i += 1) {
|
||||
const gap = values[i + 1] - values[i];
|
||||
if (gap > largestGap) {
|
||||
largestGap = gap;
|
||||
gapIndex = i;
|
||||
}
|
||||
}
|
||||
const start = values[(gapIndex + 1) % values.length];
|
||||
let end = values[gapIndex];
|
||||
if (end < start) end += 360;
|
||||
const span = end - start;
|
||||
if (span >= 360 - 1e-9) return { west: -180, east: 180, crosses: false };
|
||||
const west = start > 180 ? start - 360 : start;
|
||||
const endOnCircle = end >= 360 ? end - 360 : end;
|
||||
const east = endOnCircle > 180 ? endOnCircle - 360 : endOnCircle;
|
||||
return { west, east, crosses: west > east };
|
||||
}
|
||||
|
||||
/**
|
||||
* Index a MultiPolygon for repeated containment tests: unwrapped rings with
|
||||
* per-part bounding boxes, plus the overall bbox as [west, south, east, north]
|
||||
* in −180..180 (west > east when it crosses the antimeridian).
|
||||
* @param {Array} coordinates MultiPolygon coordinates.
|
||||
* @returns {{parts: Array, bbox: number[], vertexCount: number, crossesAntimeridian: boolean}|null}
|
||||
*/
|
||||
export function prepareArea(coordinates) {
|
||||
const multi = normalizeMultiPolygon(coordinates);
|
||||
if (!multi) return null;
|
||||
let vertexCount = 0;
|
||||
let crosses = false;
|
||||
const parts = multi.map((polygon) => {
|
||||
const outer = unwrapRing(polygon[0]);
|
||||
if (outer !== polygon[0]) crosses = true;
|
||||
const holes = polygon.slice(1).map((ring) => {
|
||||
let hole = unwrapRing(ring);
|
||||
// Keep a hole in the same longitude frame as its outer ring.
|
||||
const hb = ringBbox(hole);
|
||||
const ob = ringBbox(outer);
|
||||
const mid = (hb[0] + hb[2]) / 2;
|
||||
if (mid < ob[0]) hole = hole.map(([x, y]) => [x + 360, y]);
|
||||
else if (mid > ob[2]) hole = hole.map(([x, y]) => [x - 360, y]);
|
||||
return hole;
|
||||
});
|
||||
for (const ring of polygon) vertexCount += ring.length;
|
||||
return { outer, holes, bbox: ringBbox(outer) };
|
||||
});
|
||||
let south = Infinity;
|
||||
let north = -Infinity;
|
||||
for (const { bbox } of parts) {
|
||||
south = Math.min(south, bbox[1]);
|
||||
north = Math.max(north, bbox[3]);
|
||||
}
|
||||
const envelope = longitudeEnvelope(parts);
|
||||
const bbox = [envelope.west, south, envelope.east, north];
|
||||
crosses ||= envelope.crosses;
|
||||
return { parts, bbox, vertexCount, crossesAntimeridian: crosses };
|
||||
}
|
||||
|
||||
function inPart(part, lon, lat) {
|
||||
const [w, s, e, n] = part.bbox;
|
||||
if (lat < s || lat > n || lon < w || lon > e) return false;
|
||||
if (!pointInRingXY(part.outer, lon, lat)) return false;
|
||||
for (const hole of part.holes)
|
||||
if (pointInRingXY(hole, lon, lat)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a point lies inside the area: inside some part's outer ring and in
|
||||
* none of that part's holes. Points exactly on an edge follow the ray-cast
|
||||
* convention and may land either side.
|
||||
* @param {object} prepared From prepareArea.
|
||||
* @param {number} lat
|
||||
* @param {number} lon
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function pointInPreparedArea(prepared, lat, lon) {
|
||||
if (!prepared || !Number.isFinite(lat) || !Number.isFinite(lon)) return false;
|
||||
for (const part of prepared.parts) {
|
||||
if (inPart(part, lon, lat)) return true;
|
||||
if (inPart(part, lon + 360, lat)) return true;
|
||||
if (inPart(part, lon - 360, lat)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Spherical-excess ring area in km² (unsigned). */
|
||||
export function ringAreaKm2(ring) {
|
||||
const n = ring.length;
|
||||
if (n < 3) return 0;
|
||||
let sum = 0;
|
||||
for (let i = 0; i < n - 1; i += 1) {
|
||||
const [lon1, lat1] = ring[i];
|
||||
const [lon2, lat2] = ring[i + 1];
|
||||
sum +=
|
||||
toRad(lon2 - lon1) * (2 + Math.sin(toRad(lat1)) + Math.sin(toRad(lat2)));
|
||||
}
|
||||
return Math.abs((sum * EARTH_RADIUS_KM * EARTH_RADIUS_KM) / 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Area of a MultiPolygon in km², holes subtracted.
|
||||
* @param {Array} coordinates
|
||||
* @returns {number}
|
||||
*/
|
||||
export function multiPolygonAreaKm2(coordinates) {
|
||||
const multi = normalizeMultiPolygon(coordinates);
|
||||
if (!multi) return 0;
|
||||
let total = 0;
|
||||
for (const polygon of multi) {
|
||||
total += ringAreaKm2(unwrapRing(polygon[0]));
|
||||
for (const hole of polygon.slice(1)) total -= ringAreaKm2(unwrapRing(hole));
|
||||
}
|
||||
return Math.max(0, total);
|
||||
}
|
||||
|
||||
/** Counts of parts and holes. */
|
||||
export function multiPolygonCounts(coordinates) {
|
||||
const multi = normalizeMultiPolygon(coordinates) || [];
|
||||
return {
|
||||
parts: multi.length,
|
||||
holes: multi.reduce((sum, polygon) => sum + polygon.length - 1, 0),
|
||||
};
|
||||
}
|
||||
|
||||
/** Mean of the largest part's outer ring vertices, wrapped to −180..180. */
|
||||
export function areaAnchor(coordinates) {
|
||||
const multi = normalizeMultiPolygon(coordinates);
|
||||
if (!multi) return null;
|
||||
let best = null;
|
||||
let bestArea = -1;
|
||||
for (const polygon of multi) {
|
||||
const ring = unwrapRing(polygon[0]);
|
||||
const area = ringAreaKm2(ring);
|
||||
if (area > bestArea) {
|
||||
bestArea = area;
|
||||
best = ring;
|
||||
}
|
||||
}
|
||||
let lon = 0;
|
||||
let lat = 0;
|
||||
const pts = best.slice(0, -1);
|
||||
for (const [x, y] of pts) {
|
||||
lon += x;
|
||||
lat += y;
|
||||
}
|
||||
return { lon: wrap180(lon / pts.length), lat: lat / pts.length };
|
||||
}
|
||||
|
||||
// ── Display copies ────────────────────────────────────────────
|
||||
|
||||
function dpRing(ring, tol) {
|
||||
const n = ring.length;
|
||||
if (n <= 5) return ring;
|
||||
const keep = new Uint8Array(n);
|
||||
keep[0] = 1;
|
||||
keep[n - 1] = 1;
|
||||
const stack = [[0, n - 1]];
|
||||
while (stack.length) {
|
||||
const [a, b] = stack.pop();
|
||||
if (b - a < 2) continue;
|
||||
const [ax, ay] = ring[a];
|
||||
const vx = ring[b][0] - ax;
|
||||
const vy = ring[b][1] - ay;
|
||||
const c2 = vx * vx + vy * vy;
|
||||
let worst = -1;
|
||||
let worstD = tol;
|
||||
for (let i = a + 1; i < b; i += 1) {
|
||||
const wx = ring[i][0] - ax;
|
||||
const wy = ring[i][1] - ay;
|
||||
const t =
|
||||
c2 === 0 ? 0 : Math.max(0, Math.min(1, (vx * wx + vy * wy) / c2));
|
||||
const d = Math.hypot(wx - t * vx, wy - t * vy);
|
||||
if (d > worstD) {
|
||||
worstD = d;
|
||||
worst = i;
|
||||
}
|
||||
}
|
||||
if (worst >= 0) {
|
||||
keep[worst] = 1;
|
||||
stack.push([a, worst], [worst, b]);
|
||||
}
|
||||
}
|
||||
const out = [];
|
||||
for (let i = 0; i < n; i += 1) if (keep[i]) out.push(ring[i]);
|
||||
return out.length >= 4 ? out : ring;
|
||||
}
|
||||
|
||||
/**
|
||||
* A simplified copy of the area for drawing only: the largest `maxParts`
|
||||
* parts, each ring Douglas-Peucker simplified until the whole copy fits in
|
||||
* `maxVertices`. Rings keep their antimeridian-unwrapped longitudes so a
|
||||
* crossing part draws as one shape.
|
||||
* @param {Array} coordinates MultiPolygon coordinates.
|
||||
* @param {{maxVertices?: number, maxParts?: number}} [options]
|
||||
* @returns {{parts: Array<{outer: Array, holes: Array}>, drawnParts: number, totalParts: number}}
|
||||
*/
|
||||
export function displayParts(
|
||||
coordinates,
|
||||
{ maxVertices = 6000, maxParts = 40 } = {},
|
||||
) {
|
||||
const multi = normalizeMultiPolygon(coordinates) || [];
|
||||
const ranked = multi
|
||||
.map((polygon) => ({
|
||||
rings: polygon.map((ring) => unwrapRing(ring)),
|
||||
area: ringAreaKm2(unwrapRing(polygon[0])),
|
||||
}))
|
||||
.sort((a, b) => b.area - a.area)
|
||||
.slice(0, maxParts);
|
||||
const bbox = ranked.length ? ringBbox(ranked[0].rings[0]) : [0, 0, 0, 0];
|
||||
let tol = Math.max(
|
||||
1e-6,
|
||||
Math.max(bbox[2] - bbox[0], bbox[3] - bbox[1]) / 1500,
|
||||
);
|
||||
let parts = [];
|
||||
for (let attempt = 0; attempt < 12; attempt += 1) {
|
||||
parts = ranked.map(({ rings }) => ({
|
||||
outer: dpRing(rings[0], tol),
|
||||
holes: rings.slice(1).map((ring) => dpRing(ring, tol)),
|
||||
}));
|
||||
const total = parts.reduce(
|
||||
(sum, p) =>
|
||||
sum + p.outer.length + p.holes.reduce((h, r) => h + r.length, 0),
|
||||
0,
|
||||
);
|
||||
if (total <= maxVertices) break;
|
||||
tol *= 2;
|
||||
}
|
||||
return { parts, drawnParts: parts.length, totalParts: multi.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* A circle as a closed ring (planar metres around the centre — accurate for
|
||||
* the few-kilometre buffers it is used for).
|
||||
* @returns {Array<[number, number]>}
|
||||
*/
|
||||
export function circleRing(lat, lon, radiusM, segments = 48) {
|
||||
const mPerDegLat = 111_320;
|
||||
const mPerDegLon = mPerDegLat * Math.max(0.01, Math.cos(toRad(lat)));
|
||||
const ring = [];
|
||||
for (let i = 0; i < segments; i += 1) {
|
||||
const a = (i / segments) * Math.PI * 2;
|
||||
ring.push([
|
||||
lon + (Math.cos(a) * radiusM) / mPerDegLon,
|
||||
lat + (Math.sin(a) * radiusM) / mPerDegLat,
|
||||
]);
|
||||
}
|
||||
ring.push([ring[0][0], ring[0][1]]);
|
||||
return ring;
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
// Area geometry: containment over full MultiPolygons (islands, holes, the
|
||||
// antimeridian), OSM relation assembly and display copies that never touch the
|
||||
// counting geometry.
|
||||
//
|
||||
// Run with: npm test
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
circleRing,
|
||||
displayParts,
|
||||
multiPolygonAreaKm2,
|
||||
multiPolygonCounts,
|
||||
normalizeMultiPolygon,
|
||||
pointInPreparedArea,
|
||||
prepareArea,
|
||||
} from './areaGeometry.js';
|
||||
|
||||
const square = (w, s, e, n) => [
|
||||
[w, s],
|
||||
[e, s],
|
||||
[e, n],
|
||||
[w, n],
|
||||
[w, s],
|
||||
];
|
||||
|
||||
test('a point on an island counts, not only on the largest part', () => {
|
||||
// Mainland 0..10, island 20..22: the old largest-ring shortcut dropped the island.
|
||||
const area = prepareArea([[square(0, 0, 10, 10)], [square(20, 0, 22, 2)]]);
|
||||
assert.equal(pointInPreparedArea(area, 5, 5), true);
|
||||
assert.equal(pointInPreparedArea(area, 1, 21), true);
|
||||
assert.equal(pointInPreparedArea(area, 1, 15), false);
|
||||
});
|
||||
|
||||
test('a point inside a hole is outside the area', () => {
|
||||
const area = prepareArea([[square(0, 0, 10, 10), square(4, 4, 6, 6)]]);
|
||||
assert.equal(pointInPreparedArea(area, 5, 5), false);
|
||||
assert.equal(pointInPreparedArea(area, 2, 2), true);
|
||||
// An island inside the hole (an enclave within an enclave) counts again.
|
||||
const nested = prepareArea([
|
||||
[square(0, 0, 10, 10), square(3, 3, 7, 7)],
|
||||
[square(4, 4, 6, 6)],
|
||||
]);
|
||||
assert.equal(pointInPreparedArea(nested, 5, 5), true);
|
||||
assert.equal(pointInPreparedArea(nested, 3.5, 3.5), false);
|
||||
});
|
||||
|
||||
test('a ring crossing the antimeridian contains points on both sides', () => {
|
||||
// 175E → 175W as one continuous ring (a drawn box over Fiji).
|
||||
const ring = [
|
||||
[175, -20],
|
||||
[-175, -20],
|
||||
[-175, -15],
|
||||
[175, -15],
|
||||
[175, -20],
|
||||
];
|
||||
const area = prepareArea([[ring]]);
|
||||
assert.equal(area.crossesAntimeridian, true);
|
||||
assert.equal(pointInPreparedArea(area, -17, 178), true);
|
||||
assert.equal(pointInPreparedArea(area, -17, -178), true);
|
||||
assert.equal(pointInPreparedArea(area, -17, 0), false);
|
||||
assert.equal(pointInPreparedArea(area, -17, 170), false);
|
||||
// bbox is west > east across the line.
|
||||
assert.deepEqual(area.bbox, [175, -20, -175, -15]);
|
||||
// Area of a 10°×5° box near 17°S, not the 350° long way round.
|
||||
const km2 = multiPolygonAreaKm2([[ring]]);
|
||||
assert.ok(km2 > 500_000 && km2 < 620_000, `area ${km2}`);
|
||||
});
|
||||
|
||||
test('a multipolygon split at ±180 keeps a tight bbox', () => {
|
||||
const area = prepareArea([
|
||||
[square(177, -19, 180, -16)],
|
||||
[square(-180, -19, -178, -16)],
|
||||
]);
|
||||
assert.equal(area.crossesAntimeridian, true);
|
||||
assert.deepEqual(area.bbox, [177, -19, -178, -16]);
|
||||
assert.equal(pointInPreparedArea(area, -17, -179), true);
|
||||
assert.equal(pointInPreparedArea(area, -17, 179), true);
|
||||
});
|
||||
|
||||
test('a crossing part and a separate island share one tight longitude frame', () => {
|
||||
const crossing = [
|
||||
[175, -20],
|
||||
[-175, -20],
|
||||
[-175, -15],
|
||||
[175, -15],
|
||||
[175, -20],
|
||||
];
|
||||
const area = prepareArea([
|
||||
[crossing],
|
||||
[square(-170, -19, -160, -16)],
|
||||
]);
|
||||
assert.equal(area.crossesAntimeridian, true);
|
||||
assert.deepEqual(area.bbox, [175, -20, -160, -15]);
|
||||
assert.equal(pointInPreparedArea(area, -17, 178), true);
|
||||
assert.equal(pointInPreparedArea(area, -17, -165), true);
|
||||
assert.equal(pointInPreparedArea(area, -17, 0), false);
|
||||
});
|
||||
|
||||
test('normalization closes rings and drops degenerate parts', () => {
|
||||
const multi = normalizeMultiPolygon({
|
||||
type: 'Polygon',
|
||||
coordinates: [
|
||||
[
|
||||
[0, 0],
|
||||
[1, 0],
|
||||
[1, 1],
|
||||
],
|
||||
[
|
||||
[0.2, 0.2],
|
||||
[0.2, 0.2],
|
||||
],
|
||||
],
|
||||
});
|
||||
assert.equal(multi.length, 1);
|
||||
assert.equal(multi[0].length, 1, 'degenerate hole dropped');
|
||||
assert.deepEqual(multi[0][0][0], multi[0][0][3]);
|
||||
assert.equal(
|
||||
normalizeMultiPolygon({ type: 'LineString', coordinates: [] }),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
normalizeMultiPolygon([
|
||||
[
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
],
|
||||
]),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('area subtracts holes and counts parts', () => {
|
||||
const coords = [
|
||||
[square(0, 0, 1, 1), square(0.25, 0.25, 0.75, 0.75)],
|
||||
[square(5, 5, 6, 6)],
|
||||
];
|
||||
const whole = multiPolygonAreaKm2([[square(0, 0, 1, 1)]]);
|
||||
const km2 = multiPolygonAreaKm2(coords);
|
||||
assert.ok(km2 < whole * 2 && km2 > whole * 1.7, `${km2} vs ${whole}`);
|
||||
assert.deepEqual(multiPolygonCounts(coords), { parts: 2, holes: 1 });
|
||||
});
|
||||
|
||||
test('display copies are bounded and leave the counting geometry intact', () => {
|
||||
const dense = [];
|
||||
for (let i = 0; i < 20_000; i += 1) {
|
||||
const a = (i / 20_000) * Math.PI * 2;
|
||||
dense.push([
|
||||
Math.cos(a) * (1 + 0.01 * Math.sin(a * 200)),
|
||||
Math.sin(a) * (1 + 0.01 * Math.sin(a * 200)),
|
||||
]);
|
||||
}
|
||||
dense.push(dense[0].slice());
|
||||
const coords = [[dense]];
|
||||
const snapshot = JSON.stringify(coords);
|
||||
const shown = displayParts(coords, { maxVertices: 2000 });
|
||||
const total = shown.parts.reduce((sum, p) => sum + p.outer.length, 0);
|
||||
assert.ok(total <= 2000, `${total} vertices`);
|
||||
assert.equal(JSON.stringify(coords), snapshot, 'input untouched');
|
||||
// A point the simplified copy might shave off still counts on the original.
|
||||
const area = prepareArea(coords);
|
||||
assert.equal(pointInPreparedArea(area, 0, 0), true);
|
||||
});
|
||||
|
||||
test('display copies keep the largest parts first', () => {
|
||||
const coords = [
|
||||
[square(0, 0, 1, 1)],
|
||||
[square(10, 10, 15, 15)],
|
||||
[square(20, 20, 20.1, 20.1)],
|
||||
];
|
||||
const shown = displayParts(coords, { maxParts: 2 });
|
||||
assert.equal(shown.totalParts, 3);
|
||||
assert.equal(shown.drawnParts, 2);
|
||||
assert.equal(shown.parts[0].outer[0][0], 10);
|
||||
});
|
||||
|
||||
test('a circle ring is closed and about the requested radius', () => {
|
||||
const ring = circleRing(37.8, -122.39, 500);
|
||||
assert.deepEqual(ring[0], ring[ring.length - 1]);
|
||||
const km2 = multiPolygonAreaKm2([[ring]]);
|
||||
assert.ok(Math.abs(km2 - Math.PI * 0.25) < 0.02, `${km2}`);
|
||||
});
|
||||
@@ -0,0 +1,551 @@
|
||||
/**
|
||||
* Named place → area handle, through the outline ladder that needs no public
|
||||
* Overpass:
|
||||
*
|
||||
* 1. the area store (a query already resolved this session);
|
||||
* 2. bundled Natural Earth physical/marine regions;
|
||||
* 3. bundled countries and states/provinces by name — a name several
|
||||
* prominent units share ("Georgia", "Punjab") asks which one, unless
|
||||
* the view is inside exactly one of them;
|
||||
* 4. the annotation outline ladder (`resolveNamedOutline`): bundled
|
||||
* counties, US Census places and neighbourhood packs, then the guarded
|
||||
* Nominatim outline route, then Overpass only when an operator
|
||||
* configured it.
|
||||
*
|
||||
* Providers are injected, so the ladder runs under node:test:
|
||||
* findNaturalRegion(query) → {name, polygons, kind}|null
|
||||
* findAdminCandidates(query) → Array<AdminArea> (see data/adminBoundaries.js)
|
||||
* resolveNamedOutline(query, {level, levelHint, signal}) →
|
||||
* {ok:true, name, polygons, source, rung, approximate?, country?}
|
||||
* |{ok:false, code, error}
|
||||
* viewCenter() → {lat, lon}|null
|
||||
*
|
||||
* @module data/areaResolver
|
||||
*/
|
||||
|
||||
import { AREA_LEVELS } from './areaStore.js';
|
||||
import { pointInPreparedArea, prepareArea } from './areaGeometry.js';
|
||||
import {
|
||||
isAreaCandidateId,
|
||||
MAX_AREA_CANDIDATE_ID_LENGTH,
|
||||
} from '../voice/areaCandidateContract.js';
|
||||
export {
|
||||
AREA_CANDIDATE_ID_PATTERN,
|
||||
isAreaCandidateId,
|
||||
MAX_AREA_CANDIDATE_ID_LENGTH,
|
||||
} from '../voice/areaCandidateContract.js';
|
||||
|
||||
/** Most candidates a clarification offers. */
|
||||
export const MAX_AREA_CANDIDATES = 4;
|
||||
/** Candidates remembered for a follow-up `candidateId`. */
|
||||
const CANDIDATE_MEMORY = 24;
|
||||
|
||||
/**
|
||||
* A place name as a cache and matching key: case-folded and NFKC-normalized,
|
||||
* punctuation dropped, letters, marks and digits in every script kept (東京
|
||||
* and 大阪 are different keys). Empty when the text names nothing.
|
||||
*/
|
||||
export function normalizeAreaQuery(text) {
|
||||
return String(text || '')
|
||||
.normalize('NFKC')
|
||||
.toLowerCase()
|
||||
.replace(/[^\p{L}\p{M}\p{N} ]+/gu, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.replace(/^the /, '');
|
||||
}
|
||||
|
||||
/** Words that name the kind of area rather than the area. */
|
||||
const LEVEL_WORDS = [
|
||||
[/\b(province|state|region|oblast|prefecture|canton|governorate)$/, 'admin1'],
|
||||
[/\b(county|district|department)$/, 'admin2'],
|
||||
[/\b(city|town|municipality|metropolitan city)$/, 'city'],
|
||||
[/\b(neighbou?rhood|borough|suburb|quarter)$/, 'district'],
|
||||
[/\b(country|nation)$/, 'country'],
|
||||
];
|
||||
|
||||
/**
|
||||
* Strip a trailing level word ("Bagmati province" → "bagmati", admin1). The
|
||||
* word is kept as a hint, not a filter, since names often contain it.
|
||||
*/
|
||||
export function splitLevelWord(query) {
|
||||
const norm = normalizeAreaQuery(query);
|
||||
for (const [re, level] of LEVEL_WORDS) {
|
||||
if (re.test(norm)) {
|
||||
const bare = norm.replace(re, '').trim();
|
||||
if (bare) return { bare, level };
|
||||
}
|
||||
}
|
||||
return { bare: norm, level: null };
|
||||
}
|
||||
|
||||
/** Area level of a bundled admin unit. */
|
||||
const ADMIN_LEVEL = Object.freeze({
|
||||
country: 'country',
|
||||
state: 'admin1',
|
||||
county: 'admin2',
|
||||
});
|
||||
|
||||
const ADMIN_NAME_LEVEL = Object.freeze({
|
||||
country: 'country',
|
||||
nation: 'country',
|
||||
province: 'admin1',
|
||||
state: 'admin1',
|
||||
region: 'admin1',
|
||||
oblast: 'admin1',
|
||||
prefecture: 'admin1',
|
||||
canton: 'admin1',
|
||||
governorate: 'admin1',
|
||||
county: 'admin2',
|
||||
district: 'admin2',
|
||||
department: 'admin2',
|
||||
});
|
||||
|
||||
function explicitAdminName(text) {
|
||||
const normalized = normalizeAreaQuery(String(text).split(',')[0]);
|
||||
const match = normalized.match(
|
||||
/\b(country|nation|province|state|region|zone|oblast|prefecture|canton|governorate|county|district|department)$/,
|
||||
);
|
||||
return {
|
||||
normalized,
|
||||
bare: match ? normalized.slice(0, -match[0].length).trim() : normalized,
|
||||
word: match?.[1] || null,
|
||||
level: match ? ADMIN_NAME_LEVEL[match[1]] || null : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Fail closed when a bundled unit's own name contradicts the requested unit. */
|
||||
function bundledAdminIdentityRefusal(admin, query, wantedLevel) {
|
||||
const source = explicitAdminName(admin?.name || '');
|
||||
const requested = explicitAdminName(query);
|
||||
const expectedLevel = wantedLevel || requested.level;
|
||||
const emittedLevel = ADMIN_LEVEL[admin?.kind] || null;
|
||||
if (!source.normalized || source.word === 'zone')
|
||||
return {
|
||||
ok: false,
|
||||
code: 'AREA_IDENTITY_UNVERIFIED',
|
||||
error: `The bundled boundary is ${admin?.name || 'an unverified administrative unit'}, not a verified current match for "${query}".`,
|
||||
};
|
||||
if (
|
||||
source.bare !== requested.bare ||
|
||||
(source.level && expectedLevel && source.level !== expectedLevel) ||
|
||||
(expectedLevel && emittedLevel !== expectedLevel)
|
||||
)
|
||||
return {
|
||||
ok: false,
|
||||
code: 'AREA_IDENTITY_MISMATCH',
|
||||
error: `The bundled boundary is ${admin.name}, not the requested administrative area "${query}".`,
|
||||
};
|
||||
return null;
|
||||
}
|
||||
|
||||
function stableCandidateHash(value) {
|
||||
let hash = 0x811c9dc5;
|
||||
for (const char of String(value)) {
|
||||
hash ^= char.codePointAt(0);
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
return (hash >>> 0).toString(16).padStart(8, '0');
|
||||
}
|
||||
|
||||
function candidateIdOf(admin) {
|
||||
const prefix = `ne:${admin.kind}:`;
|
||||
const slug = normalizeAreaQuery(`${admin.country} ${admin.name}`).replace(
|
||||
/ /g,
|
||||
'-',
|
||||
);
|
||||
const full = `${prefix}${slug}`;
|
||||
if (full.length <= MAX_AREA_CANDIDATE_ID_LENGTH) return full;
|
||||
const suffix = `-${stableCandidateHash(slug)}`;
|
||||
let shortened = '';
|
||||
for (const char of slug) {
|
||||
if (
|
||||
`${prefix}${shortened}${char}${suffix}`.length >
|
||||
MAX_AREA_CANDIDATE_ID_LENGTH
|
||||
)
|
||||
break;
|
||||
shortened += char;
|
||||
}
|
||||
return `${prefix}${shortened.replace(/-+$/u, '')}${suffix}`;
|
||||
}
|
||||
|
||||
/** Short, speakable identity for a clarification choice. */
|
||||
function describeCandidate(admin) {
|
||||
return {
|
||||
candidateId: candidateIdOf(admin),
|
||||
name: admin.name,
|
||||
level: ADMIN_LEVEL[admin.kind] || null,
|
||||
...(admin.country && admin.kind !== 'country'
|
||||
? { country: admin.country }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Distinct places among bundled candidates. Within one country, the country
|
||||
* itself stands for its same-named state ("Mexico" is the country, not the
|
||||
* State of Mexico); across countries each name is its own place.
|
||||
*/
|
||||
export function distinctAdminPlaces(candidates) {
|
||||
const byCountry = new Map();
|
||||
for (const admin of candidates) {
|
||||
const list = byCountry.get(admin.country) || [];
|
||||
list.push(admin);
|
||||
byCountry.set(admin.country, list);
|
||||
}
|
||||
const out = [];
|
||||
for (const list of byCountry.values()) {
|
||||
const country = list.find((admin) => admin.kind === 'country');
|
||||
out.push(...(country ? [country] : list));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a resolver bound to an area store and providers.
|
||||
* @param {object} options
|
||||
*/
|
||||
export function createAreaResolver({
|
||||
store,
|
||||
findNaturalRegion = async () => null,
|
||||
findAdminCandidates = async () => [],
|
||||
resolveNamedOutline = async () => ({
|
||||
ok: false,
|
||||
code: 'AREA_UNAVAILABLE',
|
||||
error: 'Boundary lookup is unavailable here.',
|
||||
}),
|
||||
viewCenter = () => null,
|
||||
onCredit = () => {},
|
||||
now = () => Date.now(),
|
||||
maxInFlight = 8,
|
||||
} = {}) {
|
||||
if (!store) throw new TypeError('An area store is required');
|
||||
/** candidateId → bundled unit and original ask, for a clarification answered later. */
|
||||
const candidateMemory = new Map();
|
||||
/** query key → in-flight lookup, so a repeat joins the first. */
|
||||
const inFlight = new Map();
|
||||
const lifetime = new AbortController();
|
||||
const inFlightLimit = Number.isFinite(maxInFlight)
|
||||
? Math.max(1, Math.floor(maxInFlight))
|
||||
: 8;
|
||||
let activeLookups = 0;
|
||||
let disposed = false;
|
||||
|
||||
const assertActive = () => lifetime.signal.throwIfAborted();
|
||||
|
||||
const remember = (admin, identity) => {
|
||||
const id = candidateIdOf(admin);
|
||||
candidateMemory.delete(id);
|
||||
candidateMemory.set(id, { admin, identity });
|
||||
while (candidateMemory.size > CANDIDATE_MEMORY)
|
||||
candidateMemory.delete(candidateMemory.keys().next().value);
|
||||
};
|
||||
|
||||
function storeAdmin(admin, confidence, identity) {
|
||||
assertActive();
|
||||
const refused = bundledAdminIdentityRefusal(
|
||||
admin,
|
||||
identity?.query,
|
||||
identity?.wantedLevel,
|
||||
);
|
||||
if (refused) return refused;
|
||||
const record = store.put({
|
||||
geometry: admin.polygons,
|
||||
name: admin.name,
|
||||
level: ADMIN_LEVEL[admin.kind] || null,
|
||||
source: admin.source,
|
||||
sourceId: candidateIdOf(admin),
|
||||
meta: {
|
||||
...(admin.kind !== 'country' && admin.country
|
||||
? { country: admin.country }
|
||||
: {}),
|
||||
rung: 'bundled',
|
||||
},
|
||||
});
|
||||
if (!record)
|
||||
return {
|
||||
ok: false,
|
||||
code: 'AREA_NOT_FOUND',
|
||||
error: `The boundary for ${admin.name} is not a usable polygon.`,
|
||||
};
|
||||
onCredit(admin.source);
|
||||
return { ok: true, record, confidence, rung: 'bundled' };
|
||||
}
|
||||
|
||||
async function lookup({ query, level, within }, { signal }) {
|
||||
const { level: levelHint } = splitLevelWord(query);
|
||||
const searchText = within ? `${query}, ${within}` : query;
|
||||
// Offline first: a named natural region, unless an admin level or a
|
||||
// country/state qualifier was given (the pack cannot check `within`).
|
||||
if ((!level || level === 'natural') && !within) {
|
||||
const ne = await findNaturalRegion(query, { signal }).catch(() => null);
|
||||
signal?.throwIfAborted?.();
|
||||
if (ne?.polygons?.length) {
|
||||
const record = store.put({
|
||||
geometry: ne.polygons.map((ring) => [ring]),
|
||||
name: ne.name,
|
||||
level: 'natural',
|
||||
source: 'natural-earth',
|
||||
sourceId: `ne:${normalizeAreaQuery(ne.name).replace(/ /g, '-')}`,
|
||||
meta: { kind: ne.kind, rung: 'bundled' },
|
||||
});
|
||||
if (record) {
|
||||
onCredit('natural-earth');
|
||||
return { ok: true, record, confidence: 0.95, rung: 'bundled' };
|
||||
}
|
||||
}
|
||||
}
|
||||
signal?.throwIfAborted?.();
|
||||
// Bundled countries and states by name. A name prominent units share is
|
||||
// asked about, never guessed — except when the view is inside one.
|
||||
const wanted = level || levelHint;
|
||||
if (!wanted || wanted === 'country' || wanted === 'admin1') {
|
||||
let admins = await findAdminCandidates(searchText, { signal }).catch(
|
||||
() => [],
|
||||
);
|
||||
signal?.throwIfAborted?.();
|
||||
if (wanted) admins = admins.filter((a) => ADMIN_LEVEL[a.kind] === wanted);
|
||||
let places = distinctAdminPlaces(admins);
|
||||
const identity = { query, wantedLevel: wanted };
|
||||
const checked = places.map((admin) => ({
|
||||
admin,
|
||||
refused: bundledAdminIdentityRefusal(admin, query, wanted),
|
||||
}));
|
||||
places = checked
|
||||
.filter(({ refused }) => !refused)
|
||||
.map(({ admin }) => admin);
|
||||
if (!places.length && checked.length)
|
||||
return checked.find(({ refused }) => refused)?.refused;
|
||||
if (places.length === 1) {
|
||||
return storeAdmin(places[0], 0.95, identity);
|
||||
}
|
||||
if (places.length > 1) {
|
||||
const center = viewCenter();
|
||||
const holding = center
|
||||
? places.filter((admin) => {
|
||||
const prepared = prepareArea(admin.polygons);
|
||||
return (
|
||||
prepared &&
|
||||
pointInPreparedArea(prepared, center.lat, center.lon)
|
||||
);
|
||||
})
|
||||
: [];
|
||||
// Chosen by the view, so never remembered for the name: asked again
|
||||
// over the other one, it answers the other one.
|
||||
if (holding.length === 1)
|
||||
return { ...storeAdmin(holding[0], 0.85, identity), byView: true };
|
||||
const offered = places.slice(0, MAX_AREA_CANDIDATES);
|
||||
offered.forEach((admin) => remember(admin, identity));
|
||||
return {
|
||||
ok: false,
|
||||
needsClarification: true,
|
||||
code: 'AMBIGUOUS_AREA',
|
||||
candidates: offered.map(describeCandidate),
|
||||
error: `"${query}" names ${offered.length} places — ask which one.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
signal?.throwIfAborted?.();
|
||||
// Everything else: the annotation outline ladder.
|
||||
const outline = await resolveNamedOutline(searchText, {
|
||||
level,
|
||||
levelHint,
|
||||
signal,
|
||||
});
|
||||
signal?.throwIfAborted?.();
|
||||
if (!outline?.ok)
|
||||
return {
|
||||
ok: false,
|
||||
code: outline?.code || 'AREA_NOT_FOUND',
|
||||
error: outline?.error || `No boundary found for "${query}".`,
|
||||
};
|
||||
const record = store.put({
|
||||
geometry: outline.polygons,
|
||||
name: outline.name || query,
|
||||
level: level || levelHint || outline.adminLevel || null,
|
||||
source: outline.approximate ? 'approximate' : outline.source,
|
||||
approximate: Boolean(outline.approximate),
|
||||
meta: {
|
||||
...(outline.country ? { country: outline.country } : {}),
|
||||
...(outline.adminName ? { adminName: outline.adminName } : {}),
|
||||
...(outline.adminLevel ? { adminLevel: outline.adminLevel } : {}),
|
||||
rung: outline.rung || null,
|
||||
...(outline.basis ? { basis: outline.basis } : {}),
|
||||
},
|
||||
});
|
||||
if (!record)
|
||||
return {
|
||||
ok: false,
|
||||
code: 'AREA_NOT_FOUND',
|
||||
error: `The boundary for ${query} is not a usable polygon.`,
|
||||
};
|
||||
return {
|
||||
ok: true,
|
||||
record,
|
||||
confidence: outline.approximate ? 0.5 : 0.8,
|
||||
rung: outline.rung || null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a place to an area handle.
|
||||
* @param {{query?: string, level?: string, within?: string, candidateId?: string}} args
|
||||
* @param {{signal?: AbortSignal, budgetMs?: number}} [options] Past the
|
||||
* budget the call answers AREA_TIMEOUT while the lookup continues and
|
||||
* fills the caches, so asking again shortly is fast.
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
async function resolve(args = {}, { signal, budgetMs = 8000 } = {}) {
|
||||
const started = now();
|
||||
const level = AREA_LEVELS.includes(args.level) ? args.level : null;
|
||||
const within = String(args.within || '').trim();
|
||||
const candidateId = String(args.candidateId || '').trim();
|
||||
const finish = (result, cached = false) => ({
|
||||
...result,
|
||||
cached,
|
||||
ms: now() - started,
|
||||
});
|
||||
|
||||
if (disposed)
|
||||
return finish({
|
||||
ok: false,
|
||||
code: 'CANCELLED',
|
||||
cancelled: true,
|
||||
error: 'Area resolver has been disposed.',
|
||||
});
|
||||
|
||||
if (candidateId) {
|
||||
if (!isAreaCandidateId(candidateId))
|
||||
return finish({
|
||||
ok: false,
|
||||
code: 'BAD_CANDIDATE',
|
||||
error:
|
||||
'candidateId is malformed or too long. Resolve the name again.',
|
||||
});
|
||||
const remembered = candidateMemory.get(candidateId);
|
||||
if (!remembered)
|
||||
return finish({
|
||||
ok: false,
|
||||
code: 'UNKNOWN_CANDIDATE',
|
||||
error: `Unknown candidateId "${candidateId}". Resolve the name again.`,
|
||||
});
|
||||
if (signal?.aborted)
|
||||
return finish({
|
||||
ok: false,
|
||||
code: 'CANCELLED',
|
||||
cancelled: true,
|
||||
error: 'This request was superseded.',
|
||||
});
|
||||
return finish(storeAdmin(remembered.admin, 0.9, remembered.identity));
|
||||
}
|
||||
|
||||
const query = String(args.query || '').trim();
|
||||
if (!query)
|
||||
return finish({
|
||||
ok: false,
|
||||
code: 'BAD_AREA',
|
||||
error: 'Name a place to resolve.',
|
||||
});
|
||||
const nameKey = normalizeAreaQuery(query);
|
||||
// A name that normalizes to nothing is never cached or shared.
|
||||
const key = nameKey
|
||||
? `${nameKey}|${level || ''}|${normalizeAreaQuery(within)}`
|
||||
: null;
|
||||
const recalled = key ? store.recallQuery(key) : null;
|
||||
if (recalled)
|
||||
return finish(
|
||||
{ ok: true, record: recalled, confidence: 0.95, rung: 'store' },
|
||||
true,
|
||||
);
|
||||
|
||||
let pending = key ? inFlight.get(key) : null;
|
||||
if (!pending) {
|
||||
if (activeLookups >= inFlightLimit)
|
||||
return finish({
|
||||
ok: false,
|
||||
code: 'AREA_BUSY',
|
||||
error:
|
||||
'Too many area lookups are already running — try again shortly.',
|
||||
});
|
||||
// The shared lookup outlives any one caller's signal: a superseded turn
|
||||
// must not cancel the answer a repeat is waiting on. The resolver's own
|
||||
// lifetime still owns the provider work, so final teardown stops it.
|
||||
activeLookups += 1;
|
||||
pending = lookup({ query, level, within }, { signal: lifetime.signal })
|
||||
.then((result) => {
|
||||
assertActive();
|
||||
if (result.ok && key && !result.byView)
|
||||
store.rememberQuery(key, result.record.areaId);
|
||||
return result;
|
||||
})
|
||||
.catch((error) =>
|
||||
lifetime.signal.aborted
|
||||
? {
|
||||
ok: false,
|
||||
code: 'CANCELLED',
|
||||
cancelled: true,
|
||||
error: 'Area resolver has been disposed.',
|
||||
}
|
||||
: {
|
||||
ok: false,
|
||||
code: 'AREA_UNAVAILABLE',
|
||||
error: error?.message || 'Boundary lookup failed.',
|
||||
},
|
||||
)
|
||||
.finally(() => {
|
||||
activeLookups -= 1;
|
||||
if (key) inFlight.delete(key);
|
||||
});
|
||||
if (key) inFlight.set(key, pending);
|
||||
}
|
||||
let timer;
|
||||
const racers = [pending];
|
||||
if (Number.isFinite(budgetMs))
|
||||
racers.push(
|
||||
new Promise((resolveTimeout) => {
|
||||
timer = setTimeout(
|
||||
() =>
|
||||
resolveTimeout({
|
||||
ok: false,
|
||||
code: 'AREA_TIMEOUT',
|
||||
error: `Looking up "${query}" is taking a while — ask again in a moment.`,
|
||||
}),
|
||||
budgetMs,
|
||||
);
|
||||
}),
|
||||
);
|
||||
if (signal)
|
||||
racers.push(
|
||||
new Promise((resolveAbort) => {
|
||||
const cancelled = () =>
|
||||
resolveAbort({
|
||||
ok: false,
|
||||
code: 'CANCELLED',
|
||||
cancelled: true,
|
||||
error: 'Superseded.',
|
||||
});
|
||||
if (signal.aborted) cancelled();
|
||||
signal.addEventListener?.('abort', cancelled, { once: true });
|
||||
}),
|
||||
);
|
||||
try {
|
||||
return finish(await Promise.race(racers));
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
lifetime.abort();
|
||||
inFlight.clear();
|
||||
candidateMemory.clear();
|
||||
}
|
||||
|
||||
return {
|
||||
resolve,
|
||||
dispose,
|
||||
candidate: (id) => candidateMemory.get(id) || null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,625 @@
|
||||
// Area handles: the session store and the resolution ladder (bundled Natural
|
||||
// Earth regions → bundled countries and states → the annotation outline
|
||||
// ladder), with every rung mocked. Pins the clarification contract for
|
||||
// ambiguous names and the fast cached path.
|
||||
//
|
||||
// Run with: npm test
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createAreaStore, summarizeArea } from './areaStore.js';
|
||||
import {
|
||||
AREA_CANDIDATE_ID_PATTERN,
|
||||
createAreaResolver,
|
||||
distinctAdminPlaces,
|
||||
isAreaCandidateId,
|
||||
MAX_AREA_CANDIDATE_ID_LENGTH,
|
||||
splitLevelWord,
|
||||
} from './areaResolver.js';
|
||||
import { pointInPreparedArea } from './areaGeometry.js';
|
||||
import { findAdminCandidates } from './adminBoundaries.js';
|
||||
|
||||
const square = (w, s, e, n) => [
|
||||
[w, s],
|
||||
[e, s],
|
||||
[e, n],
|
||||
[w, n],
|
||||
[w, s],
|
||||
];
|
||||
|
||||
const admin = (kind, name, country, box, extra = {}) => ({
|
||||
kind,
|
||||
name,
|
||||
country,
|
||||
source: 'natural-earth',
|
||||
polygons: [[square(...box)]],
|
||||
...extra,
|
||||
});
|
||||
const GEORGIA_COUNTRY = admin(
|
||||
'country',
|
||||
'Georgia',
|
||||
'Georgia',
|
||||
[40, 41, 46.7, 43.6],
|
||||
);
|
||||
const GEORGIA_STATE = admin(
|
||||
'state',
|
||||
'Georgia',
|
||||
'United States of America',
|
||||
[-85.6, 30.4, -80.8, 35],
|
||||
);
|
||||
const PUNJAB_IN = admin('state', 'Punjab', 'India', [73.8, 29.5, 77, 32.5]);
|
||||
const PUNJAB_PK = admin('state', 'Punjab', 'Pakistan', [69.3, 27.7, 73.7, 34]);
|
||||
const TEXAS = admin(
|
||||
'state',
|
||||
'Texas',
|
||||
'United States of America',
|
||||
[-106, 26, -94, 36],
|
||||
);
|
||||
|
||||
const BAGMATI = {
|
||||
ok: true,
|
||||
name: 'Bagmati Province',
|
||||
polygons: [[square(84.4, 26.9, 86.6, 28.4), square(85.3, 27.6, 85.4, 27.7)]],
|
||||
source: 'osm',
|
||||
rung: 'nominatim',
|
||||
adminName: 'Bagmati Province',
|
||||
};
|
||||
|
||||
function makeResolver(overrides = {}) {
|
||||
const store = createAreaStore();
|
||||
const calls = { outline: [], natural: 0, admin: 0 };
|
||||
const resolver = createAreaResolver({
|
||||
store,
|
||||
findNaturalRegion: async (q) => {
|
||||
calls.natural += 1;
|
||||
return /alps/i.test(q)
|
||||
? {
|
||||
name: 'Alps',
|
||||
kind: 'natural',
|
||||
polygons: [square(5, 44, 16, 48), square(17, 44, 18, 45)],
|
||||
}
|
||||
: null;
|
||||
},
|
||||
findAdminCandidates: async (q) => {
|
||||
calls.admin += 1;
|
||||
if (/^georgia$/i.test(q)) return [GEORGIA_STATE, GEORGIA_COUNTRY];
|
||||
if (/^georgia, united states/i.test(q)) return [GEORGIA_STATE];
|
||||
if (/^punjab$/i.test(q)) return [PUNJAB_PK, PUNJAB_IN];
|
||||
if (/^punjab, pakistan/i.test(q)) return [PUNJAB_PK];
|
||||
if (/^texas$/i.test(q)) return [TEXAS];
|
||||
return [];
|
||||
},
|
||||
resolveNamedOutline: async (q, options) => {
|
||||
calls.outline.push({ q, ...options, signal: undefined });
|
||||
if (/bagmati/i.test(q)) return BAGMATI;
|
||||
return {
|
||||
ok: false,
|
||||
code: 'AREA_NOT_FOUND',
|
||||
error: `No boundary found for "${q}".`,
|
||||
};
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
return { store, resolver, calls };
|
||||
}
|
||||
|
||||
test('the store keeps geometry local and summarizes without coordinates', () => {
|
||||
const store = createAreaStore({ maxEntries: 2 });
|
||||
const a = store.put({
|
||||
geometry: [[square(0, 0, 1, 1)]],
|
||||
name: 'A',
|
||||
source: 'drawn',
|
||||
level: 'drawn',
|
||||
});
|
||||
const b = store.put({
|
||||
geometry: [[square(2, 2, 3, 3)]],
|
||||
name: 'B',
|
||||
source: 'us-census',
|
||||
sourceId: 'place:1',
|
||||
});
|
||||
assert.equal(b.areaId, 'place:1');
|
||||
assert.equal(b.source, 'us-census');
|
||||
store.get(a.areaId); // A is now the most recently used
|
||||
store.put({ geometry: [[square(4, 4, 5, 5)]], name: 'C', source: 'osm' });
|
||||
assert.equal(store.has('place:1'), false, 'least recently used evicted');
|
||||
assert.ok(store.get(a.areaId));
|
||||
const summary = summarizeArea(a);
|
||||
assert.deepEqual(
|
||||
Object.keys(summary).sort(),
|
||||
[
|
||||
'areaId',
|
||||
'areaKm2',
|
||||
'bbox',
|
||||
'holes',
|
||||
'level',
|
||||
'name',
|
||||
'parts',
|
||||
'source',
|
||||
].sort(),
|
||||
);
|
||||
assert.equal(JSON.stringify(summary).includes('geometry'), false);
|
||||
assert.equal(
|
||||
store.put({
|
||||
geometry: [
|
||||
[
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
],
|
||||
],
|
||||
name: 'bad',
|
||||
}),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('query aliases have their own LRU bound even for one stable area', () => {
|
||||
const store = createAreaStore({ maxEntries: 2, maxQueryEntries: 3 });
|
||||
const area = store.put({
|
||||
geometry: [[square(0, 0, 1, 1)]],
|
||||
name: 'Stable',
|
||||
source: 'osm',
|
||||
sourceId: 'osm:stable',
|
||||
});
|
||||
for (let i = 0; i < 50; i += 1)
|
||||
store.rememberQuery(`alias-${i}`, area.areaId);
|
||||
assert.equal(store.recallQuery('alias-0'), null, 'old aliases are evicted');
|
||||
assert.equal(store.recallQuery('alias-46'), null, 'the bound is independent');
|
||||
assert.equal(store.recallQuery('alias-47')?.areaId, area.areaId);
|
||||
assert.equal(store.recallQuery('alias-49')?.areaId, area.areaId);
|
||||
assert.equal(store.size, 1, 'alias churn does not evict the area record');
|
||||
});
|
||||
|
||||
test('a stable source keeps one handle when stored again', () => {
|
||||
const store = createAreaStore();
|
||||
for (let i = 0; i < 2; i += 1)
|
||||
store.put({
|
||||
geometry: [[square(0, 0, 1, 1)]],
|
||||
name: 'X',
|
||||
source: 'natural-earth',
|
||||
sourceId: 'ne:state:x',
|
||||
});
|
||||
assert.equal(store.size, 1);
|
||||
});
|
||||
|
||||
test('a named natural region resolves offline with every part', async () => {
|
||||
const { resolver, calls } = makeResolver();
|
||||
const result = await resolver.resolve({ query: 'the Alps' });
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.record.source, 'natural-earth');
|
||||
assert.equal(result.record.parts, 2);
|
||||
assert.equal(calls.outline.length, 0, 'no lookup');
|
||||
assert.ok(result.ms < 300);
|
||||
});
|
||||
|
||||
test('a bundled state resolves by name with no lookup', async () => {
|
||||
const { resolver, calls } = makeResolver();
|
||||
const result = await resolver.resolve({ query: 'Texas' });
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.rung, 'bundled');
|
||||
assert.equal(result.record.level, 'admin1');
|
||||
assert.equal(result.record.source, 'natural-earth');
|
||||
assert.equal(result.record.meta.country, 'United States of America');
|
||||
assert.equal(calls.outline.length, 0);
|
||||
});
|
||||
|
||||
test('Bagmati goes to the outline ladder and keeps its hole', async () => {
|
||||
const { resolver, calls } = makeResolver();
|
||||
const result = await resolver.resolve({ query: 'Bagmati province' });
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.rung, 'nominatim');
|
||||
assert.equal(result.record.source, 'osm');
|
||||
assert.equal(result.record.level, 'admin1', 'the level word is kept');
|
||||
assert.equal(result.record.holes, 1);
|
||||
assert.equal(pointInPreparedArea(result.record.prepared, 27.2, 85), true);
|
||||
assert.equal(
|
||||
pointInPreparedArea(result.record.prepared, 27.65, 85.35),
|
||||
false,
|
||||
'inside the hole',
|
||||
);
|
||||
assert.equal(calls.outline[0].levelHint, 'admin1');
|
||||
});
|
||||
|
||||
test('a legacy bundled zone is never stored as the requested current province', async () => {
|
||||
const legacy = admin(
|
||||
'state',
|
||||
'Bagmati Zone',
|
||||
'Nepal',
|
||||
[84.645, 27.357, 86.055, 28.335],
|
||||
);
|
||||
const { store, resolver } = makeResolver({
|
||||
findAdminCandidates: async () => [legacy],
|
||||
});
|
||||
const result = await resolver.resolve({ query: 'Bagmati province' });
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.code, 'AREA_IDENTITY_UNVERIFIED');
|
||||
assert.equal(result.record, undefined);
|
||||
assert.equal(store.size, 0);
|
||||
const repeated = await resolver.resolve({ query: 'Bagmati province' });
|
||||
assert.equal(repeated.code, 'AREA_IDENTITY_UNVERIFIED');
|
||||
assert.equal(repeated.cached, false);
|
||||
assert.equal(store.size, 0);
|
||||
});
|
||||
|
||||
test('legacy zone identity cannot escape through view choice or candidate memory', async () => {
|
||||
const nepal = admin(
|
||||
'state',
|
||||
'Bagmati Zone',
|
||||
'Nepal',
|
||||
[84.645, 27.357, 86.055, 28.335],
|
||||
);
|
||||
const namesake = admin(
|
||||
'state',
|
||||
'Bagmati Zone',
|
||||
'Elsewhere',
|
||||
[10, 10, 11, 11],
|
||||
);
|
||||
const { store, resolver } = makeResolver({
|
||||
findAdminCandidates: async () => [nepal, namesake],
|
||||
viewCenter: () => ({ lat: 27.7, lon: 85.3 }),
|
||||
});
|
||||
const result = await resolver.resolve({ query: 'Bagmati province' });
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.code, 'AREA_IDENTITY_UNVERIFIED');
|
||||
assert.equal(result.needsClarification, undefined);
|
||||
assert.equal(result.candidates, undefined);
|
||||
assert.equal(store.size, 0);
|
||||
assert.equal(
|
||||
(await resolver.resolve({ candidateId: 'ne:state:nepal-bagmati-zone' }))
|
||||
.code,
|
||||
'UNKNOWN_CANDIDATE',
|
||||
);
|
||||
assert.equal(store.size, 0);
|
||||
});
|
||||
|
||||
test('a repeat is answered from the store without a lookup', async () => {
|
||||
const { resolver, calls } = makeResolver();
|
||||
await resolver.resolve({ query: 'Bagmati' });
|
||||
const again = await resolver.resolve({ query: 'bagmati' });
|
||||
assert.equal(again.ok, true);
|
||||
assert.equal(again.cached, true);
|
||||
assert.equal(calls.outline.length, 1);
|
||||
});
|
||||
|
||||
test('an ambiguous name asks instead of guessing, and the choice resolves', async () => {
|
||||
const { resolver, calls } = makeResolver();
|
||||
const result = await resolver.resolve({ query: 'Georgia' });
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.needsClarification, true);
|
||||
assert.equal(result.candidates.length, 2);
|
||||
assert.deepEqual(result.candidates[0], {
|
||||
candidateId: 'ne:state:united-states-of-america-georgia',
|
||||
name: 'Georgia',
|
||||
level: 'admin1',
|
||||
country: 'United States of America',
|
||||
});
|
||||
assert.equal(result.candidates[1].level, 'country');
|
||||
assert.equal(calls.outline.length, 0, 'no lookup spent on a question');
|
||||
const chosen = await resolver.resolve({
|
||||
candidateId: result.candidates[0].candidateId,
|
||||
});
|
||||
assert.equal(chosen.ok, true);
|
||||
assert.equal(chosen.record.meta.country, 'United States of America');
|
||||
});
|
||||
|
||||
test('an already-aborted clarification choice does not persist page state', async () => {
|
||||
const { store, resolver } = makeResolver();
|
||||
const result = await resolver.resolve({ query: 'Georgia' });
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const chosen = await resolver.resolve(
|
||||
{ candidateId: result.candidates[0].candidateId },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
assert.equal(chosen.code, 'CANCELLED');
|
||||
assert.equal(chosen.cancelled, true);
|
||||
assert.equal(store.size, 0);
|
||||
});
|
||||
|
||||
test('candidate IDs accept every emitted choice and reject malformed or oversized input', async () => {
|
||||
const { store, resolver } = makeResolver();
|
||||
const result = await resolver.resolve({ query: 'Georgia' });
|
||||
const grammar = new RegExp(AREA_CANDIDATE_ID_PATTERN, 'u');
|
||||
for (const choice of result.candidates) {
|
||||
assert.ok(choice.candidateId.length <= MAX_AREA_CANDIDATE_ID_LENGTH);
|
||||
assert.match(choice.candidateId, grammar);
|
||||
assert.equal(isAreaCandidateId(choice.candidateId), true);
|
||||
assert.equal(
|
||||
(await resolver.resolve({ candidateId: choice.candidateId })).ok,
|
||||
true,
|
||||
);
|
||||
}
|
||||
assert.equal(result.candidates[0].candidateId.length, 41);
|
||||
|
||||
store.put({
|
||||
geometry: [[square(0, 0, 1, 1)]],
|
||||
name: 'Not a candidate',
|
||||
source: 'osm',
|
||||
sourceId: 'area-1',
|
||||
});
|
||||
for (const bad of [
|
||||
'area-1',
|
||||
'ne:state:bad slug',
|
||||
'ne:state:../../etc',
|
||||
'ne:state:!!!',
|
||||
'ne:state:a_b',
|
||||
'ne:planet:earth',
|
||||
`ne:state:${'a'.repeat(MAX_AREA_CANDIDATE_ID_LENGTH)}`,
|
||||
]) {
|
||||
const rejected = await resolver.resolve({ candidateId: bad });
|
||||
assert.equal(rejected.code, 'BAD_CANDIDATE');
|
||||
}
|
||||
});
|
||||
|
||||
test('long bundled names produce bounded, distinct candidate handles', async () => {
|
||||
const name = 'A'.repeat(70);
|
||||
const candidates = [
|
||||
admin('state', name, `B${'b'.repeat(70)}`, [0, 0, 1, 1]),
|
||||
admin('state', name, `C${'c'.repeat(70)}`, [2, 2, 3, 3]),
|
||||
];
|
||||
const { resolver } = makeResolver({
|
||||
findAdminCandidates: async () => candidates,
|
||||
});
|
||||
const result = await resolver.resolve({ query: name });
|
||||
assert.equal(result.needsClarification, true);
|
||||
assert.equal(
|
||||
new Set(result.candidates.map((choice) => choice.candidateId)).size,
|
||||
2,
|
||||
);
|
||||
for (const choice of result.candidates) {
|
||||
assert.equal(isAreaCandidateId(choice.candidateId), true);
|
||||
assert.ok(choice.candidateId.length <= MAX_AREA_CANDIDATE_ID_LENGTH);
|
||||
assert.equal(
|
||||
(await resolver.resolve({ candidateId: choice.candidateId })).ok,
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('within or level settles an ambiguous name', async () => {
|
||||
const { resolver } = makeResolver();
|
||||
const state = await resolver.resolve({
|
||||
query: 'Georgia',
|
||||
within: 'United States',
|
||||
});
|
||||
assert.equal(state.ok, true);
|
||||
assert.equal(state.record.level, 'admin1');
|
||||
const country = await resolver.resolve({
|
||||
query: 'Georgia',
|
||||
level: 'country',
|
||||
});
|
||||
assert.equal(country.ok, true);
|
||||
assert.equal(country.record.level, 'country');
|
||||
const both = await resolver.resolve({ query: 'Punjab', level: 'admin1' });
|
||||
assert.equal(both.needsClarification, true, 'both Punjabs are admin1');
|
||||
const pk = await resolver.resolve({ query: 'Punjab', within: 'Pakistan' });
|
||||
assert.equal(pk.record.meta.country, 'Pakistan');
|
||||
});
|
||||
|
||||
test('the view settles an ambiguous name only when it is inside one of them', async () => {
|
||||
const { resolver } = makeResolver({
|
||||
viewCenter: () => ({ lat: 31, lon: 75.5 }),
|
||||
});
|
||||
const result = await resolver.resolve({ query: 'Punjab' });
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.record.meta.country, 'India');
|
||||
const away = makeResolver({ viewCenter: () => ({ lat: 0, lon: 0 }) });
|
||||
const asked = await away.resolver.resolve({ query: 'Punjab' });
|
||||
assert.equal(asked.needsClarification, true);
|
||||
// A choice the view made is not remembered for the name.
|
||||
let view = { lat: 31, lon: 75.5 };
|
||||
const moving = makeResolver({ viewCenter: () => view });
|
||||
await moving.resolver.resolve({ query: 'Punjab' });
|
||||
view = { lat: 31, lon: 71 };
|
||||
const again = await moving.resolver.resolve({ query: 'Punjab' });
|
||||
assert.equal(again.record.meta.country, 'Pakistan');
|
||||
});
|
||||
|
||||
test('a qualifier skips the natural-region pack, which cannot check it', async () => {
|
||||
const { resolver, calls } = makeResolver();
|
||||
await resolver.resolve({ query: 'the Alps', within: 'Austria' });
|
||||
assert.equal(calls.natural, 0);
|
||||
assert.equal(calls.outline.length, 1);
|
||||
});
|
||||
|
||||
test('the area just stored survives eviction even when every other area is pinned', () => {
|
||||
const store = createAreaStore({ maxEntries: 2, isPinned: () => true });
|
||||
for (let i = 0; i < 3; i += 1)
|
||||
store.put({
|
||||
geometry: [[square(i, 0, i + 0.5, 0.5)]],
|
||||
name: `A${i}`,
|
||||
source: 'drawn',
|
||||
});
|
||||
const last = store.put({
|
||||
geometry: [[square(9, 0, 9.5, 0.5)]],
|
||||
name: 'new',
|
||||
source: 'drawn',
|
||||
});
|
||||
assert.ok(store.get(last.areaId), 'the new handle is usable');
|
||||
});
|
||||
|
||||
test('a country stands for its same-named state; namesakes abroad are rivals', () => {
|
||||
const mexico = admin('country', 'Mexico', 'Mexico', [-118, 14, -86, 33]);
|
||||
const state = admin(
|
||||
'state',
|
||||
'State of Mexico',
|
||||
'Mexico',
|
||||
[-100, 18, -98, 20],
|
||||
);
|
||||
assert.deepEqual(distinctAdminPlaces([state, mexico]), [mexico]);
|
||||
assert.equal(distinctAdminPlaces([PUNJAB_IN, PUNJAB_PK]).length, 2);
|
||||
});
|
||||
|
||||
test('bundled candidates: shared prominent names list every unit, minor ones none', async () => {
|
||||
const names = async (q) =>
|
||||
(await findAdminCandidates(q)).map((a) => `${a.kind}:${a.country}`);
|
||||
assert.deepEqual((await names('Punjab')).sort(), [
|
||||
'state:India',
|
||||
'state:Pakistan',
|
||||
]);
|
||||
assert.deepEqual((await names('Georgia')).sort(), [
|
||||
'country:Georgia',
|
||||
'state:United States of America',
|
||||
]);
|
||||
assert.deepEqual(await names('Punjab, India'), ['state:India']);
|
||||
assert.deepEqual(await names('Kent'), [], 'minor units are the geocoder’s');
|
||||
});
|
||||
|
||||
test('an outline ladder refusal passes through with its code', async () => {
|
||||
const { resolver } = makeResolver();
|
||||
const miss = await resolver.resolve({ query: 'Nowhere Province' });
|
||||
assert.equal(miss.ok, false);
|
||||
assert.equal(miss.code, 'AREA_NOT_FOUND');
|
||||
const down = makeResolver({
|
||||
resolveNamedOutline: async () => ({
|
||||
ok: false,
|
||||
code: 'AREA_UNAVAILABLE',
|
||||
error: 'No outline source can trace it here.',
|
||||
}),
|
||||
});
|
||||
const failed = await down.resolver.resolve({ query: 'Kathmandu' });
|
||||
assert.equal(failed.code, 'AREA_UNAVAILABLE');
|
||||
});
|
||||
|
||||
test('an approximate outline is stored and labelled as such', async () => {
|
||||
const { resolver } = makeResolver({
|
||||
resolveNamedOutline: async () => ({
|
||||
ok: true,
|
||||
name: 'Mission',
|
||||
polygons: [[square(0, 0, 0.01, 0.01)]],
|
||||
source: 'osm',
|
||||
rung: 'overpass',
|
||||
approximate: true,
|
||||
basis: 'buffer sized to the place',
|
||||
}),
|
||||
});
|
||||
const result = await resolver.resolve({ query: 'the Mission' });
|
||||
assert.equal(result.record.approximate, true);
|
||||
assert.equal(result.record.source, 'approximate');
|
||||
assert.equal(result.record.meta.basis, 'buffer sized to the place');
|
||||
});
|
||||
|
||||
test('a slow lookup times out but keeps filling the cache', async () => {
|
||||
let release;
|
||||
const gate = new Promise((r) => {
|
||||
release = r;
|
||||
});
|
||||
const { resolver } = makeResolver({
|
||||
resolveNamedOutline: async () => {
|
||||
await gate;
|
||||
return BAGMATI;
|
||||
},
|
||||
});
|
||||
const first = await resolver.resolve({ query: 'Bagmati' }, { budgetMs: 20 });
|
||||
assert.equal(first.code, 'AREA_TIMEOUT');
|
||||
release();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
const second = await resolver.resolve({ query: 'Bagmati' }, { budgetMs: 20 });
|
||||
assert.equal(second.ok, true);
|
||||
assert.equal(second.cached, true);
|
||||
});
|
||||
|
||||
test('a cancelled caller returns at once without cancelling the shared lookup', async () => {
|
||||
const { resolver } = makeResolver();
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const result = await resolver.resolve(
|
||||
{ query: 'Bagmati' },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
assert.equal(result.code, 'CANCELLED');
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
const again = await resolver.resolve({ query: 'Bagmati' });
|
||||
assert.equal(again.ok, true);
|
||||
});
|
||||
|
||||
test('distinct area lookups are hard-bounded while same-key callers still coalesce', async () => {
|
||||
let release;
|
||||
const gate = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
let calls = 0;
|
||||
const { resolver } = makeResolver({
|
||||
maxInFlight: 1,
|
||||
resolveNamedOutline: async (query) => {
|
||||
calls += 1;
|
||||
await gate;
|
||||
return { ...BAGMATI, name: query };
|
||||
},
|
||||
});
|
||||
const first = resolver.resolve({ query: 'First Area' });
|
||||
const joined = resolver.resolve({ query: 'First Area' });
|
||||
const refused = await resolver.resolve({ query: 'Second Area' });
|
||||
assert.equal(refused.code, 'AREA_BUSY');
|
||||
release();
|
||||
const [a, b] = await Promise.all([first, joined]);
|
||||
assert.equal(calls, 1, 'the same key shares the one live provider lookup');
|
||||
assert.equal(a.ok, true);
|
||||
assert.equal(b.ok, true);
|
||||
});
|
||||
|
||||
test('resolver disposal aborts provider lifetime and prevents late store/cache writes', async () => {
|
||||
let release;
|
||||
let providerSignal;
|
||||
const gate = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const { resolver, store } = makeResolver({
|
||||
resolveNamedOutline: async (_query, { signal }) => {
|
||||
providerSignal = signal;
|
||||
await gate; // Deliberately ignores abort until it returns.
|
||||
return BAGMATI;
|
||||
},
|
||||
});
|
||||
const pending = resolver.resolve({ query: 'Late Area' });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
resolver.dispose();
|
||||
assert.equal(providerSignal.aborted, true);
|
||||
release();
|
||||
const result = await pending;
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(store.size, 0, 'late provider data never reaches the store');
|
||||
const after = await resolver.resolve({ query: 'Late Area' });
|
||||
assert.equal(after.code, 'CANCELLED');
|
||||
});
|
||||
|
||||
test('level words map to area levels', () => {
|
||||
assert.deepEqual(splitLevelWord('Bagmati Province'), {
|
||||
bare: 'bagmati',
|
||||
level: 'admin1',
|
||||
});
|
||||
assert.deepEqual(splitLevelWord('Kathmandu'), {
|
||||
bare: 'kathmandu',
|
||||
level: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('non-Latin names are distinct keys: 東京 then 大阪 looks up both', async () => {
|
||||
const asked = [];
|
||||
const { resolver } = makeResolver({
|
||||
resolveNamedOutline: async (q) => {
|
||||
asked.push(q);
|
||||
return { ...BAGMATI, name: q };
|
||||
},
|
||||
});
|
||||
const first = await resolver.resolve({ query: '東京' });
|
||||
const second = await resolver.resolve({ query: '大阪' });
|
||||
assert.notEqual(first.record.areaId, second.record.areaId);
|
||||
assert.equal(second.cached, false, 'not the cached Tokyo');
|
||||
assert.deepEqual(asked, ['東京', '大阪']);
|
||||
// The same name with a different `within` is a different question.
|
||||
await resolver.resolve({ query: '東京', within: '日本' });
|
||||
assert.equal(asked.length, 3);
|
||||
assert.equal((await resolver.resolve({ query: '東京' })).cached, true);
|
||||
});
|
||||
|
||||
test('a name that normalizes to nothing is never cached or shared', async () => {
|
||||
let calls = 0;
|
||||
const { resolver } = makeResolver({
|
||||
resolveNamedOutline: async () => {
|
||||
calls += 1;
|
||||
return BAGMATI;
|
||||
},
|
||||
});
|
||||
await resolver.resolve({ query: '!!!' });
|
||||
await resolver.resolve({ query: '???' });
|
||||
assert.equal(calls, 2, 'two punctuation-only asks are two lookups');
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* Session-scoped store of area handles. A voice turn refers to an area by its
|
||||
* `areaId`; the geometry itself stays here and never goes to the model.
|
||||
*
|
||||
* Records are immutable once stored. A source with a stable identity (an OSM
|
||||
* relation, a Natural Earth region, an annotation) keeps the same id when it
|
||||
* is stored again, so repeating "outline Bagmati" does not mint new handles.
|
||||
* Least recently used records are evicted past `maxEntries`, except those
|
||||
* `isPinned` reports as still in use.
|
||||
*
|
||||
* Pure: no Cesium, DOM or network.
|
||||
* @module data/areaStore
|
||||
*/
|
||||
|
||||
import {
|
||||
multiPolygonAreaKm2,
|
||||
multiPolygonCounts,
|
||||
normalizeMultiPolygon,
|
||||
prepareArea,
|
||||
} from './areaGeometry.js';
|
||||
|
||||
/** Where an area came from, as the model and the panel name it. */
|
||||
export const AREA_SOURCES = Object.freeze([
|
||||
'natural-earth',
|
||||
'us-census',
|
||||
'wof',
|
||||
'datasf',
|
||||
'osm',
|
||||
'openfreemap',
|
||||
'annotation',
|
||||
'drawn',
|
||||
'approximate',
|
||||
]);
|
||||
|
||||
/** Levels a resolved area can have. */
|
||||
export const AREA_LEVELS = Object.freeze([
|
||||
'country',
|
||||
'admin1',
|
||||
'admin2',
|
||||
'city',
|
||||
'district',
|
||||
'natural',
|
||||
'site',
|
||||
'drawn',
|
||||
]);
|
||||
|
||||
const round = (value, digits) => {
|
||||
const f = 10 ** digits;
|
||||
return Math.round(value * f) / f;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create an area store.
|
||||
* @param {{maxEntries?: number, maxQueryEntries?: number, now?: () => number, isPinned?: (areaId: string) => boolean}} [options]
|
||||
*/
|
||||
export function createAreaStore({
|
||||
maxEntries = 32,
|
||||
maxQueryEntries = Math.max(32, maxEntries * 8),
|
||||
now = () => Date.now(),
|
||||
isPinned = () => false,
|
||||
} = {}) {
|
||||
/** Insertion order doubles as recency: get() re-inserts. */
|
||||
const records = new Map();
|
||||
/** query key → areaId, for cached resolutions. */
|
||||
const queries = new Map();
|
||||
let seq = 0;
|
||||
|
||||
function forgetRecordQueries(areaId) {
|
||||
for (const [key, queried] of queries)
|
||||
if (queried === areaId) queries.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop least recently used records past the bound, skipping pinned ones
|
||||
* (an area an outline on the map still refers to keeps its geometry for as
|
||||
* long as the outline exists; the annotation cap bounds how many that is).
|
||||
*/
|
||||
function evict(keep) {
|
||||
if (records.size <= maxEntries) return;
|
||||
for (const id of [...records.keys()]) {
|
||||
if (records.size <= maxEntries) break;
|
||||
// The record just stored is always kept; pinned ones may exceed the bound.
|
||||
if (id === keep || isPinned(id)) continue;
|
||||
records.delete(id);
|
||||
forgetRecordQueries(id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store an area. Returns the stored record, or null when the geometry is
|
||||
* unusable.
|
||||
* @param {object} input
|
||||
* @param {object|Array} input.geometry GeoJSON Polygon/MultiPolygon or coordinates.
|
||||
* @param {string} input.name
|
||||
* @param {string} input.source One of AREA_SOURCES.
|
||||
* @param {string} [input.sourceId] Stable identity, e.g. `ne:state:india-punjab`.
|
||||
* @param {string} [input.level]
|
||||
* @param {boolean} [input.approximate]
|
||||
* @param {object} [input.meta] Extra facts (country, license, completeness).
|
||||
* @returns {object|null}
|
||||
*/
|
||||
function put(input) {
|
||||
const geometry = normalizeMultiPolygon(input?.geometry);
|
||||
if (!geometry) return null;
|
||||
const prepared = prepareArea(geometry);
|
||||
if (!prepared) return null;
|
||||
const areaId = input.sourceId
|
||||
? String(input.sourceId)
|
||||
: `area-${(seq += 1)}`;
|
||||
const counts = multiPolygonCounts(geometry);
|
||||
const record = Object.freeze({
|
||||
areaId,
|
||||
name: String(input.name || 'Unnamed area').slice(0, 160),
|
||||
level: AREA_LEVELS.includes(input.level) ? input.level : null,
|
||||
source: AREA_SOURCES.includes(input.source)
|
||||
? input.source
|
||||
: 'approximate',
|
||||
approximate: Boolean(input.approximate),
|
||||
geometry,
|
||||
prepared,
|
||||
bbox: prepared.bbox,
|
||||
areaKm2: multiPolygonAreaKm2(geometry),
|
||||
parts: counts.parts,
|
||||
holes: counts.holes,
|
||||
meta: Object.freeze({ ...(input.meta || {}) }),
|
||||
storedAt: now(),
|
||||
});
|
||||
records.delete(areaId);
|
||||
records.set(areaId, record);
|
||||
evict(areaId);
|
||||
return record;
|
||||
}
|
||||
|
||||
/** The record for an id, marking it recently used; null when unknown or evicted. */
|
||||
function get(areaId) {
|
||||
const record = records.get(String(areaId ?? ''));
|
||||
if (!record) return null;
|
||||
records.delete(record.areaId);
|
||||
records.set(record.areaId, record);
|
||||
return record;
|
||||
}
|
||||
|
||||
return {
|
||||
put,
|
||||
get,
|
||||
has: (areaId) => records.has(String(areaId ?? '')),
|
||||
/** Remember which area a query resolved to. */
|
||||
rememberQuery(key, areaId) {
|
||||
if (!key || !records.has(areaId)) return;
|
||||
queries.delete(key);
|
||||
queries.set(key, areaId);
|
||||
while (queries.size > maxQueryEntries)
|
||||
queries.delete(queries.keys().next().value);
|
||||
},
|
||||
/** The area a query resolved to earlier, if still stored. */
|
||||
recallQuery(key) {
|
||||
const id = queries.get(key);
|
||||
const record = id ? get(id) : null;
|
||||
if (!record) {
|
||||
queries.delete(key);
|
||||
return null;
|
||||
}
|
||||
queries.delete(key);
|
||||
queries.set(key, id);
|
||||
return record;
|
||||
},
|
||||
/** Newest first. */
|
||||
list: () => [...records.values()].reverse(),
|
||||
/** The most recently stored or used record matching `predicate`. */
|
||||
latest(predicate = () => true) {
|
||||
return [...records.values()].reverse().find(predicate) || null;
|
||||
},
|
||||
delete(areaId) {
|
||||
const id = String(areaId ?? '');
|
||||
const deleted = records.delete(id);
|
||||
if (deleted) forgetRecordQueries(id);
|
||||
return deleted;
|
||||
},
|
||||
clear() {
|
||||
records.clear();
|
||||
queries.clear();
|
||||
},
|
||||
get size() {
|
||||
return records.size;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The model-facing summary of an area: identity, size and provenance, never
|
||||
* coordinates beyond a rounded bbox.
|
||||
* @param {object} record
|
||||
* @returns {object}
|
||||
*/
|
||||
export function summarizeArea(record) {
|
||||
if (!record) return null;
|
||||
return {
|
||||
areaId: record.areaId,
|
||||
name: record.name,
|
||||
level: record.level,
|
||||
source: record.source,
|
||||
...(record.meta.country ? { country: record.meta.country } : {}),
|
||||
bbox: record.bbox.map((v) => round(v, 3)),
|
||||
areaKm2:
|
||||
record.areaKm2 >= 100
|
||||
? Math.round(record.areaKm2)
|
||||
: round(record.areaKm2, 2),
|
||||
parts: record.parts,
|
||||
holes: record.holes,
|
||||
...(record.approximate ? { approximate: true } : {}),
|
||||
};
|
||||
}
|
||||
@@ -558,6 +558,15 @@ export const WOF_CREDIT = {
|
||||
'<a href="https://github.com/bilawalsidhu/gods-eye-view/blob/main/src/data/local_data/wof_neighborhoods/ATTRIBUTION.md" target="_blank" rel="noopener">sources and licences</a>)',
|
||||
};
|
||||
|
||||
/** Registered when a voice place search (a configured Overpass) first shows places. */
|
||||
export const OSM_PLACES_CREDIT = {
|
||||
key: 'osm-places',
|
||||
html:
|
||||
'Places found by voice: ' +
|
||||
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> ' +
|
||||
'(ODbL 1.0)',
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-feed transit credit, registered the first time that feed's vehicles
|
||||
* render (see `src/data/transitFeeds.js` for the license of each).
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
/**
|
||||
* The OpenStreetMap feature kinds voice can search for ("hospitals",
|
||||
* "petrol stations", "police"), each with the exact tags it stands for.
|
||||
*
|
||||
* The server compiles Overpass queries only from these ids: a request names a
|
||||
* preset, never a tag, a regex or query text. Pure data; the browser and the
|
||||
* server both import it.
|
||||
*
|
||||
* @module data/osmPresets
|
||||
*/
|
||||
|
||||
/**
|
||||
* The noun is the model's data, not UI copy: it stays English.
|
||||
* id → { noun (plural, spoken), terms (other ways to say it), tags: [[key, value]] }.
|
||||
* A feature matches a preset when it carries ANY of the preset's tags.
|
||||
*/
|
||||
const PRESETS = {
|
||||
hospital: {
|
||||
noun: 'hospitals',
|
||||
terms: ['hospital', 'er', 'emergency room', 'medical center'],
|
||||
tags: [['amenity', 'hospital']],
|
||||
},
|
||||
clinic: {
|
||||
noun: 'clinics',
|
||||
terms: ['clinic', 'health center', 'health post', 'medical clinic'],
|
||||
tags: [['amenity', 'clinic']],
|
||||
},
|
||||
doctors: {
|
||||
noun: 'doctors',
|
||||
terms: ['doctor', "doctor's office", 'gp', 'physician'],
|
||||
tags: [['amenity', 'doctors']],
|
||||
},
|
||||
dentist: {
|
||||
noun: 'dentists',
|
||||
terms: ['dentist', 'dental clinic'],
|
||||
tags: [['amenity', 'dentist']],
|
||||
},
|
||||
pharmacy: {
|
||||
noun: 'pharmacies',
|
||||
terms: ['pharmacy', 'chemist', 'drugstore', 'drug store'],
|
||||
tags: [['amenity', 'pharmacy']],
|
||||
},
|
||||
school: {
|
||||
noun: 'schools',
|
||||
terms: ['school', 'primary school', 'secondary school', 'high school'],
|
||||
tags: [['amenity', 'school']],
|
||||
},
|
||||
kindergarten: {
|
||||
noun: 'kindergartens',
|
||||
terms: ['kindergarten', 'preschool', 'nursery school'],
|
||||
tags: [['amenity', 'kindergarten']],
|
||||
},
|
||||
university: {
|
||||
noun: 'universities',
|
||||
terms: ['university', 'campus'],
|
||||
tags: [['amenity', 'university']],
|
||||
},
|
||||
college: {
|
||||
noun: 'colleges',
|
||||
terms: ['college'],
|
||||
tags: [['amenity', 'college']],
|
||||
},
|
||||
library: {
|
||||
noun: 'libraries',
|
||||
terms: ['library'],
|
||||
tags: [['amenity', 'library']],
|
||||
},
|
||||
police: {
|
||||
noun: 'police stations',
|
||||
terms: ['police', 'police station', 'police post'],
|
||||
tags: [['amenity', 'police']],
|
||||
},
|
||||
fire_station: {
|
||||
noun: 'fire stations',
|
||||
terms: ['fire station', 'fire department', 'firehouse'],
|
||||
tags: [['amenity', 'fire_station']],
|
||||
},
|
||||
prison: {
|
||||
noun: 'prisons',
|
||||
terms: ['prison', 'jail', 'correctional facility'],
|
||||
tags: [['amenity', 'prison']],
|
||||
},
|
||||
courthouse: {
|
||||
noun: 'courthouses',
|
||||
terms: ['courthouse', 'court'],
|
||||
tags: [['amenity', 'courthouse']],
|
||||
},
|
||||
townhall: {
|
||||
noun: 'town halls',
|
||||
terms: ['town hall', 'city hall'],
|
||||
tags: [['amenity', 'townhall']],
|
||||
},
|
||||
embassy: {
|
||||
noun: 'embassies',
|
||||
terms: ['embassy', 'consulate'],
|
||||
tags: [
|
||||
['office', 'diplomatic'],
|
||||
['amenity', 'embassy'],
|
||||
],
|
||||
},
|
||||
post_office: {
|
||||
noun: 'post offices',
|
||||
terms: ['post office'],
|
||||
tags: [['amenity', 'post_office']],
|
||||
},
|
||||
bank: { noun: 'banks', terms: ['bank'], tags: [['amenity', 'bank']] },
|
||||
atm: {
|
||||
noun: 'ATMs',
|
||||
terms: ['atm', 'cash machine', 'cashpoint'],
|
||||
tags: [['amenity', 'atm']],
|
||||
},
|
||||
fuel: {
|
||||
noun: 'fuel stations',
|
||||
terms: [
|
||||
'gas station',
|
||||
'petrol station',
|
||||
'fuel station',
|
||||
'filling station',
|
||||
'gas',
|
||||
],
|
||||
tags: [['amenity', 'fuel']],
|
||||
},
|
||||
charging_station: {
|
||||
noun: 'EV charging stations',
|
||||
terms: ['charging station', 'ev charger', 'ev charging', 'car charger'],
|
||||
tags: [['amenity', 'charging_station']],
|
||||
},
|
||||
parking: {
|
||||
noun: 'car parks',
|
||||
terms: ['parking', 'parking lot', 'car park', 'parking garage'],
|
||||
tags: [['amenity', 'parking']],
|
||||
},
|
||||
restaurant: {
|
||||
noun: 'restaurants',
|
||||
terms: ['restaurant'],
|
||||
tags: [['amenity', 'restaurant']],
|
||||
},
|
||||
cafe: {
|
||||
noun: 'cafés',
|
||||
terms: ['cafe', 'café', 'coffee shop', 'coffee'],
|
||||
tags: [['amenity', 'cafe']],
|
||||
},
|
||||
fast_food: {
|
||||
noun: 'fast-food places',
|
||||
terms: ['fast food', 'fast-food'],
|
||||
tags: [['amenity', 'fast_food']],
|
||||
},
|
||||
bar: {
|
||||
noun: 'bars',
|
||||
terms: ['bar', 'pub'],
|
||||
tags: [
|
||||
['amenity', 'bar'],
|
||||
['amenity', 'pub'],
|
||||
],
|
||||
},
|
||||
place_of_worship: {
|
||||
noun: 'places of worship',
|
||||
terms: [
|
||||
'place of worship',
|
||||
'church',
|
||||
'mosque',
|
||||
'temple',
|
||||
'synagogue',
|
||||
'gurdwara',
|
||||
],
|
||||
tags: [['amenity', 'place_of_worship']],
|
||||
},
|
||||
marketplace: {
|
||||
noun: 'markets',
|
||||
terms: ['market', 'marketplace', 'bazaar'],
|
||||
tags: [['amenity', 'marketplace']],
|
||||
},
|
||||
supermarket: {
|
||||
noun: 'supermarkets',
|
||||
terms: ['supermarket', 'grocery store', 'grocery'],
|
||||
tags: [['shop', 'supermarket']],
|
||||
},
|
||||
mall: {
|
||||
noun: 'shopping malls',
|
||||
terms: ['mall', 'shopping mall', 'shopping center', 'shopping centre'],
|
||||
tags: [['shop', 'mall']],
|
||||
},
|
||||
convenience: {
|
||||
noun: 'convenience stores',
|
||||
terms: ['convenience store', 'corner shop'],
|
||||
tags: [['shop', 'convenience']],
|
||||
},
|
||||
hotel: { noun: 'hotels', terms: ['hotel'], tags: [['tourism', 'hotel']] },
|
||||
hostel: {
|
||||
noun: 'hostels',
|
||||
terms: ['hostel'],
|
||||
tags: [['tourism', 'hostel']],
|
||||
},
|
||||
museum: {
|
||||
noun: 'museums',
|
||||
terms: ['museum'],
|
||||
tags: [['tourism', 'museum']],
|
||||
},
|
||||
attraction: {
|
||||
noun: 'tourist attractions',
|
||||
terms: ['tourist attraction', 'attraction', 'sight', 'sights'],
|
||||
tags: [['tourism', 'attraction']],
|
||||
},
|
||||
viewpoint: {
|
||||
noun: 'viewpoints',
|
||||
terms: ['viewpoint', 'lookout', 'scenic view'],
|
||||
tags: [['tourism', 'viewpoint']],
|
||||
},
|
||||
zoo: { noun: 'zoos', terms: ['zoo'], tags: [['tourism', 'zoo']] },
|
||||
cinema: {
|
||||
noun: 'cinemas',
|
||||
terms: ['cinema', 'movie theater', 'movie theatre'],
|
||||
tags: [['amenity', 'cinema']],
|
||||
},
|
||||
theatre: {
|
||||
noun: 'theatres',
|
||||
terms: ['theatre', 'theater', 'playhouse'],
|
||||
tags: [['amenity', 'theatre']],
|
||||
},
|
||||
stadium: {
|
||||
noun: 'stadiums',
|
||||
terms: ['stadium', 'arena'],
|
||||
tags: [['leisure', 'stadium']],
|
||||
},
|
||||
park: { noun: 'parks', terms: ['park'], tags: [['leisure', 'park']] },
|
||||
playground: {
|
||||
noun: 'playgrounds',
|
||||
terms: ['playground'],
|
||||
tags: [['leisure', 'playground']],
|
||||
},
|
||||
sports_centre: {
|
||||
noun: 'sports centres',
|
||||
terms: ['sports center', 'sports centre', 'gym', 'leisure center'],
|
||||
tags: [
|
||||
['leisure', 'sports_centre'],
|
||||
['leisure', 'fitness_centre'],
|
||||
],
|
||||
},
|
||||
cemetery: {
|
||||
noun: 'cemeteries',
|
||||
terms: ['cemetery', 'graveyard'],
|
||||
tags: [
|
||||
['landuse', 'cemetery'],
|
||||
['amenity', 'grave_yard'],
|
||||
],
|
||||
},
|
||||
toilets: {
|
||||
noun: 'public toilets',
|
||||
terms: ['toilet', 'toilets', 'restroom', 'public toilet', 'bathroom'],
|
||||
tags: [['amenity', 'toilets']],
|
||||
},
|
||||
drinking_water: {
|
||||
noun: 'drinking water points',
|
||||
terms: ['drinking water', 'water fountain', 'water point'],
|
||||
tags: [['amenity', 'drinking_water']],
|
||||
},
|
||||
shelter: {
|
||||
noun: 'shelters',
|
||||
terms: ['shelter', 'emergency shelter'],
|
||||
tags: [['amenity', 'shelter']],
|
||||
},
|
||||
airport: {
|
||||
noun: 'airports',
|
||||
terms: ['airport', 'airfield', 'aerodrome', 'airstrip'],
|
||||
tags: [['aeroway', 'aerodrome']],
|
||||
},
|
||||
helipad: {
|
||||
noun: 'helipads',
|
||||
terms: ['helipad', 'heliport'],
|
||||
tags: [
|
||||
['aeroway', 'helipad'],
|
||||
['aeroway', 'heliport'],
|
||||
],
|
||||
},
|
||||
train_station: {
|
||||
noun: 'train stations',
|
||||
terms: ['train station', 'railway station', 'rail station'],
|
||||
tags: [['railway', 'station']],
|
||||
},
|
||||
bus_station: {
|
||||
noun: 'bus stations',
|
||||
terms: ['bus station', 'bus terminal', 'bus depot'],
|
||||
tags: [['amenity', 'bus_station']],
|
||||
},
|
||||
ferry_terminal: {
|
||||
noun: 'ferry terminals',
|
||||
terms: ['ferry terminal', 'ferry'],
|
||||
tags: [['amenity', 'ferry_terminal']],
|
||||
},
|
||||
harbour: {
|
||||
noun: 'harbours',
|
||||
terms: ['harbour', 'harbor', 'port', 'marina'],
|
||||
tags: [
|
||||
['harbour', 'yes'],
|
||||
['leisure', 'marina'],
|
||||
['landuse', 'port'],
|
||||
],
|
||||
},
|
||||
bridge: {
|
||||
noun: 'bridges',
|
||||
terms: ['bridge'],
|
||||
tags: [['man_made', 'bridge']],
|
||||
},
|
||||
lighthouse: {
|
||||
noun: 'lighthouses',
|
||||
terms: ['lighthouse'],
|
||||
tags: [['man_made', 'lighthouse']],
|
||||
},
|
||||
water_tower: {
|
||||
noun: 'water towers',
|
||||
terms: ['water tower'],
|
||||
tags: [['man_made', 'water_tower']],
|
||||
},
|
||||
power_plant: {
|
||||
noun: 'power plants',
|
||||
terms: ['power plant', 'power station', 'generating station'],
|
||||
tags: [['power', 'plant']],
|
||||
},
|
||||
substation: {
|
||||
noun: 'substations',
|
||||
terms: ['substation', 'electrical substation'],
|
||||
tags: [['power', 'substation']],
|
||||
},
|
||||
wind_turbine: {
|
||||
noun: 'wind turbines',
|
||||
terms: ['wind turbine', 'windmill'],
|
||||
tags: [['generator:source', 'wind']],
|
||||
},
|
||||
dam: { noun: 'dams', terms: ['dam'], tags: [['waterway', 'dam']] },
|
||||
military: {
|
||||
noun: 'military sites',
|
||||
terms: ['military base', 'military site', 'base', 'barracks'],
|
||||
tags: [
|
||||
['landuse', 'military'],
|
||||
['military', 'base'],
|
||||
['military', 'barracks'],
|
||||
],
|
||||
},
|
||||
peak: {
|
||||
noun: 'peaks',
|
||||
terms: ['peak', 'mountain peak', 'summit', 'mountain'],
|
||||
tags: [['natural', 'peak']],
|
||||
},
|
||||
waterfall: {
|
||||
noun: 'waterfalls',
|
||||
terms: ['waterfall', 'falls'],
|
||||
tags: [['waterway', 'waterfall']],
|
||||
},
|
||||
};
|
||||
|
||||
/** Frozen preset table. */
|
||||
export const OSM_PRESETS = Object.freeze(
|
||||
Object.fromEntries(
|
||||
Object.entries(PRESETS).map(([id, preset]) => [
|
||||
id,
|
||||
Object.freeze({
|
||||
id,
|
||||
label: preset.noun,
|
||||
terms: Object.freeze([...preset.terms]),
|
||||
tags: Object.freeze(
|
||||
preset.tags.map((pair) => Object.freeze([...pair])),
|
||||
),
|
||||
}),
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
/** Every preset id, for tool enums. */
|
||||
export const OSM_PRESET_IDS = Object.freeze(Object.keys(OSM_PRESETS));
|
||||
|
||||
const normalize = (text) =>
|
||||
String(text || '')
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/\p{M}/gu, '')
|
||||
.replace(/[^a-z0-9 ]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
/** Naive singular: "pharmacies" → "pharmacy", "hospitals" → "hospital". */
|
||||
function singular(word) {
|
||||
if (word.endsWith('ies')) return `${word.slice(0, -3)}y`;
|
||||
if (/(ches|shes|sses|xes)$/.test(word)) return word.slice(0, -2);
|
||||
if (word.endsWith('s') && !word.endsWith('ss')) return word.slice(0, -1);
|
||||
return word;
|
||||
}
|
||||
const singularPhrase = (text) => text.split(' ').map(singular).join(' ');
|
||||
|
||||
const TERM_INDEX = (() => {
|
||||
const index = new Map();
|
||||
for (const preset of Object.values(OSM_PRESETS)) {
|
||||
for (const term of [
|
||||
preset.id.replace(/_/g, ' '),
|
||||
singularPhrase(normalize(preset.label)),
|
||||
...preset.terms,
|
||||
]) {
|
||||
const key = singularPhrase(normalize(term));
|
||||
if (key && !index.has(key)) index.set(key, preset.id);
|
||||
}
|
||||
}
|
||||
return index;
|
||||
})();
|
||||
|
||||
/**
|
||||
* The preset a spoken phrase names ("all the hospitals" → hospital), or null.
|
||||
* An exact preset id wins; otherwise the longest known term found in the
|
||||
* phrase.
|
||||
* @param {string} phrase
|
||||
* @returns {object|null} A preset.
|
||||
*/
|
||||
export function matchOsmPreset(phrase) {
|
||||
const raw = String(phrase || '').trim();
|
||||
if (Object.hasOwn(OSM_PRESETS, raw)) return OSM_PRESETS[raw];
|
||||
const text = singularPhrase(
|
||||
normalize(raw).replace(/^(all|every|the|any)( the)? /, ''),
|
||||
);
|
||||
if (!text) return null;
|
||||
if (TERM_INDEX.has(text)) return OSM_PRESETS[TERM_INDEX.get(text)];
|
||||
let best = null;
|
||||
for (const [term, id] of TERM_INDEX) {
|
||||
if (term.length < 3) continue;
|
||||
if (!new RegExp(`(^| )${term}( |$)`).test(text)) continue;
|
||||
if (!best || term.length > best.term.length) best = { term, id };
|
||||
}
|
||||
return best ? OSM_PRESETS[best.id] : null;
|
||||
}
|
||||
Reference in new issue
Block a user