Merge branch 'main' into upstream/traffic-paired-flow-density

This commit is contained in:
German Khokhlov authored and GitHub committed 2026-11-03 22:38:31 +04:00
commit e8735163c7
145 files changed
+18208 -1205

No files matched your search

+72
View File
@@ -421,6 +421,49 @@ const COUNTRY_TARGET_ALIASES = new Map([
['drc', 'democratic republic of the congo'],
]);
/**
* Country aliases that are another name for the very same unit. "Great
* Britain" and "Britain" are islands, "America" is also two continents and
* "Korea" two countries, so those find a country without naming it.
*/
const COUNTRY_IDENTITY_ALIASES = new Set([
'usa',
'us',
'u s',
'u s a',
'united states',
'uk',
'u k',
'russian federation',
'czechia',
'tanzania',
'drc',
]);
/**
* The unit an ask names in the matcher's own terms, when its words differ
* from the unit's name: "State of Texas" names Texas at admin level 1, and
* "USA" or "Country of USA" names United States of America. Null when the
* ask is just a name. Names are folded with `normalizeAdminName`.
* @param {string} text
* @returns {{name: string, level: 'country'|'admin1'}|null}
*/
export function adminQueryIdentity(text) {
const parsed = parseAdminQuery(text);
if (!parsed?.name) return null;
if (parsed.kind === 'state' && parsed.form === 'prefix')
return { name: parsed.name, level: 'admin1' };
if (parsed.kind !== 'country' && parsed.kind !== 'any') return null;
if (COUNTRY_IDENTITY_ALIASES.has(parsed.name))
return {
name: COUNTRY_TARGET_ALIASES.get(parsed.name),
level: 'country',
};
return parsed.kind === 'country'
? { name: parsed.name, level: 'country' }
: null;
}
const COUNTRY_ALIASES = new Map([
...COUNTRY_TARGET_ALIASES,
['england', 'united kingdom'],
@@ -796,6 +839,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
+2
View File
@@ -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'],
+230 -47
View File
@@ -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 {
@@ -456,7 +487,16 @@ const ANSWERABLE = new Set(['ok', 'empty']);
/** Rows per scan slice before yielding to the page. */
const SCAN_CHUNK = 20_000;
/**
* A slice also ends after this long: a row inside a large area pays a ray
* cast over every edge (a 6,000-vertex outline takes ~200 ms per 20,000 rows),
* so the row count alone would still make long tasks.
*/
const SCAN_BUDGET_MS = 8;
/** Rows between clock checks. */
const SCAN_CLOCK_EVERY = 256;
const yieldToPage = () => new Promise((resolve) => setTimeout(resolve, 0));
const clockMs = () => globalThis.performance?.now?.() ?? Date.now();
/**
* Compile the filters into one predicate over (layerKey, row).
@@ -597,12 +637,19 @@ export async function selectAnalystRows(
let min = Infinity;
let max = -Infinity;
let seen = 0;
let sliceStart = clockMs();
for (const { key, rows } of groups) {
const kept = [];
for (const row of rows) {
if (++seen % SCAN_CHUNK === 0) {
seen += 1;
if (
seen % SCAN_CHUNK === 0 ||
(seen % SCAN_CLOCK_EVERY === 0 &&
clockMs() - sliceStart >= SCAN_BUDGET_MS)
) {
await yieldToPage();
if (!isCurrent()) return null;
sliceStart = clockMs();
}
if (inScope && !inScope(row)) continue;
if (!passes(key, row)) continue;
@@ -650,9 +697,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 +708,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 +719,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 +862,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 +908,32 @@ 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 || [],
// The way back once the user picks: count over that area.
then: 'After the user picks, call resolve_area with that candidateId, then analyst_query with scope {kind:"area", areaId}.',
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 +941,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 +1018,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 +1100,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 +1128,102 @@ 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, presentation?: object|null, truncated?: boolean}} [options]
* `presentation` is how the set was scoped ("inside Kathmandu"), so a
* follow-up names the same scope instead of "anywhere". `truncated`
* says the set is only the first part of a larger one, so follow-ups
* stay incomplete (a count is a floor) rather than claiming the whole.
*/
remember(
layerKey,
rows,
{ scope = 'handoff', presentation = null, truncated = false } = {},
) {
if (!ANALYST_LAYERS[layerKey] || !Array.isArray(rows)) return false;
const capped = truncated === true;
// A capped set's true size is unknown.
const total = capped ? null : rows.length;
lastResult = {
...(presentation
? { presentation: structuredClone(presentation) }
: {}),
matched: [{ key: layerKey, rows: rows.slice() }],
layerKeys: [layerKey],
coverage: {
layersQueried: [
{
layerKey,
status: rows.length ? 'ok' : 'empty',
returned: rows.length,
total,
truncated: capped,
},
],
records: {
returned: rows.length,
total,
truncated: capped,
},
scope,
followUp: false,
},
};
return true;
},
reset() {
lastResult = null;
},
+282 -5
View File
@@ -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,193 @@ 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.match(r.then, /resolve_area.*candidateId.*analyst_query.*kind.*area/, 'the model gets a route back');
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');
});
test('a follow-up on handed-off places keeps the area they were found in', async () => {
const rows = [
{ id: 'node/1', lat: 27.7, lon: 85.3, name: 'Bir Hospital', kind: 'hospital', emergency: 'yes' },
{ id: 'node/2', lat: 27.71, lon: 85.31, name: 'Teaching Hospital', kind: 'hospital', emergency: 'no' },
];
const engine = createAnalystEngine({
getRecords: (key) => (key === 'osm-places' ? rows : []),
resolveRegionRing: async () => null,
getViewContext: () => ({ lat: 27.7, lon: 85.3, viewRadiusKm: 20 }),
});
assert.equal(
engine.remember('osm-places', rows, {
presentation: { scopeLabel: 'inside Kathmandu', coverageScope: 'area:area-1', areaId: 'area-1' },
}),
true,
);
const result = await engine.query({
layers: ['osm-places'],
followUp: true,
filters: [{ field: 'emergency', op: 'eq', value: 'yes' }],
});
assert.equal(result.count, 1);
assert.equal(result.scopeLabel, 'inside Kathmandu');
assert.equal(result.coverage.scope, 'area:area-1');
});
test('a follow-up on a capped handed-off set is not complete', async () => {
const rows = [
{ id: 'node/1', lat: 27.7, lon: 85.3, name: 'Bir Hospital', kind: 'hospital', emergency: 'yes' },
];
const engine = createAnalystEngine({
getRecords: (key) => (key === 'osm-places' ? rows : []),
resolveRegionRing: async () => null,
getViewContext: () => ({ lat: 27.7, lon: 85.3, viewRadiusKm: 20 }),
});
engine.remember('osm-places', rows, { truncated: true });
const result = await engine.query({ layers: ['osm-places'], followUp: true });
assert.equal(result.count, 1);
assert.equal(result.complete, false);
assert.equal(result.coverage.records.truncated, true);
assert.equal(result.coverage.records.total, null, 'the true size is unknown');
});
test('a slow scan yields on a time budget, not only every 20,000 rows', async () => {
// 4,000 rows that each cost ~0.05 ms to read: ~200 ms of work in one slice
// under a row-count budget alone.
const slow = () => {
const until = performance.now() + 0.05;
while (performance.now() < until);
return 5;
};
const rows = Array.from({ length: 4_000 }, (_, i) => ({ id: `S${i}`, lat: 0, lon: 0, get frp() { return slow(); } }));
const engine = createAnalystEngine({ getRecords: () => rows, getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }) });
// Count the scan's yields (other timers run only when it yields), not wall
// gaps, so a loaded machine cannot fail it.
let ticks = 0;
const timer = setInterval(() => ticks++, 0);
const result = await engine.query({ layers: ['local-firms'], scope: { kind: 'anywhere' }, filters: [{ field: 'frp', op: 'gt', value: 1 }] });
clearInterval(timer);
assert.equal(result.count, 4_000);
assert.ok(ticks >= 5, `the scan yielded ${ticks} times in ~200 ms of work`);
});
+397
View File
@@ -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;
}
+178
View File
@@ -0,0 +1,178 @@
// 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}`);
});
+668
View File
@@ -0,0 +1,668 @@
/**
* 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 { adminQueryIdentity, normalizeAdminName } from './adminBoundaries.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',
natural: 'natural',
});
/**
* One of several same-name natural places as a clarification choice, located
* by its rough centre ("39 N 31 W") since it has no country.
*/
function naturalCandidate(place, kind) {
const { lat, lon } = place.center || {};
const locator = Number.isFinite(lat)
? `${Math.abs(Math.round(lat))} ${lat >= 0 ? 'N' : 'S'} ${Math.abs(Math.round(lon))} ${lon >= 0 ? 'E' : 'W'}`
: '';
return {
kind: 'natural',
name: place.name,
country: '',
regionCode: locator,
polygons: place.polygons.map((ring) => [ring]),
source: 'natural-earth',
meta: { kind },
};
}
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',
});
/**
* Drop accents from Latin letters only, for comparing names: speech often
* loses them ("Quebec" for "Québec"), while a Thai or Devanagari mark changes
* the name and is kept.
*/
export function foldLatinMarks(text) {
return String(text || '')
.normalize('NFD')
.replace(/(\p{Script=Latin})\p{M}+/gu, '$1')
.normalize('NFC');
}
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: foldLatinMarks(
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.
* With `aliases`, an ask the administrative matcher reads as naming this very
* unit at its level ("State of Texas", "USA") also matches; the natural-region
* check leaves it off, since "Great Britain" is not the United Kingdom.
*/
function bundledAdminIdentityRefusal(
admin,
query,
wantedLevel,
{ aliases = true } = {},
) {
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}".`,
};
const alias = aliases ? adminQueryIdentity(query) : null;
const named =
source.bare === requested.bare ||
Boolean(
alias &&
alias.level === emittedLevel &&
alias.name === normalizeAdminName(admin.name),
);
if (
!named ||
(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}:`;
// The region code keeps same-named units apart: "Washington" is both WA
// and DC, which would otherwise share one candidate and one stored area.
const slug = normalizeAreaQuery(
`${admin.country} ${admin.name} ${admin.regionCode || ''}`,
).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,
// Tells same-named units apart when spoken: Washington WA vs DC.
...(admin.regionCode && admin.kind !== 'country'
? { region: String(admin.regionCode) }
: {}),
...(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.
*/
/** A coarse ~2° cell of the view, or "none", for keying view-picked answers. */
function viewCell(center) {
if (!Number.isFinite(center?.lat) || !Number.isFinite(center?.lon))
return 'none';
return `${Math.floor(center.lat / 2)},${Math.floor(center.lon / 2)}`;
}
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;
let bundledRefusal = null;
// 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?.();
// A region named like a country or state ("India" the peninsula,
// "Punjab" the desert) never hides it; asked for by level, it answers.
// Only a unit that really has this name counts: "Great Britain" finds
// the United Kingdom by alias, which is not a Great Britain.
// A feature split into parts is one place with every part; distant
// namesakes (Flores in the Azores and Indonesia) are asked about.
const places = ne?.places?.length
? ne.places
: ne?.polygons?.length
? [{ name: ne.name, polygons: ne.polygons }]
: [];
const shadowsAdmin =
places.length > 0 &&
level !== 'natural' &&
(await findAdminCandidates(query, { signal }).catch(() => [])).some(
(admin) =>
!bundledAdminIdentityRefusal(admin, query, null, {
aliases: false,
}),
);
signal?.throwIfAborted?.();
if (places.length > 1 && !shadowsAdmin) {
const offered = places
.slice(0, MAX_AREA_CANDIDATES)
.map((place) => naturalCandidate(place, ne.kind));
const identity = { query, wantedLevel: 'natural' };
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.`,
};
}
if (places.length === 1 && !shadowsAdmin) {
const record = store.put({
geometry: places[0].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) {
const refused = checked.find((entry) => entry.refused)?.refused;
// A retired or unverifiable unit is final. A different unit that
// merely shares the name ("Azad Kashmir" for "Kashmir") is not an
// answer, so the outline ladder still gets to look.
if (refused?.code !== 'AREA_IDENTITY_MISMATCH') return refused;
bundledRefusal = 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 (
bundledRefusal || {
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);
// The outline ladder and geocoder pick by the view ("Springfield" over
// Illinois), so an answer is reused or shared only within the same
// ~2° view cell. A name that normalizes to nothing is never cached.
const key = nameKey
? `${nameKey}|${level || ''}|${normalizeAreaQuery(within)}|${viewCell(viewCenter())}`
: 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,
);
}),
);
// Detached once this call settles: a caller's long-lived signal must not
// collect one listener per completed lookup.
let cancelled = null;
if (signal)
racers.push(
new Promise((resolveAbort) => {
const superseded = {
ok: false,
code: 'CANCELLED',
cancelled: true,
error: 'Superseded.',
};
if (signal.aborted) return resolveAbort(superseded);
cancelled = () => resolveAbort(superseded);
signal.addEventListener?.('abort', cancelled, { once: true });
return undefined;
}),
);
try {
return finish(await Promise.race(racers));
} finally {
clearTimeout(timer);
if (cancelled) signal.removeEventListener?.('abort', cancelled);
}
}
function dispose() {
if (disposed) return;
disposed = true;
lifetime.abort();
inFlight.clear();
candidateMemory.clear();
}
return {
resolve,
dispose,
candidate: (id) => candidateMemory.get(id) || null,
};
}
+896
View File
@@ -0,0 +1,896 @@
// 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("settled lookups leave no listener on the caller's signal", async () => {
const { resolver } = makeResolver();
const controller = new AbortController();
let added = 0;
let removed = 0;
const add = controller.signal.addEventListener.bind(controller.signal);
const remove = controller.signal.removeEventListener.bind(controller.signal);
controller.signal.addEventListener = (...args) => {
if (args[0] === 'abort') added += 1;
return add(...args);
};
controller.signal.removeEventListener = (...args) => {
if (args[0] === 'abort') removed += 1;
return remove(...args);
};
for (const query of ['Texas', 'Bagmati', 'Nowhere at all'])
await resolver.resolve({ query }, { signal: controller.signal });
assert.ok(added > 0, 'the lookups listened while running');
assert.equal(removed, added, 'every listener is detached once settled');
const aborted = AbortSignal.abort();
let attached = 0;
aborted.addEventListener = () => {
attached += 1;
};
const late = await resolver.resolve(
{ query: 'Punjab, Pakistan' },
{ signal: aborted },
);
assert.equal(late.code, 'CANCELLED');
assert.equal(attached, 0, 'an aborted signal gets no listener');
});
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');
});
test('a natural region never hides a country or state of the same name', async () => {
const desert = {
name: 'Punjab',
kind: 'natural',
polygons: [square(70, 28, 76, 33)],
};
const { resolver } = makeResolver({
findNaturalRegion: async (q) => (/^punjab$/i.test(q) ? desert : null),
});
const bare = await resolver.resolve({ query: 'Punjab' });
assert.equal(bare.ok, false);
assert.equal(bare.code, 'AMBIGUOUS_AREA', 'the two states are asked about');
const natural = await resolver.resolve({ query: 'Punjab', level: 'natural' });
assert.equal(natural.ok, true);
assert.equal(natural.record.level, 'natural', 'asked for, the region wins');
});
test('same-named states in one country get their own candidate and area', async () => {
const wa = admin(
'state',
'Washington',
'United States of America',
[-124, 45.5, -117, 49],
{ regionCode: 'WA' },
);
const dc = admin(
'state',
'Washington',
'United States of America',
[-77.2, 38.8, -76.9, 39],
{ regionCode: 'DC' },
);
const { resolver } = makeResolver({
findAdminCandidates: async (q) => (/^washington$/i.test(q) ? [wa, dc] : []),
});
const asked = await resolver.resolve({ query: 'Washington' });
assert.equal(asked.code, 'AMBIGUOUS_AREA');
const ids = asked.candidates.map((c) => c.candidateId);
assert.equal(new Set(ids).size, 2, `distinct ids: ${ids.join(', ')}`);
assert.deepEqual(
asked.candidates.map((c) => c.region),
['WA', 'DC'],
'the choices can be told apart',
);
const picked = await Promise.all(
ids.map((candidateId) => resolver.resolve({ candidateId })),
);
assert.ok(picked.every((p) => p.ok));
assert.notEqual(
picked[0].record.areaId,
picked[1].record.areaId,
'two areas, not one overwritten',
);
assert.equal(
pointInPreparedArea(picked[0].record.prepared, 47, -120),
true,
'Washington State',
);
assert.equal(
pointInPreparedArea(picked[1].record.prepared, 38.9, -77),
true,
'District of Columbia',
);
});
test('a bundled name mismatch falls through to the outline ladder', async () => {
const azad = admin('state', 'Azad Kashmir', 'Pakistan', [73, 33, 75, 35.5]);
const kashmir = {
ok: true,
name: 'Kashmir',
polygons: [[square(73, 32, 80, 37)]],
source: 'osm',
rung: 'nominatim',
};
let outlineOk = true;
const { resolver } = makeResolver({
findAdminCandidates: async (q) => (/^kashmir$/i.test(q) ? [azad] : []),
resolveNamedOutline: async () =>
outlineOk
? kashmir
: { ok: false, code: 'AREA_NOT_FOUND', error: 'none' },
});
const found = await resolver.resolve({ query: 'Kashmir' });
assert.equal(found.ok, true, found.error);
assert.equal(found.record.name, 'Kashmir');
outlineOk = false;
const { resolver: offline } = makeResolver({
findAdminCandidates: async () => [azad],
resolveNamedOutline: async () => ({
ok: false,
code: 'AREA_NOT_FOUND',
error: 'none',
}),
});
const refused = await offline.resolve({ query: 'Kashmir' });
assert.equal(
refused.code,
'AREA_IDENTITY_MISMATCH',
'the bundled refusal is kept',
);
});
test('a view-picked outline answer is not reused for the same name elsewhere', async () => {
let view = { lat: 39.8, lon: -89.6 }; // Springfield, Illinois
const springfield = (center) => ({
ok: true,
name:
center.lon < -80 ? 'Springfield, Illinois' : 'Springfield, Massachusetts',
polygons: [
[
square(
center.lon - 0.1,
center.lat - 0.1,
center.lon + 0.1,
center.lat + 0.1,
),
],
],
source: 'osm',
rung: 'nominatim',
});
const { resolver } = makeResolver({
viewCenter: () => view,
resolveNamedOutline: async () => springfield(view),
});
const illinois = await resolver.resolve({ query: 'Springfield' });
assert.equal(illinois.record.name, 'Springfield, Illinois');
const again = await resolver.resolve({ query: 'Springfield' });
assert.equal(again.cached, true, 'the same view still reuses it');
view = { lat: 42.1, lon: -72.6 }; // Springfield, Massachusetts
const massachusetts = await resolver.resolve({ query: 'Springfield' });
assert.equal(massachusetts.cached, false);
assert.equal(massachusetts.record.name, 'Springfield, Massachusetts');
});
test('a name spoken without accents matches its accented unit, Latin letters only', async () => {
const quebec = admin('state', 'Québec', 'Canada', [-79.8, 45, -57.1, 62.6]);
const cordobaAr = admin(
'state',
'Córdoba',
'Argentina',
[-65.8, -35, -61.8, -29.5],
);
const cordobaEs = admin('state', 'Córdoba', 'Spain', [-5.6, 37.2, -4, 38.7]);
const thai = admin('state', 'ป่าย', 'Thailand', [98, 19, 99, 20]);
const { resolver } = makeResolver({
findAdminCandidates: async (q) =>
/^quebec$/i.test(q)
? [quebec]
: /^cordoba$/i.test(q)
? [cordobaAr, cordobaEs]
: q === 'ปาย'
? [thai]
: [],
});
const qc = await resolver.resolve({ query: 'Quebec' });
assert.equal(qc.ok, true, qc.error);
assert.equal(qc.record.name, 'Québec');
const cordoba = await resolver.resolve({ query: 'Cordoba' });
assert.equal(cordoba.code, 'AMBIGUOUS_AREA', 'both Córdobas are asked about');
assert.equal(cordoba.candidates.length, 2);
const tone = await resolver.resolve({ query: 'ปาย' });
assert.equal(tone.ok, false, 'a Thai tone mark still tells names apart');
});
test('a natural name for two distant places is asked about, and the choice resolves', async () => {
const azores = {
name: 'Flores',
featurecla: 'Island',
polygons: [square(-31.3, 39.4, -31.1, 39.5)],
areaKm2: 140,
center: { lat: 39.45, lon: -31.2 },
};
const indonesia = {
name: 'Flores',
featurecla: 'Island',
polygons: [square(119.8, -8.9, 123, -8.1)],
areaKm2: 14000,
center: { lat: -8.5, lon: 121.4 },
};
const { resolver } = makeResolver({
findNaturalRegion: async (q) =>
/^flores$/i.test(q)
? {
name: 'Flores',
kind: 'natural',
polygons: indonesia.polygons,
places: [indonesia, azores],
}
: null,
});
const asked = await resolver.resolve({ query: 'Flores' });
assert.equal(asked.code, 'AMBIGUOUS_AREA');
assert.equal(asked.candidates.length, 2);
assert.ok(asked.candidates.every((c) => c.level === 'natural' && c.region));
const picked = await resolver.resolve({
candidateId: asked.candidates[1].candidateId,
});
assert.equal(picked.ok, true, picked.error);
assert.equal(picked.record.level, 'natural');
assert.equal(
pointInPreparedArea(picked.record.prepared, 39.45, -31.2),
true,
'the Azores Flores',
);
});
test('a natural feature in several parts is stored whole', async () => {
const north = square(33.7, 2.4, 44.2, 15.4);
const south = square(28.9, -15.6, 35.8, 2.4);
const { resolver } = makeResolver({
findNaturalRegion: async (q) =>
/rift/i.test(q)
? {
name: 'Great Rift Valley',
kind: 'natural',
polygons: [north],
places: [
{
name: 'Great Rift Valley',
featurecla: 'Valley',
polygons: [north, south],
areaKm2: 2,
center: { lat: 0, lon: 36 },
},
],
}
: null,
});
const rift = await resolver.resolve({ query: 'Great Rift Valley' });
assert.equal(rift.ok, true);
assert.equal(
pointInPreparedArea(rift.record.prepared, -10, 33),
true,
'the southern part counts',
);
});
+212
View File
@@ -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 } : {}),
};
}
+23
View File
@@ -150,6 +150,20 @@ export const DATA_CREDITS = [
key: 'usgs',
html: 'Earthquakes: Data courtesy of the U.S. Geological Survey',
},
{
key: 'openseamap',
html:
'Sea marks overlay: ' +
'<a href="https://www.openseamap.org" target="_blank" rel="noopener">© OpenSeaMap contributors</a> (CC BY-SA 2.0) · ' +
'Data © OpenStreetMap contributors (ODbL)',
},
{
key: 'opensnowmap',
html:
'Ski pistes overlay: ' +
'<a href="https://www.opensnowmap.org" target="_blank" rel="noopener">© www.opensnowmap.org</a> (CC BY-SA) · ' +
'Data © OpenStreetMap contributors (ODbL)',
},
{
key: 'nasa-gibs',
html:
@@ -558,6 +572,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).
+2
View File
@@ -666,6 +666,8 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
disposition: 'enabled+options',
optionOwner: 'satellites',
}),
Object.freeze({ id: 'sea-marks', token: '6', disposition: 'enabled-only' }),
Object.freeze({ id: 'ski-pistes', token: '7', disposition: 'enabled-only' }),
Object.freeze({
id: 'street-level',
token: '0',
+13 -15
View File
@@ -202,8 +202,8 @@ function encode(state) {
test('production registry is exact, canonical, and rejects incomplete contracts', async () => {
assert.equal(validateLayerStateRegistry(), true);
assert.equal(REGISTERED_LAYER_IDS.length, 32);
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 32);
assert.equal(REGISTERED_LAYER_IDS.length, 34);
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 34);
assert.ok(REGISTERED_LAYER_IDS.includes('transit'));
assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort());
assert.deepEqual(LEGACY_LAYER_STATE_TOKENS, {
@@ -242,20 +242,18 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
for (const [id, token] of Object.entries(LEGACY_LAYER_STATE_TOKENS)) {
assert.equal(LAYER_STATE_TOKEN_RESERVATIONS[id], token);
}
assert.equal(nextLayerStateToken(), '6');
assert.equal(nextLayerStateToken(), '8');
assert.equal(
nextLayerStateToken({
...LAYER_STATE_TOKEN_RESERVATIONS,
alpha: '6',
bravo: '7',
alpha: '8',
bravo: '9',
}),
'8',
'00',
);
const digitsExhausted = {
...LAYER_STATE_TOKEN_RESERVATIONS,
...Object.fromEntries(
[...'6789'].map((digit) => [`prior-${digit}`, digit]),
),
...Object.fromEntries([...'89'].map((digit) => [`prior-${digit}`, digit])),
};
assert.equal(nextLayerStateToken(digitsExhausted), '00');
assert.equal(
@@ -307,8 +305,8 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
assert.equal(
validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
...LAYER_STATE_TOKEN_RESERVATIONS,
future: '6',
next: '7',
future: '8',
next: '9',
}),
true,
);
@@ -318,7 +316,7 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
...LAYER_STATE_TOKEN_RESERVATIONS,
future: '00',
}),
/next free token 6/,
/next free token 8/,
);
const beforeLastDigit = { ...digitsExhausted };
delete beforeLastDigit['prior-9'];
@@ -341,10 +339,10 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
assert.throws(
() =>
validateLayerStateAllocations(
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '6' },
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '6', competing: '6' },
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '8' },
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '8', competing: '8' },
),
/next free token 7/,
/next free token 9/,
);
assert.throws(
() => validateLayerStateAllocations({ future: '00' }, { future: '01' }),
+30 -28
View File
@@ -128,7 +128,7 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
{ ...parseLayerStateTokenReservations(reservationRows) },
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
);
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 32);
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 34);
assert.deepEqual(
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
{
@@ -137,6 +137,8 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
'gnss-integrity': '3',
'air-quality': '4',
's2-ghostmaps': '5',
'sea-marks': '6',
'ski-pistes': '7',
},
);
assert.throws(
@@ -162,9 +164,9 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
/Duplicate layer-state token reservation/,
);
assert.equal(
parseLayerStateTokenReservations([...reservationRows, ['future', '6']])
parseLayerStateTokenReservations([...reservationRows, ['future', '8']])
.future,
'6',
'8',
);
assert.throws(
() =>
@@ -182,14 +184,14 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
test('published retired digits and competing PRs advance the merge-time allocation', () => {
withPublishedBase(
{ rows: [...reservationRows, ['retired-layer', '6']] },
{ rows: [...reservationRows, ['retired-layer', '8']] },
(cwd) => {
const published = readPublishedLayerStateReservations('HEAD', cwd);
assert.equal(nextLayerStateToken(published), '7');
assert.equal(nextLayerStateToken(published), '9');
assert.equal(
validateLayerStateAllocations(published, {
...published,
newcomer: '7',
newcomer: '9',
}),
true,
);
@@ -197,15 +199,15 @@ test('published retired digits and competing PRs advance the merge-time allocati
() =>
validateLayerStateAllocations(published, {
...published,
competing: '6',
competing: '8',
}),
/next free token 7/,
/next free token 9/,
);
assert.throws(
() =>
validateLayerStateAllocations(published, {
...LAYER_STATE_TOKEN_RESERVATIONS,
newcomer: '6',
newcomer: '8',
}),
/changed or removed: retired-layer/,
);
@@ -213,8 +215,8 @@ test('published retired digits and competing PRs advance the merge-time allocati
);
});
test('PR B manually replaces provisional 6 with 7 after PR A publishes 6', () => {
withPublishedBase({ rows: [...reservationRows, ['pr-a', '6']] }, (cwd) => {
test('PR B manually replaces provisional 8 with 9 after PR A publishes 8', () => {
withPublishedBase({ rows: [...reservationRows, ['pr-a', '8']] }, (cwd) => {
mkdirSync(path.join(cwd, 'scripts'));
const fixtureChecker = path.join(
cwd,
@@ -235,8 +237,8 @@ test('PR B manually replaces provisional 6 with 7 after PR A publishes 6', () =>
writeCodecFixture(
cwd,
[...reservationRows, ['pr-b', '6']],
[{ id: 'pr-b', token: '6' }],
[...reservationRows, ['pr-b', '8']],
[{ id: 'pr-b', token: '8' }],
);
const beforeRebase = runCheck();
assert.equal(beforeRebase.status, 1);
@@ -244,10 +246,10 @@ test('PR B manually replaces provisional 6 with 7 after PR A publishes 6', () =>
writeCodecFixture(
cwd,
[...reservationRows, ['pr-a', '6'], ['pr-b', '6']],
[...reservationRows, ['pr-a', '8'], ['pr-b', '8']],
[
{ id: 'pr-a', token: '6' },
{ id: 'pr-b', token: '6' },
{ id: 'pr-a', token: '8' },
{ id: 'pr-b', token: '8' },
],
);
const staleRebase = runCheck();
@@ -255,16 +257,16 @@ test('PR B manually replaces provisional 6 with 7 after PR A publishes 6', () =>
assert.match(staleRebase.stderr, /Duplicate layer-state token reservation/);
const published = readPublishedLayerStateReservations('HEAD', cwd);
assert.equal(nextLayerStateToken(published), '7');
const validBaselineRows = [...reservationRows, ['pr-a', '6']];
writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '6' }]);
assert.equal(nextLayerStateToken(published), '9');
const validBaselineRows = [...reservationRows, ['pr-a', '8']];
writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '8' }]);
const helper = spawnSync(
process.execPath,
[realpathSync(fixtureNext), 'pr-b'],
{ cwd, encoding: 'utf8' },
);
assert.equal(helper.status, 0, helper.stderr);
assert.match(helper.stdout, /pr-b: 7/);
assert.match(helper.stdout, /pr-b: 9/);
assert.deepEqual(
JSON.parse(readFileSync(path.join(cwd, LEDGER_PATH), 'utf8')),
validBaselineRows,
@@ -272,20 +274,20 @@ test('PR B manually replaces provisional 6 with 7 after PR A publishes 6', () =>
);
writeCodecFixture(
cwd,
[...reservationRows, ['pr-a', '6'], ['pr-b', '7']],
[...reservationRows, ['pr-a', '8'], ['pr-b', '9']],
[
{ id: 'pr-a', token: '6' },
{ id: 'pr-b', token: '7' },
{ id: 'pr-a', token: '8' },
{ id: 'pr-b', token: '9' },
],
);
const corrected = runCheck();
assert.equal(corrected.status, 0, corrected.stderr);
assert.match(corrected.stdout, /33 published, 1 new/);
assert.match(corrected.stdout, /35 published, 1 new/);
});
});
test('allocation batches cross the last digit and base-36 pair boundaries in order', () => {
const digitRows = [...'6789'].map((digit) => [`retired-${digit}`, digit]);
const digitRows = [...'89'].map((digit) => [`retired-${digit}`, digit]);
const allDigits = parseLayerStateTokenReservations([
...reservationRows,
...digitRows,
@@ -337,7 +339,7 @@ test('allocation batches cross the last digit and base-36 pair boundaries in ord
test('an isolated valid two-character fixture round-trips an l field beyond the old 64-character cap', async () => {
const cwd = mkdtempSync(path.join(tmpdir(), 'gev-layer-token-width-'));
try {
const priorDigits = [...'6789'].map((digit) => [
const priorDigits = [...'89'].map((digit) => [
`qa-prior-digit-${digit}`,
digit,
]);
@@ -374,7 +376,7 @@ test('an isolated valid two-character fixture round-trips an l field beyond the
const params = new URLSearchParams([['v', '2']]);
codec.encodeLayerStateParams(params, state);
assert.ok(params.get('l').length > 64, 'fixture must cross the old cap');
assert.equal(params.get('l').length, 75);
assert.equal(params.get('l').length, 79);
const restored = codec.decodeLayerStateParams(params);
assert.deepEqual(restored?.enabledLayerIds, expectedLayerIds);
assert.deepEqual(restored?.options, state.options);
@@ -392,7 +394,7 @@ test('checker reads a complete future base ledger and rejects retired-token reus
},
(cwd) => {
const published = readPublishedLayerStateReservations('HEAD', cwd);
assert.equal(Object.keys(published).length, 33);
assert.equal(Object.keys(published).length, 35);
assert.equal(published['retired-layer'], '00');
assert.throws(
() =>
@@ -22,6 +22,8 @@
["rocket-launches", "x"],
["s2-ghostmaps", "5"],
["satellites", "s"],
["sea-marks", "6"],
["ski-pistes", "7"],
["street-level", "0"],
["telegeography-submarine-cables", "u"],
["traffic", "t"],
+60 -5
View File
@@ -189,7 +189,58 @@ const loadIndex = createRetryableLoader(async () => {
return index;
});
function toResult(entry, candidates = 1) {
/** Same-name parts this close are one feature split by the pack. */
const SAME_PLACE_GAP_KM = 100;
/** Gap between two bboxes in km, measured the short way across 180°. */
function bboxGapKm(a, b) {
const midLat = (a[1] + a[3] + b[1] + b[3]) / 4;
const kmPerLon = 111.32 * Math.cos((midLat * Math.PI) / 180);
let dLon = Infinity;
for (const shift of [-360, 0, 360]) {
dLon = Math.min(
dLon,
Math.max(0, Math.max(a[0], b[0] + shift) - Math.min(a[2], b[2] + shift)),
);
}
const dLat = Math.max(0, Math.max(a[1], b[1]) - Math.min(a[3], b[3]));
return Math.hypot(dLon * kmPerLon, dLat * 111.32);
}
/**
* Distinct places among same-name entries: parts that touch or nearly touch
* (the Great Rift Valley's two halves, the Lau Group across 180°) merge into
* one place with every part; distant namesakes (Flores in the Azores and in
* Indonesia) stay separate. Largest place first.
*/
function groupPlaces(list) {
const groups = [];
for (const entry of list) {
const near = groups.filter((group) =>
group.some(
(other) => bboxGapKm(other.bbox, entry.bbox) <= SAME_PLACE_GAP_KM,
),
);
const merged = [entry, ...near.flat()];
for (const group of near) groups.splice(groups.indexOf(group), 1);
groups.push(merged);
}
return groups
.map((group) => {
const largest = group.reduce((a, b) => (b.areaKm2 > a.areaKm2 ? b : a));
const [w, s, e, n] = largest.bbox;
return {
name: largest.name,
featurecla: largest.featurecla,
polygons: group.flatMap((entry) => entry.polygons),
areaKm2: group.reduce((sum, entry) => sum + entry.areaKm2, 0),
center: { lat: (s + n) / 2, lon: (w + e) / 2 },
};
})
.sort((a, b) => b.areaKm2 - a.areaKm2);
}
function toResult(entry, candidates = 1, list = [entry]) {
return {
name: entry.name,
featurecla: entry.featurecla,
@@ -198,6 +249,7 @@ function toResult(entry, candidates = 1) {
bboxDiagonalKm: entry.bboxDiagonalKm,
areaKm2: entry.areaKm2,
candidates,
places: groupPlaces(list),
};
}
@@ -210,9 +262,12 @@ function toResult(entry, candidates = 1) {
* @param {string} query e.g. "the Alps", "Rockies", "Gulf of Mexico"
* @returns {Promise<{name:string, featurecla:string, kind:'natural'|'marine',
* polygons:Array<Array<[number,number]>>, bboxDiagonalKm:number,
* areaKm2:number, candidates:number}|null>} largest-area match plus the
* number of same-name entries, or null when nothing matches. Uniqueness in
* this pack alone does not establish the user's intended place.
* areaKm2:number, candidates:number, places:Array<{name:string,
* featurecla:string, polygons:Array, areaKm2:number,
* center:{lat:number, lon:number}}>}|null>} largest-area match plus the
* number of same-name entries, or null when nothing matches. `places`
* groups those entries into distinct places, each with all its parts.
* Uniqueness in this pack alone does not establish the user's intended place.
*/
export async function findNaturalRegion(query) {
const norm = normalizeName(query);
@@ -226,7 +281,7 @@ export async function findNaturalRegion(query) {
for (const key of candidates) {
if (!key) continue;
const list = index.get(key);
if (list && list.length) return toResult(list[0], list.length);
if (list && list.length) return toResult(list[0], list.length, list);
}
return null;
}
+18
View File
@@ -195,3 +195,21 @@ test('pointInRing: basic square', () => {
assert.equal(pointInRing(sq, 15, 5), false);
assert.equal(pointInRing([[0, 0], [1, 1]], 0.5, 0.5), false, 'degenerate ring');
});
test('same-name parts of one feature are one place; distant namesakes are separate places', async () => {
for (const name of ['Great Rift Valley', 'Lau Group', 'Transantarctic Mountains']) {
const region = await findNaturalRegion(name);
assert.equal(region?.places?.length, 1, `${name} is one place`);
const place = region.places[0];
assert.equal(place.polygons.length >= 2, true, `${name} keeps every part`);
assert.ok(place.areaKm2 > region.areaKm2, `${name} counts more than its largest part`);
}
for (const name of ['Flores', 'Cordillera Oriental']) {
const region = await findNaturalRegion(name);
assert.equal(region?.places?.length, 2, `${name} names two places`);
for (const place of region.places) {
assert.ok(Number.isFinite(place.center.lat) && Number.isFinite(place.center.lon));
}
}
assert.equal((await findNaturalRegion('Alps'))?.places?.length, 1);
});
+190
View File
@@ -0,0 +1,190 @@
import * as Cesium from 'cesium';
import { NO_IMAGERY_HOST, resolveImageryHost } from '../maps/imageryHost.js';
/**
* @file Open-data raster overlays: OpenSeaMap sea marks and OpenSnowMap ski
* pistes (#10). Each overlay is its own layer with its own row under
* Infrastructure; both are built from the descriptors below.
*
* OpenRailwayMap and OpenInfraMap, also named in #10, are deliberately not
* included:
* - OpenRailwayMap's public tile server (tiles.openrailwaymap.org) only
* serves requests whose Referer is openrailwaymap.org; every other Referer,
* including none, gets 403. A tab on this app sends its own origin, so the
* tiles would fail, and embedding them would run against their tile-usage
* policy either way.
* - OpenInfraMap serves MapLibre vector tiles, not raster XYZ tiles. That
* needs a vector-tile rendering pipeline, not an imagery-layer drop-in.
*
* The overlays drape on whatever surface the active map stack offers (see
* `maps/imageryHost.js`): the globe on the OSM and Bing stacks, the Google
* Photorealistic tileset's own `imageryLayers` on Google 3D. Only a stack with
* neither leaves them hidden, and the row says so.
* @module data/osmOverlays
*/
/** @type {ReadonlyArray<{id:string,name:string,icon:string,source:string,urlTemplate:string,credit:string,maximumLevel:number}>} */
export const OSM_OVERLAYS = Object.freeze([
Object.freeze({
id: 'sea-marks',
name: 'Sea Marks',
icon: '⚓',
source: 'OpenSeaMap',
urlTemplate: 'https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png',
// Chart tiles CC BY-SA 2.0; the underlying data is OpenStreetMap (ODbL).
credit:
'Sea marks © OpenSeaMap contributors (CC BY-SA 2.0) · Data © OpenStreetMap contributors (ODbL)',
maximumLevel: 18,
}),
Object.freeze({
id: 'ski-pistes',
name: 'Ski Pistes',
icon: '⛷',
source: 'OpenSnowMap',
urlTemplate: 'https://www.opensnowmap.org/pistes/{z}/{x}/{y}.png',
// OpenSnowMap asks for "(c)www.opensnowmap.org" wherever its tiles appear.
credit:
'Ski pistes © www.opensnowmap.org (CC BY-SA) · Data © OpenStreetMap contributors (ODbL)',
maximumLevel: 18,
}),
]);
/** Why an enabled overlay is showing nothing, or null. */
export function osmOverlayHiddenReason(enabled, hostKind) {
return enabled && hostKind === 'none' ? NO_IMAGERY_HOST : null;
}
/**
* Create the layer for one overlay descriptor. Each application catalog gets
* its own instances; the Cesium factories are injectable so tests run without
* WebGL.
*/
export function createOsmOverlayLayer(
overlay,
{
host = resolveImageryHost,
createProvider = (descriptor) =>
new Cesium.UrlTemplateImageryProvider({
url: descriptor.urlTemplate,
credit: descriptor.credit,
maximumLevel: descriptor.maximumLevel,
}),
createImageryLayer = (provider) => new Cesium.ImageryLayer(provider),
} = {},
) {
if (!OSM_OVERLAYS.includes(overlay))
throw new TypeError(`Unknown open map overlay: ${overlay?.id}`);
let viewer = null;
let mapStack = null;
let unsubscribeMap = null;
let enabled = false;
let hostInfo = { collection: null, kind: 'none' };
/** The imagery layer this instance owns while enabled, and where it sits. */
let owned = null;
const usable = (collection) => collection && !collection.isDestroyed?.();
function detach(destroy) {
if (!owned) return;
const { collection, layer } = owned;
if (usable(collection) && collection.contains(layer))
collection.remove(layer, destroy);
else if (destroy && !layer.isDestroyed?.()) layer.destroy?.();
owned.collection = null;
if (destroy) owned = null;
}
function resolveHost() {
return (
host({ viewer, tileset: mapStack?.getImageryHostTileset?.() }) || {
collection: null,
kind: 'none',
}
);
}
/** Create, move or drop the owned imagery layer to match the state. */
function sync() {
if (!viewer) return;
hostInfo = enabled ? resolveHost() : { collection: null, kind: 'none' };
if (!enabled) {
detach(true);
} else {
owned ||= {
layer: createImageryLayer(createProvider(overlay)),
collection: null,
};
if (owned.collection !== hostInfo.collection) {
detach(false);
if (usable(hostInfo.collection)) {
// Appended, so it draws above the map stack's base imagery.
hostInfo.collection.add(owned.layer);
owned.collection = hostInfo.collection;
}
}
}
viewer.scene?.requestRender?.();
}
return {
id: overlay.id,
name: overlay.name,
icon: overlay.icon,
source: overlay.source,
updateInterval: 0,
init(nextViewer) {
viewer = nextViewer;
},
/** Follow map-stack switches so the drape moves with the surface. */
attachMapStackController(controller) {
unsubscribeMap?.();
unsubscribeMap = null;
mapStack = controller || null;
const off = controller?.subscribe?.(() => {
if (enabled) sync();
});
unsubscribeMap = typeof off === 'function' ? off : null;
},
enable() {
enabled = true;
sync();
},
disable() {
enabled = false;
sync();
},
update() {
return true;
},
getStats() {
// Without a controller subscription the stats poll doubles as the
// map-stack watch.
if (enabled && !unsubscribeMap) {
const next = resolveHost();
if (next.collection !== hostInfo.collection) sync();
}
return {
count: enabled ? 1 : 0,
error: osmOverlayHiddenReason(enabled, hostInfo.kind),
};
},
/** Owned layer count and host kind, for tests and live gates. */
diagnostics() {
return { host: hostInfo.kind, ownedCount: owned ? 1 : 0 };
},
destroy() {
enabled = false;
detach(true);
unsubscribeMap?.();
unsubscribeMap = null;
mapStack = null;
viewer = null;
hostInfo = { collection: null, kind: 'none' };
},
};
}
/** One layer per overlay, in panel order. */
export function createOsmOverlayLayers(options) {
return OSM_OVERLAYS.map((overlay) => createOsmOverlayLayer(overlay, options));
}
+167
View File
@@ -0,0 +1,167 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
OSM_OVERLAYS,
createOsmOverlayLayer,
createOsmOverlayLayers,
osmOverlayHiddenReason,
} from './osmOverlays.js';
import { NO_IMAGERY_HOST } from '../maps/imageryHost.js';
function fakeCollection() {
const layers = [];
const removed = [];
return {
layers,
removed,
add(layer) {
layers.push(layer);
},
contains(layer) {
return layers.includes(layer);
},
remove(layer, destroy) {
const index = layers.indexOf(layer);
if (index < 0) return false;
layers.splice(index, 1);
removed.push({ layer, destroy });
if (destroy) layer.destroyed = true;
return true;
},
};
}
function fakeMapStack() {
const subscribers = new Set();
return {
tileset: null,
getImageryHostTileset() {
return this.tileset;
},
subscribe(fn) {
subscribers.add(fn);
return () => subscribers.delete(fn);
},
emit() {
for (const fn of subscribers) fn();
},
subscribers,
};
}
function setup(overlay = OSM_OVERLAYS[0]) {
const globe = fakeCollection();
const viewer = {
imageryLayers: globe,
scene: { globe: { show: true }, requestRender() {} },
};
const created = [];
const layer = createOsmOverlayLayer(overlay, {
createProvider: (descriptor) => ({ descriptor }),
createImageryLayer: (provider) => {
const imagery = { provider, destroyed: false };
created.push(imagery);
return imagery;
},
});
const mapStack = fakeMapStack();
layer.init(viewer);
layer.attachMapStackController(mapStack);
return { layer, viewer, globe, created, mapStack };
}
test('OSM_OVERLAYS: the two shipped layers, each with a tile template and credit', () => {
assert.deepEqual(
OSM_OVERLAYS.map((o) => o.id),
['sea-marks', 'ski-pistes'],
);
for (const overlay of OSM_OVERLAYS) {
assert.match(overlay.urlTemplate, /^https:\/\/.*\{z\}.*\{x\}.*\{y\}/);
assert.match(overlay.credit, /OpenStreetMap contributors \(ODbL\)/);
}
assert.match(OSM_OVERLAYS[0].credit, /CC BY-SA 2\.0/);
assert.match(OSM_OVERLAYS[1].credit, /www\.opensnowmap\.org/);
});
test('createOsmOverlayLayers builds one independent layer per overlay', () => {
const layers = createOsmOverlayLayers();
assert.deepEqual(
layers.map((layer) => [layer.id, layer.name]),
[
['sea-marks', 'Sea Marks'],
['ski-pistes', 'Ski Pistes'],
],
);
assert.notEqual(createOsmOverlayLayers()[0], layers[0]);
assert.throws(() => createOsmOverlayLayer({ id: 'other' }), /Unknown/);
});
test('osmOverlayHiddenReason: only when enabled with no imagery host', () => {
assert.equal(osmOverlayHiddenReason(false, 'none'), null);
assert.equal(osmOverlayHiddenReason(true, 'globe'), null);
assert.equal(osmOverlayHiddenReason(true, 'tileset'), null);
assert.equal(osmOverlayHiddenReason(true, 'none'), NO_IMAGERY_HOST);
});
test('a layer owns no imagery until enabled and destroys it on disable', () => {
const { layer, globe, created } = setup(OSM_OVERLAYS[1]);
assert.equal(created.length, 0);
layer.enable();
assert.equal(globe.layers.length, 1);
assert.equal(globe.layers[0].provider.descriptor.id, 'ski-pistes');
assert.deepEqual(layer.getStats(), { count: 1, error: null });
layer.disable();
assert.equal(globe.layers.length, 0);
assert.equal(created[0].destroyed, true);
assert.deepEqual(layer.diagnostics(), { host: 'none', ownedCount: 0 });
assert.deepEqual(layer.getStats(), { count: 0, error: null });
layer.enable();
assert.equal(created.length, 2, 're-enabling creates a fresh imagery layer');
});
test('the overlay follows a map-stack switch onto the Google 3D tileset and back', () => {
const { layer, viewer, globe, mapStack } = setup();
layer.enable();
const [imagery] = globe.layers;
const tileset = { imageryLayers: fakeCollection() };
viewer.scene.globe.show = false;
mapStack.tileset = tileset;
mapStack.emit();
assert.equal(globe.layers.length, 0);
assert.deepEqual(tileset.imageryLayers.layers, [imagery]);
assert.equal(globe.removed[0].destroy, false);
assert.equal(layer.diagnostics().host, 'tileset');
assert.equal(layer.getStats().error, null);
viewer.scene.globe.show = true;
mapStack.tileset = null;
mapStack.emit();
assert.deepEqual(globe.layers, [imagery]);
assert.equal(tileset.imageryLayers.layers.length, 0);
});
test('with no surface to drape on the row explains itself, and recovers when one appears', () => {
const { layer, viewer, globe, mapStack } = setup();
viewer.scene.globe.show = false;
layer.enable();
assert.equal(globe.layers.length, 0);
assert.equal(layer.getStats().error, NO_IMAGERY_HOST);
viewer.scene.globe.show = true;
mapStack.emit();
assert.equal(globe.layers.length, 1);
assert.equal(layer.getStats().error, null);
});
test('destroy removes owned imagery and unsubscribes from the map stack', () => {
const { layer, globe, created, mapStack } = setup();
layer.enable();
layer.destroy();
assert.equal(globe.layers.length, 0);
assert.equal(created[0].destroyed, true);
assert.equal(mapStack.subscribers.size, 0);
});
+426
View File
@@ -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;
}
+211
View File
@@ -541,6 +541,191 @@ test('a mirror answering an invalid reason phrase rejects instead of crashing',
await assert.rejects(Promise.race([fetchMirror(), crashed]), TypeError);
});
test('priority-country configuration is bounded, normalized and validated before requests', async () => {
const { parseRadioPriorityCountries } = await import('../../server/providers/radio/catalog.js');
assert.deepEqual(parseRadioPriorityCountries(' in, PL,in, '), ['IN', 'PL']);
assert.deepEqual(parseRadioPriorityCountries(''), []);
assert.deepEqual(parseRadioPriorityCountries('uk,GB,UK'), ['GB']);
assert.deepEqual(parseRadioPriorityCountries('US,CA,IN,PL,DE,FR,UK,GB,JP'), ['US', 'CA', 'IN', 'PL', 'DE', 'FR', 'GB', 'JP']);
for (const value of ['ZZ', 'India', 'IN&limit=9999', 'US,CA,IN,PL,DE,FR,GB,JP,AU']) {
assert.throws(() => createProxy({ priorityCountries: value }), /RADIO_PRIORITY_COUNTRIES/);
}
});
test('UK aliases query and report GB once and retain British stations', async () => {
const countryQueries = [];
const middleware = createProxy({
priorityCountries: 'UK,GB',
fetchImpl: async (url) => {
const parsed = new URL(url);
if (parsed.pathname === '/json/servers')
return responseJson([{ name: 'de1.api.radio-browser.info' }]);
const country = parsed.searchParams.get('countrycode');
if (country) {
countryQueries.push(country);
return responseJson([station({ country: 'United Kingdom', countrycode: 'GB' })]);
}
return responseJson(healthyRowsForQuery(url));
},
});
const response = await invoke(middleware, '/stations');
assert.equal(response.status, 200);
const body = JSON.parse(response.body);
assert.deepEqual(countryQueries, ['GB']);
assert.deepEqual(body.coverage.priorityCountries, [
{ countryCode: 'GB', succeeded: true, stationCount: 1 },
]);
assert.equal(body.stations.find((row) => row.id === UUID)?.countryCode, 'GB');
});
test('low-click country stations survive a full global catalog, with bounded cached queries and deduplication', async () => {
const calls = [];
let active = 0;
let peak = 0;
const middleware = createProxy({
priorityCountries: 'IN,PL',
fetchImpl: async (url) => {
const parsed = new URL(url);
calls.push(parsed);
if (parsed.pathname === '/json/servers') return responseJson([{ name: 'de1.api.radio-browser.info' }]);
if (parsed.pathname.startsWith('/json/url/')) return responseJson({ ok: true });
peak = Math.max(peak, ++active);
await new Promise((resolve) => setTimeout(resolve, 1));
active--;
const country = parsed.searchParams.get('countrycode');
if (country) {
assert.equal(parsed.searchParams.get('limit'), '200');
assert.equal(parsed.searchParams.get('is_https'), 'true');
const rows = catalogRows(country === 'IN' ? '10000000' : '20000000', 'community', 60)
.map((row) => ({ ...row, countrycode: country, clickcount: 0 }));
return responseJson([rows[0], ...rows]);
}
return responseJson(catalogRows('30000000', queryTag(url) || 'news', 900));
},
});
const responses = await Promise.all([invoke(middleware, '/stations'), invoke(middleware, '/stations')]);
const body = JSON.parse(responses[0].body);
assert.equal(body.degraded, false);
assert.equal(body.stations.length, 750);
assert.equal(new Set(body.stations.map((row) => row.id)).size, 750);
for (const country of ['IN', 'PL'])
assert.ok(body.stations.filter((row) => row.countryCode === country).length >= 40);
assert.ok(body.stations.some((row) => row.countryCode === 'US'));
assert.equal(calls.filter((url) => url.pathname.includes('/stations/')).length, 11);
assert.ok(peak <= 3);
const count = calls.length;
await invoke(middleware, '/stations');
assert.equal(calls.length, count, 'country queries share the 45-minute cache');
const id = body.stations.find((row) => row.countryCode === 'IN').id;
assert.equal((await invoke(middleware, `/click/${id}`, 'POST')).status, 204);
});
test('country failures never block cold or warm global catalog admission, even after seven days', async () => {
for (const coldFailure of [false, true]) {
let clock = 4_000_000;
let fail = coldFailure;
let calls = 0;
const middleware = createProxy({
priorityCountries: ['IN'], now: () => clock,
fetchImpl: async (url) => {
calls++;
if (String(url).includes('/json/servers')) return responseJson([{ name: 'de1.api.radio-browser.info' }]);
if (new URL(url).searchParams.has('countrycode')) {
if (fail) throw new Error('offline');
return responseJson([station({ countrycode: 'IN', country: 'India', tags: 'community' })]);
}
return responseJson(healthyRowsForQuery(url));
},
});
let generation = 0;
for (const elapsed of [0, 46 * 60 * 1000, 8 * 24 * 60 * 60 * 1000]) {
clock += elapsed;
if (elapsed) fail = true;
const response = await invoke(middleware, '/stations');
const body = JSON.parse(response.body);
assert.equal(response.status, 200);
assert.equal(body.stale, false);
assert.equal(body.degraded, false);
assert.ok(body.acceptedGeneration > generation);
generation = body.acceptedGeneration;
assert.equal(body.coverage.priorityCountries[0].succeeded, !fail);
assert.equal(body.coverage.priorityCountries[0].stationCount, fail ? 0 : 1);
const count = calls;
clock += 60_001;
assert.deepEqual(JSON.parse((await invoke(middleware, '/stations')).body), body);
assert.equal(calls, count, 'country failure does not shorten the healthy cache');
}
}
});
test('valid countries with no eligible stations are accepted and cached; malformed country responses stay isolated', async () => {
for (const mode of ['wrong', 'oversized', 'empty', 'ineligible', 'malformed']) {
let clock = 4_000_000;
let calls = 0;
const middleware = createProxy({
priorityCountries: ['IN'], now: () => clock,
fetchImpl: async (url) => {
calls++;
if (String(url).includes('/json/servers')) return responseJson([{ name: 'de1.api.radio-browser.info' }]);
if (new URL(url).searchParams.has('countrycode')) {
const rows = {
empty: [], wrong: [station()], malformed: [{ name: 'missing fields' }],
oversized: catalogRows('10000000', 'community', 201).map((row) => ({ ...row, countrycode: 'IN' })),
ineligible: [{ hls: 1 }, { codec: 'OGG' }, { codec: 'OPUS' }, { url_resolved: 'https://127.0.0.1/live.mp3' }]
.map((overrides) => station({ countrycode: 'IN', ...overrides })),
};
return responseJson(rows[mode]);
}
return responseJson(healthyRowsForQuery(url));
},
});
const response = await invoke(middleware, '/stations');
const body = JSON.parse(response.body);
assert.equal(response.status, 200, mode);
assert.equal(body.degraded, false, mode);
assert.ok(body.acceptedGeneration > 0, mode);
assert.equal(body.coverage.priorityCountries[0].stationCount, 0, mode);
assert.equal(body.coverage.priorityCountries[0].succeeded, !['oversized', 'malformed'].includes(mode), mode);
const count = calls;
clock += 44 * 60 * 1000;
assert.deepEqual(JSON.parse((await invoke(middleware, '/stations')).body), body);
assert.equal(calls, count, `${mode} country response preserves the 45-minute cache`);
}
});
for (const priorityCountries of ['', 'IN']) {
test(`cold failures coalesce, bound mirrors and back off with priority countries ${priorityCountries || 'unset'}`, async () => {
let clock = Date.UTC(2026, 0, 1);
let calls = 0;
let offline = true;
const middleware = createProxy({
priorityCountries, now: () => clock,
fetchImpl: async (url) => {
calls++;
if (String(url).includes('/json/servers')) return responseJson([{ name: 'de1.api.radio-browser.info' }]);
if (offline) throw new Error('offline');
if (new URL(url).searchParams.has('countrycode')) return responseJson([]);
return responseJson(healthyRowsForQuery(url));
},
});
const responses = await Promise.all([invoke(middleware, '/stations'), invoke(middleware, '/stations')]);
assert.ok(responses.every((response) => response.status === 503));
const initial = calls;
assert.equal(initial, 1 + (priorityCountries ? 10 : 9) * 3, 'one discovery and at most three mirrors per query');
offline = false;
clock += 59_999;
assert.equal((await invoke(middleware, '/stations')).status, 503);
assert.equal(calls, initial);
clock += 2;
const recovered = await invoke(middleware, '/stations');
assert.equal(recovered.status, 200);
assert.equal(JSON.parse(recovered.body).degraded, false);
assert.ok(calls > initial);
const afterRecovery = calls;
await invoke(middleware, '/stations');
assert.equal(calls, afterRecovery);
});
}
test('case expansion is capped after tag normalization in the portable and server station path', () => {
const row = normalizeRadioBrowserStation(station({tags: 'İ'.repeat(80) + ', NEWS__RADIO ,news-radio'}));
@@ -599,3 +784,29 @@ test('server structural rejections reach the client diagnostic without discardin
radioLayer.destroy();
}
});
test('country coverage retains valid rows and upstream rejection diagnostics in a cached healthy catalog', async () => {
let calls = 0;
const middleware = createProxy({
priorityCountries: 'IN',
fetchImpl: async (url) => {
calls++;
if (String(url).includes('/json/servers')) return responseJson([{ name: 'de1.api.radio-browser.info' }]);
if (new URL(url).searchParams.has('countrycode')) return responseJson([
station({ stationuuid: '60000000-0000-4000-8000-000000000001', countrycode: 'IN' }),
{ name: 'missing required fields' },
]);
return responseJson(healthyRowsForQuery(url));
},
});
const response = await invoke(middleware, '/stations');
const body = JSON.parse(response.body);
assert.equal(response.status, 200);
assert.equal(body.degraded, false);
assert.equal(body.rejectedStationCount, 1);
assert.deepEqual(body.coverage.priorityCountries, [{ countryCode: 'IN', succeeded: true, stationCount: 1 }]);
const count = calls;
assert.deepEqual(JSON.parse((await invoke(middleware, '/stations')).body), body);
assert.equal(calls, count);
});