mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
use bundled place boundaries and shared OpenFreeMap tiles before a guarded Nominatim fallback preserve multipart shapes, holes and cancellation, and keep pending annotations visible while ground sampling finishes across frames keep non-Latin name distinctions when selecting provider results, and retain an outline anchor only when it lies in filled geometry
2449 lines
102 KiB
JavaScript
2449 lines
102 KiB
JavaScript
import { isUnavailableCapability } from '../sources/capability.js';
|
||
import { requireFeatureSource } from '../sources/featureSource.js';
|
||
import { createOverpassFeatureSource } from '../sources/overpassFeatures.js';
|
||
import {
|
||
ringAreaM2,
|
||
stitchLine,
|
||
bufferCorridor,
|
||
simplifyRing,
|
||
closeRing,
|
||
ringCentroid,
|
||
approximateAreaM2,
|
||
approximateDistanceM,
|
||
pointInPolygon,
|
||
} from '../sources/featureGeometry.js';
|
||
import { defaultGeospatial } from '../search/defaults.js';
|
||
import * as Cesium from 'cesium';
|
||
import { lookupNeighborhoodRing } from '../data/neighborhoodPolygons.js';
|
||
import {
|
||
lookupNaturalRegionOutline,
|
||
findNaturalRegion,
|
||
} from '../data/naturalEarthRegions.js';
|
||
import {
|
||
findAdminArea,
|
||
findAdminAreaAt,
|
||
polygonsContain as polygonsContainPoint,
|
||
} from '../data/adminBoundaries.js';
|
||
import {
|
||
findNeighborhoodArea,
|
||
findNeighborhoodAreaAt,
|
||
findPlaceArea,
|
||
findPlaceAreaAt,
|
||
NEIGHBORHOOD_NEAR_KM,
|
||
normalizePlaceName,
|
||
PLACE_NEAR_KM,
|
||
} from '../data/placeBoundaries.js';
|
||
import {
|
||
registerDynamicCredit,
|
||
NATURAL_EARTH_CREDIT,
|
||
US_CENSUS_CREDIT,
|
||
OSM_CREDIT,
|
||
OPENMAPTILES_CREDIT,
|
||
WOF_CREDIT,
|
||
} from '../data/dataCredits.js';
|
||
import { createOutlineRungs } from './outlineRungs.js';
|
||
import { unavailablePlaceSearch } from '../search/placeSearch.js';
|
||
import { isPickedWorldPosition } from '../data/scenePick.js';
|
||
|
||
/** Ms the analyst region lookup waits on geocode + admin boundary. */
|
||
const REGION_FALLBACK_BUDGET_MS = 3_000;
|
||
|
||
/**
|
||
* Names that end like a landmark rather than a district: "Central Park",
|
||
* "Lake Merritt"-style suffixes are checked on the last word only, so "Park
|
||
* Slope" and "Notting Hill" stay districts.
|
||
*/
|
||
const LANDMARK_NAME =
|
||
/\b(?:park|gardens?|square|plaza|lake|river|bay|beach|pier|harbou?r|airport|station|stadium|arena|cemetery|zoo|university|college|campus|palace|castle|cathedral|church|temple|mosque|bridge|market|mall|cent(?:er|re)|tower|museum|reservoir|forest|woods?|heath|common|green)$/i;
|
||
|
||
/** The name part of an ask, before any comma qualifier. */
|
||
function stripQualifiers(query) {
|
||
return String(query || '')
|
||
.split(',')[0]
|
||
.trim();
|
||
}
|
||
|
||
/** Entity facts that rule out a state/county reading of the ask. */
|
||
const NON_ADMIN_ENTITY_KINDS = new Set([
|
||
'building',
|
||
'compound',
|
||
'street',
|
||
'point_feature',
|
||
]);
|
||
|
||
/** Own annotation lookup caches and ranking of supplied feature candidates. */
|
||
export function createAnnotationResolver({
|
||
boundarySource,
|
||
signal: lifetime,
|
||
featureSource = createOverpassFeatureSource({
|
||
boundarySource,
|
||
signal: lifetime,
|
||
}),
|
||
// Overpass-free rungs (OpenFreeMap tiles, guarded Nominatim) around the ladder.
|
||
outlineRungs = createOutlineRungs(),
|
||
} = {}) {
|
||
requireFeatureSource(featureSource);
|
||
lifetime?.throwIfAborted();
|
||
|
||
/**
|
||
* Annotation target resolver.
|
||
*
|
||
* The voice agent points things out by NAME (preferred) or explicit lat/lng.
|
||
* Research takeaway: vision models are unreliable at counting pixels on
|
||
* photoreal/oblique imagery, so we never ask the model to box pixels — we
|
||
* resolve a place name to a real-world coordinate (and, when useful, a real
|
||
* OSM footprint ring) and anchor the annotation in world space. That makes the
|
||
* annotation persist correctly as the camera moves and occlude naturally.
|
||
*
|
||
* This mirrors the geocode + Overpass-footprint patterns already used by
|
||
* `src/locations.js` (searchAndFlyTo / resolveBuildingBounds) but returns the
|
||
* raw geometry ring instead of a bounding box, which is what an outline needs.
|
||
*/
|
||
|
||
const footprintCache = new Map();
|
||
const monumentCache = new Map(); // OSM monuments/memorials near a view center, keyed by rounded coord
|
||
const enclosingAreaCache = new Map(); // smallest enclosing named non-building polygon, keyed by ~1km coord bucket
|
||
|
||
// Cache entries are { value, at }. Positive results live indefinitely; negative
|
||
// (not-found) results expire after this TTL so one bad moment doesn't poison a key
|
||
// for the whole session.
|
||
const NEG_CACHE_TTL_MS = 60_000;
|
||
|
||
/** Read a cache entry → value | null (cached not-found within TTL) | undefined (miss/expired). */
|
||
function cacheRead(cache, key) {
|
||
const entry = cache.get(key);
|
||
if (!entry) return undefined;
|
||
if (entry.value !== null) return entry.value; // positive — always valid
|
||
if (Date.now() - entry.at <= NEG_CACHE_TTL_MS) return null; // negative within TTL
|
||
cache.delete(key); // expired negative → allow a re-fetch
|
||
return undefined;
|
||
}
|
||
|
||
/** Write a positive (or definitive-null) cache entry with a timestamp. */
|
||
function cacheWrite(cache, key, value) {
|
||
if (lifetime?.aborted) return;
|
||
cache.set(key, { value, at: Date.now() });
|
||
}
|
||
|
||
/**
|
||
* Cache a null (not-found) result — but ONLY when it is DEFINITIVE (the upstream
|
||
* answered "no such place"), never on an abort or a transient error (network /
|
||
* 429 / 5xx / timeout). Caching those would poison the key; instead we leave it a
|
||
* miss so the next attempt retries. Definitive negatives still carry a TTL.
|
||
*/
|
||
function negCache(cache, key, signal, definitive = true) {
|
||
if (signal?.aborted) return; // superseded — never cache
|
||
if (!definitive) return; // transient upstream failure — allow a retry
|
||
cacheWrite(cache, key, null);
|
||
}
|
||
|
||
/**
|
||
* Resolve a single annotation target to a normalized world anchor.
|
||
*
|
||
* @param {object} opts
|
||
* @param {Cesium.Viewer} opts.viewer
|
||
* @param {string} [opts.target] Place name to geocode.
|
||
* @param {number} [opts.latitude] Explicit latitude (wins over target).
|
||
* @param {number} [opts.longitude] Explicit longitude.
|
||
* @param {boolean} [opts.footprint] Try to trace the real OSM outline ring.
|
||
* @param {string} [opts.entityKind] Voice model's entity FACT ('building'|'compound'|
|
||
* 'district'|'street'|'point_feature') — refines scope
|
||
* routing and the point-first contract; never a style choice.
|
||
* @param {boolean} [opts.deferFootprint] Progressive mode: return the anchor immediately
|
||
* (ring:null) plus a `resolveOutline()` continuation the
|
||
* caller runs AFTER drawing, upgrading the mark in place.
|
||
* @returns {Promise<null | {
|
||
* lon: number, lat: number, height: number,
|
||
* ring: Array<[number, number]> | null,
|
||
* label: string | null, source: string,
|
||
* viewport: object | null,
|
||
* resolveOutline?: () => Promise<undefined | null | { rateLimited: true, retryAfterMs: number | null }
|
||
* | { ring, footprintKind, buildingHeight, synthesized, lat, lon, height }>,
|
||
* }>}
|
||
*/
|
||
async function resolveAnnotationTarget({
|
||
placeSearch = unavailablePlaceSearch,
|
||
viewer,
|
||
target,
|
||
latitude,
|
||
longitude,
|
||
footprint = false,
|
||
intent = 'the_thing',
|
||
entityKind = null,
|
||
labelHint = null,
|
||
deferFootprint = false,
|
||
allowDistant = false,
|
||
screenX,
|
||
screenY,
|
||
signal,
|
||
}) {
|
||
signal = lifetime
|
||
? signal
|
||
? AbortSignal.any([lifetime, signal])
|
||
: lifetime
|
||
: signal;
|
||
signal?.throwIfAborted();
|
||
let lon = Number(longitude);
|
||
let lat = Number(latitude);
|
||
let label = null;
|
||
let source = 'coordinate';
|
||
let geocodeTypes = [];
|
||
let geocodePrimary = null;
|
||
// The Places `viewport` (lat/lng box framing the resolved place), captured when a
|
||
// Places Text Search anchors the target. Used downstream to SIZE a fallback grounds
|
||
// disc to the real feature instead of a blind GROUNDS_RADIUS_M constant.
|
||
let placeViewport = null;
|
||
// Canonical Places display name + `types` of the anchored feature — the Places
|
||
// analogue of geocodePrimary/geocodeTypes: the name feeds OSM matching stripped of
|
||
// locality suffixes; the types classify the entity (point-like vs area-like).
|
||
let placesPrimary = null;
|
||
let placeTypes = [];
|
||
// A coarse bundled admin-1 shape that carries a city's name (Natural
|
||
// Earth's 11-vertex "Paris"): kept as the fallback while the finer rungs
|
||
// (Census, Nominatim) try first.
|
||
let coarseCityShape = null;
|
||
let bundledCenter = null;
|
||
const coarseCityHolds = (point) =>
|
||
coarseCityShape &&
|
||
point &&
|
||
polygonsContainPoint(coarseCityShape.polygons, point.lat, point.lon);
|
||
const compoundAsk =
|
||
entityKind === 'compound' ||
|
||
isCampusAsk(target) ||
|
||
isGroundsLikeQuery(target) ||
|
||
isGroundsLikeQuery(labelHint);
|
||
const askNamesAdmin = Boolean(adminScopeFromAsk(target, entityKind));
|
||
// Instrumentation (logged once per target at the end): which sources were tried and what they returned.
|
||
const trace = {
|
||
query: String(target || '').trim(),
|
||
places: 'skipped',
|
||
geocode: 'none',
|
||
osmSnap: 'skipped',
|
||
};
|
||
// Guard bypass is an ASK-SIDE fact. A returned admin type can be a wrong match
|
||
// ("the Texas Capitol" → the state), so geocode types must never grant it.
|
||
const bypassNearViewGuards = Boolean(adminScopeFromAsk(target, entityKind));
|
||
|
||
// Bundled administrative outlines resolve settled names without a lookup.
|
||
// Georgia uses its qualifier or camera; city/state homonyms defer to geocoding.
|
||
if (
|
||
footprint &&
|
||
(!Number.isFinite(lat) || !Number.isFinite(lon)) &&
|
||
trace.query &&
|
||
!compoundAsk &&
|
||
!NON_ADMIN_ENTITY_KINDS.has(entityKind)
|
||
) {
|
||
const center =
|
||
pickWorldFromScreen(viewer, 0.5, 0.5) || viewportProximity(viewer);
|
||
bundledCenter = center;
|
||
const admin = await findAdminArea(trace.query, { near: center }).catch(
|
||
() => null,
|
||
);
|
||
signal?.throwIfAborted();
|
||
if (admin && !askNamesAdmin && isCoarseCityShape(admin))
|
||
coarseCityShape = admin;
|
||
else if (admin)
|
||
return bundledAdminTarget(viewer, admin, trace.query, signal);
|
||
// Bundled US places and neighborhoods named at or near the view. A
|
||
// campus, grounds or monument ask ("Stanford" as a compound, "the
|
||
// Capitol grounds") wants that feature, not a district of its name,
|
||
// and "the area around X" is drawn by the ladder below.
|
||
if (intent !== 'around_the_thing' && !isMonumentLikeQuery(trace.query)) {
|
||
const area = await bundledAreaNear(trace.query, center, {
|
||
entityKind,
|
||
viewRadiusKm: viewportProximity(viewer)?.radiusKm ?? null,
|
||
});
|
||
signal?.throwIfAborted();
|
||
if (area) return bundledAdminTarget(viewer, area, trace.query, signal);
|
||
}
|
||
}
|
||
|
||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
|
||
const query = String(target || '').trim();
|
||
if (query) {
|
||
const center =
|
||
pickWorldFromScreen(viewer, 0.5, 0.5) || viewportProximity(viewer);
|
||
// Monument / grounds names scatter under Geocoding — try a view-biased Places Text Search FIRST.
|
||
// A hit near the view centre is trusted (skips the proximity gate, like the osm-local snap); on a
|
||
// miss we fall through to geocode + fetchLocalMonument below. The model's entityKind counts too:
|
||
// a point_feature by fact ("Heroes of the Alamo" — no monument word) deserves the same path.
|
||
if (
|
||
center &&
|
||
(isMonumentLikeQuery(query) ||
|
||
isGroundsLikeQuery(query) ||
|
||
entityKind === 'point_feature')
|
||
) {
|
||
const placeHit = await placesTextSearch(
|
||
query,
|
||
center.lat,
|
||
center.lon,
|
||
6000,
|
||
signal,
|
||
placeSearch,
|
||
);
|
||
if (placeHit) {
|
||
trace.places = `${placeHit.lat.toFixed(5)},${placeHit.lon.toFixed(5)}`;
|
||
if (placeHit.distanceM <= PLACES_MAX_DISTANCE_M) {
|
||
lat = placeHit.lat;
|
||
lon = placeHit.lon;
|
||
label = placeHit.label;
|
||
placeViewport = placeHit.viewport || null;
|
||
placesPrimary = placeHit.label;
|
||
placeTypes = placeHit.types || [];
|
||
source = 'places';
|
||
}
|
||
} else {
|
||
trace.places = 'miss';
|
||
}
|
||
}
|
||
if (source !== 'places') {
|
||
const geocoded = await geocodePlace(
|
||
query,
|
||
viewportBias(viewer),
|
||
signal,
|
||
placeSearch,
|
||
);
|
||
if (geocoded) {
|
||
lat = geocoded.lat;
|
||
lon = geocoded.lon;
|
||
label = geocoded.label;
|
||
geocodeTypes = geocoded.types || [];
|
||
geocodePrimary = geocoded.primaryName || null;
|
||
placeViewport = geocoded.viewport || null;
|
||
source = 'geocode';
|
||
trace.geocode = `${geocoded.lat.toFixed(5)},${geocoded.lon.toFixed(5)}`;
|
||
}
|
||
// RECOVERY: a plain landmark ("the Capitol") can Geocode to a FAR city (Washington DC) that the
|
||
// proximity gate would then reject as an honest miss — but the user means the one they're
|
||
// LOOKING AT. If the geocode missed or landed far from the view centre, try a view-biased
|
||
// Places Text Search; a hit within the trust bound overrides + skips the gate. Local geocodes
|
||
// (neighborhoods, nearby buildings) are NOT far, so they keep the geocode + scope/polygon path.
|
||
if (
|
||
center &&
|
||
trace.places === 'skipped' &&
|
||
!bypassNearViewGuards &&
|
||
!allowDistant
|
||
) {
|
||
let geocodeFar = source !== 'geocode';
|
||
if (
|
||
source === 'geocode' &&
|
||
approximateDistanceM(center.lat, center.lon, lat, lon) / 1000 >
|
||
MIN_DRIFT_FLOOR_KM
|
||
) {
|
||
geocodeFar = true;
|
||
}
|
||
if (geocodeFar) {
|
||
const placeHit = await placesTextSearch(
|
||
query,
|
||
center.lat,
|
||
center.lon,
|
||
6000,
|
||
signal,
|
||
placeSearch,
|
||
);
|
||
if (placeHit && placeHit.distanceM <= PLACES_MAX_DISTANCE_M) {
|
||
lat = placeHit.lat;
|
||
lon = placeHit.lon;
|
||
label = placeHit.label;
|
||
placeViewport = placeHit.viewport || null;
|
||
placesPrimary = placeHit.label;
|
||
placeTypes = placeHit.types || [];
|
||
geocodeTypes = [];
|
||
geocodePrimary = null;
|
||
source = 'places';
|
||
trace.places = `${placeHit.lat.toFixed(5)},${placeHit.lon.toFixed(5)} (recovery)`;
|
||
} else {
|
||
trace.places = placeHit ? 'far-ignored' : 'miss';
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Pixel fallback: the agent pointed at the viewport screenshot but the place
|
||
// could not be named/geocoded. Convert the normalized pixel back into a world
|
||
// coordinate via the depth-aware pick cascade, so the mark is STILL
|
||
// world-anchored (it persists and occludes like any other) rather than a
|
||
// transient frame-bound overlay.
|
||
if (
|
||
(!Number.isFinite(lat) || !Number.isFinite(lon)) &&
|
||
Number.isFinite(Number(screenX)) &&
|
||
Number.isFinite(Number(screenY))
|
||
) {
|
||
const picked = pickWorldFromScreen(
|
||
viewer,
|
||
Number(screenX),
|
||
Number(screenY),
|
||
);
|
||
if (picked) {
|
||
lat = picked.lat;
|
||
lon = picked.lon;
|
||
source = 'pixel';
|
||
}
|
||
}
|
||
|
||
if (!Number.isFinite(lat) || !Number.isFinite(lon))
|
||
return coarseCityHolds(bundledCenter)
|
||
? bundledAdminTarget(viewer, coarseCityShape, trace.query, signal)
|
||
: null;
|
||
|
||
// Monument/memorial/statue names geocode unreliably — Google scatters them across the city (e.g.
|
||
// several Texas Capitol monuments landed blocks-to-miles apart, looking "all over the map"). When
|
||
// the target reads like a monument AND the anchor came from a NAME, snap to the actual OSM feature
|
||
// near what the user is LOOKING AT (screen-centre), not the scattered geocode point. A hit becomes
|
||
// an 'osm-local' anchor (near the view by construction, so it skips the proximity gate below).
|
||
if (
|
||
source === 'geocode' &&
|
||
(isMonumentLikeQuery(target) || entityKind === 'point_feature')
|
||
) {
|
||
const center =
|
||
pickWorldFromScreen(viewer, 0.5, 0.5) || viewportProximity(viewer);
|
||
if (center) {
|
||
const mon = await fetchLocalMonument(
|
||
center.lat,
|
||
center.lon,
|
||
target,
|
||
signal,
|
||
);
|
||
if (mon) {
|
||
lat = mon.lat;
|
||
lon = mon.lon;
|
||
label = label || mon.label;
|
||
source = 'osm-local';
|
||
trace.osmSnap = 'hit';
|
||
} else {
|
||
trace.osmSnap = 'miss';
|
||
}
|
||
}
|
||
}
|
||
|
||
// Whether this anchor came from geocoding a NAME (vs. explicit coords / pixel / osm-local). Only
|
||
// name-geocoded anchors are subject to the proximity gate below — coords/pixels/local are trusted.
|
||
const fromGeocode = source === 'geocode';
|
||
|
||
// Proximity gate — on the ANCHOR: a NAME that resolved implausibly far from what's currently on
|
||
// screen is almost always a wrong geocoder match (e.g. "Texas Capitol grounds" → a west-Texas
|
||
// admin region ~250 km away). Reject it — an honest "couldn't find it" beats a misleading
|
||
// blob/point off in another region. Zoom-relative + floored so it only fires on egregious drift:
|
||
// zoomed out, far-but-visible places still pass; zoomed way in, nearby landmarks stay safe.
|
||
// Explicit coords / pixel picks / places / osm-local skip this (fromGeocode === false). The
|
||
// footprint resolution below re-checks its own recentered centroid against the same bound — a
|
||
// polygon that would drag a good anchor out of plausibility is dropped (the mark stays an
|
||
// honest point) rather than rejecting the whole annotation.
|
||
const vpGate = viewportProximity(viewer);
|
||
const gateDrift = (gLat, gLon) => {
|
||
if (!vpGate) return null; // no camera info → pass
|
||
const driftKm =
|
||
approximateDistanceM(vpGate.lat, vpGate.lon, gLat, gLon) / 1000;
|
||
const limitKm = Math.max(
|
||
VIEWPORT_DRIFT_FACTOR * vpGate.radiusKm,
|
||
MIN_DRIFT_FLOOR_KM,
|
||
);
|
||
return driftKm > limitKm ? { driftKm, limitKm } : null;
|
||
};
|
||
if (fromGeocode && !bypassNearViewGuards && !allowDistant) {
|
||
const drift = gateDrift(lat, lon);
|
||
if (drift) {
|
||
if (trace.query) {
|
||
console.log(
|
||
`[Resolver] "${trace.query}": places=${trace.places} geocode=${trace.geocode} ` +
|
||
`osmSnap=${trace.osmSnap} → FINAL source=rejected (proximity gate, ${drift.driftKm.toFixed(0)}km > ${drift.limitKm.toFixed(0)}km)`,
|
||
);
|
||
}
|
||
return coarseCityHolds(bundledCenter)
|
||
? bundledAdminTarget(viewer, coarseCityShape, trace.query, signal)
|
||
: null;
|
||
}
|
||
}
|
||
|
||
// OSM name-matching key: the resolved feature's CANONICAL name when we have one — the
|
||
// geocoder's primary component, else the Places hit's own display name — falling back to
|
||
// the user's words. Canonical names strip the trailing locality ("Tejano Monument", not
|
||
// "…, Austin"), so incidental city/state tokens can't win the footprint scoring (the
|
||
// Thompson-Austin bug, field test 7). A POI's canonical name can be a bare street number,
|
||
// so anything without letters falls through.
|
||
const usablePrimary =
|
||
geocodePrimary && /[a-z]/i.test(geocodePrimary) ? geocodePrimary : null;
|
||
const usablePlaces =
|
||
placesPrimary && /[a-z]/i.test(placesPrimary) ? placesPrimary : null;
|
||
const matchName =
|
||
usablePrimary || usablePlaces || String(target || '').trim();
|
||
// Scope-route on the geocoder's type so we fetch the RIGHT OSM feature at the right size:
|
||
// an admin boundary for a city/state, the enclosing compound for a mall/campus, a single
|
||
// building for a premise. The voice model's entityKind (an entity FACT) refines an
|
||
// unresolved 'auto' scope only — real geocode types always win.
|
||
// A campus or grounds ask ("Stanford University", "Stanford campus",
|
||
// "the Capitol grounds", in the target or its label) or a compound by
|
||
// entity fact wants that compound even when the geocoder types the town
|
||
// or the state: never a Census place, neighborhood or admin boundary.
|
||
const typedScope = refineScope(scopeFromTypes(geocodeTypes), entityKind);
|
||
const baseScope =
|
||
compoundAsk &&
|
||
['country', 'state', 'county', 'city', 'neighborhood'].includes(
|
||
typedScope,
|
||
)
|
||
? 'compound'
|
||
: typedScope;
|
||
// Point-like targets (monuments/statues/memorials/…) resolve POINT-FIRST: only an
|
||
// (almost) exactly-named, monument-scale polygon may replace the point; a nearby polygon
|
||
// sharing locality words must not (docs/field-test-rootcause-2026-06-30.md §1).
|
||
const pointLike = isPointLikeTarget(
|
||
target,
|
||
entityKind,
|
||
placeTypes,
|
||
labelHint,
|
||
);
|
||
// Grounds/compound asks (target OR label wording, or entityKind fact) go outline-first:
|
||
// they reach the real enclosing-polygon sweep even under `around_the_thing` phrasing.
|
||
const groundsLike = isGroundsLikeAsk(target, labelHint, entityKind);
|
||
|
||
/**
|
||
* Resolve the footprint/outline for this (already-gated) anchor. Reads the outer locals
|
||
* but never mutates them — tri-state result:
|
||
* - a complete outline patch `{ ring, footprintKind, buildingHeight, synthesized,
|
||
* lat, lon, height }` whose lat/lon is the ring centroid, re-checked against the
|
||
* proximity gate;
|
||
* - `null` — DEFINITIVELY no polygon (keep the honest point; never retry);
|
||
* - `undefined` — TRANSIENT upstream failure (timeout / network blip) — a re-run may
|
||
* yet find the outline, and the /api/overpass proxy caches late completions so the
|
||
* retry is nearly free (progressive callers back off and re-invoke).
|
||
* - `{ rateLimited:true, retryAfterMs }` — an HTTP throttle that progressive callers
|
||
* retry only once, no sooner than both Retry-After and their normal ladder allow.
|
||
* The inline path awaits it right here; progressive callers (deferFootprint) invoke it
|
||
* AFTER the anchor mark is drawn and upgrade the mark in place.
|
||
*/
|
||
const resolveBaseOutline = async () => {
|
||
signal?.throwIfAborted();
|
||
let scope = baseScope;
|
||
const isAdmin =
|
||
scope === 'country' ||
|
||
scope === 'state' ||
|
||
scope === 'county' ||
|
||
scope === 'city';
|
||
const around = intent === 'around_the_thing';
|
||
// FIRST rung: bundled Natural Earth physical region (Alps, Rockies, Sahara,
|
||
// Gulf of Mexico …) — deterministic, OFFLINE, instant; mirrors the
|
||
// neighborhood-pack rung's philosophy. Two guards: (1) the ASK must NAME a
|
||
// curated region (exact/alias match, no fuzzy stealing — "Zilker Park"
|
||
// can never land here); (2) the geocoded anchor must fall INSIDE the
|
||
// matched ring (ray-cast) — disambiguating duplicate upstream names (US
|
||
// vs Spanish "Sierra Nevada") and blocking a wrong-place geocode from
|
||
// dressing itself in a range-sized ring. Range-scale geometry deliberately
|
||
// BYPASSES the compound/building scope caps and the centroid drift bound
|
||
// below: the region IS the asked scope ("outline the Alps"), the 60 km²
|
||
// compound cap is for campuses (the meadow bug,
|
||
// docs/voice-engine-evaluation-2026-07-23.md §3), and a continental ring's
|
||
// centroid legitimately sits far from any anchor. Admin and street scopes
|
||
// are excluded — "Texas" must keep resolving as an admin boundary.
|
||
if (!isAdmin && scope !== 'street' && !around) {
|
||
const ne = await lookupNaturalRegionOutline(target, lat, lon).catch(
|
||
() => null,
|
||
);
|
||
signal?.throwIfAborted();
|
||
if (ne) {
|
||
registerDynamicCredit(viewer, NATURAL_EARTH_CREDIT);
|
||
traceBundledOutline(ne.name, 'natural-earth');
|
||
const neCentroid = ringCentroid(ne.ring);
|
||
return {
|
||
ring: ne.ring,
|
||
footprintKind: 'area',
|
||
buildingHeight: null,
|
||
synthesized: false,
|
||
naturalRegion: ne.name,
|
||
lat: neCentroid?.lat ?? lat,
|
||
lon: neCentroid?.lon ?? lon,
|
||
height: sampleGroundHeight(
|
||
viewer,
|
||
neCentroid?.lon ?? lon,
|
||
neCentroid?.lat ?? lat,
|
||
),
|
||
};
|
||
}
|
||
}
|
||
let fp = null;
|
||
if (
|
||
around &&
|
||
!groundsLike &&
|
||
!isAdmin &&
|
||
scope !== 'street' &&
|
||
scope !== 'neighborhood'
|
||
) {
|
||
// "the area AROUND <landmark>" → a buffered zone on the centroid, not the exact
|
||
// footprint (research §3d / §8.5). Only for POI/building scopes — a city's or
|
||
// street's "around" is ill-defined, a neighborhood already synthesizes, and a
|
||
// GROUNDS-like ask must not short-circuit here: the model phrases "the Capitol
|
||
// grounds" as around_the_thing, but the grounds ARE the thing — the real enclosing
|
||
// polygon (below) beats a 400 m disc (field test 8's "spherical round one").
|
||
const availability = await fetchEnclosingArea(
|
||
lat,
|
||
lon,
|
||
signal,
|
||
matchName,
|
||
);
|
||
if (
|
||
isUnavailableCapability(availability) ||
|
||
isRateLimitedOutcome(availability)
|
||
)
|
||
return availability;
|
||
if (availability === undefined) return undefined;
|
||
fp = synthesizeBufferedArea(lat, lon, AROUND_LANDMARK_RADIUS_M);
|
||
} else if (isAdmin) {
|
||
// A country, state or county the geocoder typed: the bundled unit that carries the
|
||
// name AND contains the geocoded point (offline; the pack's ambiguity
|
||
// marks don't apply — the geocoder already chose "Georgia" the state).
|
||
if (scope === 'state' || scope === 'county' || scope === 'country') {
|
||
const admin = await findAdminAreaAt(
|
||
[target, matchName],
|
||
lat,
|
||
lon,
|
||
scope,
|
||
).catch(() => null);
|
||
signal?.throwIfAborted();
|
||
if (admin && !askNamesAdmin && isCoarseCityShape(admin))
|
||
coarseCityShape ||= admin;
|
||
else if (admin) return bundledAdminOutline(viewer, admin, signal);
|
||
}
|
||
// A geocoded US city, town or CDP: the Census place that holds it.
|
||
if (scope === 'city') {
|
||
const place = await findPlaceAreaAt(
|
||
[target, matchName],
|
||
lat,
|
||
lon,
|
||
).catch(() => null);
|
||
signal?.throwIfAborted();
|
||
if (place) return bundledAdminOutline(viewer, place, signal);
|
||
}
|
||
// Pure admin: only an admin boundary is correct — never fall back to a
|
||
// building/landuse (a city is never a single building).
|
||
fp = await fetchAdminArea(lat, lon, matchName, scope, signal);
|
||
} else if (scope === 'neighborhood') {
|
||
// FIRST: a bundled neighborhood polygon (reliable, deterministic, OFFLINE — no live
|
||
// Overpass). Covered neighborhoods (e.g. SF: Chinatown/Marina/Mission/Presidio)
|
||
// resolve here instantly to a REAL boundary, sidestepping the slow/flaky live-Overpass
|
||
// path that times out and falls back to points (see docs/field-test-2-analysis.md).
|
||
const ext = await lookupNeighborhoodRing(lat, lon, matchName);
|
||
signal?.throwIfAborted();
|
||
if (ext) {
|
||
traceBundledOutline(ext.name || matchName, 'datasf');
|
||
fp = { ring: ext.ring, kind: 'area', heightM: null };
|
||
}
|
||
// THEN a bundled Who's On First neighborhood carrying the name at the point.
|
||
if (!fp) {
|
||
const wof = await findNeighborhoodAreaAt(
|
||
[target, matchName],
|
||
lat,
|
||
lon,
|
||
).catch(() => null);
|
||
signal?.throwIfAborted();
|
||
if (wof) return bundledAdminOutline(viewer, wof, signal);
|
||
}
|
||
// Else fall through to the OSM admin/place → named-landuse → synthesis ladder. Each
|
||
// returns a footprint, null (definitively no polygon), or undefined (transient
|
||
// upstream failure). Synthesize a blob (the "Mission" problem, research §3b) ONLY
|
||
// when BOTH sources DEFINITIVELY have no polygon — never on a transient blip.
|
||
if (!fp) {
|
||
let adminFp = await fetchAdminArea(
|
||
lat,
|
||
lon,
|
||
matchName,
|
||
scope,
|
||
signal,
|
||
);
|
||
// A neighborhood whose canonical name carries a city suffix ("Presidio of San
|
||
// Francisco") can match the CITY admin; treat an oversized admin as a DEFINITIVE
|
||
// no-neighborhood-polygon so it still falls through to the named-landuse path.
|
||
if (isUnavailableCapability(adminFp)) return adminFp;
|
||
if (adminFp && exceedsScopeArea(adminFp, scope)) adminFp = null;
|
||
if (adminFp) {
|
||
fp = adminFp;
|
||
} else if (adminFp === null) {
|
||
// Admin/place DEFINITIVELY has no neighborhood polygon → try a NAMED landuse, district-
|
||
// sized (the Presidio, via the STRICT ≥0.3 km² gate).
|
||
const footFp = await fetchFootprint(
|
||
lat,
|
||
lon,
|
||
matchName,
|
||
scope,
|
||
signal,
|
||
'strict',
|
||
);
|
||
if (isUnavailableCapability(footFp)) return footFp;
|
||
if (footFp) {
|
||
fp = footFp;
|
||
} else if (footFp === null) {
|
||
// Strict found no district-sized named area. Before drawing a buffered blob, try a
|
||
// LOOSE footprint — a smaller named leisure/landuse polygon (e.g. Fort Mason, a
|
||
// 0.26 km² NPS park that the strict 0.3 km² floor rejects but Google mis-types as a
|
||
// "neighborhood") is a real outline and beats a disc. Name-match scoring keeps it from
|
||
// grabbing a building; the scope cap below rejects anything oversized.
|
||
const looseFp = await fetchFootprint(
|
||
lat,
|
||
lon,
|
||
matchName,
|
||
scope,
|
||
signal,
|
||
'loose',
|
||
);
|
||
if (isUnavailableCapability(looseFp)) return looseFp;
|
||
if (looseFp && looseFp.kind !== 'building') fp = looseFp;
|
||
else if (looseFp === null)
|
||
fp = synthesizeBufferedArea(lat, lon, NEIGHBORHOOD_RADIUS_M);
|
||
else if (looseFp === undefined) fp = undefined; // transient → honest point, retryable
|
||
// a building (wrong feature) → leave fp null (honest point) rather than a
|
||
// misleading blob — a re-run would only return the same cached building.
|
||
} else {
|
||
fp = undefined; // transient strict lookup → honest point, retryable
|
||
}
|
||
} else {
|
||
// adminFp === undefined (transient in the HIGHER-priority admin/place leg): do NOT
|
||
// fall through to a lower-priority landuse — a real boundary that was momentarily
|
||
// unavailable must not be replaced by a lesser polygon. Honest point, retryable.
|
||
fp = undefined;
|
||
}
|
||
}
|
||
} else if (scope === 'street') {
|
||
// Street → best-available AREA: a same-named district, else a buffered
|
||
// corridor ribbon along the centerline (always works), never a building.
|
||
fp = await fetchStreet(lat, lon, matchName, signal);
|
||
} else {
|
||
// compound / building / park / generic POI → the footprint resolver
|
||
// (which classifies building-vs-area and stitches compound multipolygons).
|
||
// Point-like targets use the strict 'point' selection: exact-ish name + monument
|
||
// scale, else no polygon at all — the honest point beats a locality-word match.
|
||
// Without a feature-name component, match the original ask and require
|
||
// identity or containment before replacing its geocoded anchor.
|
||
const footprintMode = pointLike
|
||
? 'point'
|
||
: fromGeocode && !usablePrimary
|
||
? 'anchored'
|
||
: 'loose';
|
||
fp = await fetchFootprint(
|
||
lat,
|
||
lon,
|
||
matchName,
|
||
scope,
|
||
signal,
|
||
footprintMode,
|
||
);
|
||
// A "grounds/compound/campus" phrase ("Texas Capitol grounds") names an ENCLOSING area. The
|
||
// primary footprint above returns the BUILDING (the dome) or null — neither is the grounds. The
|
||
// real enclosing polygon (e.g. "Capitol Square", leisure=park) IS in OSM but only surfaces via a
|
||
// radius sweep for NAMED non-building polygons, taking the SMALLEST that geometrically contains
|
||
// the point (research docs/compound-containment-research.md §1.4–1.5). So when a grounds-like
|
||
// query produced a building or no polygon, prefer that REAL enclosing outline; fall to a
|
||
// synthesized disc only when OSM DEFINITIVELY has none.
|
||
if (groundsLike && (fp === null || fp?.kind === 'building')) {
|
||
const area = await fetchEnclosingArea(lat, lon, signal, matchName);
|
||
if (isUnavailableCapability(area)) return area;
|
||
if (area) {
|
||
fp = area; // real grounds polygon (synthesized:false) — beats both the dome and a disc
|
||
// It's a grounds/compound feature (already capped at SCOPE_AREA_CAP_M2.compound inside
|
||
// fetchEnclosingArea). The geocoder may have typed the POI as `building` (the user pointed
|
||
// at the dome), so validate against the COMPOUND cap below, not the tighter building one.
|
||
scope = 'compound';
|
||
} else if (area === null) {
|
||
// OSM definitively has no enclosing polygon → loose dotted-disc footprint (the same
|
||
// approximate-area treatment neighborhoods get), sized from the place viewport when
|
||
// available (Places or geocode), else GROUNDS_RADIUS_M. A grounds ask never keeps the
|
||
// bare BUILDING either: the dome is the exact feature, not the grounds — and under
|
||
// progressive dedup an identical building ring would collapse into the building mark
|
||
// and eat its caption. Never on `area === undefined` (transient — a retry may yet
|
||
// find the real outline).
|
||
fp = synthesizeBufferedArea(
|
||
lat,
|
||
lon,
|
||
groundsRadiusFromViewport(placeViewport),
|
||
);
|
||
} else if (fp?.kind === 'building') {
|
||
// Transient sweep failure with only the building in hand → honest point +
|
||
// RETRYABLE (the backoff retry / a re-narration re-runs the sweep), never the
|
||
// wrong exact-building shape.
|
||
fp = undefined;
|
||
}
|
||
}
|
||
}
|
||
return finishOutline(fp, scope);
|
||
};
|
||
|
||
/** Validate a footprint for its scope and re-centre it (resolveOutline's patch). */
|
||
const finishOutline = (fp, scope) => {
|
||
// Scope sanity: reject a real footprint whose area is wildly wrong for the asked
|
||
// scope (a building/compound/neighborhood intent must never draw a state-sized
|
||
// blob). Synthesized discs are deliberately sized and exempt.
|
||
if (
|
||
fp &&
|
||
!fp.synthesized &&
|
||
Array.isArray(fp.ring) &&
|
||
fp.ring.length >= 3 &&
|
||
exceedsScopeArea(fp, scope)
|
||
) {
|
||
fp = null;
|
||
}
|
||
if (isUnavailableCapability(fp) || isRateLimitedOutcome(fp)) return fp;
|
||
if (fp === undefined) return undefined; // TRANSIENT — a backoff retry may still find it
|
||
if (!fp || !Array.isArray(fp.ring) || fp.ring.length < 3) return null;
|
||
let centroid = ringCentroid(fp.ring);
|
||
if (!centroid) return null;
|
||
// An outer-ring mean can lie in a hole or outside a concave part. Keep
|
||
// a filled original anchor or decline, without changing ordinary centering
|
||
// or inventing a new interior point.
|
||
if (
|
||
Array.isArray(fp.polygons) &&
|
||
!polygonsContainPoint(fp.polygons, centroid.lat, centroid.lon)
|
||
) {
|
||
if (!polygonsContainPoint(fp.polygons, lat, lon)) return null;
|
||
centroid = { lat, lon };
|
||
}
|
||
// Same drift bound as the anchor gate: a polygon whose centroid would drag a good
|
||
// anchor out of plausibility is the wrong feature — drop it, keep the point.
|
||
if (
|
||
fromGeocode &&
|
||
!bypassNearViewGuards &&
|
||
gateDrift(centroid.lat, centroid.lon)
|
||
)
|
||
return null;
|
||
return {
|
||
ring: fp.ring,
|
||
footprintKind: fp.kind,
|
||
buildingHeight: fp.heightM,
|
||
synthesized: Boolean(fp.synthesized),
|
||
...(Array.isArray(fp.polygons) ? { polygons: fp.polygons } : {}),
|
||
lat: centroid.lat,
|
||
lon: centroid.lon,
|
||
height: sampleGroundHeight(viewer, centroid.lon, centroid.lat),
|
||
};
|
||
};
|
||
|
||
// Streets and pointed-at buildings from tiles, then this ladder, then the
|
||
// guarded Nominatim outline (see outlineRungs.js).
|
||
const outlineContext = {
|
||
scope: baseScope,
|
||
target,
|
||
matchName,
|
||
lat,
|
||
lon,
|
||
fromName: fromGeocode || source === 'places' || source === 'osm-local',
|
||
pointLike,
|
||
groundsLike,
|
||
around: intent === 'around_the_thing',
|
||
view: viewportProximity(viewer),
|
||
signal,
|
||
credit: (kind) =>
|
||
registerDynamicCredit(
|
||
viewer,
|
||
kind === 'tiles' ? OPENMAPTILES_CREDIT : OSM_CREDIT,
|
||
),
|
||
};
|
||
const resolveOutline = async () => {
|
||
const result = await outlineRungs.resolve(outlineContext, {
|
||
base: resolveBaseOutline,
|
||
finish: finishOutline,
|
||
});
|
||
signal?.throwIfAborted();
|
||
// Nothing finer answered (no polygon, Nominatim off, busy or capped):
|
||
// the coarse bundled shape beats a point.
|
||
if (
|
||
coarseCityHolds({ lat, lon }) &&
|
||
!(result?.ring && !result.synthesized)
|
||
)
|
||
return bundledAdminOutline(viewer, coarseCityShape, signal);
|
||
return result;
|
||
};
|
||
|
||
let ring = null;
|
||
let footprintKind = null; // 'building' | 'area'
|
||
let buildingHeight = null; // meters, only for buildings
|
||
let outlineUnavailable = false;
|
||
let synthesized = false; // true = buffered/approximate area, render dashed/feathered
|
||
let polygons = null; // every part of a multi-part outline, when known
|
||
if (footprint && !deferFootprint) {
|
||
const fp = await resolveOutline();
|
||
outlineUnavailable = isUnavailableCapability(fp);
|
||
if (fp && !outlineUnavailable && !isRateLimitedOutcome(fp)) {
|
||
ring = fp.ring;
|
||
footprintKind = fp.footprintKind;
|
||
buildingHeight = fp.buildingHeight;
|
||
synthesized = fp.synthesized;
|
||
polygons = fp.polygons || null;
|
||
// Re-center the anchor on the resolved footprint centroid.
|
||
lat = fp.lat;
|
||
lon = fp.lon;
|
||
source = 'footprint';
|
||
}
|
||
}
|
||
|
||
const height = sampleGroundHeight(viewer, lon, lat);
|
||
// One concise line per target so the resolution path is visible in the browser console.
|
||
if (trace.query) {
|
||
console.log(
|
||
`[Resolver] "${trace.query}": places=${trace.places} geocode=${trace.geocode} ` +
|
||
`osmSnap=${trace.osmSnap} → FINAL source=${source} ${lat.toFixed(5)},${lon.toFixed(5)}` +
|
||
(footprint && deferFootprint ? ' (outline pending)' : ''),
|
||
);
|
||
}
|
||
return {
|
||
lon,
|
||
lat,
|
||
height,
|
||
ring,
|
||
footprintKind,
|
||
buildingHeight,
|
||
label,
|
||
source,
|
||
synthesized,
|
||
outlineUnavailable,
|
||
...(polygons ? { polygons } : {}),
|
||
viewport: placeViewport,
|
||
...(footprint && deferFootprint ? { resolveOutline } : {}),
|
||
};
|
||
}
|
||
|
||
// Upper area bound (m²) per scope. A resolved footprint bigger than its scope's
|
||
// cap is the wrong feature (e.g. a whole city returned for a neighborhood), so we
|
||
// drop it rather than draw a misleading blob. state / country / auto / street have
|
||
// no cap (they are legitimately large or capped elsewhere).
|
||
const SCOPE_AREA_CAP_M2 = {
|
||
building: 0.6e6, // 0.6 km²
|
||
compound: 60e6, // 60 km² (Presidio ≈ 6 km²; a mall ≈ 0.2 km²)
|
||
neighborhood: 80e6, // 80 km²
|
||
city: 9e9, // 9,000 km²
|
||
county: 1.2e11, // 120,000 km²
|
||
};
|
||
|
||
// Proximity gate tolerances (see the gate in resolveAnnotationTarget). A geocoded anchor more
|
||
// than VIEWPORT_DRIFT_FACTOR × the on-screen viewport radius away — but never less than
|
||
// MIN_DRIFT_FLOOR_KM — is treated as a wrong match and rejected. The factor keeps it zoom-aware
|
||
// (far-but-visible places pass when zoomed out); the floor avoids over-rejecting nearby places
|
||
// when zoomed way in. The area cap alone can't catch this: admin scopes (state/country) are
|
||
// uncapped, so a landmark that mis-geocodes to an admin region traces an uncapped blob.
|
||
const VIEWPORT_DRIFT_FACTOR = 8;
|
||
const MIN_DRIFT_FLOOR_KM = 50;
|
||
|
||
// A view-biased Places Text Search hit is trusted only when it lands within this many metres of the
|
||
// view centre — a sanity bound so a stray cross-city Text Search result can't anchor far from what
|
||
// the user is looking at (it falls back to the geocode + osm-snap path instead). Generous enough to
|
||
// cover a large compound's monuments seen from an oblique view (Text Search is biased to 6 km here).
|
||
const PLACES_MAX_DISTANCE_M = 8000;
|
||
|
||
// Synthesis radii (m) per osm-place-resolution-research.md §8.5. Used when OSM has only
|
||
// a label point (most US neighborhoods) or the user asks for the area AROUND a landmark.
|
||
const NEIGHBORHOOD_RADIUS_M = 750; // urban-neighborhood blob (600–900 m band)
|
||
const AROUND_LANDMARK_RADIUS_M = 400; // "the area around X" — a few blocks (300–500 m)
|
||
const GROUNDS_RADIUS_M = 300; // "X grounds/compound/campus" loose disc when OSM has no polygon
|
||
const GROUNDS_RADIUS_MIN_M = 150; // viewport-derived grounds disc is clamped to this band so a tiny
|
||
const GROUNDS_RADIUS_MAX_M = 1200; // place can't shrink to a dot, nor a city-wide viewport balloon
|
||
const COARSE_CITY_MAX_KM2 = 5_000; // city-sized admin-1 units (see isCoarseCityShape)
|
||
const COARSE_CITY_MAX_VERTICES = 32;
|
||
|
||
/** Credit the pack a bundled boundary came from. */
|
||
function creditAdminSource(viewer, admin) {
|
||
const credit = {
|
||
'natural-earth': NATURAL_EARTH_CREDIT,
|
||
'us-census': US_CENSUS_CREDIT,
|
||
wof: WOF_CREDIT,
|
||
}[admin.source];
|
||
if (credit) registerDynamicCredit(viewer, credit);
|
||
}
|
||
|
||
/**
|
||
* The bundled US place or neighborhood an ask names at or near the view,
|
||
* from Census places, the San Francisco DataSF pack (first in SF), then
|
||
* Who's On First. How far "near" reaches follows the view (half its
|
||
* radius); a state-qualified place ("Springfield, Illinois") needs no
|
||
* view. A district ask takes a neighborhood first and a place only when
|
||
* the view is inside it ("Richmond" over SF is not Richmond, CA). Without
|
||
* an entity kind the candidate that holds the view wins, else the nearer.
|
||
* A name that ends like a landmark ("Central Park", "Lake Merritt") is
|
||
* left to the landmark ladder unless the ask is a district.
|
||
*/
|
||
async function bundledAreaNear(
|
||
query,
|
||
center,
|
||
{ entityKind = null, viewRadiusKm = null } = {},
|
||
) {
|
||
const district = entityKind === 'district';
|
||
if (!district && LANDMARK_NAME.test(stripQualifiers(query))) return null;
|
||
const reach = Number.isFinite(viewRadiusKm) ? viewRadiusKm / 2 : null;
|
||
const clamp = (km, lo, hi) =>
|
||
km === null ? hi : Math.min(hi, Math.max(lo, km));
|
||
const placeReach = district ? 0 : clamp(reach, 3, PLACE_NEAR_KM);
|
||
const place = await findPlaceArea(query, {
|
||
near: center,
|
||
nearKm: placeReach,
|
||
}).catch(() => null);
|
||
const holds = (area) =>
|
||
Boolean(center) &&
|
||
polygonsContainPoint(area.polygons, center.lat, center.lon);
|
||
// A district ask only takes a place the view is inside (or one a state
|
||
// qualifier named outright).
|
||
const placeOk =
|
||
place && (!district || holds(place) || /,/.test(query)) ? place : null;
|
||
if (!center) return placeOk;
|
||
let hood = null;
|
||
const sf = await lookupNeighborhoodRing(center.lat, center.lon, query, {
|
||
exact: true,
|
||
}).catch(() => null);
|
||
if (sf) hood = sfNeighborhoodArea(sf);
|
||
else
|
||
hood = await findNeighborhoodArea(query, {
|
||
near: center,
|
||
nearKm: clamp(reach, 1, NEIGHBORHOOD_NEAR_KM),
|
||
}).catch(() => null);
|
||
// Outside a district ask, an alternate name unrelated to the name is too
|
||
// weak: Se in Sao Paulo lists "Sao Paulo" among its names, and "outline
|
||
// Sao Paulo" means the city ("Richmond" for Richmond District still counts).
|
||
if (
|
||
!district &&
|
||
hood?.byAlias &&
|
||
!normalizePlaceName(hood.name).includes(
|
||
normalizePlaceName(stripQualifiers(query)),
|
||
)
|
||
)
|
||
hood = null;
|
||
if (!hood || !placeOk) return district ? hood || placeOk : placeOk || hood;
|
||
if (district) return hood;
|
||
const hoodHolds = holds(hood);
|
||
const placeHolds = holds(placeOk);
|
||
if (hoodHolds !== placeHolds) return hoodHolds ? hood : placeOk;
|
||
const distance = (area) =>
|
||
approximateDistanceM(
|
||
center.lat,
|
||
center.lon,
|
||
area.label.lat,
|
||
area.label.lon,
|
||
);
|
||
return distance(hood) <= distance(placeOk) ? hood : placeOk;
|
||
}
|
||
|
||
/** An SF DataSF ring in the bundled-area shape `bundledAdminTarget` takes. */
|
||
function sfNeighborhoodArea({ ring, name }) {
|
||
const lons = ring.map((p) => p[0]);
|
||
const lats = ring.map((p) => p[1]);
|
||
return {
|
||
kind: 'neighborhood',
|
||
name,
|
||
region: null,
|
||
source: 'datasf',
|
||
polygons: [[ring]],
|
||
ring,
|
||
bbox: [
|
||
Math.min(...lons),
|
||
Math.min(...lats),
|
||
Math.max(...lons),
|
||
Math.max(...lats),
|
||
],
|
||
label: ringCentroid(ring),
|
||
candidates: 1,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Outline patch (resolveOutline's contract) for a bundled administrative unit.
|
||
* `ring` is the main part, closed; `polygons` carries every part with its
|
||
* holes (Hawaii's islands, Berlin inside Brandenburg) for renderers that
|
||
* draw them. Like the Natural Earth rung, it bypasses the scope caps and the
|
||
* centroid drift bound: the unit IS the asked scope.
|
||
*/
|
||
function bundledAdminOutline(viewer, admin, signal) {
|
||
signal?.throwIfAborted();
|
||
creditAdminSource(viewer, admin);
|
||
traceBundledOutline(admin.name, admin.source, admin.polygons?.length);
|
||
const { lat, lon } = admin.label;
|
||
return {
|
||
ring: closeRing([...admin.ring]), // copy: closeRing mutates, the pack is shared
|
||
polygons: admin.polygons,
|
||
footprintKind: 'area',
|
||
buildingHeight: null,
|
||
synthesized: false,
|
||
adminArea: admin.name,
|
||
lat,
|
||
lon,
|
||
height: sampleGroundHeight(viewer, lon, lat),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Whether a bundled unit is too coarse to stand for a city of its name: a
|
||
* Natural Earth admin-1 at city size (up to 5,000 km²) drawn with fewer
|
||
* than 32 vertices. Paris (11), Buenos Aires (11), Washington DC (9),
|
||
* Vienna and Prague (23) and Seoul (28) are; Berlin (48), Hamburg (43),
|
||
* Bremen (39) and every larger state are not. Countries never are.
|
||
*/
|
||
function isCoarseCityShape(admin) {
|
||
if (admin?.source !== 'natural-earth' || admin.kind !== 'state')
|
||
return false;
|
||
if (!(admin.areaKm2 <= COARSE_CITY_MAX_KM2)) return false;
|
||
const vertices = (admin.polygons || []).reduce(
|
||
(n, poly) => n + poly.reduce((m, ring) => m + ring.length, 0),
|
||
0,
|
||
);
|
||
return vertices < COARSE_CITY_MAX_VERTICES;
|
||
}
|
||
|
||
/** One trace line per outline a bundled pack answered (same shape as outlineRungs). */
|
||
function traceBundledOutline(name, source, parts = 1) {
|
||
console.log(
|
||
`[Outline] "${name}": bundled → ${source} (${parts || 1} part(s))`,
|
||
);
|
||
}
|
||
|
||
/** A complete resolved target for a bundled administrative unit (no outline pending). */
|
||
function bundledAdminTarget(viewer, admin, query, signal) {
|
||
const outline = bundledAdminOutline(viewer, admin, signal);
|
||
const [west, south, east, north] = admin.bbox;
|
||
console.log(
|
||
`[Resolver] "${query}": bundled ${admin.kind} "${admin.name}"` +
|
||
`${admin.region ? `, ${admin.region}` : ''} (${admin.source}, ` +
|
||
`${admin.polygons.length} part(s), ${admin.candidates} candidate(s)) → FINAL source=bundled`,
|
||
);
|
||
return {
|
||
lon: outline.lon,
|
||
lat: outline.lat,
|
||
height: outline.height,
|
||
ring: outline.ring,
|
||
polygons: outline.polygons,
|
||
footprintKind: 'area',
|
||
buildingHeight: null,
|
||
label: admin.name,
|
||
source: 'bundled',
|
||
synthesized: false,
|
||
outlineUnavailable: false,
|
||
viewport: {
|
||
low: { latitude: south, longitude: west },
|
||
high: { latitude: north, longitude: east },
|
||
},
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Synthesize an approximate circular AREA by buffering a label point. Returns a ring
|
||
* marked `synthesized:true` so the renderers draw it "approximate" (dashed/feathered),
|
||
* never as an authoritative boundary (research §8.6 invariant 3). Pure local math — no
|
||
* Overpass call — so it always succeeds and adds no proxy/rate-limit cost.
|
||
* @param {number} lat
|
||
* @param {number} lon
|
||
* @param {number} radiusM
|
||
* @returns {{ring:[number,number][], kind:'area', heightM:null, synthesized:true}}
|
||
*/
|
||
function synthesizeBufferedArea(lat, lon, radiusM) {
|
||
const mPerDegLat = 111320;
|
||
const mPerDegLon = mPerDegLat * Math.cos((lat * Math.PI) / 180);
|
||
const ring = [];
|
||
const N = 44;
|
||
for (let i = 0; i <= N; i++) {
|
||
const a = (i / N) * Math.PI * 2;
|
||
ring.push([
|
||
lon + (Math.cos(a) * radiusM) / mPerDegLon,
|
||
lat + (Math.sin(a) * radiusM) / mPerDegLat,
|
||
]);
|
||
}
|
||
return { ring, kind: 'area', heightM: null, synthesized: true };
|
||
}
|
||
|
||
/**
|
||
* Radius (m) for a synthesized grounds disc, sized to the place's Google Places
|
||
* `viewport` (a lat/lng box) when one is available: half its diagonal, clamped to
|
||
* a sane band. Google never returns a polygon, but the viewport frames the real
|
||
* feature, so this is far better than a blind constant. Falls back to
|
||
* GROUNDS_RADIUS_M when there is no viewport.
|
||
* @param {{low:{latitude:number,longitude:number},high:{latitude:number,longitude:number}}|null} viewport
|
||
*/
|
||
function groundsRadiusFromViewport(viewport) {
|
||
const lo = viewport?.low;
|
||
const hi = viewport?.high;
|
||
if (
|
||
!lo ||
|
||
!hi ||
|
||
![lo.latitude, lo.longitude, hi.latitude, hi.longitude].every(
|
||
Number.isFinite,
|
||
)
|
||
) {
|
||
return GROUNDS_RADIUS_M;
|
||
}
|
||
const diagM = approximateDistanceM(
|
||
lo.latitude,
|
||
lo.longitude,
|
||
hi.latitude,
|
||
hi.longitude,
|
||
);
|
||
const r = diagM / 2;
|
||
if (!Number.isFinite(r) || r <= 0) return GROUNDS_RADIUS_M;
|
||
return Math.max(GROUNDS_RADIUS_MIN_M, Math.min(GROUNDS_RADIUS_MAX_M, r));
|
||
}
|
||
|
||
function exceedsScopeArea(fp, scope) {
|
||
const cap = SCOPE_AREA_CAP_M2[scope];
|
||
if (!cap) return false;
|
||
return ringAreaM2(fp.ring) > cap;
|
||
}
|
||
|
||
/** Shoelace area (m²) of a [[lon,lat], ...] ring in a local equirectangular projection. */
|
||
|
||
/** Resolve a name through the supplied service; geometry selection stays here. */
|
||
async function geocodePlace(query, biasRect, signal, placeSearch) {
|
||
const { place } = await placeSearch.geocode(query, {
|
||
bias: biasRect,
|
||
signal,
|
||
});
|
||
signal?.throwIfAborted();
|
||
if (!place) return null;
|
||
return {
|
||
lat: place.lat,
|
||
lon: place.lng,
|
||
label: shortLabel(place.label),
|
||
primaryName: place.name || null,
|
||
types: place.types,
|
||
viewport: normalizeGeocodeViewport(place.viewport),
|
||
};
|
||
}
|
||
|
||
/** Geocoding returns {southwest:{lat,lng},northeast:{lat,lng}}; normalize to the Places
|
||
* {low,high} lat/lng shape the rest of the pipeline (disc sizing, framing) consumes. */
|
||
function normalizeGeocodeViewport(vp) {
|
||
const sw = vp?.southwest;
|
||
const ne = vp?.northeast;
|
||
if (![sw?.lat, sw?.lng, ne?.lat, ne?.lng].every(Number.isFinite))
|
||
return null;
|
||
return {
|
||
low: { latitude: sw.lat, longitude: sw.lng },
|
||
high: { latitude: ne.lat, longitude: ne.lng },
|
||
};
|
||
}
|
||
|
||
let placesCaches = new WeakMap(); // Cache isolated by provider configuration
|
||
|
||
/**
|
||
* View-biased Google Places TEXT SEARCH for a named landmark/POI. Geocoding
|
||
* scatters obscure monument/POI names across the city; a Text Search biased to
|
||
* the view centre lands on the ACTUAL feature near what the user is looking at.
|
||
* Goes through the `/api/google/text-search` proxy (key stays server-side) and
|
||
* returns the closest result, or null on no-match / transient failure. The
|
||
* `viewport` (a lat/lng bounding box framing the place, or null) is carried
|
||
* through so the resolver can SIZE a fallback grounds disc to the real feature.
|
||
* @returns {Promise<null | { lat:number, lon:number, label:string|null, distanceM:number,
|
||
* viewport:{low:{latitude:number,longitude:number},high:{latitude:number,longitude:number}}|null }>}
|
||
*/
|
||
async function placesTextSearch(
|
||
query,
|
||
centerLat,
|
||
centerLon,
|
||
radiusM,
|
||
signal,
|
||
service = defaultGeospatial,
|
||
) {
|
||
const placesCache = placesCaches.get(service) || new Map();
|
||
placesCaches.set(service, placesCache);
|
||
const q = String(query || '').trim();
|
||
if (!q || !Number.isFinite(centerLat) || !Number.isFinite(centerLon))
|
||
return null;
|
||
|
||
const cacheKey = `${q.toLowerCase()}|${centerLat.toFixed(3)},${centerLon.toFixed(3)}|${radiusM}`;
|
||
const cached = cacheRead(placesCache, cacheKey);
|
||
if (cached !== undefined) return cached;
|
||
|
||
try {
|
||
const data = {
|
||
places: await service.textSearch?.(
|
||
q,
|
||
{
|
||
latitude: centerLat,
|
||
longitude: centerLon,
|
||
radiusM,
|
||
},
|
||
{ signal },
|
||
),
|
||
};
|
||
const hit = Array.isArray(data?.places)
|
||
? data.places.find(
|
||
(p) =>
|
||
Number.isFinite(p?.latitude) && Number.isFinite(p?.longitude),
|
||
)
|
||
: null;
|
||
if (!hit) {
|
||
negCache(placesCache, cacheKey, signal, true);
|
||
return null;
|
||
} // definitive no-match
|
||
const place = {
|
||
lat: hit.latitude,
|
||
lon: hit.longitude,
|
||
label: hit.name || null,
|
||
distanceM: approximateDistanceM(
|
||
centerLat,
|
||
centerLon,
|
||
hit.latitude,
|
||
hit.longitude,
|
||
),
|
||
viewport: hit.viewport || null,
|
||
// Entity identity/classification — the proxy already pays for these in its field
|
||
// mask, so keep them: `primaryType`/`types` classify the feature (point-like
|
||
// monument vs compound) and `id` is a stable identity key for future caching/dedup.
|
||
id: hit.id || null,
|
||
primaryType: hit.primaryType || null,
|
||
types: Array.isArray(hit.types) ? hit.types : [],
|
||
};
|
||
cacheWrite(placesCache, cacheKey, place);
|
||
return place;
|
||
} catch {
|
||
negCache(placesCache, cacheKey, signal, false); // network/abort — transient
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Map the Google geocode `types` to a resolution SCOPE so we fetch the right
|
||
* OSM feature at the right size. Country-agnostic: scope only selects the query
|
||
* strategy; the specific admin level is found by name within `is_in` results.
|
||
*/
|
||
function scopeFromTypes(types) {
|
||
const t = new Set((types || []).map((s) => String(s).toLowerCase()));
|
||
if (t.has('country')) return 'country';
|
||
if (t.has('administrative_area_level_1')) return 'state';
|
||
if (
|
||
t.has('administrative_area_level_2') ||
|
||
t.has('administrative_area_level_3')
|
||
)
|
||
return 'county';
|
||
if (t.has('locality') || t.has('postal_town')) return 'city';
|
||
if (
|
||
t.has('sublocality') ||
|
||
t.has('sublocality_level_1') ||
|
||
t.has('neighborhood')
|
||
)
|
||
return 'neighborhood';
|
||
if (t.has('route') || t.has('intersection')) return 'street';
|
||
if (t.has('premise') || t.has('subpremise') || t.has('street_address'))
|
||
return 'building';
|
||
if (
|
||
t.has('shopping_mall') ||
|
||
t.has('university') ||
|
||
t.has('hospital') ||
|
||
t.has('airport') ||
|
||
t.has('park') ||
|
||
t.has('stadium') ||
|
||
t.has('amusement_park') ||
|
||
t.has('campus') ||
|
||
t.has('zoo') ||
|
||
t.has('cemetery') ||
|
||
t.has('tourist_attraction')
|
||
)
|
||
return 'compound';
|
||
// Lakes / reservoirs / mountains: compound-sized natural areas (caps their footprint
|
||
// at the 60 km² compound bound instead of leaving 'auto' uncapped).
|
||
if (t.has('natural_feature')) return 'compound';
|
||
return 'auto';
|
||
}
|
||
|
||
/**
|
||
* Broad administrative scope explicitly stated by the ask, or null. Structured
|
||
* entityKind facts take precedence over wording; every kind in today's voice tool
|
||
* schema is non-admin, while the admin cases keep forward-compatible handling for
|
||
* a future schema addition. Geocode result types are deliberately not an input.
|
||
*/
|
||
function adminScopeFromAsk(target, entityKind) {
|
||
if (typeof entityKind === 'string' && entityKind.trim()) {
|
||
const kind = entityKind.trim().toLowerCase();
|
||
return kind === 'country' || kind === 'state' || kind === 'county'
|
||
? kind
|
||
: null;
|
||
}
|
||
|
||
const ask = String(target || '')
|
||
.trim()
|
||
.toLowerCase();
|
||
if (/\b(?:country|nation)\s+of\s+\S/.test(ask)) return 'country';
|
||
if (/^(?:the\s+)?state\s+of\s+\S/.test(ask)) return 'state';
|
||
if (/\bcounty\s+of\s+\S/.test(ask) || /\bcounty$/.test(ask))
|
||
return 'county';
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Refine an UNRESOLVED ('auto') scope with the voice model's `entityKind` — the model's
|
||
* statement of what kind of thing the target IS (an entity fact from the conversation,
|
||
* not a render choice). Real geocode types are data and always win; entityKind only
|
||
* fills the gap they leave (Places-sourced anchors never have geocode types, so they
|
||
* are always 'auto' without this). 'point_feature' is handled by the point-first
|
||
* contract (isPointLikeTarget), not by scope. Exported for tests.
|
||
*/
|
||
function refineScope(scope, entityKind) {
|
||
if (scope !== 'auto') return scope;
|
||
if (entityKind === 'building') return 'building';
|
||
if (entityKind === 'compound') return 'compound';
|
||
if (entityKind === 'district') return 'neighborhood';
|
||
if (entityKind === 'street') return 'street';
|
||
return scope;
|
||
}
|
||
|
||
/** A distinct Overpass throttle result that must not enter the ordinary transient ladder. */
|
||
function isRateLimitedOutcome(value) {
|
||
return value?.rateLimited === true;
|
||
}
|
||
|
||
/**
|
||
* Resolve an administrative boundary (country/state/county/city/neighborhood).
|
||
* `is_in(point)` returns every admin area containing the point at all levels;
|
||
* we pick the one whose NAME best matches the query (country-agnostic — no need
|
||
* to know each country's admin_level mapping), pivot it to its relation, and
|
||
* simplify the outline so even a state/country draws cleanly.
|
||
*/
|
||
async function fetchAdminArea(lat, lon, query, scope, signal) {
|
||
// Scope changes both the name-matching bias and the fallback strategy (only
|
||
// neighborhood runs the place=/named-landuse fallback), so it must be in the key —
|
||
// else a city-scope definitive null would suppress a later neighborhood lookup's
|
||
// fetchPlaceArea recovery at the same rounded point.
|
||
const cacheKey = `admin|${scope}|${lat.toFixed(4)},${lon.toFixed(4)}|${query.toLowerCase()}`;
|
||
const cachedFp = cacheRead(footprintCache, cacheKey);
|
||
if (cachedFp !== undefined) return cachedFp;
|
||
|
||
const candidates = await featureSource.getAdministrativeAreas(
|
||
{ lat, lon },
|
||
{ signal: signal },
|
||
);
|
||
if (isUnavailableCapability(candidates) || isRateLimitedOutcome(candidates))
|
||
return candidates;
|
||
if (!candidates) return undefined; // transient upstream failure (vs null = definitive miss)
|
||
|
||
const queryWords = normalizedWords(query);
|
||
const scored = [];
|
||
for (const el of candidates) {
|
||
if (el.category !== 'administrative') continue;
|
||
// Match against the FULL name set (incl. official_name) so the query can still hit a
|
||
// verbose official name — but score COMPLETENESS against the CORE name only. A long
|
||
// official_name ("City and County of San Francisco") otherwise DILUTES the real
|
||
// boundary's completeness and lets a less-specific duplicate ("San Francisco County")
|
||
// win — which then has no backing relation and collapses the whole resolution to a dot.
|
||
const coreWords = normalizedWords(
|
||
[el.names.primary, el.names.english].filter(Boolean).join(' '),
|
||
);
|
||
const fullWords = normalizedWords(
|
||
[el.names.primary, el.names.english, el.names.official]
|
||
.filter(Boolean)
|
||
.join(' '),
|
||
);
|
||
const overlap = wordOverlap(queryWords, fullWords);
|
||
if (!overlap) continue;
|
||
const intentCoverage = queryWords.size ? overlap / queryWords.size : 0;
|
||
// The matched admin area must BE (most of) what the user named — not just share an
|
||
// incidental token ("Texas" inside "Texas State Capitol"). Require ≥ half the
|
||
// canonical-name words, so a wrong-scope admin (the state) is rejected and the
|
||
// footprint fallback gets a chance instead.
|
||
if (intentCoverage < 0.5) continue;
|
||
const coreOverlap = wordOverlap(queryWords, coreWords);
|
||
const completeness = coreWords.size ? coreOverlap / coreWords.size : 0;
|
||
// Exact-name bonus: the candidate whose CORE name set EQUALS the query is the real
|
||
// one (decisively outranks a "<name> County" style duplicate).
|
||
const exactName =
|
||
coreWords.size === queryWords.size && coreOverlap === queryWords.size;
|
||
const level = Number(el.level) || 99;
|
||
// Bias toward the scope's specificity: city/neighborhood prefer the MORE specific
|
||
// (higher admin_level) match; state/country prefer the broader one.
|
||
let levelBias;
|
||
if (scope === 'city' || scope === 'neighborhood') levelBias = level * 14;
|
||
else if (scope === 'county') levelBias = -Math.abs(level - 6) * 14;
|
||
else levelBias = -level * 14;
|
||
const score =
|
||
intentCoverage * 1200 +
|
||
completeness * 500 +
|
||
(exactName ? 900 : 0) +
|
||
levelBias;
|
||
scored.push({ el, score, coverage: intentCoverage });
|
||
}
|
||
// Deterministic order: score desc, then ascending OSM id. Overpass mirrors don't
|
||
// guarantee element order, so without the id tiebreak a score tie could resolve
|
||
// differently per mirror (breaks the "same name → same geometry" invariant).
|
||
scored.sort((a, b) => b.score - a.score || a.el.id - b.el.id);
|
||
|
||
const best = scored.length ? scored[0].el : null;
|
||
const bestCoverage = scored.length ? scored[0].coverage : 0;
|
||
|
||
// For a neighborhood, a place= polygon is the correct source. Try it whenever there
|
||
// is NO admin match OR the admin only PARTIALLY covers the query — a small city can
|
||
// satisfy a city-suffixed neighborhood ("Berkeley" for "Downtown Berkeley") at 0.5
|
||
// coverage and slip under the area cap, so never accept such a partial admin
|
||
// without first trying the place= polygon (and a named landuse via the caller).
|
||
if (scope === 'neighborhood' && (!best || bestCoverage < 0.8)) {
|
||
const place = await fetchPlaceArea(lat, lon, query, signal);
|
||
if (isUnavailableCapability(place) || isRateLimitedOutcome(place))
|
||
return place;
|
||
if (place) {
|
||
cacheWrite(footprintCache, cacheKey, place);
|
||
return place;
|
||
}
|
||
if (place === undefined) return undefined; // transient place= lookup → don't cache, retry
|
||
// Definitively no place polygon. Do NOT return a partial-match admin (likely the
|
||
// wrong-scope city); let the caller try a named landuse / honest point.
|
||
negCache(footprintCache, cacheKey, signal, true);
|
||
return null;
|
||
}
|
||
if (!best) {
|
||
negCache(footprintCache, cacheKey, signal, true); // got candidates, none matched → definitive
|
||
return null;
|
||
}
|
||
|
||
// Walk candidates best-first until one pivots to a usable admin RELATION. The top
|
||
// scorer can be a duplicate admin AREA with no backing relation (OSM's
|
||
// "San Francisco County" duplicate); fall through to the next rather than giving up.
|
||
// Cap at the top 4 so a pathological is_in (many overlapping admins) can't issue dozens
|
||
// of pivots.
|
||
let transient = false;
|
||
for (const cand of scored.slice(0, 4)) {
|
||
// Client budget must OUTLAST the QL timeout (25 s) + proxy transit: aborting at
|
||
// 16 s turned finishable region pivots (Sicilia's dense coastline) into permanent
|
||
// "transients" — every retry died the same death (field test 2026-07-23). The
|
||
// outline is progressive, so a long budget blocks nothing; repeats are disk-cached.
|
||
const relEls = await featureSource.getAreaGeometry(cand.el.id, {
|
||
signal: signal,
|
||
});
|
||
if (isUnavailableCapability(relEls) || isRateLimitedOutcome(relEls))
|
||
return relEls;
|
||
if (relEls === null) {
|
||
transient = true;
|
||
break;
|
||
} // network blip — don't definitively fail
|
||
const relEl = relEls[0];
|
||
if (!relEl) continue; // no relation backing this area — try the next candidate
|
||
const coords = relEl.coordinates;
|
||
if (coords.length < 3) continue; // incomplete geometry — try the next
|
||
|
||
let ring = closeRing(coords.map((p) => [p.lon, p.lat]));
|
||
// Simplify: tight for small neighborhoods, looser for states/countries.
|
||
const tolM =
|
||
scope === 'neighborhood'
|
||
? 6
|
||
: scope === 'city'
|
||
? 12
|
||
: scope === 'county'
|
||
? 40
|
||
: 120;
|
||
ring = simplifyRing(ring, tolM);
|
||
const fp = { ring, kind: 'area', heightM: null };
|
||
|
||
// Scope sanity BEFORE caching: a city-suffixed neighborhood name ("Downtown San
|
||
// Francisco") can match the CITY admin. Caching an oversized ring would poison the
|
||
// key and skip the place= fallback forever.
|
||
if (exceedsScopeArea(fp, scope)) {
|
||
if (scope === 'neighborhood') {
|
||
const place = await fetchPlaceArea(lat, lon, query, signal);
|
||
if (isUnavailableCapability(place) || isRateLimitedOutcome(place))
|
||
return place;
|
||
if (place) {
|
||
cacheWrite(footprintCache, cacheKey, place);
|
||
return place;
|
||
}
|
||
if (place === undefined) return undefined; // transient place= lookup → retry
|
||
negCache(footprintCache, cacheKey, signal, true);
|
||
return null;
|
||
}
|
||
continue; // oversized for this scope — try the next candidate
|
||
}
|
||
|
||
cacheWrite(footprintCache, cacheKey, fp);
|
||
return fp;
|
||
}
|
||
// No candidate pivoted to a usable in-scope relation.
|
||
if (transient) return undefined; // network blip during a pivot → don't cache, retry
|
||
// For a neighborhood, an admin match may exist (so the early place= fallback above was
|
||
// skipped) yet fail to yield a usable relation. Consult place= BEFORE declaring a
|
||
// definitive miss, so a null return truly means "no admin AND no place polygon" — the
|
||
// caller relies on that to gate named-landuse fallback / synthesis.
|
||
if (scope === 'neighborhood') {
|
||
const place = await fetchPlaceArea(lat, lon, query, signal);
|
||
if (isUnavailableCapability(place) || isRateLimitedOutcome(place))
|
||
return place;
|
||
if (place) {
|
||
cacheWrite(footprintCache, cacheKey, place);
|
||
return place;
|
||
}
|
||
if (place === undefined) return undefined; // transient place= lookup → retry
|
||
}
|
||
negCache(footprintCache, cacheKey, signal, true);
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Neighborhood fallback: OSM often tags neighborhoods as `place=` areas/relations
|
||
* rather than admin boundaries. Query those near the point and match the canonical
|
||
* name (area-capped so a "neighborhood" never grabs a whole city).
|
||
*/
|
||
async function fetchPlaceArea(lat, lon, query, signal) {
|
||
const queryWords = normalizedWords(query);
|
||
const els = await featureSource.getNeighborhoodAreas(
|
||
{ lat, lon },
|
||
{ signal: signal },
|
||
);
|
||
if (isUnavailableCapability(els) || isRateLimitedOutcome(els)) return els;
|
||
if (els === null) return undefined; // transient upstream failure (vs [] = no match)
|
||
let bestRing = null;
|
||
let bestScore = 0;
|
||
for (const el of els) {
|
||
const coords = el.coordinates;
|
||
if (coords.length < 3) continue;
|
||
const names = el.names;
|
||
const nameWords = normalizedWords(
|
||
[names.primary, names.english].filter(Boolean).join(' '),
|
||
);
|
||
const overlap = wordOverlap(queryWords, nameWords);
|
||
if (!overlap) continue;
|
||
// The OSM place name is usually just the neighborhood ("Downtown", "Chinatown")
|
||
// while the query carries a city suffix ("Downtown San Francisco"). Match on how
|
||
// fully the query covers the FEATURE'S name (not the query) — so a one-word place
|
||
// whose name the query fully contains still wins — then prefer containment.
|
||
const nameCoverage = nameWords.size ? overlap / nameWords.size : 0;
|
||
if (nameCoverage < 0.6) continue;
|
||
if (approximateAreaM2(coords) > 80_000_000) continue; // a neighborhood isn't a city
|
||
const score =
|
||
nameCoverage * 1000 +
|
||
(pointInPolygon(lon, lat, coords) ? 400 : 0) +
|
||
overlap * 50;
|
||
if (score > bestScore) {
|
||
bestScore = score;
|
||
bestRing = closeRing(coords.map((p) => [p.lon, p.lat]));
|
||
}
|
||
}
|
||
return bestRing ? { ring: bestRing, kind: 'area', heightM: null } : null;
|
||
}
|
||
|
||
/**
|
||
* Street → best-available AREA, with graceful degradation:
|
||
* Tier C — a same-named district / commercial area near the street.
|
||
* Tier F — buffer the matching street centerline into a corridor ribbon.
|
||
* Always returns an area (or null), never a building.
|
||
*/
|
||
async function fetchStreet(lat, lon, query, signal) {
|
||
const cacheKey = `street|${lat.toFixed(4)},${lon.toFixed(4)}|${query.toLowerCase()}`;
|
||
const cachedFp = cacheRead(footprintCache, cacheKey);
|
||
if (cachedFp !== undefined) return cachedFp;
|
||
const queryWords = normalizedWords(query);
|
||
|
||
// Tier C — a same-named district / quarter / named commercial area.
|
||
const areaEls = await featureSource.getStreetAreas(
|
||
{ lat, lon },
|
||
{ signal: signal },
|
||
);
|
||
if (isUnavailableCapability(areaEls) || isRateLimitedOutcome(areaEls))
|
||
return areaEls;
|
||
if (areaEls) {
|
||
let bestRing = null;
|
||
let bestScore = 0;
|
||
for (const el of areaEls) {
|
||
const coords = el.coordinates;
|
||
if (coords.length < 3) continue;
|
||
const names = el.names;
|
||
const nameWords = normalizedWords(
|
||
[names.primary, names.english].filter(Boolean).join(' '),
|
||
);
|
||
const overlap = wordOverlap(queryWords, nameWords);
|
||
if (!overlap) continue;
|
||
if (approximateAreaM2(coords) > 2_000_000) continue; // a street isn't a whole suburb
|
||
const score =
|
||
overlap * 1000 + (pointInPolygon(lon, lat, coords) ? 300 : 0);
|
||
if (score > bestScore) {
|
||
bestScore = score;
|
||
bestRing = closeRing(coords.map((p) => [p.lon, p.lat]));
|
||
}
|
||
}
|
||
if (bestRing) {
|
||
const fp = { ring: bestRing, kind: 'area', heightM: null };
|
||
cacheWrite(footprintCache, cacheKey, fp);
|
||
return fp;
|
||
}
|
||
}
|
||
|
||
// Tier F — buffer the matching centerline into a corridor ribbon (workhorse).
|
||
const wayEls = await featureSource.getStreetLines(
|
||
{ lat, lon },
|
||
{ signal: signal },
|
||
);
|
||
if (isUnavailableCapability(wayEls) || isRateLimitedOutcome(wayEls))
|
||
return wayEls;
|
||
if (wayEls) {
|
||
const segments = [];
|
||
for (const el of wayEls) {
|
||
const names = el.names;
|
||
const nameWords = normalizedWords(
|
||
[names.primary, names.english].filter(Boolean).join(' '),
|
||
);
|
||
if (!wordOverlap(queryWords, nameWords)) continue;
|
||
const geom = el.coordinates;
|
||
if (geom.length >= 2) segments.push(geom.map((p) => [p.lon, p.lat]));
|
||
}
|
||
if (segments.length) {
|
||
const line = stitchLine(segments);
|
||
if (line.length >= 2) {
|
||
const fp = {
|
||
ring: bufferCorridor(line, 11),
|
||
kind: 'area',
|
||
heightM: null,
|
||
};
|
||
cacheWrite(footprintCache, cacheKey, fp);
|
||
return fp;
|
||
}
|
||
}
|
||
}
|
||
// Definitive only if BOTH tier queries actually returned data; a null from
|
||
// either is a transient upstream failure we should not cache — and should
|
||
// report as TRANSIENT (undefined) so the deferred-outline retry re-runs it.
|
||
const definitive = areaEls !== null && wayEls !== null;
|
||
negCache(footprintCache, cacheKey, signal, definitive);
|
||
return definitive ? null : undefined;
|
||
}
|
||
|
||
/**
|
||
* Fetch the best-matching OSM polygon near a point and classify it as a single
|
||
* `building` (small footprint, gets extruded into a volume) or an `area` (a
|
||
* named district / campus / compound / park, draped as a flat outline).
|
||
*
|
||
* Buildings are searched in a tight radius; areas use a wide radius because a
|
||
* named compound like the Presidio (~6 km²) has its boundary nodes well over a
|
||
* kilometre from the geocoded centroid. `out geom` returns full geometry for any
|
||
* way/relation with a node inside the radius. NAMED water bodies (lakes /
|
||
* reservoirs, `natural=water`) are first-class candidates: without them the true
|
||
* feature for "Lady Bird Lake" is never in the set and a shoreline park NAMED
|
||
* AFTER the lake wins on word overlap instead (field test 9).
|
||
*
|
||
* Selection `mode`:
|
||
* 'loose' — default word-overlap scoring (generic POIs/compounds).
|
||
* 'strict' — named, non-building, district-sized areas only (neighborhood fallback).
|
||
* 'point' — point-like targets: (almost) exactly-named, monument-scale polygons only.
|
||
* 'anchored' — address-only geocodes: require name identity or anchor containment.
|
||
*
|
||
* @returns {Promise<null | { ring: Array<[number,number]>, kind: 'building'|'area', heightM: number|null }>}
|
||
*/
|
||
async function fetchFootprint(
|
||
lat,
|
||
lon,
|
||
query,
|
||
scope,
|
||
signal,
|
||
mode = 'loose',
|
||
) {
|
||
// The resolution MODE changes what counts as a match, so it must be part of the
|
||
// key — otherwise a loose lookup's building could be returned for a strict
|
||
// neighborhood (or point-like) lookup at the same rounded coord/name, or vice-versa.
|
||
const cacheKey = `fp|${mode}|${lat.toFixed(5)},${lon.toFixed(5)}|${query.toLowerCase()}`;
|
||
const cachedFp = cacheRead(footprintCache, cacheKey);
|
||
if (cachedFp !== undefined) return cachedFp;
|
||
|
||
const elements = await featureSource.getFootprints(
|
||
{ lat, lon },
|
||
{ signal: signal },
|
||
);
|
||
if (isUnavailableCapability(elements) || isRateLimitedOutcome(elements))
|
||
return elements;
|
||
if (elements === null || signal?.aborted) return undefined;
|
||
const fp = selectFootprint(elements, lat, lon, query, mode);
|
||
if (fp) {
|
||
cacheWrite(footprintCache, cacheKey, fp); // positive footprint — always cacheable
|
||
return fp;
|
||
}
|
||
negCache(footprintCache, cacheKey, signal, true); // got a response, no match → definitive
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Find the REAL enclosing "grounds / compound / campus" polygon containing a point
|
||
* — the smallest NAMED, NON-building `leisure` / `landuse` / `boundary` / `amenity`
|
||
* / `natural=water` polygon that geometrically CONTAINS it. (Water is in the sweep
|
||
* because a lake asked for as a compound — "Lady Bird Lake", entityKind:compound —
|
||
* anchors IN the water; without the tag it could never match.) This is "one level
|
||
* up" the spatial
|
||
* hierarchy from a building (e.g. the Texas Capitol dome → "Capitol Square",
|
||
* leisure=park). OSM has no deterministic "parent polygon" call and the canonical
|
||
* compound name is rarely what the user utters ("grounds", not "Capitol Square"),
|
||
* so SELECTION is by containment → smallest area, with a name-match only as a
|
||
* tiebreak BONUS (never a filter). See docs/compound-containment-research.md §1.4–1.5.
|
||
*
|
||
* Modeled on fetchLocalMonument: a 12 s fail-fast (an enrichment, not worth blocking
|
||
* narration — and narration no longer waits on it since outlines went progressive, so
|
||
* the budget can absorb a slow Overpass mirror instead of stranding the mark a point),
|
||
* and the result cached by ~1 km coord bucket so a whole grounds batch costs ONE
|
||
* query. Follows the file's tri-state convention:
|
||
* - `{ ring, kind:'area', heightM:null }` (synthesized:false — a REAL footprint),
|
||
* - `null` on a DEFINITIVE no-enclosing-polygon (got a response, nothing contained), or
|
||
* - `undefined` on a TRANSIENT Overpass failure (timeout / network — a retry may yet find it).
|
||
*
|
||
* @returns {Promise<undefined | null | { ring: Array<[number,number]>, kind: 'area', heightM: null }>}
|
||
*/
|
||
async function fetchEnclosingArea(lat, lon, signal, query = '') {
|
||
const cacheKey = `${lat.toFixed(2)},${lon.toFixed(2)}`; // ~1 km buckets — grounds annotations share one
|
||
const cached = cacheRead(enclosingAreaCache, cacheKey);
|
||
if (cached !== undefined) return cached;
|
||
|
||
// Fail-fast budget: an opportunistic enrichment, not worth blocking on a hung Overpass —
|
||
// but generous enough (12 s ≈ the footprint fetch budget) that an ordinary slow mirror
|
||
// doesn't strand a grounds mark as a point. The feature source returns null on a transient
|
||
// failure (timeout/502).
|
||
const elements = await featureSource.getEnclosingAreas(
|
||
{ lat, lon },
|
||
{ signal: signal },
|
||
);
|
||
if (isUnavailableCapability(elements) || isRateLimitedOutcome(elements))
|
||
return elements;
|
||
if (elements === null) return undefined; // transient upstream failure (vs [] = definitive no-match)
|
||
|
||
const queryWords = normalizedWords(query);
|
||
let best = null; // smallest CONTAINING candidate
|
||
let bestArea = Infinity;
|
||
let bestNameMatch = false;
|
||
for (const el of elements) {
|
||
if (el.building) continue; // exclude buildings (the exact feature, never the grounds)
|
||
const coords = el.coordinates;
|
||
if (coords.length < 3) continue;
|
||
if (!pointInPolygon(lon, lat, coords)) continue; // require genuine containment
|
||
const ringLonLat = closeRing(coords.map((p) => [p.lon, p.lat]));
|
||
const areaM2 = ringAreaM2(ringLonLat);
|
||
if (areaM2 <= 0) continue;
|
||
if (areaM2 > SCOPE_AREA_CAP_M2.compound) continue; // too big to be "the compound"
|
||
// Name-match is a TIEBREAK BONUS, not a filter: at (near-)equal area, prefer a candidate the
|
||
// user's words actually name. Smallest-area still dominates — containment + min-area is what
|
||
// makes the Capitol land on "Capitol Square" even though the user said "grounds".
|
||
const nameWords = normalizedWords(
|
||
[
|
||
el.names.primary,
|
||
el.names.english,
|
||
el.names.official,
|
||
el.names.alternate,
|
||
]
|
||
.filter(Boolean)
|
||
.join(' '),
|
||
);
|
||
const nameMatch = wordOverlap(queryWords, nameWords) > 0;
|
||
const better =
|
||
areaM2 < bestArea * 0.999 ||
|
||
(areaM2 <= bestArea * 1.05 && nameMatch && !bestNameMatch); // near-tie: name-match wins
|
||
if (better) {
|
||
best = { ring: ringLonLat, kind: 'area', heightM: null };
|
||
bestArea = areaM2;
|
||
bestNameMatch = nameMatch;
|
||
}
|
||
}
|
||
|
||
// Cache on ANY definitive outcome (a real footprint OR a clean no-match) so a whole grounds batch
|
||
// makes at most ONE call. A transient (`undefined`) already returned above without caching.
|
||
if (best) {
|
||
cacheWrite(enclosingAreaCache, cacheKey, best);
|
||
return best;
|
||
}
|
||
negCache(enclosingAreaCache, cacheKey, signal, true); // got a response, nothing contained → definitive
|
||
return null;
|
||
}
|
||
|
||
/** A target that reads like a fine-grained monument/marker (vs a building/district). */
|
||
function isMonumentLikeQuery(query) {
|
||
return /\b(monument|memorial|statue|sculpture|fountain|cenotaph|obelisk|plaque|bust)\b/i.test(
|
||
String(query || ''),
|
||
);
|
||
}
|
||
|
||
/** A target that reads like an enclosing GROUNDS / COMPOUND / CAMPUS (e.g. "Texas Capitol grounds")
|
||
* rather than a single building — used to synthesize a loose-footprint disc when OSM has no real
|
||
* polygon, instead of failing outright. */
|
||
/**
|
||
* A university, college or campus named as such: "Stanford University",
|
||
* "Boston College", "University of Texas", "Stanford campus". Towns named
|
||
* after one ("College Station", "University Park") are not.
|
||
*/
|
||
function isCampusAsk(query) {
|
||
const text = stripQualifiers(String(query || '')).trim();
|
||
return (
|
||
/\b(?:university|college|campus)$/i.test(text) ||
|
||
/^(?:the\s+)?(?:university|college)\s+of\b/i.test(text)
|
||
);
|
||
}
|
||
|
||
function isGroundsLikeQuery(query) {
|
||
return /\b(grounds|compound|campus|complex|quad|plaza)\b/i.test(
|
||
String(query || ''),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Whether a target is a POINT-LIKE cultural marker (monument/statue/memorial/plaque/…)
|
||
* that must resolve point-first (field test 7 §1). Classification ladder, most
|
||
* authoritative first:
|
||
* 1. the voice model's explicit `entityKind` (an entity fact — trusted both ways),
|
||
* 2. grounds-like wording in the target OR label (area intent) vetoes,
|
||
* 3. the monument wordlist,
|
||
* 4. the Places `types` of the anchored feature (data — present on places-sourced
|
||
* anchors; 'monument'/'sculpture' are unambiguous point classes).
|
||
*/
|
||
function isPointLikeTarget(target, entityKind, placeTypes, label) {
|
||
if (entityKind === 'point_feature') return true;
|
||
if (entityKind) return false; // model asserted an area-like kind (building/compound/…)
|
||
if (isGroundsLikeQuery(target) || isGroundsLikeQuery(label)) return false;
|
||
if (isMonumentLikeQuery(target)) return true;
|
||
const t = new Set((placeTypes || []).map((s) => String(s).toLowerCase()));
|
||
return t.has('monument') || t.has('sculpture');
|
||
}
|
||
|
||
/**
|
||
* Whether an annotation asks for an enclosing GROUNDS / COMPOUND / CAMPUS area. The model
|
||
* often puts the grounds word in the LABEL, not the target ("target: Texas State Capitol,
|
||
* label: Capitol grounds" — field test 8), so both count; an explicit `entityKind` (entity
|
||
* fact) is trusted both ways. Grounds-like asks reach the REAL enclosing-polygon sweep even
|
||
* under `around_the_thing` phrasing — the thing named IS the grounds, so a buffered
|
||
* around-disc would be the wrong shape. Exported for tests.
|
||
*/
|
||
function isGroundsLikeAsk(target, label, entityKind) {
|
||
if (entityKind) return entityKind === 'compound';
|
||
return isGroundsLikeQuery(target) || isGroundsLikeQuery(label);
|
||
}
|
||
|
||
/**
|
||
* Find the actual OSM monument/memorial/statue NEAR a view centre, name-matched. Google geocodes
|
||
* these obscure names unreliably (it scatters several Capitol-grounds monuments across the city),
|
||
* so for a monument-like target we anchor on what the user is LOOKING AT and snap to the real
|
||
* feature. The Overpass result set is cached by rounded centre, so a whole batch of monuments on
|
||
* one set of grounds costs a SINGLE query. Returns {lat, lon, label} on a name match, else null
|
||
* (no match, or a transient/timed-out Overpass) → the caller keeps the geocode point.
|
||
*/
|
||
const monumentInflight = new Map(); // centerKey → in-flight sweep promise (batch dedup)
|
||
|
||
async function fetchLocalMonument(lat, lon, query, signal) {
|
||
const centerKey = `${lat.toFixed(2)},${lon.toFixed(2)}`; // ~1 km buckets — grounds monuments share one
|
||
let features = cacheRead(monumentCache, centerKey);
|
||
if (features === undefined) {
|
||
// One sweep per bucket even under the engine's CONCURRENT batch resolution: every
|
||
// monument in an annotate() batch awaits the same in-flight promise. The shared fetch
|
||
// deliberately ignores the callers' abort signals (it is bounded by its own 6 s
|
||
// timeout, and one caller's clear() must not kill the others' snap).
|
||
let pending = monumentInflight.get(centerKey);
|
||
if (!pending) {
|
||
// Short timeout + fail-fast: this is an opportunistic snap, not worth blocking narration
|
||
// on a slow/overloaded Overpass. The feature source returns null on a transient failure.
|
||
pending = featureSource
|
||
.getMonuments({ lat, lon }, { signal: undefined })
|
||
.then((elements) => {
|
||
if (
|
||
elements === null ||
|
||
isUnavailableCapability(elements) ||
|
||
isRateLimitedOutcome(elements)
|
||
)
|
||
return null; // transient — NEVER cached (a poisoned bucket
|
||
// would silently disable the snap for the whole session, field test 7 §1)
|
||
const feats = [];
|
||
for (const el of elements) {
|
||
const name =
|
||
el.names.primary ||
|
||
el.names.english ||
|
||
el.names.official ||
|
||
el.names.alternate;
|
||
if (!name) continue;
|
||
const p = el.point;
|
||
if (!p) continue;
|
||
feats.push({
|
||
name,
|
||
lat: p.lat,
|
||
lon: p.lon,
|
||
words: normalizedWords(name),
|
||
});
|
||
}
|
||
// Definitive outcome (Overpass answered, possibly with zero features) → cacheable.
|
||
cacheWrite(monumentCache, centerKey, feats);
|
||
return feats;
|
||
})
|
||
.finally(() => monumentInflight.delete(centerKey));
|
||
monumentInflight.set(centerKey, pending);
|
||
}
|
||
features = await pending;
|
||
if (features === null) return null; // transient sweep failure → keep the geocode anchor, retry next call
|
||
}
|
||
if (!features.length) return null;
|
||
const qWords = normalizedWords(query);
|
||
if (!qWords.size) return null;
|
||
// Best match: the feature whose name is MOSTLY covered by the query (so "Tejano Monument"
|
||
// doesn't match "Texas African American History Memorial", and a bare "the monument" matches
|
||
// nothing). completeness = how much of the feature name the query accounts for.
|
||
let best = null;
|
||
let bestScore = 0;
|
||
for (const f of features) {
|
||
const overlap = wordOverlap(f.words, qWords);
|
||
if (!overlap) continue;
|
||
const completeness = f.words.size ? overlap / f.words.size : 0;
|
||
if (completeness < 0.6) continue;
|
||
const score = overlap * 10 + completeness;
|
||
if (score > bestScore) {
|
||
bestScore = score;
|
||
best = f;
|
||
}
|
||
}
|
||
return best ? { lat: best.lat, lon: best.lon, label: best.name } : null;
|
||
}
|
||
|
||
// Point-like footprint ceiling: big enough for the large DC-style memorial complexes
|
||
// (Lincoln Memorial ≈ 7k m², WWII / 9-11 Memorial plazas ≈ 30k m²), far too small for
|
||
// the park/campus that CONTAINS a monument (Golden Gate Park ≈ 4.1M m²).
|
||
const POINTLIKE_AREA_CAP_M2 = 60_000;
|
||
|
||
/**
|
||
* Pick the best OSM polygon for a query from raw Overpass elements. `mode` selects the
|
||
* acceptance contract ('loose' | 'strict' | 'point' | 'anchored' — see fetchFootprint). Exported for
|
||
* the unit tests, which pin the mode contracts with fixtures captured from live data.
|
||
*/
|
||
function selectFootprint(
|
||
elements,
|
||
targetLat,
|
||
targetLon,
|
||
query,
|
||
mode = 'loose',
|
||
) {
|
||
const requireName = mode === 'strict';
|
||
const queryWords = normalizedWords(query);
|
||
let best = null;
|
||
let bestScore = -Infinity;
|
||
|
||
for (const element of elements) {
|
||
const coords = element.coordinates;
|
||
if (coords.length < 3) continue;
|
||
|
||
const names = element.names;
|
||
const isBuilding = element.building;
|
||
const areaM2 = approximateAreaM2(coords);
|
||
|
||
const nameWords = normalizedWords(
|
||
[
|
||
names.primary,
|
||
names.english,
|
||
names.official,
|
||
names.alternate,
|
||
names.short,
|
||
]
|
||
.filter(Boolean)
|
||
.join(' '),
|
||
);
|
||
const nameOverlap = wordOverlap(queryWords, nameWords);
|
||
// How completely the query covers this feature's name (1.0 ≈ exact match).
|
||
const completeness = nameWords.size ? nameOverlap / nameWords.size : 0;
|
||
const named = nameOverlap > 0;
|
||
// Admin/neighborhood fallback (requireName): accept only a NAMED, non-building
|
||
// AREA of district size (≥0.3 km², e.g. the Presidio's landuse). A neighborhood
|
||
// is never a single building or a tiny parcel, so a building named "Mission ..."
|
||
// or a 0.01 km² lot must not stand in for it — better an honest labeled point.
|
||
if (requireName && (!named || isBuilding || areaM2 < 300_000)) continue;
|
||
// Point-like contract: a polygon may stand in for a monument/statue/memorial ONLY
|
||
// when it clearly IS that feature — most of the query names it (intentCoverage) AND
|
||
// the query accounts for most of ITS name (completeness) AND it is monument-scale.
|
||
// Bare word overlap must not qualify: "Thompson Austin" shares only the locality
|
||
// token with "Tejano Monument, Austin" and outscored everything under loose scoring
|
||
// (field test 7 §1). No qualifying polygon → null → the honest point anchor stays.
|
||
if (mode === 'point') {
|
||
const intentCoverage = queryWords.size
|
||
? nameOverlap / queryWords.size
|
||
: 0;
|
||
if (!named || intentCoverage < 0.5 || completeness < 0.6) continue;
|
||
if (areaM2 > POINTLIKE_AREA_CAP_M2) continue;
|
||
}
|
||
|
||
// Size gating: buildings are small; areas can be large but only when named
|
||
// (so "Presidio" can match a 6 km² compound without grabbing a whole city).
|
||
if (isBuilding) {
|
||
if (areaM2 < 40 || areaM2 > 400_000) continue;
|
||
} else {
|
||
if (areaM2 < 200) continue;
|
||
if (!named && areaM2 > 600_000) continue;
|
||
if (named && areaM2 > 80_000_000) continue;
|
||
}
|
||
|
||
const contains = pointInPolygon(targetLon, targetLat, coords);
|
||
if (mode === 'anchored' && !contains) {
|
||
const intentCoverage = queryWords.size
|
||
? nameOverlap / queryWords.size
|
||
: 0;
|
||
if (!named || intentCoverage < 0.5 || completeness < 0.6) continue;
|
||
}
|
||
const centroid = ringCentroid(coords.map((p) => [p.lon, p.lat]));
|
||
const distanceM = centroid
|
||
? approximateDistanceM(targetLat, targetLon, centroid.lat, centroid.lon)
|
||
: 9999;
|
||
|
||
// Name match dominates; completeness breaks ties so the feature literally
|
||
// named "Presidio" beats a building that merely contains the word. Size is
|
||
// only a mild tiebreak now (no hard penalty against large named areas).
|
||
let score =
|
||
nameOverlap * 1000 +
|
||
completeness * 600 +
|
||
(contains ? 450 : 0) -
|
||
distanceM * 0.25 -
|
||
Math.sqrt(areaM2) * 0.05;
|
||
|
||
// With no name match at all, prefer a precise building over a vague blob.
|
||
if (!named && isBuilding && contains) score += 250;
|
||
|
||
if (score > bestScore) {
|
||
bestScore = score;
|
||
best = {
|
||
ring: closeRing(coords.map((p) => [p.lon, p.lat])),
|
||
kind: isBuilding ? 'building' : 'area',
|
||
heightM: isBuilding
|
||
? (element.heightM ??
|
||
Math.max(10, Math.min(70, Math.sqrt(Math.max(1, areaM2)) * 0.6)))
|
||
: null,
|
||
};
|
||
}
|
||
}
|
||
|
||
return best;
|
||
}
|
||
|
||
function normalizedWords(value) {
|
||
return new Set(
|
||
String(value || '')
|
||
.toLowerCase()
|
||
.normalize('NFKD')
|
||
.replace(/[^a-z0-9]+/g, ' ')
|
||
.trim()
|
||
.split(/\s+/)
|
||
.filter((word) => word.length > 2),
|
||
);
|
||
}
|
||
|
||
function wordOverlap(left, right) {
|
||
let matches = 0;
|
||
for (const word of left) {
|
||
if (right.has(word)) matches++;
|
||
}
|
||
return matches;
|
||
}
|
||
|
||
// --- viewer helpers ---------------------------------------------------------
|
||
|
||
/**
|
||
* Current view rectangle as a Google `bounds` string `swLat,swLng|neLat,neLng`,
|
||
* used to bias geocoding toward what the user is looking at.
|
||
*/
|
||
/**
|
||
* The current view as a center + radius (km), feeding the geocode proximity gate: a named place
|
||
* that resolved much farther than this radius from center is rejected as a wrong match.
|
||
*
|
||
* Center is the CAMERA SUBPOINT (its lon/lat), not the view-rectangle center — the subpoint is
|
||
* always available, whereas computeViewRectangle() returns undefined exactly when the user is in
|
||
* the common low/oblique view with the horizon in frame (which is when the bad geocodes bite).
|
||
* Radius is scaled from camera height (a generous proxy for how much ground is on screen), with a
|
||
* floor so a super-zoomed-in view doesn't over-reject genuinely nearby places.
|
||
*/
|
||
function viewportProximity(viewer) {
|
||
try {
|
||
const carto = viewer?.camera?.positionCartographic;
|
||
if (!carto) return null;
|
||
const lat = Cesium.Math.toDegrees(carto.latitude);
|
||
const lon = Cesium.Math.toDegrees(carto.longitude);
|
||
const radiusKm = Math.max((carto.height / 1000) * 3, 5);
|
||
if (![lat, lon, radiusKm].every(Number.isFinite)) return null;
|
||
return { lat, lon, radiusKm };
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/** Exported for searchAndFlyTo (src/locations.js), which shares this bias. */
|
||
function viewportBias(viewer) {
|
||
try {
|
||
const rect = viewer?.camera?.computeViewRectangle?.();
|
||
if (!rect) return null;
|
||
const swLat = Cesium.Math.toDegrees(rect.south).toFixed(4);
|
||
const swLng = Cesium.Math.toDegrees(rect.west).toFixed(4);
|
||
const neLat = Cesium.Math.toDegrees(rect.north).toFixed(4);
|
||
const neLng = Cesium.Math.toDegrees(rect.east).toFixed(4);
|
||
if ([swLat, swLng, neLat, neLng].some((v) => v === 'NaN')) return null;
|
||
return `${swLat},${swLng}|${neLat},${neLng}`;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* View-biased Places recovery for a NAME whose geocode missed or landed implausibly
|
||
* far from what the user is looking at — the searchAndFlyTo twin of the recovery
|
||
* inside resolveAnnotationTarget ("the Capitol" → Washington DC while hovering
|
||
* Austin). When there is no geocode, or it sits more than MIN_DRIFT_FLOOR_KM from
|
||
* the view centre, a Places Text Search biased to the view centre is trusted within
|
||
* PLACES_MAX_DISTANCE_M. A near geocode returns null untouched — local hits keep the
|
||
* plain geocode path. Returns the Places hit
|
||
* ({ lat, lon, label, types, viewport, distanceM, … }) or null.
|
||
*/
|
||
async function placesNearViewRecovery(
|
||
viewer,
|
||
query,
|
||
geocoded = null,
|
||
signal = undefined,
|
||
placeSearch = defaultGeospatial,
|
||
) {
|
||
const center =
|
||
pickWorldFromScreen(viewer, 0.5, 0.5) || viewportProximity(viewer);
|
||
if (!center) return null;
|
||
const geocodeFar =
|
||
!geocoded ||
|
||
approximateDistanceM(center.lat, center.lon, geocoded.lat, geocoded.lon) /
|
||
1000 >
|
||
MIN_DRIFT_FLOOR_KM;
|
||
if (!geocodeFar) return null;
|
||
const hit = await placesTextSearch(
|
||
query,
|
||
center.lat,
|
||
center.lon,
|
||
6000,
|
||
signal,
|
||
placeSearch,
|
||
);
|
||
return hit && hit.distanceM <= PLACES_MAX_DISTANCE_M ? hit : null;
|
||
}
|
||
|
||
/**
|
||
* Convert a NORMALIZED screen point (x,y in [0,1] of the current viewport) into
|
||
* a world lon/lat using the depth-aware pick cascade — the inverse of the
|
||
* project-to-screen used by the renderers. This is the "point at the pixel"
|
||
* fallback: the agent indicates a spot in the viewport screenshot when it can't
|
||
* name the place, and we anchor the mark to the actual world point under it.
|
||
*/
|
||
function pickWorldFromScreen(viewer, nx, ny) {
|
||
const scene = viewer?.scene;
|
||
if (!scene) return null;
|
||
const canvas = scene.canvas;
|
||
const w = canvas.clientWidth || canvas.width || 0;
|
||
const h = canvas.clientHeight || canvas.height || 0;
|
||
if (!w || !h) return null;
|
||
const px = Math.max(0, Math.min(1, nx)) * w;
|
||
const py = Math.max(0, Math.min(1, ny)) * h;
|
||
const pos = new Cesium.Cartesian2(px, py);
|
||
|
||
// Each stage is validated before it is accepted: a depth pick over empty sky
|
||
// can return a NaN or centre-of-the-earth Cartesian, and converting one of
|
||
// those throws inside Cesium. A degenerate pick IS a missed pick, so it falls
|
||
// through to the next stage and ultimately to the caller's null.
|
||
let cart = null;
|
||
if (
|
||
scene.pickPositionSupported &&
|
||
typeof scene.pickPosition === 'function'
|
||
) {
|
||
try {
|
||
cart = scene.pickPosition(pos);
|
||
} catch {
|
||
cart = null;
|
||
}
|
||
}
|
||
if (
|
||
!isPickedWorldPosition(cart) &&
|
||
typeof viewer.camera.pickEllipsoid === 'function'
|
||
) {
|
||
try {
|
||
cart = viewer.camera.pickEllipsoid(pos, Cesium.Ellipsoid.WGS84);
|
||
} catch {
|
||
cart = null;
|
||
}
|
||
}
|
||
if (
|
||
!isPickedWorldPosition(cart) &&
|
||
typeof viewer.camera.getPickRay === 'function'
|
||
) {
|
||
try {
|
||
const ray = viewer.camera.getPickRay(pos);
|
||
cart = ray ? scene.globe?.pick(ray, scene) || null : null;
|
||
} catch {
|
||
cart = null;
|
||
}
|
||
}
|
||
if (!isPickedWorldPosition(cart)) return null;
|
||
const carto = Cesium.Cartographic.fromCartesian(cart);
|
||
if (!carto) return null;
|
||
return {
|
||
lat: Cesium.Math.toDegrees(carto.latitude),
|
||
lon: Cesium.Math.toDegrees(carto.longitude),
|
||
// The height the pick actually landed on — a roof, a hillside, or 0 on
|
||
// the ellipsoid. Callers that only want a coordinate ignore it; the draw
|
||
// tool's live preview needs it, or the rubber band sinks to sea level
|
||
// while the pointer is on a hill.
|
||
height: Number.isFinite(carto.height) ? carto.height : 0,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Best-effort ground height at a coordinate. The Cesium globe is hidden behind
|
||
* the Google 3D tiles, so we try to clamp onto the photoreal tile surface; if
|
||
* the tiles for that spot aren't loaded we fall back to the ellipsoid (0).
|
||
*/
|
||
function sampleGroundHeight(viewer, lon, lat) {
|
||
const scene = viewer?.scene;
|
||
if (!scene) return 0;
|
||
try {
|
||
if (
|
||
scene.clampToHeightSupported &&
|
||
typeof scene.clampToHeight === 'function'
|
||
) {
|
||
const carto = Cesium.Cartographic.fromDegrees(lon, lat);
|
||
const surface = Cesium.Cartographic.toCartesian(carto);
|
||
const clamped = scene.clampToHeight(surface);
|
||
if (clamped) {
|
||
const h = Cesium.Cartographic.fromCartesian(clamped).height;
|
||
if (Number.isFinite(h)) return h;
|
||
}
|
||
}
|
||
} catch {
|
||
/* tiles not ready — fall through */
|
||
}
|
||
const globeHeight = scene.globe?.getHeight?.(
|
||
Cesium.Cartographic.fromDegrees(lon, lat),
|
||
);
|
||
return Number.isFinite(globeHeight) && globeHeight > 0 ? globeHeight : 0;
|
||
}
|
||
|
||
function shortLabel(formattedAddress) {
|
||
if (!formattedAddress) return null;
|
||
return String(formattedAddress).split(',')[0].trim() || null;
|
||
}
|
||
|
||
/**
|
||
* Region ring for ANALYST queries ("how many flights over Texas / the Alps") —
|
||
* a name-only entry point that reuses this module's boundary machinery
|
||
* without the annotation pipeline. Unique Natural Earth names resolve first
|
||
* (offline); duplicate names require a geocoded containment anchor, then
|
||
* geocode + admin boundary for states/countries/counties (Tier A disk-cached
|
||
* Overpass, so repeat asks are instant). Returns null when the name doesn't
|
||
* resolve to a region-like boundary — the analyst engine reports that
|
||
* honestly rather than silently scoping to nothing.
|
||
*
|
||
* The geocode + admin-boundary rung is capped at `budgetMs`. Past it the
|
||
* call returns `{name, ring: null, error: 'region-timeout'}` so a voice
|
||
* answer is not held for tens of seconds; the lookup keeps running and
|
||
* fills the boundary cache, so asking again shortly is fast.
|
||
*
|
||
* @param {string} name e.g. "Texas", "the Alps", "France", "Gulf of Mexico"
|
||
* @param {AbortSignal} [signal]
|
||
* @param {object} [placeSearch]
|
||
* @param {{budgetMs?: number}} [options] `Infinity` waits for the lookup.
|
||
* @returns {Promise<{name:string, ring:Array<[number,number]>, source:string}|{name:string, ring:null, error:'region-timeout'}|null>}
|
||
*/
|
||
async function resolveRegionRingForQuery(
|
||
name,
|
||
signal,
|
||
placeSearch = unavailablePlaceSearch,
|
||
{ budgetMs = REGION_FALLBACK_BUDGET_MS } = {},
|
||
) {
|
||
signal = lifetime
|
||
? signal
|
||
? AbortSignal.any([lifetime, signal])
|
||
: lifetime
|
||
: signal;
|
||
signal?.throwIfAborted();
|
||
const q = String(name || '').trim();
|
||
if (!q) return null;
|
||
const ne = await findNaturalRegion(q).catch(() => null);
|
||
signal?.throwIfAborted();
|
||
if (ne?.candidates === 1 && ne.polygons?.length) {
|
||
// Largest ring carries the query scope; multi-ring regions (Andes) keep
|
||
// their main cordillera — good enough for containment counting.
|
||
const ring = [...ne.polygons].sort((a, b) => b.length - a.length)[0];
|
||
if (ring?.length >= 3)
|
||
return { name: ne.name, ring, source: 'natural-earth' };
|
||
}
|
||
// Bundled states/provinces/counties by name (offline); the main part only,
|
||
// like the Natural Earth rung above.
|
||
const admin = await findAdminArea(q).catch(() => null);
|
||
signal?.throwIfAborted();
|
||
if (admin)
|
||
return {
|
||
name: admin.name,
|
||
ring: [...admin.ring],
|
||
source: 'bundled-admin',
|
||
};
|
||
const lookup = resolveAdminRegionRing(q, signal, placeSearch);
|
||
if (!Number.isFinite(budgetMs)) return lookup;
|
||
let timer;
|
||
const timeout = new Promise((resolve) => {
|
||
timer = setTimeout(
|
||
() => resolve({ name: q, ring: null, error: 'region-timeout' }),
|
||
budgetMs,
|
||
);
|
||
});
|
||
try {
|
||
return await Promise.race([lookup, timeout]);
|
||
} finally {
|
||
clearTimeout(timer);
|
||
}
|
||
}
|
||
|
||
async function resolveAdminRegionRing(q, signal, placeSearch) {
|
||
const geo = await geocodePlace(q, null, signal, placeSearch).catch(
|
||
() => null,
|
||
);
|
||
signal?.throwIfAborted();
|
||
if (!geo) return null;
|
||
const scope = scopeFromTypes(geo.types);
|
||
if (
|
||
scope === 'auto' ||
|
||
(scope === 'compound' && geo.types?.includes('natural_feature'))
|
||
) {
|
||
const natural = await lookupNaturalRegionOutline(
|
||
q,
|
||
geo.lat,
|
||
geo.lon,
|
||
).catch(() => null);
|
||
signal?.throwIfAborted();
|
||
if (natural)
|
||
return {
|
||
name: natural.name,
|
||
ring: natural.ring,
|
||
source: 'natural-earth',
|
||
};
|
||
}
|
||
if (!['country', 'state', 'county', 'city'].includes(scope)) return null;
|
||
if (scope === 'state' || scope === 'county' || scope === 'country') {
|
||
const admin = await findAdminAreaAt(
|
||
[q, geo.primaryName],
|
||
geo.lat,
|
||
geo.lon,
|
||
scope,
|
||
).catch(() => null);
|
||
signal?.throwIfAborted();
|
||
if (admin)
|
||
return { name: q, ring: [...admin.ring], source: 'bundled-admin' };
|
||
}
|
||
if (scope === 'city') {
|
||
const place = await findPlaceAreaAt(
|
||
[q, geo.primaryName],
|
||
geo.lat,
|
||
geo.lon,
|
||
).catch(() => null);
|
||
signal?.throwIfAborted();
|
||
if (place)
|
||
return { name: q, ring: [...place.ring], source: 'bundled-admin' };
|
||
}
|
||
const fp = await fetchAdminArea(geo.lat, geo.lon, q, scope, signal).catch(
|
||
() => null,
|
||
);
|
||
signal?.throwIfAborted();
|
||
if (fp?.ring?.length >= 3)
|
||
return { name: q, ring: fp.ring, source: 'remote-boundary' };
|
||
return null;
|
||
}
|
||
|
||
lifetime?.addEventListener(
|
||
'abort',
|
||
() => {
|
||
footprintCache.clear();
|
||
monumentCache.clear();
|
||
enclosingAreaCache.clear();
|
||
placesCaches = new WeakMap();
|
||
monumentInflight.clear();
|
||
},
|
||
{ once: true },
|
||
);
|
||
return {
|
||
resolveAnnotationTarget,
|
||
refineScope,
|
||
isRateLimitedOutcome,
|
||
isGroundsLikeAsk,
|
||
selectFootprint,
|
||
viewportBias,
|
||
placesNearViewRecovery,
|
||
resolveRegionRingForQuery,
|
||
// Scene helpers, not resolution: the manual draw tool turns a click into a
|
||
// world point with the same depth-aware cascade the agent's pixel fallback
|
||
// uses, and the engine samples the surface under a hand-placed mark. Both
|
||
// are exposed so there is ONE pick cascade in the app, not a second one
|
||
// written beside it.
|
||
pickWorldFromScreen,
|
||
sampleGroundHeight,
|
||
};
|
||
}
|