Files
gods-eye-view/src/voice/areaActions.js
T
Sameh Khamis 68667e2e3c fix(area): accept "State of Texas" and "USA" as the units they name
resolve_area compared the spoken name with the unit's name literally, so
"State of Texas" (admin1) and "USA" or "UK" (country) were refused with
AREA_IDENTITY_MISMATCH although the bundled matcher found exactly those
units. Both identity checks, the bundled one and the outline ladder's, now
also accept the prefix forms and same-unit country aliases the matcher
reads, at the level they imply, so the level check still applies.
"Great Britain", "Britain", "America" and "Korea" still need their own
names, and the natural-region check stays literal.
2026-11-03 09:12:28 -08:00

844 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, foldLatinMarks } from '../data/areaResolver.js';
import { circleRing, displayParts } from '../data/areaGeometry.js';
import { findNaturalRegion } from '../data/naturalEarthRegions.js';
import {
adminQueryIdentity,
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();
// Forms the administrative matcher reads as one unit's name: "State of
// Texas" is Texas at admin level 1, "USA" is United States of America.
const named = adminQueryIdentity(value);
if (named?.level === 'admin1')
return {
name: foldLatinMarks(normalized.replace(/^(?:the )?.+? of /, '')),
level: 'admin1',
};
if (named?.level === 'country')
return { name: foldLatinMarks(named.name), level: 'country' };
const match = normalized.match(
/\b(country|nation|province|state|region|zone|oblast|prefecture|canton|governorate|county|district|department)$/,
);
return {
// Compared, never shown: "Quebec" as spoken names "Québec".
name: foldLatinMarks(
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(),
};
}