mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
A radius the user gives always draws a buffer, but the enclosing outline was still looked up first (up to 6 s and a guarded Nominatim request) and then thrown away. It is no longer requested. The slow-outline test now checks the default 250 m fallback it was meant to cover.
828 lines
27 KiB
JavaScript
828 lines
27 KiB
JavaScript
/**
|
|
* Voice area handles: `resolve_area` and the area scopes of `analyst_query`.
|
|
*
|
|
* One area store per action runner holds every area a turn can name — places
|
|
* resolved by name, landmark areas drawn "around X", and annotation outlines
|
|
* (the draw tool's areas and voice outlines). The model gets an `areaId` and a
|
|
* summary; the geometry stays here.
|
|
*
|
|
* Names resolve through the same outline ladder as `annotate_map` (bundled
|
|
* packs, the guarded Nominatim outline route, OpenFreeMap tiles, and
|
|
* Overpass only when an operator configured it) — see data/areaResolver.js.
|
|
*
|
|
* Drawing uses the annotation engine with a simplified display copy. Counting
|
|
* always reads the stored geometry, so a simplified outline never changes a
|
|
* count.
|
|
*
|
|
* @module voice/areaActions
|
|
*/
|
|
|
|
import { createAreaStore, summarizeArea } from '../data/areaStore.js';
|
|
import { createAreaResolver } from '../data/areaResolver.js';
|
|
import { circleRing, displayParts } from '../data/areaGeometry.js';
|
|
import { findNaturalRegion } from '../data/naturalEarthRegions.js';
|
|
import { findAdminCandidates as findBundledAdminCandidates } from '../data/adminBoundaries.js';
|
|
import {
|
|
NATURAL_EARTH_CREDIT,
|
|
US_CENSUS_CREDIT,
|
|
WOF_CREDIT,
|
|
registerDynamicCredit,
|
|
} from '../data/dataCredits.js';
|
|
import { isUnavailableCapability } from '../sources/capability.js';
|
|
import { unavailablePlaceSearch } from '../search/placeSearch.js';
|
|
|
|
/** A spoken resolve waits this long before answering "still looking". */
|
|
const RESOLVE_BUDGET_MS = 8_000;
|
|
/** An analyst region scope waits this long (the base region-scope budget). */
|
|
const REGION_BUDGET_MS = 3_000;
|
|
/** Finding the landmark itself gives up after this. */
|
|
const AROUND_BUDGET_MS = 10_000;
|
|
/** The enclosing area gets this long before the buffer is used instead. */
|
|
const AROUND_OUTLINE_MS = 6_000;
|
|
/** Buffer radius around a landmark with no enclosing polygon. */
|
|
const AROUND_DEFAULT_RADIUS_M = 250;
|
|
const AROUND_MAX_RADIUS_M = 20_000;
|
|
|
|
/** What the annotation resolver should treat a named area as. */
|
|
const ENTITY_KIND = Object.freeze({
|
|
country: 'country',
|
|
admin1: 'state',
|
|
admin2: 'county',
|
|
district: 'district',
|
|
site: 'compound',
|
|
});
|
|
|
|
/** How each area source is named on screen. */
|
|
const SOURCE_LABELS = Object.freeze({
|
|
'natural-earth': 'Natural Earth',
|
|
'us-census': 'US Census',
|
|
wof: "Who's On First",
|
|
datasf: 'DataSF',
|
|
osm: 'OpenStreetMap',
|
|
openfreemap: 'OpenStreetMap via OpenFreeMap',
|
|
drawn: 'drawn by hand',
|
|
annotation: 'map outline',
|
|
approximate: 'approximate outline',
|
|
});
|
|
|
|
const ADMIN_IDENTITY_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',
|
|
zone: 'retired',
|
|
});
|
|
|
|
function administrativeIdentity(value) {
|
|
const normalized = String(value || '')
|
|
.normalize('NFKC')
|
|
.toLocaleLowerCase()
|
|
.replace(/[^\p{L}\p{M}\p{N}]+/gu, ' ')
|
|
.trim();
|
|
const match = normalized.match(
|
|
/\b(country|nation|province|state|region|zone|oblast|prefecture|canton|governorate|county|district|department)$/,
|
|
);
|
|
return {
|
|
name: match ? normalized.slice(0, -match[0].length).trim() : normalized,
|
|
level: match ? ADMIN_IDENTITY_LEVEL[match[1]] || null : null,
|
|
};
|
|
}
|
|
|
|
/** Outline ladder source → area source and rung. */
|
|
function outlineOrigin(outlineSource) {
|
|
switch (outlineSource) {
|
|
case 'natural-earth':
|
|
case 'us-census':
|
|
case 'wof':
|
|
case 'datasf':
|
|
return { source: outlineSource, rung: 'bundled' };
|
|
case 'openfreemap':
|
|
return { source: 'openfreemap', rung: 'openfreemap' };
|
|
case 'nominatim':
|
|
return { source: 'osm', rung: 'nominatim' };
|
|
default:
|
|
// The base ladder's own OSM rungs run only on an operator Overpass.
|
|
return { source: 'osm', rung: 'overpass' };
|
|
}
|
|
}
|
|
|
|
/** Round an area for speech: "about 20,300 km²". */
|
|
export function spokenArea(km2) {
|
|
if (!Number.isFinite(km2) || km2 <= 0) return null;
|
|
if (km2 < 1) return `about ${Math.round(km2 * 100) / 100} km²`;
|
|
const digits = Math.max(0, Math.floor(Math.log10(km2)) - 1);
|
|
const rounded = Math.round(km2 / 10 ** digits) * 10 ** digits;
|
|
return `about ${rounded.toLocaleString('en-US')} km²`;
|
|
}
|
|
|
|
const cancelledResult = () => ({
|
|
ok: false,
|
|
code: 'CANCELLED',
|
|
cancelled: true,
|
|
error: 'Superseded.',
|
|
});
|
|
|
|
/**
|
|
* Build the runner's area services.
|
|
* @param {object} options
|
|
* @param {object} options.viewer
|
|
* @param {object|null} options.annotations Annotation engine.
|
|
* @param {object} [options.placeSearch]
|
|
* @param {object} [options.annotationResolver] The annotation outline ladder.
|
|
*/
|
|
export function createVoiceAreas({
|
|
viewer,
|
|
annotations = null,
|
|
placeSearch = unavailablePlaceSearch,
|
|
annotationResolver = null,
|
|
findRegion = findNaturalRegion,
|
|
findAdminCandidates = findBundledAdminCandidates,
|
|
aroundOutlineMs = AROUND_OUTLINE_MS,
|
|
} = {}) {
|
|
// An area an outline on the map still refers to is never evicted: its
|
|
// counting geometry must outlive the display copy drawn from it.
|
|
const store = createAreaStore({
|
|
isPinned: (areaId) =>
|
|
typeof annotations?.list === 'function' &&
|
|
annotations.list().some((anno) => anno?.areaId === areaId),
|
|
});
|
|
|
|
/** The ground at the centre of the view, else the camera's position. */
|
|
function viewCenter() {
|
|
const picked = annotationResolver?.pickWorldFromScreen?.(viewer, 0.5, 0.5);
|
|
if (Number.isFinite(picked?.lat) && Number.isFinite(picked?.lon))
|
|
return picked;
|
|
const carto = viewer?.camera?.positionCartographic;
|
|
return carto
|
|
? {
|
|
lat: (carto.latitude * 180) / Math.PI,
|
|
lon: (carto.longitude * 180) / Math.PI,
|
|
}
|
|
: null;
|
|
}
|
|
|
|
/**
|
|
* The outline ladder for one resolved anchor, awaited with a budget.
|
|
* Resolves the resolver's tri-state outline, or `{timeout: true}`.
|
|
*/
|
|
async function awaitOutline(resolved, budgetMs) {
|
|
if (resolved?.ring && !resolved.resolveOutline) return resolved;
|
|
if (typeof resolved?.resolveOutline !== 'function') return null;
|
|
if (!Number.isFinite(budgetMs))
|
|
return resolved.resolveOutline().catch(() => undefined);
|
|
let timer;
|
|
try {
|
|
return await Promise.race([
|
|
resolved.resolveOutline().catch(() => undefined),
|
|
new Promise((resolveTimeout) => {
|
|
timer = setTimeout(() => resolveTimeout({ timeout: true }), budgetMs);
|
|
}),
|
|
]);
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A named place through the annotation outline ladder: bundled packs near
|
|
* the view or confirmed by the geocoder, then the guarded Nominatim
|
|
* outline, then an operator Overpass when configured.
|
|
*/
|
|
async function resolveNamedOutline(query, { level, levelHint, signal }) {
|
|
if (typeof annotationResolver?.resolveAnnotationTarget !== 'function')
|
|
return {
|
|
ok: false,
|
|
code: 'AREA_UNAVAILABLE',
|
|
error: 'Boundary lookup is unavailable here.',
|
|
};
|
|
let resolved;
|
|
try {
|
|
resolved = await annotationResolver.resolveAnnotationTarget({
|
|
placeSearch,
|
|
viewer,
|
|
target: query,
|
|
footprint: true,
|
|
deferFootprint: true,
|
|
entityKind: ENTITY_KIND[level || levelHint] || null,
|
|
allowDistant: true,
|
|
signal,
|
|
});
|
|
} catch (error) {
|
|
if (signal?.aborted) throw error;
|
|
resolved = null;
|
|
}
|
|
if (!resolved)
|
|
return {
|
|
ok: false,
|
|
code: 'AREA_NOT_FOUND',
|
|
error: `Could not find "${query}".`,
|
|
};
|
|
const directBundledOutline = Boolean(
|
|
resolved?.ring &&
|
|
!resolved.resolveOutline &&
|
|
resolved.source === 'bundled',
|
|
);
|
|
const outline = await awaitOutline(resolved, Infinity);
|
|
signal?.throwIfAborted?.();
|
|
if (!outline?.ring || outline.ring.length < 3) {
|
|
if (outline === null)
|
|
return {
|
|
ok: false,
|
|
code: 'AREA_NOT_FOUND',
|
|
error: `No boundary found for "${query}".`,
|
|
};
|
|
return {
|
|
ok: false,
|
|
code: 'AREA_UNAVAILABLE',
|
|
error: isUnavailableCapability(outline)
|
|
? `No outline source can trace "${query}" here — it stays a point.`
|
|
: `The boundary for "${query}" is not available right now — try again shortly.`,
|
|
};
|
|
}
|
|
const { source, rung } = outlineOrigin(outline.outlineSource);
|
|
const primaryLabel =
|
|
String(resolved.label || query)
|
|
.split(',')[0]
|
|
.trim() || query;
|
|
const requestedLabel = String(query).split(',')[0].trim();
|
|
const requestedLevel = level || levelHint || null;
|
|
const administrativeRequest = ['country', 'admin1', 'admin2'].includes(
|
|
requestedLevel,
|
|
);
|
|
const administrativeLabel =
|
|
/\b(country|nation|province|state|region|zone|oblast|prefecture|canton|governorate|county|district|department)$/i.test(
|
|
primaryLabel,
|
|
);
|
|
const sourceIdentity = String(
|
|
outline.adminArea || (directBundledOutline ? resolved.label : ''),
|
|
)
|
|
.split(',')[0]
|
|
.trim();
|
|
const sourceLevel = String(outline.adminLevel || '').trim();
|
|
const sourceAdmin = administrativeIdentity(sourceIdentity);
|
|
const primaryAdmin = administrativeIdentity(primaryLabel);
|
|
const requestedAdmin = administrativeIdentity(requestedLabel);
|
|
// The server leaves a Zone without a level only where zones were retired
|
|
// (Nepal). A verified level means a current zone, so "Zone" claims no level.
|
|
if (sourceLevel && sourceAdmin.level === 'retired') {
|
|
for (const admin of [sourceAdmin, primaryAdmin, requestedAdmin])
|
|
if (admin.level === 'retired') admin.level = null;
|
|
}
|
|
const sourceClaimsAdministrative = Boolean(
|
|
administrativeRequest ||
|
|
administrativeLabel ||
|
|
outline.adminArea ||
|
|
sourceAdmin.level,
|
|
);
|
|
const expectedAdministrativeLevel =
|
|
requestedLevel || primaryAdmin.level || requestedAdmin.level || '';
|
|
const verifiedSourceLevel =
|
|
sourceLevel ||
|
|
(directBundledOutline &&
|
|
sourceAdmin.level &&
|
|
sourceAdmin.level !== 'retired'
|
|
? sourceAdmin.level
|
|
: '');
|
|
if (sourceAdmin.level === 'retired') {
|
|
return {
|
|
ok: false,
|
|
code: 'AREA_IDENTITY_MISMATCH',
|
|
error: `The boundary source returned ${sourceIdentity}, a retired administrative identity.`,
|
|
};
|
|
}
|
|
if (outline.synthesized && sourceClaimsAdministrative) {
|
|
return {
|
|
ok: false,
|
|
code: 'AREA_IDENTITY_UNVERIFIED',
|
|
error: `The available outline for "${query}" is only an approximation, not a verified administrative boundary.`,
|
|
};
|
|
}
|
|
if (sourceClaimsAdministrative && !sourceIdentity) {
|
|
return {
|
|
ok: false,
|
|
code: 'AREA_IDENTITY_UNVERIFIED',
|
|
error: `The boundary source did not verify which administrative area "${query}" names.`,
|
|
};
|
|
}
|
|
if (sourceIdentity && sourceAdmin.name !== primaryAdmin.name) {
|
|
return {
|
|
ok: false,
|
|
code: 'AREA_IDENTITY_MISMATCH',
|
|
error: `The boundary source returned ${sourceIdentity}, not ${primaryLabel}.`,
|
|
};
|
|
}
|
|
if (
|
|
administrativeRequest &&
|
|
sourceIdentity &&
|
|
sourceAdmin.name !== requestedAdmin.name
|
|
) {
|
|
return {
|
|
ok: false,
|
|
code: 'AREA_IDENTITY_MISMATCH',
|
|
error: `The boundary source returned ${sourceIdentity}, not ${requestedLabel}.`,
|
|
};
|
|
}
|
|
if (
|
|
sourceClaimsAdministrative &&
|
|
sourceAdmin.level &&
|
|
expectedAdministrativeLevel &&
|
|
sourceAdmin.level !== expectedAdministrativeLevel
|
|
) {
|
|
return {
|
|
ok: false,
|
|
code: 'AREA_IDENTITY_MISMATCH',
|
|
error: `The boundary source returned ${sourceIdentity}, not ${expectedAdministrativeLevel}.`,
|
|
};
|
|
}
|
|
if (sourceClaimsAdministrative && !verifiedSourceLevel) {
|
|
return {
|
|
ok: false,
|
|
code: 'AREA_IDENTITY_UNVERIFIED',
|
|
error: `The boundary source did not verify the administrative level for "${query}".`,
|
|
};
|
|
}
|
|
if (
|
|
sourceClaimsAdministrative &&
|
|
expectedAdministrativeLevel &&
|
|
verifiedSourceLevel !== expectedAdministrativeLevel
|
|
) {
|
|
return {
|
|
ok: false,
|
|
code: 'AREA_IDENTITY_MISMATCH',
|
|
error: `The boundary source returned ${sourceLevel}, not ${expectedAdministrativeLevel}.`,
|
|
};
|
|
}
|
|
const polygons =
|
|
Array.isArray(outline.polygons) && outline.polygons.length
|
|
? outline.polygons
|
|
: [[outline.ring]];
|
|
return {
|
|
ok: true,
|
|
name: sourceIdentity || primaryLabel,
|
|
...(sourceIdentity ? { adminName: sourceIdentity } : {}),
|
|
...(verifiedSourceLevel ? { adminLevel: verifiedSourceLevel } : {}),
|
|
polygons,
|
|
source,
|
|
rung,
|
|
approximate: Boolean(outline.synthesized),
|
|
...(outline.synthesized ? { basis: 'buffer sized to the place' } : {}),
|
|
};
|
|
}
|
|
|
|
const resolver = createAreaResolver({
|
|
store,
|
|
findNaturalRegion: findRegion,
|
|
findAdminCandidates,
|
|
resolveNamedOutline,
|
|
viewCenter,
|
|
onCredit: (source) => {
|
|
const credit = {
|
|
'natural-earth': NATURAL_EARTH_CREDIT,
|
|
'us-census': US_CENSUS_CREDIT,
|
|
wof: WOF_CREDIT,
|
|
}[source];
|
|
if (credit) registerDynamicCredit(viewer, credit);
|
|
},
|
|
});
|
|
|
|
const boardGeneration = () =>
|
|
typeof annotations?.generation === 'function'
|
|
? annotations.generation()
|
|
: 0;
|
|
|
|
/** Draw a stored area through the annotation engine. */
|
|
async function drawArea(
|
|
record,
|
|
{
|
|
flyTo = true,
|
|
color = 'cyan',
|
|
signal = null,
|
|
isCurrent = () => true,
|
|
} = {},
|
|
) {
|
|
if (signal?.aborted || !isCurrent())
|
|
return { drawn: false, cancelled: true };
|
|
if (typeof annotations?.annotate !== 'function')
|
|
return { drawn: false, reason: 'Annotation engine unavailable' };
|
|
const shown = displayParts(record.geometry, {
|
|
// Draped outlines are tessellated against the terrain and tiles; a few
|
|
// hundred vertices per area keeps drawing fast. Counting is unaffected.
|
|
maxVertices: 1200,
|
|
maxParts: 24,
|
|
});
|
|
if (!shown.parts.length) return { drawn: false, reason: 'Nothing to draw' };
|
|
const polygons = shown.parts.map((part) => [part.outer, ...part.holes]);
|
|
const result = await annotations.annotate(
|
|
[
|
|
{
|
|
type: 'area',
|
|
manual: true,
|
|
origin: 'area',
|
|
areaId: record.areaId,
|
|
ring: polygons[0][0],
|
|
polygons,
|
|
label: record.name,
|
|
color,
|
|
approximate: record.approximate,
|
|
},
|
|
],
|
|
{
|
|
persist: true,
|
|
flyTo,
|
|
clearPrevious: false,
|
|
signal,
|
|
isCurrent,
|
|
},
|
|
);
|
|
if (signal?.aborted || !isCurrent() || result?.aborted)
|
|
return { drawn: false, cancelled: true };
|
|
const item = (result?.results || [])[0];
|
|
const drawn = Boolean(item?.ok);
|
|
const reason = drawn
|
|
? null
|
|
: result?.capped
|
|
? 'annotation limit reached'
|
|
: item?.error || result?.error || 'the outline could not be added';
|
|
return {
|
|
drawn,
|
|
annotationId: item?.id || null,
|
|
...(reason ? { reason } : {}),
|
|
...(shown.drawnParts < shown.totalParts
|
|
? { drawnParts: shown.drawnParts, totalParts: shown.totalParts }
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
/** Area-like annotations, newest first. */
|
|
function areaAnnotations(predicate = () => true) {
|
|
const list =
|
|
typeof annotations?.list === 'function' ? annotations.list() : [];
|
|
return list
|
|
.filter(
|
|
(anno) =>
|
|
Array.isArray(anno?.ring) && anno.ring.length >= 3 && predicate(anno),
|
|
)
|
|
.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
|
|
}
|
|
|
|
/**
|
|
* The area an outline on the map stands for. An outline drawn from an area
|
|
* handle counts over that handle's full geometry, never over its simplified
|
|
* drawing; when the handle is gone (the page reloaded, so the store is
|
|
* new while the persisted outline remains) the answer is AREA_EXPIRED,
|
|
* not a count over the drawing. A hand-drawn outline IS its geometry. A
|
|
* voice annotation outline counts over every part it drew and is marked
|
|
* approximate when it was a buffer.
|
|
* @returns {object} A stored record, or a refusal with `code`.
|
|
*/
|
|
function areaForAnnotation(anno) {
|
|
if (anno.areaId) {
|
|
const stored = store.get(anno.areaId);
|
|
if (stored) return stored;
|
|
return {
|
|
code: 'AREA_EXPIRED',
|
|
error: `The outline of ${anno.label || 'that area'} was drawn before this page loaded — resolve it again to count inside it.`,
|
|
};
|
|
}
|
|
const drawn = anno.origin === 'drawn';
|
|
const geometry =
|
|
Array.isArray(anno.polygons) && anno.polygons.length
|
|
? anno.polygons
|
|
: [[anno.ring]];
|
|
return (
|
|
store.put({
|
|
geometry,
|
|
name: anno.label || (drawn ? 'the drawn area' : 'the outlined area'),
|
|
source: drawn ? 'drawn' : 'annotation',
|
|
level: drawn ? 'drawn' : null,
|
|
sourceId: `anno:${anno.id}`,
|
|
approximate: Boolean(anno.synthesized),
|
|
}) || {
|
|
code: 'AREA_UNKNOWN',
|
|
error: 'That outline is not a usable area.',
|
|
}
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Provider for analyst_query's `area`, `drawn` and `annotation` scopes.
|
|
* @param {{kind: string, areaId?: string, id?: string}} scope
|
|
*/
|
|
async function resolveAreaScope(scope) {
|
|
const found = (record) =>
|
|
record?.code
|
|
? { ok: false, code: record.code, error: record.error }
|
|
: record
|
|
? {
|
|
ok: true,
|
|
area: {
|
|
areaId: record.areaId,
|
|
name: record.name,
|
|
prepared: record.prepared,
|
|
source: record.source,
|
|
sourceLabel: SOURCE_LABELS[record.source] || record.source,
|
|
approximate: record.approximate,
|
|
},
|
|
}
|
|
: null;
|
|
if (scope.kind === 'area') {
|
|
return (
|
|
found(store.get(scope.areaId)) || {
|
|
ok: false,
|
|
code: 'AREA_UNKNOWN',
|
|
error: `No area "${scope.areaId}" on this page — call resolve_area again.`,
|
|
}
|
|
);
|
|
}
|
|
if (scope.kind === 'drawn') {
|
|
const anno = areaAnnotations((a) => a.origin === 'drawn')[0];
|
|
return (
|
|
found(anno && areaForAnnotation(anno)) || {
|
|
ok: false,
|
|
code: 'NO_DRAWN_AREA',
|
|
error:
|
|
'Nothing is drawn on the map — draw an area with the draw tool first.',
|
|
}
|
|
);
|
|
}
|
|
const anno = scope.id
|
|
? areaAnnotations((a) => a.id === scope.id)[0]
|
|
: areaAnnotations()[0];
|
|
return (
|
|
found(anno && areaForAnnotation(anno)) || {
|
|
ok: false,
|
|
code: 'NO_AREA_ANNOTATION',
|
|
error: scope.id
|
|
? `Annotation ${scope.id} is not an outlined area.`
|
|
: 'No outlined area is on the map.',
|
|
}
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Provider for analyst_query's `region` scope: the same ladder, the base
|
|
* 3 s budget, answers in the engine's region shape.
|
|
*/
|
|
async function resolveRegion(name, { signal } = {}) {
|
|
const result = await resolver.resolve(
|
|
{ query: name },
|
|
{ signal, budgetMs: REGION_BUDGET_MS },
|
|
);
|
|
if (result.ok)
|
|
return {
|
|
name: result.record.name,
|
|
prepared: result.record.prepared,
|
|
geometry: result.record.geometry,
|
|
source: result.record.source,
|
|
sourceLabel:
|
|
SOURCE_LABELS[result.record.source] || result.record.source,
|
|
areaId: result.record.areaId,
|
|
approximate: result.record.approximate,
|
|
};
|
|
if (result.needsClarification) return result;
|
|
if (result.code === 'AREA_TIMEOUT')
|
|
return { name, ring: null, error: 'region-timeout' };
|
|
return null;
|
|
}
|
|
|
|
/** "An area around <landmark>": its enclosing area, else a buffer. */
|
|
async function resolveAround(args, { signal }) {
|
|
const target = String(args.query || '').trim();
|
|
if (
|
|
!target ||
|
|
typeof annotationResolver?.resolveAnnotationTarget !== 'function'
|
|
)
|
|
return { ok: false, code: 'BAD_AREA', error: 'Name a landmark.' };
|
|
const budget = AbortSignal.timeout(AROUND_BUDGET_MS);
|
|
const combined = signal ? AbortSignal.any([signal, budget]) : budget;
|
|
// A radius the user gave always draws a buffer, so the enclosing outline
|
|
// (and its Nominatim request) is never looked up for it.
|
|
const explicitRadius =
|
|
args.radiusM !== undefined &&
|
|
args.radiusM !== null &&
|
|
Number.isFinite(Number(args.radiusM));
|
|
let resolved;
|
|
let outline = null;
|
|
try {
|
|
// The anchor first; the enclosing outline (Nominatim grounds, then the
|
|
// OpenFreeMap area holding it) is a bounded extra.
|
|
resolved = await annotationResolver.resolveAnnotationTarget({
|
|
placeSearch,
|
|
viewer,
|
|
target,
|
|
footprint: true,
|
|
deferFootprint: true,
|
|
entityKind: 'compound',
|
|
allowDistant: true,
|
|
signal: combined,
|
|
});
|
|
outline =
|
|
resolved && !explicitRadius
|
|
? await awaitOutline(resolved, aroundOutlineMs)
|
|
: null;
|
|
} catch {
|
|
resolved = null;
|
|
}
|
|
if (signal?.aborted) return cancelledResult();
|
|
if (!resolved) {
|
|
return budget.aborted
|
|
? {
|
|
ok: false,
|
|
code: 'AREA_TIMEOUT',
|
|
error: `Finding ${target} is taking a while — ask again in a moment.`,
|
|
}
|
|
: {
|
|
ok: false,
|
|
code: 'AREA_NOT_FOUND',
|
|
error: `Could not find ${target}.`,
|
|
};
|
|
}
|
|
const radiusM = Math.max(
|
|
50,
|
|
Math.min(
|
|
AROUND_MAX_RADIUS_M,
|
|
Number(args.radiusM) || AROUND_DEFAULT_RADIUS_M,
|
|
),
|
|
);
|
|
// A real enclosing outline counts with every part it came with; anything
|
|
// else (no outline, a sized buffer, a slow lookup) is a labelled buffer.
|
|
const exact =
|
|
!explicitRadius &&
|
|
Array.isArray(outline?.ring) &&
|
|
outline.ring.length >= 3 &&
|
|
!outline.synthesized;
|
|
const origin = exact ? outlineOrigin(outline.outlineSource) : null;
|
|
const geometry = exact
|
|
? Array.isArray(outline.polygons) && outline.polygons.length
|
|
? outline.polygons
|
|
: [[outline.ring]]
|
|
: [[circleRing(resolved.lat, resolved.lon, radiusM)]];
|
|
// The landmark as the user named it ("Ferry Building"), not the geocoder's
|
|
// street address.
|
|
const name = String(target.split(',')[0].trim() || target).slice(0, 120);
|
|
const record = store.put({
|
|
geometry,
|
|
name,
|
|
source: exact ? origin.source : 'approximate',
|
|
level: 'site',
|
|
approximate: !exact,
|
|
meta: {
|
|
around: target,
|
|
rung: exact ? origin.rung : 'buffer',
|
|
...(exact ? {} : { radiusM }),
|
|
basis: exact
|
|
? 'enclosing mapped area'
|
|
: `${radiusM} m around the landmark`,
|
|
},
|
|
});
|
|
if (!record)
|
|
return {
|
|
ok: false,
|
|
code: 'AREA_NOT_FOUND',
|
|
error: `Could not outline ${target}.`,
|
|
};
|
|
return {
|
|
ok: true,
|
|
record,
|
|
confidence: exact ? 0.8 : 0.5,
|
|
rung: exact ? origin.rung : 'buffer',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The `resolve_area` action.
|
|
* @param {object} args Tool arguments.
|
|
* @param {{signal?: AbortSignal, isCurrent?: () => boolean, progress?: Function}} [runOptions]
|
|
* `progress(step, label)` reports `resolve` then `draw` for narration.
|
|
*/
|
|
async function resolveAreaAction(args = {}, runOptions = {}) {
|
|
const current = () =>
|
|
!runOptions.signal?.aborted &&
|
|
(typeof runOptions.isCurrent !== 'function' || runOptions.isCurrent());
|
|
const progress = (step, label) => {
|
|
if (current()) runOptions.progress?.(step, label);
|
|
};
|
|
progress('resolve', String(args.query || ''));
|
|
const started = Date.now();
|
|
// The board as it was when the user asked: a clear while the lookup runs
|
|
// means the outline (and its camera move) is no longer wanted.
|
|
const boardAtStart = boardGeneration();
|
|
const result = args.around
|
|
? await resolveAround(args, { signal: runOptions.signal })
|
|
: await resolver.resolve(
|
|
{
|
|
query: args.query,
|
|
level: args.level,
|
|
within: args.within,
|
|
candidateId: args.candidateId,
|
|
},
|
|
{ signal: runOptions.signal, budgetMs: RESOLVE_BUDGET_MS },
|
|
);
|
|
if (!current())
|
|
return {
|
|
ok: false,
|
|
action: 'resolve_area',
|
|
code: 'CANCELLED',
|
|
cancelled: true,
|
|
error: 'This request was superseded.',
|
|
};
|
|
if (!result.ok) {
|
|
return {
|
|
ok: false,
|
|
action: 'resolve_area',
|
|
code: result.code || 'AREA_NOT_FOUND',
|
|
error: result.error,
|
|
...(result.needsClarification
|
|
? {
|
|
needsClarification: true,
|
|
query: String(args.query || ''),
|
|
candidates: result.candidates,
|
|
}
|
|
: {}),
|
|
};
|
|
}
|
|
const { record } = result;
|
|
const draw = args.draw === true || Boolean(args.around);
|
|
const boardCleared = draw && boardGeneration() !== boardAtStart;
|
|
if (draw && !boardCleared) progress('draw', record.name);
|
|
const drawing =
|
|
draw && !boardCleared
|
|
? await drawArea(record, {
|
|
flyTo: args.flyTo !== false,
|
|
signal: runOptions.signal,
|
|
isCurrent: current,
|
|
})
|
|
: null;
|
|
if (!current() || drawing?.cancelled)
|
|
return {
|
|
ok: false,
|
|
action: 'resolve_area',
|
|
code: 'CANCELLED',
|
|
cancelled: true,
|
|
error: 'This request was superseded.',
|
|
};
|
|
const size = spokenArea(record.areaKm2);
|
|
const basis = record.meta.basis;
|
|
const baseSay = record.approximate
|
|
? `Rough area around ${record.name}${size ? ` — ${size}` : ''}`
|
|
: `${record.name}${record.meta.country ? `, ${record.meta.country}` : ''}${size ? ` — ${size}` : ''}`;
|
|
const notDrawn = boardCleared
|
|
? 'the map was cleared while looking it up'
|
|
: drawing && !drawing.drawn
|
|
? drawing.reason
|
|
: null;
|
|
const drawFailed = Boolean(notDrawn);
|
|
return {
|
|
ok: true,
|
|
action: 'resolve_area',
|
|
...summarizeArea(record),
|
|
...(record.meta.adminName
|
|
? { sourceIdentity: record.meta.adminName }
|
|
: {}),
|
|
confidence: result.confidence,
|
|
cached: Boolean(result.cached),
|
|
ms: Date.now() - started,
|
|
resolveMs: result.ms ?? null,
|
|
...(drawing ? { drawn: drawing.drawn } : {}),
|
|
...(drawFailed
|
|
? {
|
|
partial: true,
|
|
notDrawn,
|
|
}
|
|
: {}),
|
|
...(boardCleared ? { drawn: false } : {}),
|
|
say: drawFailed
|
|
? `${baseSay}. I resolved the area, but couldn't draw the outline: ${notDrawn}.`
|
|
: baseSay,
|
|
display: {
|
|
source: SOURCE_LABELS[record.source] || record.source,
|
|
...(basis ? { basis } : {}),
|
|
...(record.parts > 1 ? { parts: `${record.parts} parts` } : {}),
|
|
...(drawing?.totalParts
|
|
? {
|
|
drawnParts: `${drawing.drawnParts} of ${drawing.totalParts} parts drawn`,
|
|
}
|
|
: {}),
|
|
},
|
|
};
|
|
}
|
|
|
|
return {
|
|
store,
|
|
resolver,
|
|
resolveAreaAction,
|
|
resolveAreaScope,
|
|
resolveRegion,
|
|
drawArea,
|
|
dispose: () => resolver.dispose(),
|
|
};
|
|
}
|