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, nearestPointOnLines, } 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'; /** Prepare only local dateline outlines for planar admission and centering. */ function localOutlineFrame(fp) { const original = { ring: fp?.ring, polygons: fp?.polygons, reference: null }; if (!Array.isArray(fp?.ring) || fp.ring.length < 3) return original; let west = Infinity; let east = -Infinity; for (const [lon] of fp.ring) { west = Math.min(west, lon); east = Math.max(east, lon); } if (east - west <= 180) return original; const reference = fp.ring[0][0]; west = Infinity; east = -Infinity; const shift = (ring) => ring.map(([lon, lat]) => { const x = lon - 360 * Math.round((lon - reference) / 360); west = Math.min(west, x); east = Math.max(east, x); return [x, lat]; }); const ring = shift(fp.ring); const polygons = Array.isArray(fp.polygons) ? fp.polygons.map((part) => part.map(shift)) : fp.polygons; // Shapes still spanning half the globe retain their existing behavior. Source coordinates // are never rewritten, including holes and unrelated multipart geometry. if (!Number.isFinite(east - west) || east - west >= 180) return original; return { ring, polygons, reference }; } /** Ms the analyst region lookup waits on geocode + admin boundary. */ const REGION_FALLBACK_BUDGET_MS = 3_000; /** How far a street may run from its anchor when the ribbon holds neither. */ const STREET_SNAP_MAX_M = 100; /** * 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); const featureOutlineCredit = featureSource.outlineCredit === 'osm' ? 'osm' : null; 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, * label: string | null, source: string, * viewport: object | null, * resolveOutline?: () => Promise, * }>} */ 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 " → 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) => { const frame = localOutlineFrame(fp); // 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(frame, 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(frame.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(frame.polygons) && !polygonsContainPoint(frame.polygons, centroid.lat, centroid.lon) ) { const anchorLon = frame.reference === null ? lon : lon - 360 * Math.round((lon - frame.reference) / 360); if (polygonsContainPoint(frame.polygons, lat, anchorLon)) { centroid = { lat, lon }; } else if (Array.isArray(fp.lines)) { // A bent street's ribbon rarely holds its ring mean, and a geocoded // anchor can sit beside the centreline: centre on the street itself, // only when it runs beside the anchor. const onStreet = nearestPointOnLines(fp.lines, lat, lon); if (!onStreet || onStreet.distanceM > STREET_SNAP_MAX_M) return null; centroid = { lat: onStreet.lat, lon: onStreet.lon }; } else { return null; } } if ( frame.reference !== null && (centroid.lon < -180 || centroid.lon >= 180) ) centroid.lon = ((((centroid.lon + 180) % 360) + 360) % 360) - 180; // 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), outlineCredit: fp.outlineCredit || null, ...(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 let outlineCredit = null; 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; outlineCredit = fp.outlineCredit || 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, outlineCredit, ...(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). // Whole words: an alias hit on "York" does not keep "Yorkville". 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} */ 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 " 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, outlineCredit: featureOutlineCredit, }; // 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, outlineCredit: featureOutlineCredit, } : 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, outlineCredit: featureOutlineCredit, }; 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, outlineCredit: featureOutlineCredit, }; 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, 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, 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, outlineCredit: featureOutlineCredit, }; 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', outlineCredit: featureOutlineCredit, 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, }; }