diff --git a/CHANGELOG.md b/CHANGELOG.md index 6381678c3..60675eedd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -91,6 +91,8 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ## [Unreleased] +- Give application request services, terrain/floor caches and annotation lookup state explicit owners and cancellation; share them across controls, layers and voice. + - Construct application layers from explicit sources, with standalone provider selection and catalog-owned aircraft classification; controls and voice queries use those instances. - Let application data and controls receive the same explicit layer catalog; keep standalone defaults and registration-before-restoration ordering. diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index 73da4a0ae..9739c6bac 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -430,3 +430,21 @@ exports. Browser regression probes use the registered instance's testing surface to avoid accidentally inspecting an unused compatibility instance. Existing source setters apply only to compatibility instances; the normal application supplies its sources at construction. + +### Application operations + +`application/operations` accepts request-service instances and an application +lifetime. It constructs terrain resolution, coarse floor/mesh caches and an +annotation resolver without selecting upstream providers. Its checked graph is +separate from standalone setup. Scene construction returns these operations; the +catalog and controls use the same surface owner. Layers still own their individual +ground-snap caches and model resources. + +Terrain cancellation rejects late replies before caching, clears floor queues and +removes the map-stack listener. Annotation lookup caches are instance-owned and +cleared on cancellation. Geometry selection and floor policies are unchanged. +HUD and weather controllers accept their respective service; regional lookup and +location framing use the supplied operations. Voice shares the same boundary and +floor services, with analyst memory scoped to the runner. Direct compatibility +entrypoints retain default services; normal assembly does not configure their +source slots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1750b0ae5..dcc8cc85d 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,14 @@ # God's Eye View Current State +Application scene setup owns request services, terrain/floor caches and annotation +lookup state. Controls, layers and voice share those owners, and cancellation +clears caches and removes map-stack listeners. Standalone setup supplies the +existing service clients. HUD summaries, regional brief, cockpit weather, location +framing and annotation boundaries receive their service instances explicitly. +Compatibility entrypoints retain defaults for direct callers; normal startup does +not change global service slots. Analyst follow-up memory belongs to its voice runner. + + Application startup constructs fresh layer instances from explicit source objects. Standalone composition selects the existing providers. Controls, launch orbit lookups, Contacts voice answers and ISS pass queries use the registered catalog. diff --git a/package.json b/package.json index 737bbda92..5469db8b7 100644 --- a/package.json +++ b/package.json @@ -194,6 +194,7 @@ }, "./application/catalog": "./src/app/catalog.js", "./standalone/catalog": "./src/standalone/catalog.js", - "./application/layers": "./src/app/constructCatalog.js" + "./application/layers": "./src/app/constructCatalog.js", + "./application/operations": "./src/app/operations.js" } } diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 698c18c2f..b830e0562 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -577,5 +577,14 @@ "src/app/layers/aisLiveVessels.js", "src/app/constructCatalog.js", "src/standalone/layerSources.js", - "src/app/constructCatalog.test.mjs" + "src/app/constructCatalog.test.mjs", + "src/app/surfaceServices.js", + "src/app/operations.js", + "src/data/surfaceServices.js", + "src/annotations/resolver.js", + "src/services/terrainHeights.js", + "src/services/groundFloor.js", + "src/services/meshFloorSampler.js", + "src/services/groundSnap.js", + "src/app/operations.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index e04ca1225..5ebd26b62 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1521,7 +1521,15 @@ "src/app/layers/overlayHost.js", "src/app/layers/aisLiveVessels.js", "src/app/constructCatalog.js", - "src/standalone/layerSources.js" + "src/standalone/layerSources.js", + "src/app/surfaceServices.js", + "src/app/operations.js", + "src/data/surfaceServices.js", + "src/annotations/resolver.js", + "src/services/terrainHeights.js", + "src/services/groundFloor.js", + "src/services/meshFloorSampler.js", + "src/services/groundSnap.js" ], "external": [ "@mapbox/vector-tile", @@ -1541,6 +1549,7 @@ "exports": ["./application/layers"], "modules": [ "src/annotations/annotationResolver.js", + "src/annotations/resolver.js", "src/app/catalog.js", "src/app/constructCatalog.js", "src/app/layers/aisLiveVessels.js", @@ -1581,14 +1590,11 @@ "src/data/detectionCohort.js", "src/data/detectionDraw.js", "src/data/detectionPolicy.js", - "src/data/fireAnchors.js", "src/data/firmsAdapt.js", "src/data/firmsLabels.js", "src/data/flowMatch.js", "src/data/focusDeemphasis.js", "src/data/geoid.js", - "src/data/groundFloor.js", - "src/data/groundSnap.js", "src/data/iconOrientation.js", "src/data/infrastructure.js", "src/data/installationFeedback.js", @@ -1601,7 +1607,6 @@ "src/data/local_data/natural_earth/marine.json", "src/data/local_data/natural_earth/regions.json", "src/data/local_data/neighborhoods/san-francisco.json", - "src/data/meshFloorSampler.js", "src/data/militaryAwarenessEngine.js", "src/data/militaryInstallationData.js", "src/data/modelVisualAnchor.js", @@ -1616,7 +1621,6 @@ "src/data/satelliteClass.js", "src/data/scenePick.js", "src/data/spriteOrder.js", - "src/data/terrainHeights.js", "src/data/tomtomTiles.js", "src/data/tr3bRegistry.js", "src/data/trackedCamera.js", @@ -1859,5 +1863,46 @@ "pbf", "mgrs" ] + }, + "application-operations": { + "exports": ["./application/operations"], + "modules": [ + "src/annotations/annotationResolver.js", + "src/annotations/resolver.js", + "src/app/operations.js", + "src/app/sourceSlot.js", + "src/app/surfaceServices.js", + "src/data/dataCredits.js", + "src/data/geoid.js", + "src/data/local_data/natural_earth/marine.json", + "src/data/local_data/natural_earth/regions.json", + "src/data/local_data/neighborhoods/san-francisco.json", + "src/data/naturalEarthRegions.js", + "src/data/neighborhoodPolygons.js", + "src/data/retryableLoad.js", + "src/data/scenePick.js", + "src/keylessGeocoder.js", + "src/layers/firms/anchors.js", + "src/locations.js", + "src/search/defaults.js", + "src/search/geospatial.js", + "src/search/google.js", + "src/search/http.js", + "src/search/placeSearch.js", + "src/services/application.js", + "src/services/groundFloor.js", + "src/services/groundSnap.js", + "src/services/meshFloorSampler.js", + "src/services/requests.js", + "src/services/terrainHeights.js" + ], + "external": [ + "cesium", + "egm96-universal", + "satellite.js", + "@mapbox/vector-tile", + "pbf", + "mgrs" + ] } } diff --git a/scripts/track-regression.mjs b/scripts/track-regression.mjs index c62c27dd8..f6e6d1121 100644 --- a/scripts/track-regression.mjs +++ b/scripts/track-regression.mjs @@ -2986,17 +2986,12 @@ async function main() { } return window.__dfCountModels(icao); }; - // Vite serves an edited source file as `…/groundFloor.js?t=`; - // importing the PLAIN path then hands back a second, unrelated module - // instance whose cells the app never reads. Offer the URL the app itself - // loaded first, then the plain path (clean, never-hot-reloaded server). - const seen = performance.getEntriesByType('resource') - .map((e) => e.name) - .filter((n) => /\/src\/data\/groundFloor\.js(\?|$)/.test(n)); - window.__dfCandidates = [...new Set([...seen.reverse(), `${window.__gevQaSourceBase || '/src'}/data/groundFloor.js`])]; + // Read the application's surface owner, then prove it is the one + // used by the actual poll/render path with the unchanged floor seed. + window.__dfCandidates = window.__godsEyeView.surfaceServices?.groundFloor ? ['application surface'] : []; return { candidates: window.__dfCandidates.length }; }); - record('display-floor: groundFloor module URL candidates found', dfSetup.candidates > 0, + record('display-floor: ground-floor service owner found', dfSetup.candidates > 0, JSON.stringify(dfSetup)); // Identity probe. Seed a contact's FIX cell BEFORE its first fix arrives, @@ -3012,8 +3007,7 @@ async function main() { const tried = []; for (let i = 0; i < window.__dfCandidates.length; i++) { const url = window.__dfCandidates[i]; - let gf; - try { gf = await import(/* @vite-ignore */ url); } catch { tried.push({ url, h: null }); continue; } + const gf = window.__godsEyeView.surfaceServices.groundFloor; if (typeof gf.reportMeshFloorCell !== 'function') { tried.push({ url, h: null }); continue; } gf.setMeshFloorPreferred(true); gf._clearMeshFloorCellsForTest(); diff --git a/src/annotations/annotationResolver.js b/src/annotations/annotationResolver.js index 72b83b03d..960ea3a39 100644 --- a/src/annotations/annotationResolver.js +++ b/src/annotations/annotationResolver.js @@ -1,1874 +1,4 @@ +import { createAnnotationResolver } from './resolver.js'; import { applicationServices } from '../services/application.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 { registerDynamicCredit, NATURAL_EARTH_CREDIT } from '../data/dataCredits.js'; -import { unavailablePlaceSearch } from '../search/placeSearch.js'; -import { isPickedWorldPosition } from '../data/scenePick.js'; - -/** - * 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) { - 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); -} - -/** Wire an external AbortSignal to a local controller; returns a detach fn. */ -function linkAbort(controller, externalSignal) { - if (!externalSignal) return () => {}; - if (externalSignal.aborted) { controller.abort(); return () => {}; } - const onAbort = () => controller.abort(); - externalSignal.addEventListener('abort', onAbort, { once: true }); - return () => externalSignal.removeEventListener('abort', onAbort); -} - -/** - * 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, - * }>} - */ -export 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, -}) { - 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 = []; - // 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)); - - 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 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 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. - const baseScope = refineScope(scopeFromTypes(geocodeTypes), entityKind); - // 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 resolveOutline = async () => { - 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); - if (ne) { - registerDynamicCredit(viewer, NATURAL_EARTH_CREDIT); - 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"). - fp = synthesizeBufferedArea(lat, lon, AROUND_LANDMARK_RADIUS_M); - } else if (isAdmin) { - // 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); - if (ext) fp = { ring: ext.ring, kind: 'area', heightM: null }; - // 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 (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 (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 (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 (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; - } - } - } - // Scope sanity: reject a real footprint whose area is wildly wrong for the asked - // scope (a building/compound/neighborhood intent must never draw a state-sized - // blob). Synthesized discs are deliberately sized and exempt. - if (fp && !fp.synthesized && Array.isArray(fp.ring) && fp.ring.length >= 3 && exceedsScopeArea(fp, scope)) { - fp = null; - } - if (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; - const centroid = ringCentroid(fp.ring); - if (!centroid) return null; - // 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), - lat: centroid.lat, - lon: centroid.lon, - height: sampleGroundHeight(viewer, centroid.lon, centroid.lat), - }; - }; - - let ring = null; - let footprintKind = null; // 'building' | 'area' - let buildingHeight = null; // meters, only for buildings - let synthesized = false; // true = buffered/approximate area, render dashed/feathered - if (footprint && !deferFootprint) { - const fp = await resolveOutline(); - if (fp && !isRateLimitedOutcome(fp)) { - ring = fp.ring; - footprintKind = fp.footprintKind; - buildingHeight = fp.buildingHeight; - synthesized = fp.synthesized; - // 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, - 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 - -/** - * 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. */ -function ringAreaM2(ring) { - if (!Array.isArray(ring) || ring.length < 3) return 0; - const mLat = 111_320; - const mLon = mLat * Math.cos(Cesium.Math.toRadians(ring[0][1])); - let area = 0; - for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { - const xi = ring[i][0] * mLon; - const yi = ring[i][1] * mLat; - const xj = ring[j][0] * mLon; - const yj = ring[j][1] * mLat; - area += xj * yi - xi * yj; - } - return Math.abs(area) / 2; -} - -/** 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 }, - }; -} - -const 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. - */ -export 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. */ -export function isRateLimitedOutcome(value) { - return value?.rateLimited === true; -} - -/** POST an Overpass QL query and return elements, a transient null, or a throttle object. */ -async function overpassJson(query, timeoutMs = 14000, signal) { - const controller = new AbortController(); - const detach = linkAbort(controller, signal); - const timer = window.setTimeout(() => controller.abort(), timeoutMs); - try { - return await applicationServices.boundaries.query(query, { signal: controller.signal }); - } catch { - return null; - } finally { - window.clearTimeout(timer); - detach(); - } -} - -/** - * 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 overpassJson( - `[out:json][timeout:25];is_in(${lat},${lon})->.a;area.a["boundary"="administrative"]["admin_level"];out tags;`, - 14000, - signal, - ); - if (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.type !== 'area' || !el.tags) 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.tags.name, el.tags['name:en']].filter(Boolean).join(' ')); - const fullWords = normalizedWords([el.tags.name, el.tags['name:en'], el.tags.official_name].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.tags.admin_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 (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 overpassJson(`[out:json][timeout:25];area(${cand.el.id})->.x;rel(pivot.x);out geom;`, 28000, signal); - if (isRateLimitedOutcome(relEls)) return relEls; - if (relEls === null) { transient = true; break; } // network blip — don't definitively fail - const relEl = relEls.find((e) => e.type === 'relation'); - if (!relEl) continue; // no relation backing this area — try the next candidate - const coords = elementCoordinates(relEl); - if (coords.length < 3) continue; // incomplete geometry — try the next - - let ring = closeRing(coords.map((p) => [p.lon, p.lat])); - // Simplify: tight for small neighborhoods, looser for states/countries. - const tolM = scope === 'neighborhood' ? 6 : scope === 'city' ? 12 : scope === 'county' ? 40 : 120; - ring = simplifyRing(ring, tolM); - const fp = { ring, kind: 'area', heightM: null }; - - // Scope sanity BEFORE caching: a city-suffixed neighborhood name ("Downtown San - // Francisco") can match the CITY admin. Caching an oversized ring would poison the - // key and skip the place= fallback forever. - if (exceedsScopeArea(fp, scope)) { - if (scope === 'neighborhood') { - const place = await fetchPlaceArea(lat, lon, query, signal); - if (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 (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 overpassJson( - `[out:json][timeout:20];(` - + `way(around:1500,${lat},${lon})["place"~"neighbourhood|suburb|quarter|borough"]["name"];` - + `relation(around:1500,${lat},${lon})["place"~"neighbourhood|suburb|quarter|borough"]["name"];` - + `relation(around:1500,${lat},${lon})["boundary"="place"]["name"];` - + `);out tags geom;`, - 14000, - signal, - ); - if (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 = elementCoordinates(el); - if (coords.length < 3) continue; - const tags = el.tags || {}; - const nameWords = normalizedWords([tags.name, tags['name:en']].filter(Boolean).join(' ')); - const overlap = wordOverlap(queryWords, nameWords); - if (!overlap) continue; - // The OSM place name is usually just the neighborhood ("Downtown", "Chinatown") - // while the query carries a city suffix ("Downtown San Francisco"). Match on how - // fully the query covers the FEATURE'S name (not the query) — so a one-word place - // whose name the query fully contains still wins — then prefer containment. - const nameCoverage = nameWords.size ? overlap / nameWords.size : 0; - if (nameCoverage < 0.6) continue; - if (approximateAreaM2(coords) > 80_000_000) continue; // a neighborhood isn't a city - const score = nameCoverage * 1000 + (pointInPolygon(lon, lat, coords) ? 400 : 0) + overlap * 50; - if (score > bestScore) { bestScore = score; bestRing = closeRing(coords.map((p) => [p.lon, p.lat])); } - } - return bestRing ? { ring: bestRing, kind: 'area', heightM: null } : null; -} - -/** - * Street → best-available AREA, with graceful degradation: - * Tier C — a same-named district / commercial area near the street. - * Tier F — buffer the matching street centerline into a corridor ribbon. - * Always returns an area (or null), never a building. - */ -async function fetchStreet(lat, lon, query, signal) { - const cacheKey = `street|${lat.toFixed(4)},${lon.toFixed(4)}|${query.toLowerCase()}`; - const cachedFp = cacheRead(footprintCache, cacheKey); - if (cachedFp !== undefined) return cachedFp; - const queryWords = normalizedWords(query); - - // Tier C — a same-named district / quarter / named commercial area. - const areaEls = await overpassJson( - `[out:json][timeout:20];(` - + `way(around:450,${lat},${lon})["place"~"quarter|neighbourhood|suburb|city_block"];` - + `relation(around:450,${lat},${lon})["place"~"quarter|neighbourhood|suburb"];` - + `way(around:450,${lat},${lon})["landuse"~"commercial|retail"]["name"];` - + `relation(around:450,${lat},${lon})["landuse"~"commercial|retail"]["name"]["type"="multipolygon"];` - + `);out tags geom;`, - 14000, - signal, - ); - if (isRateLimitedOutcome(areaEls)) return areaEls; - if (areaEls) { - let bestRing = null; - let bestScore = 0; - for (const el of areaEls) { - const coords = elementCoordinates(el); - if (coords.length < 3) continue; - const tags = el.tags || {}; - const nameWords = normalizedWords([tags.name, tags['name:en']].filter(Boolean).join(' ')); - const overlap = wordOverlap(queryWords, nameWords); - if (!overlap) continue; - if (approximateAreaM2(coords) > 2_000_000) continue; // a street isn't a whole suburb - const score = overlap * 1000 + (pointInPolygon(lon, lat, coords) ? 300 : 0); - if (score > bestScore) { bestScore = score; bestRing = closeRing(coords.map((p) => [p.lon, p.lat])); } - } - if (bestRing) { - const fp = { ring: bestRing, kind: 'area', heightM: null }; - cacheWrite(footprintCache, cacheKey, fp); - return fp; - } - } - - // Tier F — buffer the matching centerline into a corridor ribbon (workhorse). - const wayEls = await overpassJson(`[out:json][timeout:20];way(around:320,${lat},${lon})["highway"]["name"];out geom;`, 14000, signal); - if (isRateLimitedOutcome(wayEls)) return wayEls; - if (wayEls) { - const segments = []; - for (const el of wayEls) { - const tags = el.tags || {}; - const nameWords = normalizedWords([tags.name, tags['name:en']].filter(Boolean).join(' ')); - if (!wordOverlap(queryWords, nameWords)) continue; - const geom = (el.geometry || []).filter((p) => Number.isFinite(p?.lat) && Number.isFinite(p?.lon)); - if (geom.length >= 2) segments.push(geom.map((p) => [p.lon, p.lat])); - } - if (segments.length) { - const line = stitchLine(segments); - if (line.length >= 2) { - const fp = { ring: bufferCorridor(line, 11), kind: 'area', heightM: null }; - cacheWrite(footprintCache, cacheKey, fp); - return fp; - } - } - } - // Definitive only if BOTH tier queries actually returned data; a null from - // either is a transient upstream failure we should not cache — and should - // report as TRANSIENT (undefined) so the deferred-outline retry re-runs it. - const definitive = areaEls !== null && wayEls !== null; - negCache(footprintCache, cacheKey, signal, definitive); - return definitive ? null : undefined; -} - -/** Chain street way-segments into one contiguous polyline by endpoint matching. */ -function stitchLine(segments) { - const same = (a, b) => approximateDistanceM(a[1], a[0], b[1], b[0]) < 2; - const remaining = segments.map((s) => s.slice()); - let line = remaining.shift(); - let advanced = true; - while (advanced && remaining.length) { - advanced = false; - for (let i = 0; i < remaining.length; i++) { - const s = remaining[i]; - if (same(line[line.length - 1], s[0])) line = line.concat(s.slice(1)); - else if (same(line[line.length - 1], s[s.length - 1])) line = line.concat(s.slice(0, -1).reverse()); - else if (same(line[0], s[s.length - 1])) line = s.slice(0, -1).concat(line); - else if (same(line[0], s[0])) line = s.slice(1).reverse().concat(line); - else continue; - remaining.splice(i, 1); - advanced = true; - break; - } - } - return line; -} - -/** Offset a centerline into a closed corridor ring (~2*halfWidthM wide). */ -function bufferCorridor(line, halfWidthM) { - const lat0 = line[0][1]; - const mLon = 111320 * Math.cos((lat0 * Math.PI) / 180); - const mLat = 111320; - const P = line.map(([lon, lat]) => [lon * mLon, lat * mLat]); - const left = []; - const right = []; - for (let i = 0; i < P.length; i++) { - const a = P[Math.max(0, i - 1)]; - const b = P[Math.min(P.length - 1, i + 1)]; - let dx = b[0] - a[0]; - let dy = b[1] - a[1]; - const len = Math.hypot(dx, dy) || 1; - dx /= len; dy /= len; - const nx = -dy; - const ny = dx; - left.push([P[i][0] + nx * halfWidthM, P[i][1] + ny * halfWidthM]); - right.push([P[i][0] - nx * halfWidthM, P[i][1] - ny * halfWidthM]); - } - const ring = [...left, ...right.reverse(), left[0]]; - return ring.map(([x, y]) => [x / mLon, y / mLat]); -} - -/** Douglas–Peucker ring simplification with a metres tolerance. Pre-decimates - * very large rings (state/country) to keep the recursion shallow. */ -function simplifyRing(ring, tolM) { - if (ring.length <= 24) return ring; - let pts = ring; - // Hard-cap the input to Douglas-Peucker so a pathological 50k-point country - // boundary can't dominate a frame, even with the iterative implementation. - if (pts.length > 4000) { - const step = Math.ceil(pts.length / 4000); - pts = pts.filter((_, i) => i % step === 0 || i === ring.length - 1); - } - const lat0 = pts[0][1]; - const tol = tolM / (111320 * Math.cos((lat0 * Math.PI) / 180)); - const out = douglasPeucker(pts, tol); - return out.length >= 4 ? out : ring; -} - -function douglasPeucker(points, tol) { - const n = points.length; - if (n < 3) return points.slice(); - // Iterative, index-based (no per-call array slicing / recursion), so a large - // state/country boundary can't blow the call stack or thrash GC and freeze the - // main thread during a voice turn. - const keep = new Uint8Array(n); - keep[0] = 1; - keep[n - 1] = 1; - const stack = [[0, n - 1]]; - while (stack.length) { - const seg = stack.pop(); - const first = seg[0]; - const last = seg[1]; - let index = -1; - let maxD = 0; - const a = points[first]; - const b = points[last]; - for (let i = first + 1; i < last; i += 1) { - const dist = perpDistance(points[i], a, b); - if (dist > maxD) { maxD = dist; index = i; } - } - if (maxD > tol && index > first) { - keep[index] = 1; - stack.push([first, index]); - stack.push([index, last]); - } - } - const out = []; - for (let i = 0; i < n; i += 1) if (keep[i]) out.push(points[i]); - return out; -} - -function perpDistance(p, a, b) { - const dx = b[0] - a[0]; - const dy = b[1] - a[1]; - const len2 = dx * dx + dy * dy; - if (len2 === 0) return Math.hypot(p[0] - a[0], p[1] - a[1]); - let t = ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2; - t = Math.max(0, Math.min(1, t)); - return Math.hypot(p[0] - (a[0] + t * dx), p[1] - (a[1] + t * dy)); -} - -/** - * 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; - - // `out geom` (not `out tags geom`) so relation members come back WITH their - // way geometry — needed to trace multipolygon compounds like the Presidio. - const overpassQuery = ` - [out:json][timeout:25]; - ( - way(around:320,${lat},${lon})["building"]; - relation(around:320,${lat},${lon})["building"]; - way(around:1800,${lat},${lon})["landuse"]; - relation(around:1800,${lat},${lon})["landuse"]; - way(around:1800,${lat},${lon})["leisure"]; - relation(around:1800,${lat},${lon})["leisure"]; - way(around:1800,${lat},${lon})["aeroway"="aerodrome"]; - relation(around:1800,${lat},${lon})["aeroway"="aerodrome"]; - way(around:1800,${lat},${lon})["natural"="water"]["name"]; - relation(around:1800,${lat},${lon})["natural"="water"]["name"]["type"="multipolygon"]; - way(around:1200,${lat},${lon})["shop"="mall"]; - relation(around:1200,${lat},${lon})["shop"="mall"]; - way(around:800,${lat},${lon})["amenity"]; - way(around:800,${lat},${lon})["tourism"]; - ); - out geom; - `; - - const elements = await overpassJson(overpassQuery, 12000, signal); - if (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; -} - -const ENCLOSING_RADIUS_M = 600; // sweep this far for an enclosing named non-building polygon - -/** - * 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; - - // Named non-building polygons near the point. `out geom` returns inline geometry so it - // stitches with elementCoordinates/stitchRing (multipolygon outers). Buildings are EXCLUDED - // in JS below (a tag filter can't drop a relation whose members include a building). - const q = ` - [out:json][timeout:25]; - ( - way(around:${ENCLOSING_RADIUS_M},${lat},${lon})["leisure"]["name"]; - relation(around:${ENCLOSING_RADIUS_M},${lat},${lon})["leisure"]["name"]["type"="multipolygon"]; - way(around:${ENCLOSING_RADIUS_M},${lat},${lon})["landuse"]["name"]; - relation(around:${ENCLOSING_RADIUS_M},${lat},${lon})["landuse"]["name"]["type"="multipolygon"]; - way(around:${ENCLOSING_RADIUS_M},${lat},${lon})["boundary"]["name"]; - relation(around:${ENCLOSING_RADIUS_M},${lat},${lon})["boundary"]["name"]; - way(around:${ENCLOSING_RADIUS_M},${lat},${lon})["amenity"]["name"]; - relation(around:${ENCLOSING_RADIUS_M},${lat},${lon})["amenity"]["name"]["type"="multipolygon"]; - way(around:${ENCLOSING_RADIUS_M},${lat},${lon})["natural"="water"]["name"]; - relation(around:${ENCLOSING_RADIUS_M},${lat},${lon})["natural"="water"]["name"]["type"="multipolygon"]; - ); - out geom; - `; - // 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. overpassJson returns null on a transient - // failure (timeout/502). - const elements = await overpassJson(q, 12000, signal); - if (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.tags?.building) continue; // exclude buildings (the exact feature, never the grounds) - const coords = elementCoordinates(el); - 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.tags?.name, el.tags?.['name:en'], el.tags?.official_name, el.tags?.alt_name, - ].filter(Boolean).join(' ')); - const nameMatch = wordOverlap(queryWords, nameWords) > 0; - const better = areaM2 < bestArea * 0.999 - || (areaM2 <= bestArea * 1.05 && nameMatch && !bestNameMatch); // near-tie: name-match wins - if (better) { - best = { ring: ringLonLat, kind: 'area', heightM: null }; - bestArea = areaM2; - bestNameMatch = nameMatch; - } - } - - // Cache on ANY definitive outcome (a real footprint OR a clean no-match) so a whole grounds batch - // makes at most ONE call. A transient (`undefined`) already returned above without caching. - if (best) { cacheWrite(enclosingAreaCache, cacheKey, best); return best; } - negCache(enclosingAreaCache, cacheKey, signal, true); // got a response, nothing contained → definitive - return null; -} - -const MONUMENT_RADIUS_M = 2500; // search this far from the view centre for a named monument -/** 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. */ -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. - */ -export 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) { - const q = ` - [out:json][timeout:20]; - ( - nwr(around:${MONUMENT_RADIUS_M},${lat},${lon})["historic"~"memorial|monument|statue|tomb"]; - nwr(around:${MONUMENT_RADIUS_M},${lat},${lon})["tourism"="artwork"]["name"]; - nwr(around:${MONUMENT_RADIUS_M},${lat},${lon})["memorial"]["name"]; - ); - out center tags; - `; - // Short timeout + fail-fast: this is an opportunistic snap, not worth blocking narration - // on a slow/overloaded Overpass. overpassJson returns null on a transient failure. - pending = overpassJson(q, 6000) - .then((elements) => { - if (elements === null || 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.tags?.name || el.tags?.['name:en'] || el.tags?.official_name || el.tags?.alt_name; - if (!name) continue; - const p = Number.isFinite(el.lat) ? { lat: el.lat, lon: el.lon } - : (el.center ? { lat: el.center.lat, lon: el.center.lon } : null); - 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. - */ -export 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 = elementCoordinates(element); - if (coords.length < 3) continue; - - const tags = element.tags || {}; - const isBuilding = Boolean(tags.building); - const areaM2 = approximateAreaM2(coords); - - const nameWords = normalizedWords([ - tags.name, tags['name:en'], tags.official_name, tags.alt_name, tags.short_name, - ].filter(Boolean).join(' ')); - const nameOverlap = wordOverlap(queryWords, nameWords); - // How completely the query covers this feature's name (1.0 ≈ exact match). - const completeness = nameWords.size ? nameOverlap / nameWords.size : 0; - const named = nameOverlap > 0; - // Admin/neighborhood fallback (requireName): accept only a NAMED, non-building - // AREA of district size (≥0.3 km², e.g. the Presidio's landuse). A neighborhood - // is never a single building or a tiny parcel, so a building named "Mission ..." - // or a 0.01 km² lot must not stand in for it — better an honest labeled point. - if (requireName && (!named || isBuilding || areaM2 < 300_000)) continue; - // Point-like contract: a polygon may stand in for a monument/statue/memorial ONLY - // when it clearly IS that feature — most of the query names it (intentCoverage) AND - // the query accounts for most of ITS name (completeness) AND it is monument-scale. - // Bare word overlap must not qualify: "Thompson Austin" shares only the locality - // token with "Tejano Monument, Austin" and outscored everything under loose scoring - // (field test 7 §1). No qualifying polygon → null → the honest point anchor stays. - if (mode === 'point') { - const intentCoverage = queryWords.size ? nameOverlap / queryWords.size : 0; - if (!named || intentCoverage < 0.5 || completeness < 0.6) continue; - if (areaM2 > POINTLIKE_AREA_CAP_M2) continue; - } - - // Size gating: buildings are small; areas can be large but only when named - // (so "Presidio" can match a 6 km² compound without grabbing a whole city). - if (isBuilding) { - if (areaM2 < 40 || areaM2 > 400_000) continue; - } else { - if (areaM2 < 200) continue; - if (!named && areaM2 > 600_000) continue; - if (named && areaM2 > 80_000_000) continue; - } - - const contains = pointInPolygon(targetLon, targetLat, coords); - if (mode === 'anchored' && !contains) { - const intentCoverage = queryWords.size ? nameOverlap / queryWords.size : 0; - if (!named || intentCoverage < 0.5 || completeness < 0.6) continue; - } - const centroid = ringCentroid(coords.map((p) => [p.lon, p.lat])); - const distanceM = centroid - ? approximateDistanceM(targetLat, targetLon, centroid.lat, centroid.lon) - : 9999; - - // Name match dominates; completeness breaks ties so the feature literally - // named "Presidio" beats a building that merely contains the word. Size is - // only a mild tiebreak now (no hard penalty against large named areas). - let score = nameOverlap * 1000 - + completeness * 600 - + (contains ? 450 : 0) - - distanceM * 0.25 - - Math.sqrt(areaM2) * 0.05; - - // With no name match at all, prefer a precise building over a vague blob. - if (!named && isBuilding && contains) score += 250; - - if (score > bestScore) { - bestScore = score; - best = { - ring: closeRing(coords.map((p) => [p.lon, p.lat])), - kind: isBuilding ? 'building' : 'area', - heightM: isBuilding ? buildingHeightFromTags(tags, areaM2) : null, - }; - } - } - - return best; -} - -/** Estimate a building's height (m) from OSM tags, falling back to footprint size. */ -function buildingHeightFromTags(tags, areaM2) { - const explicit = parseMeters(tags.height || tags['building:height']); - if (explicit) return explicit; - const levels = Number.parseFloat(tags['building:levels']); - const roof = parseMeters(tags['roof:height']) || 0; - if (Number.isFinite(levels) && levels > 0) return levels * 3.3 + roof; - const side = Math.sqrt(Math.max(1, areaM2)); - return Math.max(10, Math.min(70, side * 0.6)); -} - -function parseMeters(value) { - if (value == null) return 0; - const n = Number.parseFloat(String(value).replace(',', '.')); - if (!Number.isFinite(n) || n <= 0) return 0; - return /\b(ft|feet|foot)\b/i.test(String(value)) ? n * 0.3048 : n; -} - -// --- geometry helpers ------------------------------------------------------- - -function elementCoordinates(element) { - if (Array.isArray(element.geometry)) { - return element.geometry.filter((p) => Number.isFinite(p?.lat) && Number.isFinite(p?.lon)); - } - if (!Array.isArray(element.members)) return []; - // For a multipolygon relation the boundary is split across several outer - // ways (the Presidio has 8), so chain them into one ring by matching - // endpoints rather than taking a single segment. - const outerWays = element.members - .filter((m) => (m.role === 'outer' || !m.role) && Array.isArray(m.geometry)) - .map((m) => m.geometry.filter((p) => Number.isFinite(p?.lat) && Number.isFinite(p?.lon))) - .filter((w) => w.length >= 2); - return stitchRing(outerWays); -} - -/** - * Stitch relation outer ways into a footprint ring. Builds ALL connected - * components (a multipolygon outer boundary is split across several ways), then - * returns the largest component whose endpoints actually meet — a true closed - * ring. If no component closes cleanly, it accepts the largest only when the - * endpoint gap is small relative to its own span (a minor seam); otherwise the - * relation geometry is incomplete and it returns [] rather than fabricate a - * straight chord across the gap (the reported bay-spanning blob). - */ -function stitchRing(ways) { - if (!ways.length) return []; - const components = buildRingComponents(ways); - if (!components.length) return []; - - const CLOSE_TOL_M = 30; // endpoints within 30 m → a genuinely closed ring - const closed = components.filter((c) => endpointGapM(c) <= CLOSE_TOL_M); - if (closed.length) { - // Pick by projected AREA, not vertex count — a small, highly-detailed island - // must not beat the large simple mainland. (For a disjoint multipolygon this - // returns only the largest closed outer ring; full multi-ring rendering is a - // renderer change tracked separately.) - return largestByArea(closed); - } - - // Nothing closes exactly. Bridge a seam ONLY when the gap is BOTH a small - // fraction of the feature's own span (so half a compound's boundary missing is - // rejected) AND under a hard absolute ceiling (so a state/country can never get a - // kilometres-long chord — the bay blob). A complete relation closes at ~0; a - // genuine seam (e.g. the Presidio's ~few-hundred-m coastline gap) bridges; a - // truly incomplete relation is rejected → point fallback. - const largest = largestByArea(components); - const allow = Math.min(ringSpanM(largest) * 0.15, 1200); - return endpointGapM(largest) <= allow ? largest : []; -} - -/** Largest component by projected area (each component is an array of {lat,lon}). */ -function largestByArea(components) { - let best = components[0]; - let bestArea = approximateAreaM2(best); - for (let i = 1; i < components.length; i += 1) { - const area = approximateAreaM2(components[i]); - if (area > bestArea) { bestArea = area; best = components[i]; } - } - return best; -} - -/** Rough diameter (m) of a chain's bounding box. */ -function ringSpanM(chain) { - let minLat = Infinity; let maxLat = -Infinity; let minLon = Infinity; let maxLon = -Infinity; - for (const p of chain) { - if (p.lat < minLat) minLat = p.lat; - if (p.lat > maxLat) maxLat = p.lat; - if (p.lon < minLon) minLon = p.lon; - if (p.lon > maxLon) maxLon = p.lon; - } - return approximateDistanceM(minLat, minLon, maxLat, maxLon); -} - -/** Chain ways into maximal connected components by endpoint matching. */ -function buildRingComponents(ways) { - const same = (a, b) => Math.abs(a.lon - b.lon) < 1e-7 && Math.abs(a.lat - b.lat) < 1e-7; - const remaining = ways.map((w) => w.slice()); - const components = []; - while (remaining.length) { - let chain = remaining.shift(); - let grew = true; - while (grew) { - grew = false; - const head = chain[0]; - const tail = chain[chain.length - 1]; - for (let i = 0; i < remaining.length; i += 1) { - const w = remaining[i]; - const ws = w[0]; - const we = w[w.length - 1]; - if (same(tail, ws)) chain = chain.concat(w.slice(1)); - else if (same(tail, we)) chain = chain.concat(w.slice(0, -1).reverse()); - else if (same(head, we)) chain = w.slice(0, -1).concat(chain); - else if (same(head, ws)) chain = w.slice(1).reverse().concat(chain); - else continue; - remaining.splice(i, 1); - grew = true; - break; - } - } - components.push(chain); - } - return components; -} - -/** Great-circle distance (m) between a chain's first and last vertex. */ -function endpointGapM(chain) { - if (!chain || chain.length < 2) return Infinity; - const a = chain[0]; - const b = chain[chain.length - 1]; - return approximateDistanceM(a.lat, a.lon, b.lat, b.lon); -} - - -function closeRing(ring) { - if (ring.length < 3) return ring; - const [fx, fy] = ring[0]; - const [lx, ly] = ring[ring.length - 1]; - if (fx !== lx || fy !== ly) ring.push([fx, fy]); - return ring; -} - -function ringCentroid(ring) { - if (!ring || ring.length < 3) return null; - let sumLat = 0; - let sumLon = 0; - for (const [lon, lat] of ring) { - sumLat += lat; - sumLon += lon; - } - return { lat: sumLat / ring.length, lon: sumLon / ring.length }; -} - -function approximateAreaM2(coords) { - // Shoelace in a local equirectangular projection (good enough for buildings). - if (coords.length < 3) return 0; - const lat0 = coords[0].lat; - const mPerDegLat = 111_320; - const mPerDegLon = mPerDegLat * Math.cos(Cesium.Math.toRadians(lat0)); - let area = 0; - for (let i = 0, j = coords.length - 1; i < coords.length; j = i++) { - const xi = coords[i].lon * mPerDegLon; - const yi = coords[i].lat * mPerDegLat; - const xj = coords[j].lon * mPerDegLon; - const yj = coords[j].lat * mPerDegLat; - area += xj * yi - xi * yj; - } - return Math.abs(area) / 2; -} - -function approximateDistanceM(latA, lonA, latB, lonB) { - const latScale = 111_320; - const lonScale = latScale * Math.cos(Cesium.Math.toRadians((latA + latB) / 2)); - return Math.hypot((latB - latA) * latScale, (lonB - lonA) * lonScale); -} - -function pointInPolygon(lon, lat, coords) { - let inside = false; - for (let i = 0, j = coords.length - 1; i < coords.length; j = i++) { - const a = coords[i]; - const b = coords[j]; - const intersects = ((a.lat > lat) !== (b.lat > lat)) - && (lon < (b.lon - a.lon) * (lat - a.lat) / ((b.lat - a.lat) || Number.EPSILON) + a.lon); - if (intersects) inside = !inside; - } - return inside; -} - -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. */ -export 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. - */ -export 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), - }; -} - -/** - * 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. Natural Earth pack first (offline, - * instant; largest-area match is correct for a global name-only ask), 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. - * - * @param {string} name e.g. "Texas", "the Alps", "France", "Gulf of Mexico" - * @param {AbortSignal} [signal] - * @returns {Promise<{name:string, ring:Array<[number,number]>}|null>} - */ -export async function resolveRegionRingForQuery(name, signal, placeSearch = unavailablePlaceSearch) { - const q = String(name || '').trim(); - if (!q) return null; - const ne = await findNaturalRegion(q).catch(() => null); - if (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 }; - } - const geo = await geocodePlace(q, null, signal, placeSearch).catch(() => null); - if (!geo) return null; - const scope = scopeFromTypes(geo.types); - if (!['country', 'state', 'county', 'city'].includes(scope)) return null; - const fp = await fetchAdminArea(geo.lat, geo.lon, q, scope, signal).catch(() => null); - if (fp?.ring?.length >= 3) return { name: q, ring: fp.ring }; - return null; -} +const resolver = createAnnotationResolver({ boundarySource: applicationServices.boundaries }); +export const { resolveAnnotationTarget, refineScope, isRateLimitedOutcome, isGroundsLikeAsk, selectFootprint, viewportBias, placesNearViewRecovery, resolveRegionRingForQuery } = resolver; diff --git a/src/annotations/index.js b/src/annotations/index.js index a8047c700..f6e1c901f 100644 --- a/src/annotations/index.js +++ b/src/annotations/index.js @@ -10,13 +10,13 @@ import { createHybridAnnotationRenderer } from './hybridAnnotationRenderer.js'; * footprints + screen-space SVG for callouts/rings/arrows. The engine, resolver, * and voice tool wiring are identical to the other two branches. */ -export function initAnnotations({ viewer, tileset = null, placeSearch }) { +export function initAnnotations({ viewer, tileset = null, placeSearch, resolver }) { // World-space footprint draping; clamped marks can use the photoreal tiles. if (tileset) { try { tileset.enableCollision = true; } catch { /* older tileset */ } } const renderer = createHybridAnnotationRenderer(viewer); - const engine = createAnnotationEngine({ viewer, renderer, placeSearch }); + const engine = createAnnotationEngine({ viewer, renderer, placeSearch, resolveTarget: resolver?.resolveAnnotationTarget }); window.__gevAnnotations = engine; return engine; } diff --git a/src/annotations/resolver.js b/src/annotations/resolver.js new file mode 100644 index 000000000..a76c271e8 --- /dev/null +++ b/src/annotations/resolver.js @@ -0,0 +1,2335 @@ +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 { + registerDynamicCredit, + NATURAL_EARTH_CREDIT, +} from '../data/dataCredits.js'; +import { unavailablePlaceSearch } from '../search/placeSearch.js'; +import { isPickedWorldPosition } from '../data/scenePick.js'; + +/** Own annotation lookup caches and the supplied boundary query service. */ +export function createAnnotationResolver({ + boundarySource, + signal: lifetime, +} = {}) { + if (typeof boundarySource?.query !== 'function') + throw new TypeError('Annotation boundaries require a query service'); + 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); + } + + /** Wire an external AbortSignal to a local controller; returns a detach fn. */ + function linkAbort(controller, externalSignal) { + if (!externalSignal) return () => {}; + if (externalSignal.aborted) { + controller.abort(); + return () => {}; + } + const onAbort = () => controller.abort(); + externalSignal.addEventListener('abort', onAbort, { once: true }); + return () => externalSignal.removeEventListener('abort', onAbort); + } + + /** + * 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 = []; + // 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)); + + 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 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 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. + const baseScope = refineScope(scopeFromTypes(geocodeTypes), entityKind); + // 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 resolveOutline = async () => { + 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, + ); + if (ne) { + registerDynamicCredit(viewer, NATURAL_EARTH_CREDIT); + 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"). + fp = synthesizeBufferedArea(lat, lon, AROUND_LANDMARK_RADIUS_M); + } else if (isAdmin) { + // 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); + if (ext) fp = { ring: ext.ring, kind: 'area', heightM: null }; + // 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 (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 (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 (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 (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; + } + } + } + // Scope sanity: reject a real footprint whose area is wildly wrong for the asked + // scope (a building/compound/neighborhood intent must never draw a state-sized + // blob). Synthesized discs are deliberately sized and exempt. + if ( + fp && + !fp.synthesized && + Array.isArray(fp.ring) && + fp.ring.length >= 3 && + exceedsScopeArea(fp, scope) + ) { + fp = null; + } + if (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; + const centroid = ringCentroid(fp.ring); + if (!centroid) return null; + // 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), + lat: centroid.lat, + lon: centroid.lon, + height: sampleGroundHeight(viewer, centroid.lon, centroid.lat), + }; + }; + + let ring = null; + let footprintKind = null; // 'building' | 'area' + let buildingHeight = null; // meters, only for buildings + let synthesized = false; // true = buffered/approximate area, render dashed/feathered + if (footprint && !deferFootprint) { + const fp = await resolveOutline(); + if (fp && !isRateLimitedOutcome(fp)) { + ring = fp.ring; + footprintKind = fp.footprintKind; + buildingHeight = fp.buildingHeight; + synthesized = fp.synthesized; + // 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, + 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 + + /** + * 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. */ + function ringAreaM2(ring) { + if (!Array.isArray(ring) || ring.length < 3) return 0; + const mLat = 111_320; + const mLon = mLat * Math.cos(Cesium.Math.toRadians(ring[0][1])); + let area = 0; + for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { + const xi = ring[i][0] * mLon; + const yi = ring[i][1] * mLat; + const xj = ring[j][0] * mLon; + const yj = ring[j][1] * mLat; + area += xj * yi - xi * yj; + } + return Math.abs(area) / 2; + } + + /** 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; + } + + /** POST an Overpass QL query and return elements, a transient null, or a throttle object. */ + async function overpassJson(query, timeoutMs = 14000, signal) { + const controller = new AbortController(); + const detach = linkAbort(controller, signal); + const timer = window.setTimeout(() => controller.abort(), timeoutMs); + try { + return await boundarySource.query(query, { + signal: lifetime + ? AbortSignal.any([lifetime, controller.signal]) + : controller.signal, + }); + } catch { + return null; + } finally { + window.clearTimeout(timer); + detach(); + } + } + + /** + * 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 overpassJson( + `[out:json][timeout:25];is_in(${lat},${lon})->.a;area.a["boundary"="administrative"]["admin_level"];out tags;`, + 14000, + signal, + ); + if (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.type !== 'area' || !el.tags) 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.tags.name, el.tags['name:en']].filter(Boolean).join(' '), + ); + const fullWords = normalizedWords( + [el.tags.name, el.tags['name:en'], el.tags.official_name] + .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.tags.admin_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 (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 overpassJson( + `[out:json][timeout:25];area(${cand.el.id})->.x;rel(pivot.x);out geom;`, + 28000, + signal, + ); + if (isRateLimitedOutcome(relEls)) return relEls; + if (relEls === null) { + transient = true; + break; + } // network blip — don't definitively fail + const relEl = relEls.find((e) => e.type === 'relation'); + if (!relEl) continue; // no relation backing this area — try the next candidate + const coords = elementCoordinates(relEl); + if (coords.length < 3) continue; // incomplete geometry — try the next + + let ring = closeRing(coords.map((p) => [p.lon, p.lat])); + // Simplify: tight for small neighborhoods, looser for states/countries. + const tolM = + scope === 'neighborhood' + ? 6 + : scope === 'city' + ? 12 + : scope === 'county' + ? 40 + : 120; + ring = simplifyRing(ring, tolM); + const fp = { ring, kind: 'area', heightM: null }; + + // Scope sanity BEFORE caching: a city-suffixed neighborhood name ("Downtown San + // Francisco") can match the CITY admin. Caching an oversized ring would poison the + // key and skip the place= fallback forever. + if (exceedsScopeArea(fp, scope)) { + if (scope === 'neighborhood') { + const place = await fetchPlaceArea(lat, lon, query, signal); + if (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 (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 overpassJson( + `[out:json][timeout:20];(` + + `way(around:1500,${lat},${lon})["place"~"neighbourhood|suburb|quarter|borough"]["name"];` + + `relation(around:1500,${lat},${lon})["place"~"neighbourhood|suburb|quarter|borough"]["name"];` + + `relation(around:1500,${lat},${lon})["boundary"="place"]["name"];` + + `);out tags geom;`, + 14000, + signal, + ); + if (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 = elementCoordinates(el); + if (coords.length < 3) continue; + const tags = el.tags || {}; + const nameWords = normalizedWords( + [tags.name, tags['name:en']].filter(Boolean).join(' '), + ); + const overlap = wordOverlap(queryWords, nameWords); + if (!overlap) continue; + // The OSM place name is usually just the neighborhood ("Downtown", "Chinatown") + // while the query carries a city suffix ("Downtown San Francisco"). Match on how + // fully the query covers the FEATURE'S name (not the query) — so a one-word place + // whose name the query fully contains still wins — then prefer containment. + const nameCoverage = nameWords.size ? overlap / nameWords.size : 0; + if (nameCoverage < 0.6) continue; + if (approximateAreaM2(coords) > 80_000_000) continue; // a neighborhood isn't a city + const score = + nameCoverage * 1000 + + (pointInPolygon(lon, lat, coords) ? 400 : 0) + + overlap * 50; + if (score > bestScore) { + bestScore = score; + bestRing = closeRing(coords.map((p) => [p.lon, p.lat])); + } + } + return bestRing ? { ring: bestRing, kind: 'area', heightM: null } : null; + } + + /** + * Street → best-available AREA, with graceful degradation: + * Tier C — a same-named district / commercial area near the street. + * Tier F — buffer the matching street centerline into a corridor ribbon. + * Always returns an area (or null), never a building. + */ + async function fetchStreet(lat, lon, query, signal) { + const cacheKey = `street|${lat.toFixed(4)},${lon.toFixed(4)}|${query.toLowerCase()}`; + const cachedFp = cacheRead(footprintCache, cacheKey); + if (cachedFp !== undefined) return cachedFp; + const queryWords = normalizedWords(query); + + // Tier C — a same-named district / quarter / named commercial area. + const areaEls = await overpassJson( + `[out:json][timeout:20];(` + + `way(around:450,${lat},${lon})["place"~"quarter|neighbourhood|suburb|city_block"];` + + `relation(around:450,${lat},${lon})["place"~"quarter|neighbourhood|suburb"];` + + `way(around:450,${lat},${lon})["landuse"~"commercial|retail"]["name"];` + + `relation(around:450,${lat},${lon})["landuse"~"commercial|retail"]["name"]["type"="multipolygon"];` + + `);out tags geom;`, + 14000, + signal, + ); + if (isRateLimitedOutcome(areaEls)) return areaEls; + if (areaEls) { + let bestRing = null; + let bestScore = 0; + for (const el of areaEls) { + const coords = elementCoordinates(el); + if (coords.length < 3) continue; + const tags = el.tags || {}; + const nameWords = normalizedWords( + [tags.name, tags['name:en']].filter(Boolean).join(' '), + ); + const overlap = wordOverlap(queryWords, nameWords); + if (!overlap) continue; + if (approximateAreaM2(coords) > 2_000_000) continue; // a street isn't a whole suburb + const score = + overlap * 1000 + (pointInPolygon(lon, lat, coords) ? 300 : 0); + if (score > bestScore) { + bestScore = score; + bestRing = closeRing(coords.map((p) => [p.lon, p.lat])); + } + } + if (bestRing) { + const fp = { ring: bestRing, kind: 'area', heightM: null }; + cacheWrite(footprintCache, cacheKey, fp); + return fp; + } + } + + // Tier F — buffer the matching centerline into a corridor ribbon (workhorse). + const wayEls = await overpassJson( + `[out:json][timeout:20];way(around:320,${lat},${lon})["highway"]["name"];out geom;`, + 14000, + signal, + ); + if (isRateLimitedOutcome(wayEls)) return wayEls; + if (wayEls) { + const segments = []; + for (const el of wayEls) { + const tags = el.tags || {}; + const nameWords = normalizedWords( + [tags.name, tags['name:en']].filter(Boolean).join(' '), + ); + if (!wordOverlap(queryWords, nameWords)) continue; + const geom = (el.geometry || []).filter( + (p) => Number.isFinite(p?.lat) && Number.isFinite(p?.lon), + ); + if (geom.length >= 2) segments.push(geom.map((p) => [p.lon, p.lat])); + } + if (segments.length) { + const line = stitchLine(segments); + if (line.length >= 2) { + const fp = { + ring: bufferCorridor(line, 11), + kind: 'area', + heightM: null, + }; + cacheWrite(footprintCache, cacheKey, fp); + return fp; + } + } + } + // Definitive only if BOTH tier queries actually returned data; a null from + // either is a transient upstream failure we should not cache — and should + // report as TRANSIENT (undefined) so the deferred-outline retry re-runs it. + const definitive = areaEls !== null && wayEls !== null; + negCache(footprintCache, cacheKey, signal, definitive); + return definitive ? null : undefined; + } + + /** Chain street way-segments into one contiguous polyline by endpoint matching. */ + function stitchLine(segments) { + const same = (a, b) => approximateDistanceM(a[1], a[0], b[1], b[0]) < 2; + const remaining = segments.map((s) => s.slice()); + let line = remaining.shift(); + let advanced = true; + while (advanced && remaining.length) { + advanced = false; + for (let i = 0; i < remaining.length; i++) { + const s = remaining[i]; + if (same(line[line.length - 1], s[0])) line = line.concat(s.slice(1)); + else if (same(line[line.length - 1], s[s.length - 1])) + line = line.concat(s.slice(0, -1).reverse()); + else if (same(line[0], s[s.length - 1])) + line = s.slice(0, -1).concat(line); + else if (same(line[0], s[0])) line = s.slice(1).reverse().concat(line); + else continue; + remaining.splice(i, 1); + advanced = true; + break; + } + } + return line; + } + + /** Offset a centerline into a closed corridor ring (~2*halfWidthM wide). */ + function bufferCorridor(line, halfWidthM) { + const lat0 = line[0][1]; + const mLon = 111320 * Math.cos((lat0 * Math.PI) / 180); + const mLat = 111320; + const P = line.map(([lon, lat]) => [lon * mLon, lat * mLat]); + const left = []; + const right = []; + for (let i = 0; i < P.length; i++) { + const a = P[Math.max(0, i - 1)]; + const b = P[Math.min(P.length - 1, i + 1)]; + let dx = b[0] - a[0]; + let dy = b[1] - a[1]; + const len = Math.hypot(dx, dy) || 1; + dx /= len; + dy /= len; + const nx = -dy; + const ny = dx; + left.push([P[i][0] + nx * halfWidthM, P[i][1] + ny * halfWidthM]); + right.push([P[i][0] - nx * halfWidthM, P[i][1] - ny * halfWidthM]); + } + const ring = [...left, ...right.reverse(), left[0]]; + return ring.map(([x, y]) => [x / mLon, y / mLat]); + } + + /** Douglas–Peucker ring simplification with a metres tolerance. Pre-decimates + * very large rings (state/country) to keep the recursion shallow. */ + function simplifyRing(ring, tolM) { + if (ring.length <= 24) return ring; + let pts = ring; + // Hard-cap the input to Douglas-Peucker so a pathological 50k-point country + // boundary can't dominate a frame, even with the iterative implementation. + if (pts.length > 4000) { + const step = Math.ceil(pts.length / 4000); + pts = pts.filter((_, i) => i % step === 0 || i === ring.length - 1); + } + const lat0 = pts[0][1]; + const tol = tolM / (111320 * Math.cos((lat0 * Math.PI) / 180)); + const out = douglasPeucker(pts, tol); + return out.length >= 4 ? out : ring; + } + + function douglasPeucker(points, tol) { + const n = points.length; + if (n < 3) return points.slice(); + // Iterative, index-based (no per-call array slicing / recursion), so a large + // state/country boundary can't blow the call stack or thrash GC and freeze the + // main thread during a voice turn. + const keep = new Uint8Array(n); + keep[0] = 1; + keep[n - 1] = 1; + const stack = [[0, n - 1]]; + while (stack.length) { + const seg = stack.pop(); + const first = seg[0]; + const last = seg[1]; + let index = -1; + let maxD = 0; + const a = points[first]; + const b = points[last]; + for (let i = first + 1; i < last; i += 1) { + const dist = perpDistance(points[i], a, b); + if (dist > maxD) { + maxD = dist; + index = i; + } + } + if (maxD > tol && index > first) { + keep[index] = 1; + stack.push([first, index]); + stack.push([index, last]); + } + } + const out = []; + for (let i = 0; i < n; i += 1) if (keep[i]) out.push(points[i]); + return out; + } + + function perpDistance(p, a, b) { + const dx = b[0] - a[0]; + const dy = b[1] - a[1]; + const len2 = dx * dx + dy * dy; + if (len2 === 0) return Math.hypot(p[0] - a[0], p[1] - a[1]); + let t = ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2; + t = Math.max(0, Math.min(1, t)); + return Math.hypot(p[0] - (a[0] + t * dx), p[1] - (a[1] + t * dy)); + } + + /** + * 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; + + // `out geom` (not `out tags geom`) so relation members come back WITH their + // way geometry — needed to trace multipolygon compounds like the Presidio. + const overpassQuery = ` + [out:json][timeout:25]; + ( + way(around:320,${lat},${lon})["building"]; + relation(around:320,${lat},${lon})["building"]; + way(around:1800,${lat},${lon})["landuse"]; + relation(around:1800,${lat},${lon})["landuse"]; + way(around:1800,${lat},${lon})["leisure"]; + relation(around:1800,${lat},${lon})["leisure"]; + way(around:1800,${lat},${lon})["aeroway"="aerodrome"]; + relation(around:1800,${lat},${lon})["aeroway"="aerodrome"]; + way(around:1800,${lat},${lon})["natural"="water"]["name"]; + relation(around:1800,${lat},${lon})["natural"="water"]["name"]["type"="multipolygon"]; + way(around:1200,${lat},${lon})["shop"="mall"]; + relation(around:1200,${lat},${lon})["shop"="mall"]; + way(around:800,${lat},${lon})["amenity"]; + way(around:800,${lat},${lon})["tourism"]; + ); + out geom; + `; + + const elements = await overpassJson(overpassQuery, 12000, signal); + if (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; + } + + const ENCLOSING_RADIUS_M = 600; // sweep this far for an enclosing named non-building polygon + + /** + * 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; + + // Named non-building polygons near the point. `out geom` returns inline geometry so it + // stitches with elementCoordinates/stitchRing (multipolygon outers). Buildings are EXCLUDED + // in JS below (a tag filter can't drop a relation whose members include a building). + const q = ` + [out:json][timeout:25]; + ( + way(around:${ENCLOSING_RADIUS_M},${lat},${lon})["leisure"]["name"]; + relation(around:${ENCLOSING_RADIUS_M},${lat},${lon})["leisure"]["name"]["type"="multipolygon"]; + way(around:${ENCLOSING_RADIUS_M},${lat},${lon})["landuse"]["name"]; + relation(around:${ENCLOSING_RADIUS_M},${lat},${lon})["landuse"]["name"]["type"="multipolygon"]; + way(around:${ENCLOSING_RADIUS_M},${lat},${lon})["boundary"]["name"]; + relation(around:${ENCLOSING_RADIUS_M},${lat},${lon})["boundary"]["name"]; + way(around:${ENCLOSING_RADIUS_M},${lat},${lon})["amenity"]["name"]; + relation(around:${ENCLOSING_RADIUS_M},${lat},${lon})["amenity"]["name"]["type"="multipolygon"]; + way(around:${ENCLOSING_RADIUS_M},${lat},${lon})["natural"="water"]["name"]; + relation(around:${ENCLOSING_RADIUS_M},${lat},${lon})["natural"="water"]["name"]["type"="multipolygon"]; + ); + out geom; + `; + // 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. overpassJson returns null on a transient + // failure (timeout/502). + const elements = await overpassJson(q, 12000, signal); + if (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.tags?.building) continue; // exclude buildings (the exact feature, never the grounds) + const coords = elementCoordinates(el); + 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.tags?.name, + el.tags?.['name:en'], + el.tags?.official_name, + el.tags?.alt_name, + ] + .filter(Boolean) + .join(' '), + ); + const nameMatch = wordOverlap(queryWords, nameWords) > 0; + const better = + areaM2 < bestArea * 0.999 || + (areaM2 <= bestArea * 1.05 && nameMatch && !bestNameMatch); // near-tie: name-match wins + if (better) { + best = { ring: ringLonLat, kind: 'area', heightM: null }; + bestArea = areaM2; + bestNameMatch = nameMatch; + } + } + + // Cache on ANY definitive outcome (a real footprint OR a clean no-match) so a whole grounds batch + // makes at most ONE call. A transient (`undefined`) already returned above without caching. + if (best) { + cacheWrite(enclosingAreaCache, cacheKey, best); + return best; + } + negCache(enclosingAreaCache, cacheKey, signal, true); // got a response, nothing contained → definitive + return null; + } + + const MONUMENT_RADIUS_M = 2500; // search this far from the view centre for a named monument + /** 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. */ + 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) { + const q = ` + [out:json][timeout:20]; + ( + nwr(around:${MONUMENT_RADIUS_M},${lat},${lon})["historic"~"memorial|monument|statue|tomb"]; + nwr(around:${MONUMENT_RADIUS_M},${lat},${lon})["tourism"="artwork"]["name"]; + nwr(around:${MONUMENT_RADIUS_M},${lat},${lon})["memorial"]["name"]; + ); + out center tags; + `; + // Short timeout + fail-fast: this is an opportunistic snap, not worth blocking narration + // on a slow/overloaded Overpass. overpassJson returns null on a transient failure. + pending = overpassJson(q, 6000) + .then((elements) => { + if (elements === null || 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.tags?.name || + el.tags?.['name:en'] || + el.tags?.official_name || + el.tags?.alt_name; + if (!name) continue; + const p = Number.isFinite(el.lat) + ? { lat: el.lat, lon: el.lon } + : el.center + ? { lat: el.center.lat, lon: el.center.lon } + : null; + 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 = elementCoordinates(element); + if (coords.length < 3) continue; + + const tags = element.tags || {}; + const isBuilding = Boolean(tags.building); + const areaM2 = approximateAreaM2(coords); + + const nameWords = normalizedWords( + [ + tags.name, + tags['name:en'], + tags.official_name, + tags.alt_name, + tags.short_name, + ] + .filter(Boolean) + .join(' '), + ); + const nameOverlap = wordOverlap(queryWords, nameWords); + // How completely the query covers this feature's name (1.0 ≈ exact match). + const completeness = nameWords.size ? nameOverlap / nameWords.size : 0; + const named = nameOverlap > 0; + // Admin/neighborhood fallback (requireName): accept only a NAMED, non-building + // AREA of district size (≥0.3 km², e.g. the Presidio's landuse). A neighborhood + // is never a single building or a tiny parcel, so a building named "Mission ..." + // or a 0.01 km² lot must not stand in for it — better an honest labeled point. + if (requireName && (!named || isBuilding || areaM2 < 300_000)) continue; + // Point-like contract: a polygon may stand in for a monument/statue/memorial ONLY + // when it clearly IS that feature — most of the query names it (intentCoverage) AND + // the query accounts for most of ITS name (completeness) AND it is monument-scale. + // Bare word overlap must not qualify: "Thompson Austin" shares only the locality + // token with "Tejano Monument, Austin" and outscored everything under loose scoring + // (field test 7 §1). No qualifying polygon → null → the honest point anchor stays. + if (mode === 'point') { + const intentCoverage = queryWords.size + ? nameOverlap / queryWords.size + : 0; + if (!named || intentCoverage < 0.5 || completeness < 0.6) continue; + if (areaM2 > POINTLIKE_AREA_CAP_M2) continue; + } + + // Size gating: buildings are small; areas can be large but only when named + // (so "Presidio" can match a 6 km² compound without grabbing a whole city). + if (isBuilding) { + if (areaM2 < 40 || areaM2 > 400_000) continue; + } else { + if (areaM2 < 200) continue; + if (!named && areaM2 > 600_000) continue; + if (named && areaM2 > 80_000_000) continue; + } + + const contains = pointInPolygon(targetLon, targetLat, coords); + if (mode === 'anchored' && !contains) { + const intentCoverage = queryWords.size + ? nameOverlap / queryWords.size + : 0; + if (!named || intentCoverage < 0.5 || completeness < 0.6) continue; + } + const centroid = ringCentroid(coords.map((p) => [p.lon, p.lat])); + const distanceM = centroid + ? approximateDistanceM(targetLat, targetLon, centroid.lat, centroid.lon) + : 9999; + + // Name match dominates; completeness breaks ties so the feature literally + // named "Presidio" beats a building that merely contains the word. Size is + // only a mild tiebreak now (no hard penalty against large named areas). + let score = + nameOverlap * 1000 + + completeness * 600 + + (contains ? 450 : 0) - + distanceM * 0.25 - + Math.sqrt(areaM2) * 0.05; + + // With no name match at all, prefer a precise building over a vague blob. + if (!named && isBuilding && contains) score += 250; + + if (score > bestScore) { + bestScore = score; + best = { + ring: closeRing(coords.map((p) => [p.lon, p.lat])), + kind: isBuilding ? 'building' : 'area', + heightM: isBuilding ? buildingHeightFromTags(tags, areaM2) : null, + }; + } + } + + return best; + } + + /** Estimate a building's height (m) from OSM tags, falling back to footprint size. */ + function buildingHeightFromTags(tags, areaM2) { + const explicit = parseMeters(tags.height || tags['building:height']); + if (explicit) return explicit; + const levels = Number.parseFloat(tags['building:levels']); + const roof = parseMeters(tags['roof:height']) || 0; + if (Number.isFinite(levels) && levels > 0) return levels * 3.3 + roof; + const side = Math.sqrt(Math.max(1, areaM2)); + return Math.max(10, Math.min(70, side * 0.6)); + } + + function parseMeters(value) { + if (value == null) return 0; + const n = Number.parseFloat(String(value).replace(',', '.')); + if (!Number.isFinite(n) || n <= 0) return 0; + return /\b(ft|feet|foot)\b/i.test(String(value)) ? n * 0.3048 : n; + } + + // --- geometry helpers ------------------------------------------------------- + + function elementCoordinates(element) { + if (Array.isArray(element.geometry)) { + return element.geometry.filter( + (p) => Number.isFinite(p?.lat) && Number.isFinite(p?.lon), + ); + } + if (!Array.isArray(element.members)) return []; + // For a multipolygon relation the boundary is split across several outer + // ways (the Presidio has 8), so chain them into one ring by matching + // endpoints rather than taking a single segment. + const outerWays = element.members + .filter( + (m) => (m.role === 'outer' || !m.role) && Array.isArray(m.geometry), + ) + .map((m) => + m.geometry.filter( + (p) => Number.isFinite(p?.lat) && Number.isFinite(p?.lon), + ), + ) + .filter((w) => w.length >= 2); + return stitchRing(outerWays); + } + + /** + * Stitch relation outer ways into a footprint ring. Builds ALL connected + * components (a multipolygon outer boundary is split across several ways), then + * returns the largest component whose endpoints actually meet — a true closed + * ring. If no component closes cleanly, it accepts the largest only when the + * endpoint gap is small relative to its own span (a minor seam); otherwise the + * relation geometry is incomplete and it returns [] rather than fabricate a + * straight chord across the gap (the reported bay-spanning blob). + */ + function stitchRing(ways) { + if (!ways.length) return []; + const components = buildRingComponents(ways); + if (!components.length) return []; + + const CLOSE_TOL_M = 30; // endpoints within 30 m → a genuinely closed ring + const closed = components.filter((c) => endpointGapM(c) <= CLOSE_TOL_M); + if (closed.length) { + // Pick by projected AREA, not vertex count — a small, highly-detailed island + // must not beat the large simple mainland. (For a disjoint multipolygon this + // returns only the largest closed outer ring; full multi-ring rendering is a + // renderer change tracked separately.) + return largestByArea(closed); + } + + // Nothing closes exactly. Bridge a seam ONLY when the gap is BOTH a small + // fraction of the feature's own span (so half a compound's boundary missing is + // rejected) AND under a hard absolute ceiling (so a state/country can never get a + // kilometres-long chord — the bay blob). A complete relation closes at ~0; a + // genuine seam (e.g. the Presidio's ~few-hundred-m coastline gap) bridges; a + // truly incomplete relation is rejected → point fallback. + const largest = largestByArea(components); + const allow = Math.min(ringSpanM(largest) * 0.15, 1200); + return endpointGapM(largest) <= allow ? largest : []; + } + + /** Largest component by projected area (each component is an array of {lat,lon}). */ + function largestByArea(components) { + let best = components[0]; + let bestArea = approximateAreaM2(best); + for (let i = 1; i < components.length; i += 1) { + const area = approximateAreaM2(components[i]); + if (area > bestArea) { + bestArea = area; + best = components[i]; + } + } + return best; + } + + /** Rough diameter (m) of a chain's bounding box. */ + function ringSpanM(chain) { + let minLat = Infinity; + let maxLat = -Infinity; + let minLon = Infinity; + let maxLon = -Infinity; + for (const p of chain) { + if (p.lat < minLat) minLat = p.lat; + if (p.lat > maxLat) maxLat = p.lat; + if (p.lon < minLon) minLon = p.lon; + if (p.lon > maxLon) maxLon = p.lon; + } + return approximateDistanceM(minLat, minLon, maxLat, maxLon); + } + + /** Chain ways into maximal connected components by endpoint matching. */ + function buildRingComponents(ways) { + const same = (a, b) => + Math.abs(a.lon - b.lon) < 1e-7 && Math.abs(a.lat - b.lat) < 1e-7; + const remaining = ways.map((w) => w.slice()); + const components = []; + while (remaining.length) { + let chain = remaining.shift(); + let grew = true; + while (grew) { + grew = false; + const head = chain[0]; + const tail = chain[chain.length - 1]; + for (let i = 0; i < remaining.length; i += 1) { + const w = remaining[i]; + const ws = w[0]; + const we = w[w.length - 1]; + if (same(tail, ws)) chain = chain.concat(w.slice(1)); + else if (same(tail, we)) + chain = chain.concat(w.slice(0, -1).reverse()); + else if (same(head, we)) chain = w.slice(0, -1).concat(chain); + else if (same(head, ws)) chain = w.slice(1).reverse().concat(chain); + else continue; + remaining.splice(i, 1); + grew = true; + break; + } + } + components.push(chain); + } + return components; + } + + /** Great-circle distance (m) between a chain's first and last vertex. */ + function endpointGapM(chain) { + if (!chain || chain.length < 2) return Infinity; + const a = chain[0]; + const b = chain[chain.length - 1]; + return approximateDistanceM(a.lat, a.lon, b.lat, b.lon); + } + + function closeRing(ring) { + if (ring.length < 3) return ring; + const [fx, fy] = ring[0]; + const [lx, ly] = ring[ring.length - 1]; + if (fx !== lx || fy !== ly) ring.push([fx, fy]); + return ring; + } + + function ringCentroid(ring) { + if (!ring || ring.length < 3) return null; + let sumLat = 0; + let sumLon = 0; + for (const [lon, lat] of ring) { + sumLat += lat; + sumLon += lon; + } + return { lat: sumLat / ring.length, lon: sumLon / ring.length }; + } + + function approximateAreaM2(coords) { + // Shoelace in a local equirectangular projection (good enough for buildings). + if (coords.length < 3) return 0; + const lat0 = coords[0].lat; + const mPerDegLat = 111_320; + const mPerDegLon = mPerDegLat * Math.cos(Cesium.Math.toRadians(lat0)); + let area = 0; + for (let i = 0, j = coords.length - 1; i < coords.length; j = i++) { + const xi = coords[i].lon * mPerDegLon; + const yi = coords[i].lat * mPerDegLat; + const xj = coords[j].lon * mPerDegLon; + const yj = coords[j].lat * mPerDegLat; + area += xj * yi - xi * yj; + } + return Math.abs(area) / 2; + } + + function approximateDistanceM(latA, lonA, latB, lonB) { + const latScale = 111_320; + const lonScale = + latScale * Math.cos(Cesium.Math.toRadians((latA + latB) / 2)); + return Math.hypot((latB - latA) * latScale, (lonB - lonA) * lonScale); + } + + function pointInPolygon(lon, lat, coords) { + let inside = false; + for (let i = 0, j = coords.length - 1; i < coords.length; j = i++) { + const a = coords[i]; + const b = coords[j]; + const intersects = + a.lat > lat !== b.lat > lat && + lon < + ((b.lon - a.lon) * (lat - a.lat)) / + (b.lat - a.lat || Number.EPSILON) + + a.lon; + if (intersects) inside = !inside; + } + return inside; + } + + 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), + }; + } + + /** + * 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. Natural Earth pack first (offline, + * instant; largest-area match is correct for a global name-only ask), 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. + * + * @param {string} name e.g. "Texas", "the Alps", "France", "Gulf of Mexico" + * @param {AbortSignal} [signal] + * @returns {Promise<{name:string, ring:Array<[number,number]>}|null>} + */ + async function resolveRegionRingForQuery( + name, + signal, + placeSearch = unavailablePlaceSearch, + ) { + 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); + if (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 }; + } + const geo = await geocodePlace(q, null, signal, placeSearch).catch( + () => null, + ); + if (!geo) return null; + const scope = scopeFromTypes(geo.types); + if (!['country', 'state', 'county', 'city'].includes(scope)) return null; + const fp = await fetchAdminArea(geo.lat, geo.lon, q, scope, signal).catch( + () => null, + ); + if (fp?.ring?.length >= 3) return { name: q, ring: fp.ring }; + 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, + }; +} diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index 40a52e781..184993db8 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -47,6 +47,7 @@ const SOURCE_METHODS = Object.freeze({ * The manager owns layer destruction, while abort releases classification even if startup fails. */ export function createApplicationCatalog({ + surface, sources, signal, metadata = LAYER_STATE_REGISTRY, @@ -56,6 +57,9 @@ export function createApplicationCatalog({ if (!signal?.addEventListener) throw new TypeError('An application lifetime signal is required'); signal.throwIfAborted(); + if (!surface?.groundFloor || !surface?.terrain) + throw new TypeError('Application surface services are required'); + for (const [name, methods] of Object.entries(SOURCE_METHODS)) { if ( methods.some((method) => typeof sources?.[name]?.[method] !== 'function') @@ -71,11 +75,13 @@ export function createApplicationCatalog({ try { militaryRegistry.configureSource(sources.military, { signal }); const flights = createApplicationFlights({ + surface, source: sources.flights, militaryRegistry, resolveAsset, }); const military = createApplicationMilitary({ + surface, source: sources.military, militaryRegistry, resolveAsset, @@ -85,6 +91,7 @@ export function createApplicationCatalog({ options: vesselOptions, }); const installations = createApplicationInstallations({ + surface, source: sources.installations, }); const satellites = createApplicationSatellites({ @@ -95,12 +102,12 @@ export function createApplicationCatalog({ flights, military, createApplicationEarthquakes({ source: sources.earthquakes }), - createApplicationAlpr({ source: sources.alpr }), + createApplicationAlpr({ surface, source: sources.alpr }), satellites, createApplicationLaunches({ source: sources.launches, satellites }), createApplicationTraffic({ source: sources.traffic }), - createApplicationCctv({ source: sources.cctv }), - createApplicationRadio({ source: sources.radio }), + createApplicationCctv({ surface, source: sources.cctv }), + createApplicationRadio({ surface, source: sources.radio }), createApplicationBikeshare({ source: sources.bikeshare }), vessels, installations, @@ -113,6 +120,7 @@ export function createApplicationCatalog({ ...createInfrastructureLayers(localGeoJsonServices), createApplicationCables({ source: sources.cables }), createApplicationFirms({ + surface, id: 'local-firms', name: 'FIRMS Active Fires', icon: '▲', @@ -122,7 +130,7 @@ export function createApplicationCatalog({ ], metadata, ); - return Object.freeze({ ...catalog, militaryRegistry }); + return Object.freeze({ ...catalog, militaryRegistry, surface }); } catch (error) { dispose(); throw error; diff --git a/src/app/constructCatalog.test.mjs b/src/app/constructCatalog.test.mjs index 44fc7766c..57da9e219 100644 --- a/src/app/constructCatalog.test.mjs +++ b/src/app/constructCatalog.test.mjs @@ -1,3 +1,4 @@ +import { createSurfaceServices } from './surfaceServices.js'; import test from 'node:test'; import assert from 'node:assert/strict'; import { createApplicationCatalog } from './constructCatalog.js'; @@ -30,10 +31,12 @@ test('catalogs construct distinct layers and classification from their supplied const first = createApplicationCatalog({ sources: fixtureSources(['abc123'], callsA), signal: a.signal, + surface: fixtureSurface(a.signal), }); const second = createApplicationCatalog({ sources: fixtureSources(['def456'], callsB), signal: b.signal, + surface: fixtureSurface(b.signal), }); assert.equal(first.layers.length, 17); assert.deepEqual( @@ -62,7 +65,12 @@ test('catalogs construct distinct layers and classification from their supplied test('invalid or already cancelled construction fails before classification can acquire', () => { const lifetime = new AbortController(); assert.throws( - () => createApplicationCatalog({ sources: {}, signal: lifetime.signal }), + () => + createApplicationCatalog({ + sources: {}, + signal: lifetime.signal, + surface: fixtureSurface(lifetime.signal), + }), /catalog source/, ); lifetime.abort(); @@ -75,3 +83,11 @@ test('invalid or already cancelled construction fails before classification can { name: 'AbortError' }, ); }); + +function fixtureSurface(signal) { + return createSurfaceServices({ + terrainSource: { getHeights: async () => [] }, + signal, + eventTarget: null, + }); +} diff --git a/src/app/controls.js b/src/app/controls.js index 0b02debbe..8d9b988b5 100644 --- a/src/app/controls.js +++ b/src/app/controls.js @@ -5,7 +5,7 @@ import { initCockpitCloudEffects } from '../cockpitCloudEffects.js'; /** Construct the existing controls and camera presentation. */ export function createApplicationControls({ - scene: { viewer, mapStackController }, + scene: { viewer, mapStackController, operations }, loaderStatus, Controls = StyleManager, services, @@ -15,7 +15,15 @@ export function createApplicationControls({ }) { // Initialize the style manager (post-processing, HUD, locations, share links) const styleManager = new Controls(viewer, { - services: { ...services, ...catalogControlServices(catalog) }, + services: { + ...services, + ...operations.surface.controlServices, + searchAndFlyTo: operations.searchAndFlyTo, + fetchRegionalBrief: (...args) => + operations.requests.regional.getBrief(...args), + ...catalogControlServices(catalog), + }, + requestServices: operations.requests, mapStackController, placeSearch, }); @@ -26,7 +34,9 @@ export function createApplicationControls({ // clouds use a separate, capped low-resolution GPU pass that never attaches // Cesium fog or post-process stages and is fully stopped in map mode. const weatherEffects = null; - const cockpitCloudEffects = initCockpitCloudEffects(viewer); + const cockpitCloudEffects = initCockpitCloudEffects(viewer, { + weatherService: operations.requests.weather, + }); defer(() => cockpitCloudEffects?.destroy()); // If no share link state, do default fly-to Austin diff --git a/src/app/layers/alprCameras.js b/src/app/layers/alprCameras.js index 4930d75f8..c21de9412 100644 --- a/src/app/layers/alprCameras.js +++ b/src/app/layers/alprCameras.js @@ -2,10 +2,10 @@ import { createAlprCamerasLayer } from '../../layers/alpr/index.js'; import * as render from '../../renderGovernor.js'; import * as context from '../../data/contextStore.js'; import * as picking from '../../data/pickRegistry.js'; -import * as groundFloor from '../../data/groundFloor.js'; /** Construct one layer using the application scene owners and a supplied source. */ -export function createApplicationAlpr({ source }) { +export function createApplicationAlpr({ surface, source }) { + const { groundFloor } = surface; return createAlprCamerasLayer({ source, services: { render, context, picking, groundFloor }, diff --git a/src/app/layers/cctv.js b/src/app/layers/cctv.js index 0d2f1cbaf..96b80ce8a 100644 --- a/src/app/layers/cctv.js +++ b/src/app/layers/cctv.js @@ -4,14 +4,12 @@ import * as activation from '../../cctvFocusRequest.js'; import * as overlays from '../../overlays/worldOverlay.js'; import * as locations from '../../locations.js'; import * as picking from '../../data/pickRegistry.js'; -import * as terrain from '../../data/terrainHeights.js'; -import * as ground from '../../data/groundFloor.js'; -import * as mesh from '../../data/meshFloorSampler.js'; import * as focus from '../../data/focusDeemphasis.js'; import * as render from '../../renderGovernor.js'; /** Construct one layer using the application scene owners and a supplied source. */ -export function createApplicationCctv({ source }) { +export function createApplicationCctv({ surface, source }) { + const { terrain, groundFloor: ground, meshFloor: mesh } = surface; return createCctvLayer({ source, services: { diff --git a/src/app/layers/firms.js b/src/app/layers/firms.js index 8be70b206..6e6414878 100644 --- a/src/app/layers/firms.js +++ b/src/app/layers/firms.js @@ -4,7 +4,6 @@ import * as render from '../../renderGovernor.js'; import * as sprites from '../../data/spriteOrder.js'; import * as context from '../../data/contextStore.js'; import * as picking from '../../data/pickRegistry.js'; -import * as anchors from '../../data/fireAnchors.js'; import * as overlays from '../../overlays/worldOverlay.js'; import * as focus from '../../worldFocus.js'; export const firmsServices = { @@ -12,19 +11,18 @@ export const firmsServices = { sprites, context, picking, - anchors, overlays, focus, }; /** Bind a supplied fire feed to the application scene service owners. */ -export function createApplicationFirms(options) { +export function createApplicationFirms({ surface, ...options }) { return createLayer({ ...options, icon: options.icon ?? '▲', source: options.source ?? 'NASA FIRMS', feed: options.feed, - services: firmsServices, + services: { ...firmsServices, anchors: surface.anchors }, overlayHost: options.overlayHost ?? { setEntries: overlays.setOverlayEntries, setVisible: overlays.setOverlaySourceVisible, diff --git a/src/app/layers/flights.js b/src/app/layers/flights.js index e56e3d32a..38f880a05 100644 --- a/src/app/layers/flights.js +++ b/src/app/layers/flights.js @@ -5,23 +5,22 @@ import * as trails from '../../data/trailRenderer.js'; import * as aircraftPresentation from '../../data/tr3bRegistry.js'; import * as camera from '../../data/trackedCamera.js'; import * as labels from '../../data/detectionDraw.js'; -import * as groundFloor from '../../data/groundFloor.js'; -import * as meshFloor from '../../data/meshFloorSampler.js'; import * as geoid from '../../data/geoid.js'; import * as focus from '../../data/focusDeemphasis.js'; import * as readout from '../../data/trackedReadout.js'; import * as context from '../../data/contextStore.js'; import * as render from '../../renderGovernor.js'; -import * as groundSnap from '../../data/groundSnap.js'; import * as recession from '../../data/aircraftRecession.js'; /** Construct one layer using the application scene owners and a supplied source. */ export function createApplicationFlights({ + surface, source, militaryRegistry, resolveAsset = (url) => `${import.meta.env?.BASE_URL || '/'}${url.replace(/^\//, '')}`, }) { + const { groundFloor, meshFloor, groundSnap } = surface; return createCivilFlightLayer({ source, resolveAsset, diff --git a/src/app/layers/militaryFlights.js b/src/app/layers/militaryFlights.js index 61fea4662..839220c54 100644 --- a/src/app/layers/militaryFlights.js +++ b/src/app/layers/militaryFlights.js @@ -5,23 +5,22 @@ import * as trails from '../../data/trailRenderer.js'; import * as aircraftPresentation from '../../data/tr3bRegistry.js'; import * as camera from '../../data/trackedCamera.js'; import * as labels from '../../data/detectionDraw.js'; -import * as groundFloor from '../../data/groundFloor.js'; -import * as meshFloor from '../../data/meshFloorSampler.js'; import * as geoid from '../../data/geoid.js'; import * as focus from '../../data/focusDeemphasis.js'; import * as readout from '../../data/trackedReadout.js'; import * as context from '../../data/contextStore.js'; import * as render from '../../renderGovernor.js'; -import * as groundSnap from '../../data/groundSnap.js'; import * as recession from '../../data/aircraftRecession.js'; /** Construct one layer using the application scene owners and a supplied source. */ export function createApplicationMilitary({ + surface, source, militaryRegistry, resolveAsset = (url) => `${import.meta.env?.BASE_URL || '/'}${url.replace(/^\//, '')}`, }) { + const { groundFloor, meshFloor, groundSnap } = surface; return createMilitaryFlightLayer({ source, resolveAsset, diff --git a/src/app/layers/militaryInstallations.js b/src/app/layers/militaryInstallations.js index b772bd3ac..3e1edf5e3 100644 --- a/src/app/layers/militaryInstallations.js +++ b/src/app/layers/militaryInstallations.js @@ -1,12 +1,11 @@ import { createInstallationsLayer } from '../../layers/installations/index.js'; import * as render from '../../renderGovernor.js'; import * as context from '../../data/contextStore.js'; -import * as ground from '../../data/groundFloor.js'; -import * as anchors from '../../data/fireAnchors.js'; import * as picking from '../../data/pickRegistry.js'; /** Construct one layer using the application scene owners and a supplied source. */ -export function createApplicationInstallations({ source }) { +export function createApplicationInstallations({ surface, source }) { + const { groundFloor: ground, anchors } = surface; return createInstallationsLayer({ source, services: { render, context, ground, anchors, picking }, diff --git a/src/app/layers/radio.js b/src/app/layers/radio.js index 3474996dd..2332a04f6 100644 --- a/src/app/layers/radio.js +++ b/src/app/layers/radio.js @@ -1,12 +1,12 @@ import { createRadioLayer } from '../../layers/radio/index.js'; -import * as ground from '../../data/groundFloor.js'; import * as picking from '../../data/pickRegistry.js'; import * as overlays from '../../overlays/worldOverlay.js'; import * as globe from '../../celestialRing.js'; import * as render from '../../renderGovernor.js'; /** Construct one layer using the application scene owners and a supplied source. */ -export function createApplicationRadio({ source }) { +export function createApplicationRadio({ surface, source }) { + const { groundFloor: ground } = surface; return createRadioLayer({ source, services: { ground, picking, overlays, globe, render }, diff --git a/src/app/operations.js b/src/app/operations.js new file mode 100644 index 000000000..d284ff210 --- /dev/null +++ b/src/app/operations.js @@ -0,0 +1,37 @@ +import { createSurfaceServices } from './surfaceServices.js'; +import { createAnnotationResolver } from '../annotations/resolver.js'; +import { searchAndFlyTo } from '../locations.js'; + +/** Assemble application operations from the caller's request services. */ +export function createApplicationOperations({ requests, signal, eventTarget }) { + for (const [name, method] of Object.entries({ + boundaries: 'query', + terrain: 'getHeights', + regional: 'getBrief', + weather: 'getConditions', + summary: 'summarize', + })) { + if (typeof requests?.[name]?.[method] !== 'function') + throw new TypeError(`Missing application request service: ${name}`); + } + const surface = createSurfaceServices({ + terrainSource: requests.terrain, + signal, + eventTarget, + }); + const annotationResolver = createAnnotationResolver({ + boundarySource: requests.boundaries, + signal, + }); + return Object.freeze({ + requests, + surface, + annotationResolver, + searchAndFlyTo: (viewer, query, options = {}) => + searchAndFlyTo(viewer, query, { + ...options, + boundaries: requests.boundaries, + recoverNearView: annotationResolver.placesNearViewRecovery, + }), + }); +} diff --git a/src/app/operations.test.mjs b/src/app/operations.test.mjs new file mode 100644 index 000000000..fa1424750 --- /dev/null +++ b/src/app/operations.test.mjs @@ -0,0 +1,165 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createApplicationOperations } from './operations.js'; + +function requests(terrain, boundaries = { query: async () => [] }) { + return { + terrain, + boundaries, + regional: { getBrief: async () => ({}) }, + weather: { getConditions: async () => ({}) }, + summary: { summarize: async () => ({}) }, + }; +} + +test('surface cache and stack listeners belong to the supplied application lifetime', async (t) => { + const a = new AbortController(); + const b = new AbortController(); + t.after(() => { + a.abort(); + b.abort(); + }); + const target = new EventTarget(); + let calls = 0; + const first = createApplicationOperations({ + signal: a.signal, + eventTarget: target, + requests: requests({ + async getHeights(points) { + calls++; + return points.map(() => ({ ellipsoid: 123 })); + }, + }), + }); + const second = createApplicationOperations({ + signal: b.signal, + eventTarget: target, + requests: requests({ + async getHeights(points) { + return points.map(() => ({ ellipsoid: 456 })); + }, + }), + }); + const point = { lat: 30, lon: -97 }; + await first.surface.terrain.resolveEllipsoidalGround([point]); + await first.surface.terrain.resolveEllipsoidalGround([point]); + await second.surface.terrain.resolveEllipsoidalGround([point]); + assert.equal(calls, 1); + assert.equal(first.surface.groundFloor.cachedGroundFloor(30, -97), 123); + assert.equal(second.surface.groundFloor.cachedGroundFloor(30, -97), 456); + target.dispatchEvent( + new CustomEvent('gev:map-stack-changed', { + detail: { activeId: 'esri-imagery' }, + }), + ); + assert.equal(first.surface.groundFloor.meshFloorPreferred(), false); + a.abort(); + assert.equal(first.surface.terrain.cachedEllipsoidalGround(30, -97), null); + target.dispatchEvent( + new CustomEvent('gev:map-stack-changed', { + detail: { activeId: 'photoreal' }, + }), + ); + assert.equal( + first.surface.groundFloor.meshFloorPreferred(), + false, + 'disposed listener cannot update the old owner', + ); + assert.equal(second.surface.groundFloor.meshFloorPreferred(), true); +}); + +test('a terrain reply after application abort cannot refill the cache or become a fallback', async () => { + const lifetime = new AbortController(); + let respond; + let receivedSignal; + const operations = createApplicationOperations({ + signal: lifetime.signal, + eventTarget: null, + requests: requests({ + getHeights(_points, { signal }) { + receivedSignal = signal; + return new Promise((resolve) => { + respond = resolve; + }); + }, + }), + }); + const pending = operations.surface.terrain.resolveEllipsoidalGround([ + { lat: 30, lon: -97 }, + ]); + lifetime.abort(); + respond([{ ellipsoid: 123 }]); + await assert.rejects(pending, { name: 'AbortError' }); + assert.equal(receivedSignal.aborted, true); + assert.equal( + operations.surface.terrain.cachedEllipsoidalGround(30, -97), + null, + ); +}); + +test('annotation lookup and cached outlines use the supplied source independently', async (t) => { + const priorWindow = globalThis.window; + globalThis.window = { setTimeout, clearTimeout }; + const a = new AbortController(); + const b = new AbortController(); + t.after(() => { + a.abort(); + b.abort(); + if (priorWindow === undefined) delete globalThis.window; + else globalThis.window = priorWindow; + }); + const calls = [0, 0]; + const make = (signal, index) => + createApplicationOperations({ + signal, + eventTarget: null, + requests: requests( + { getHeights: async () => [] }, + { + query: async () => { + calls[index]++; + const delta = index * 0.0001; + return [ + { + type: 'way', + id: index + 1, + tags: { building: 'yes', name: 'Source fixture hall' }, + geometry: [ + { lat: 30 - 0.0001, lon: -97 - 0.0001 + delta }, + { lat: 30 - 0.0001, lon: -97 + 0.0001 + delta }, + { lat: 30 + 0.0001, lon: -97 + 0.0001 + delta }, + { lat: 30 + 0.0001, lon: -97 - 0.0001 + delta }, + { lat: 30 - 0.0001, lon: -97 - 0.0001 + delta }, + ], + }, + ]; + }, + }, + ), + }); + const first = make(a.signal, 0); + const second = make(b.signal, 1); + const query = { + viewer: {}, + target: 'Source fixture hall', + latitude: 30, + longitude: -97, + footprint: true, + entityKind: 'building', + }; + const resultA = await first.annotationResolver.resolveAnnotationTarget(query); + const resultB = + await second.annotationResolver.resolveAnnotationTarget(query); + assert.ok(resultA.ring?.length >= 4); + assert.ok(resultB.ring?.length >= 4); + assert.notDeepEqual(resultA.ring, resultB.ring); + const before = calls[0]; + const cached = await first.annotationResolver.resolveAnnotationTarget(query); + assert.deepEqual(cached.ring, resultA.ring); + assert.equal(calls[0], before); + a.abort(); + await assert.rejects( + first.annotationResolver.resolveAnnotationTarget(query), + { name: 'AbortError' }, + ); +}); diff --git a/src/app/scene.js b/src/app/scene.js index 430fbb53a..c1a9e178a 100644 --- a/src/app/scene.js +++ b/src/app/scene.js @@ -1,3 +1,4 @@ +import { createApplicationOperations } from './operations.js'; import * as Cesium from 'cesium'; import { createApplicationViewer } from '../app/viewer.js'; import { registerDataCredits } from '../data/dataCredits.js'; @@ -13,6 +14,7 @@ import { describeError } from './errors.js'; /** Construct the application globe using the caller's local configuration. */ export async function createApplicationScene({ + requestServices, googleApiKey, cesiumToken, credits, @@ -22,6 +24,10 @@ export async function createApplicationScene({ signal, defer, }) { + const operations = createApplicationOperations({ + requests: requestServices, + signal, + }); defer(initLogoGaze()); const previousKey = window.__GOOGLE_MAPS_API_KEY__; if (googleApiKey) { @@ -109,5 +115,5 @@ export async function createApplicationScene({ }); signal.throwIfAborted(); - return { viewer, tileset, mapStackController }; + return { viewer, tileset, mapStackController, operations }; } diff --git a/src/app/surfaceServices.js b/src/app/surfaceServices.js new file mode 100644 index 000000000..0b1bfaa59 --- /dev/null +++ b/src/app/surfaceServices.js @@ -0,0 +1,37 @@ +import { createTerrainHeights } from '../services/terrainHeights.js'; +import { createGroundFloor } from '../services/groundFloor.js'; +import { createMeshFloorSampler } from '../services/meshFloorSampler.js'; +import { createGroundSnap } from '../services/groundSnap.js'; +import { createFireAnchors } from '../layers/firms/anchors.js'; + +/** Own the DEM, coarse floors and mesh samples used by one application. */ +export function createSurfaceServices({ + terrainSource, + signal, + eventTarget = globalThis.window, +}) { + const terrain = createTerrainHeights({ source: terrainSource, signal }); + const groundFloor = createGroundFloor({ terrain, signal }); + const meshFloor = createMeshFloorSampler({ + groundFloor, + signal, + eventTarget, + }); + return Object.freeze({ + terrain, + groundFloor, + meshFloor, + groundSnap: Object.freeze({ + createGroundSnap: () => createGroundSnap({ groundFloor }), + }), + anchors: createFireAnchors(groundFloor), + controlServices: Object.freeze({ + cachedGroundFloor: groundFloor.cachedGroundFloor, + cachedMeshFloor: groundFloor.cachedMeshFloor, + GROUND_FLOOR_LIFT_M: groundFloor.GROUND_FLOOR_LIFT_M, + meshFloorPreferred: groundFloor.meshFloorPreferred, + warmGroundFloor: groundFloor.warmGroundFloor, + sampleMeshFloorCells: meshFloor.sampleMeshFloorCells, + }), + }); +} diff --git a/src/app/tools.js b/src/app/tools.js index 192f02ef7..497b60720 100644 --- a/src/app/tools.js +++ b/src/app/tools.js @@ -23,13 +23,18 @@ export function createApplicationTools({ signal, defer, }) { - const { viewer, tileset, mapStackController } = scene; + const { viewer, tileset, mapStackController, operations } = scene; const { styleManager, weatherEffects, cockpitCloudEffects } = controls; const { dataManager } = data; const sceneDirector = new SceneDirector(viewer, styleManager, dataManager); defer(() => sceneDirector.destroy()); onSceneDirector?.(sceneDirector); - const annotations = initAnnotations({ viewer, tileset, placeSearch }); + const annotations = initAnnotations({ + viewer, + tileset, + placeSearch, + resolver: operations.annotationResolver, + }); defer(() => { if (window.__gevAnnotations === annotations) delete window.__gevAnnotations; annotations.destroy(); @@ -95,6 +100,7 @@ export function createApplicationTools({ weatherEffects, cockpitCloudEffects, getRenderGovernorDiagnostics, + surfaceServices: operations.surface, requestRender: governorRequestRender, }; const debug = window.__godsEyeView; @@ -103,6 +109,9 @@ export function createApplicationTools({ }); const voiceCommands = initGevVoiceCommands({ ...voice, + floorServices: operations.surface.groundFloor, + annotationResolver: operations.annotationResolver, + searchNavigation: operations.searchAndFlyTo, signal, placeSearch, viewer, diff --git a/src/cameraHandoff.test.mjs b/src/cameraHandoff.test.mjs index 2b8d3367f..2d2580120 100644 --- a/src/cameraHandoff.test.mjs +++ b/src/cameraHandoff.test.mjs @@ -241,7 +241,7 @@ test('validated voice camera destinations share the UI navigation authority faca // The corridor warm is injected the same way the floor READ is — the dolly // never reaches into the data layer itself, and the voice dispatch is the one // place that binds both. - assert.match(voice, /\(lat, lon\) => cachedGroundFloor\(lat, lon\),[\s\S]{0,200}?\(cells\) => warmGroundFloor\(cells\),/); + assert.match(voice, /\(lat, lon\) => floorServices\.cachedGroundFloor\(lat, lon\),[\s\S]{0,200}?\(cells\) => floorServices\.warmGroundFloor\(cells\),/); }); test('deferred search releases only after its final authority check', () => { diff --git a/src/cockpitCloudEffects.js b/src/cockpitCloudEffects.js index b57b56425..8c5a69e46 100644 --- a/src/cockpitCloudEffects.js +++ b/src/cockpitCloudEffects.js @@ -166,7 +166,8 @@ export function cockpitWeatherEnabledFromStoredValue(value) { * active. It owns no Cesium fog/post-process stages and cannot affect map mode. */ export class CockpitCloudEffectsController { - constructor(viewer) { + constructor(viewer, { weatherService = applicationServices.weather } = {}) { + this.weatherService = weatherService; this.viewer = viewer; this.canvas = document.createElement('canvas'); this.canvas.id = 'cockpit-cloud-effects'; @@ -369,7 +370,7 @@ export class CockpitCloudEffectsController { this.abort?.abort(); this.abort = new AbortController(); - const pending = applicationServices.weather.getConditions(point.latitude, point.longitude, { signal: this.abort.signal }); + const pending = this.weatherService.getConditions(point.latitude, point.longitude, { signal: this.abort.signal }); const request = this.abort; this.pending = pending.then(async (payload) => { if (this.abort !== request || request.signal.aborted) return null; @@ -522,6 +523,6 @@ export class CockpitCloudEffectsController { } } -export function initCockpitCloudEffects(viewer) { - return new CockpitCloudEffectsController(viewer); +export function initCockpitCloudEffects(viewer, options) { + return new CockpitCloudEffectsController(viewer, options); } diff --git a/src/data/alprCameras.js b/src/data/alprCameras.js index 5f5d05a12..5f02d1933 100644 --- a/src/data/alprCameras.js +++ b/src/data/alprCameras.js @@ -1,3 +1,4 @@ +import { defaultSurface } from './surfaceServices.js'; import { createApplicationAlpr } from '../app/layers/alprCameras.js'; import { createSourceSlot } from '../app/sourceSlot.js'; import { createOverpassAlprSource } from '../layers/alpr/index.js'; @@ -9,5 +10,6 @@ const slot = createSourceSlot( ); export const configureAlprSource = slot.configure; export default createApplicationAlpr({ + surface: defaultSurface, source: slot.source, }); diff --git a/src/data/cctv.js b/src/data/cctv.js index 069cb377c..18bbc5225 100644 --- a/src/data/cctv.js +++ b/src/data/cctv.js @@ -1,3 +1,4 @@ +import { defaultSurface } from './surfaceServices.js'; import { createApplicationCctv } from '../app/layers/cctv.js'; import { createSourceSlot } from '../app/sourceSlot.js'; import { createCctvSource } from '../layers/cctv/source.js'; @@ -9,6 +10,7 @@ const sourceSlot = createSourceSlot( ); export const configureCctvSource = sourceSlot.configure; const layer = createApplicationCctv({ + surface: defaultSurface, source: sourceSlot.source, }); export const calibrationPatchMovesAnchor = layer.calibrationPatchMovesAnchor; diff --git a/src/data/firmsHeatmap.js b/src/data/firmsHeatmap.js index 89ac3259e..001687446 100644 --- a/src/data/firmsHeatmap.js +++ b/src/data/firmsHeatmap.js @@ -1,3 +1,4 @@ +import { defaultSurface } from './surfaceServices.js'; import { createApplicationFirms, firmsServices } from '../app/layers/firms.js'; import { createSourceSlot } from '../app/sourceSlot.js'; import { @@ -10,7 +11,9 @@ const sourceSlot = createSourceSlot( 'Fire source', ); export const configureFirmsSource = sourceSlot.configure; -const helpers = createFirmsHelpers({ services: firmsServices }); +const helpers = createFirmsHelpers({ + services: { ...firmsServices, anchors: defaultSurface.anchors }, +}); export const mapAnalystRecord = helpers.mapAnalystRecord; export const fireCullPosition = helpers.fireCullPosition; export const applyHorizonCull = helpers.applyHorizonCull; @@ -22,6 +25,7 @@ export const applyFirmsOverlayPolicy = helpers.applyFirmsOverlayPolicy; /** Retain the existing default feed and helper exports for direct callers. */ export function createFirmsHeatmapLayer(options) { return createApplicationFirms({ + surface: defaultSurface, ...options, feed: options.feed ?? sourceSlot.source, }); diff --git a/src/data/flights.js b/src/data/flights.js index 47aa2e4cb..0b4d38d3b 100644 --- a/src/data/flights.js +++ b/src/data/flights.js @@ -1,9 +1,11 @@ +import { defaultSurface } from './surfaceServices.js'; import { createApplicationFlights } from '../app/layers/flights.js'; import { createOpenSkySource } from '../sources/live/standalone.js'; import * as militaryRegistry from './militaryRegistry.js'; const flightsLayer = createApplicationFlights({ + surface: defaultSurface, source: createOpenSkySource(), militaryRegistry, }); diff --git a/src/data/groundFloor.js b/src/data/groundFloor.js index 8e66c4983..0507951bf 100644 --- a/src/data/groundFloor.js +++ b/src/data/groundFloor.js @@ -1,580 +1,4 @@ -// src/data/groundFloor.js — coarse ground-floor clamp for entity render -// heights (field-test round 2026-07-06). -// -// Two owner findings drove this module: -// - RS46 (military H60): baro-only low-altitude contacts near steep terrain -// render INSIDE the hillside (no alt_geom → baro+N is off by more than the -// local relief). -// - WAKE01 (military B212): trail waypoints flagged 'ground' rendered at a -// fixed 50 m ellipsoidal sentinel — ~1.5 km underground at Kirtland AFB -// (field elevation ~1590 m ellipsoidal). -// -// The fix everywhere is the same: never render below the local ellipsoidal -// surface. This module provides the shared floor: a COARSE (3-decimal, -// ~111 m) grid over Task 3's terrainHeights resolver/cache, so that moving -// aircraft and multi-hundred-point trails collapse onto a small set of cells -// with heavy cache reuse (the exact 5-decimal grid would mint a new cache key -// every fix — the taxiing-cache lesson from the adversarial review). -// A ~111 m cell means the floor can be off by the relief within the cell in -// steep terrain; the clamp is a visual floor, not a survey — lifting an icon -// a few metres short of a ridge line still beats burying it. -// -// Zero-network on the render path: reads are synchronous cache lookups; -// `warmGroundFloor` batches the misses through resolveEllipsoidalGround -// (single-flight, fire-and-forget) exactly like flights.js's grounded-surface -// warm — results land for later polls/appends. -import { cachedRealEllipsoidalGround, resolveEllipsoidalGround } from './terrainHeights.js'; - -/** @constant {number} Metres added above the resolved floor so a clamped - * billboard/waypoint sits ON the surface instead of z-fighting it. */ -export const GROUND_FLOOR_LIFT_M = 1.5; -/** Accepted rendered-mesh window around a real ellipsoidal DEM prior. */ -export const MESH_FLOOR_BELOW_PRIOR_M = 15; -export const MESH_FLOOR_ABOVE_PRIOR_M = 80; - -/** - * Snaps a coordinate to the coarse (3-decimal, ~111 m) floor grid. - * @param {number} lat - * @param {number} lon - * @returns {{lat: number, lon: number}} Grid-cell coordinate. - */ -export function coarseFloorCoord(lat, lon) { - return { lat: Number(lat.toFixed(3)), lon: Number(lon.toFixed(3)) }; -} - -/** - * Pure floor clamp. - * - finite alt + finite ground → max(alt, ground + lift) - * - finite alt + unknown ground → alt (no data, no clamp) - * - null alt + finite ground → ground + lift (the 'ground'-waypoint case: - * the point IS on the surface; put it there) - * - null alt + unknown ground → null (caller applies its own fallback) - * @param {number|null|undefined} altM - Proposed ellipsoidal render height. - * @param {number|null|undefined} groundM - Local ellipsoidal ground, if known. - * @param {number} [liftM] - Clearance above the floor. - * @returns {number|null} - */ -export function floorAltitudeM(altM, groundM, liftM = GROUND_FLOOR_LIFT_M) { - const hasAlt = Number.isFinite(altM); - const hasGround = Number.isFinite(groundM); - if (hasAlt && hasGround) return Math.max(altM, groundM + liftM); - if (hasAlt) return altM; - if (hasGround) return groundM + liftM; - return null; -} - -/** - * Floor decision at the point of DISPLAY (2026-08-19). - * - * `floorAltitudeM` above answers "what height should this FIX render at" and - * runs once per poll. But what the user sees is the DEAD-RECKONED position: - * a Cartesian interpolation between two fixes for the whole segment, and a - * velocity extrapolation of the newest fix while a contact coasts through its - * stale-feed grace. Both drift the rendered point horizontally away from the - * fix whose cell supplied the height — across a graded apron (KAUS spans - * ~119–140 m ellipsoidal) a taxiing sprite therefore renders under the mesh it - * has drifted over (measured −15.5 m; `scripts/qa-floor-verify.mjs`). - * - * This is the same "never below the visible surface" rule applied at the - * displayed coordinate instead of the fix coordinate. It returns `null` rather - * than the unchanged height whenever nothing should move, so the per-frame - * caller can skip rebuilding a Cartesian entirely — the common case by far. - * - * @param {number|null|undefined} displayHeightM - Ellipsoidal height the - * dead-reckoned display position currently carries. - * @param {number|null|undefined} floorM - Ellipsoidal floor at the DISPLAYED - * coordinate (`cachedGroundFloor`), or null when that cell is not warm. - * @param {number} [liftM] - Clearance above the floor. - * @returns {number|null} The height to render at, or null for "leave it alone". - */ -export function displayFloorHeightM(displayHeightM, floorM, liftM = GROUND_FLOOR_LIFT_M) { - if (!Number.isFinite(displayHeightM) || !Number.isFinite(floorM)) return null; - const lifted = floorM + liftM; - return displayHeightM < lifted ? lifted : null; -} - -/** @constant {number} Pitch of the coarse floor grid in degrees — the spacing - * `coarseFloorCoord` rounds to, and therefore the step between adjacent cells. */ -export const FLOOR_CELL_DEG = 0.001; - -/** @constant {number} Resolved neighbours required before one may be borrowed. - * A single reading cannot be checked against anything: a hangar roof and an - * apron are both "a warm cell 111 m away", and there is no way to tell them - * apart from one number. Two readings at least agree or disagree. */ -export const NEIGHBOR_FLOOR_MIN_SAMPLES = 2; - -/** - * A floor for `cell` borrowed from the eight cells ADJACENT to it. - * - * Used only as a stand-in while `cell` itself is unresolved (2026-08-21: the - * Re:Earth proxy timed out four times in a row and a grounded contact standing - * on a cold cell rendered at the geoid — tens of metres under the mesh it was - * parked on). A neighbour is ~111 m away; on the aprons and taxiways where - * grounded contacts live that is the same surface, and it is a MEASURED height - * rather than an invented one. - * - * Leans to the LOWEST resolved neighbour, and refuses to answer at all from - * fewer than NEIGHBOR_FLOOR_MIN_SAMPLES of them. - * - * An earlier cut leaned HIGH, reasoning from the locked "never below the - * visible surface" principle. That principle is about a contact's OWN measured - * ground; applied to a BORROWED cell it inverts, and an owner playtest found - * why — planes floating in midair at terminal gates. The two errors are not - * symmetric: - * - Too LOW is inert. `displayFloorHeightM` only ever RAISES a position, so a - * floor under the contact simply does not lift it — the same "no clamp" the - * cold-cell case already has, bounded by one cell of grade (metres). - * - Too HIGH actively invents a position. It is bounded by BUILDING HEIGHT, - * not by grade, and a parked contact holds it: measured on the A/B rig, - * a lone roof neighbour put a contact 29.5 m up and left it there - * permanently when its own cell never warmed, and 6.3 s of visible - * stair-stepping when it did. - * A plane at a gate is on the apron, never on the terminal roof, so on a - * structure edge the LOW neighbour is the answer and the high one is an - * artefact of the photogrammetric mesh. - * - * The honest residual: on a genuine SLOPE the lowest neighbour under-reads by - * the grade across ~111 m, so the clamp lifts a little less than it could. That - * shows up as no lift rather than as a wrong one, and the contact's own cell - * corrects it as soon as it warms. - * - * @param {{lat: number, lon: number}|null|undefined} cell - Coarse cell. - * @returns {number|null} Ellipsoidal floor, or null when too few neighbours are - * warm to borrow from. - */ -export function neighborFloorM(cell) { - if (!Number.isFinite(cell?.lat) || !Number.isFinite(cell?.lon)) return null; - let lowest = null; - let resolved = 0; - for (let dLat = -1; dLat <= 1; dLat += 1) { - for (let dLon = -1; dLon <= 1; dLon += 1) { - if (dLat === 0 && dLon === 0) continue; - const h = cachedGroundFloor(cell.lat + dLat * FLOOR_CELL_DEG, cell.lon + dLon * FLOOR_CELL_DEG); - if (h == null) continue; - resolved += 1; - if (lowest == null || h < lowest) lowest = h; - } - } - return resolved >= NEIGHBOR_FLOOR_MIN_SAMPLES ? lowest : null; -} - -/** @constant {number} Cap on cells one corridor may contribute — a fast rollout - * or a spliced fix must not turn one contact into an unbounded warm batch. - * Sized for the worst realistic ground segment: a 27 m/s rollout coasts ~810 m - * (≈7 cells) in one poll interval, leaving headroom plus the endpoint slot. */ -export const CORRIDOR_MAX_CELLS = 12; -/** @constant {number} Step along a corridor leg when collecting cells (~14 m, - * an eighth of a cell). Half-cell steps were only cell-complete on LONG legs: - * a short leg rounds to a single step, so the walk degenerates to testing the - * leg's endpoints and a cell clipped between them is skipped. At this step any - * cell the path occupies for ~14 m of ground or more contains a sample — the - * guarantee this walk actually makes. The loop still exits the moment the cell - * budget fills, so a finer step costs iterations only on short corridors. */ -export const CORRIDOR_WALK_STEP_DEG = 0.000125; -/** @constant {number} Degrees added around a cell before a contact is allowed - * to adopt the next one (~22 m). A dead-reckoned position that jitters across - * a 0.001° edge would otherwise alternate floors at fleet-tick rate and pop - * the sprite between two heights. */ -export const CELL_HYSTERESIS_DEG = 0.0002; - -/** - * The floor cell a contact should read, with boundary hysteresis. - * - * Cells are 0.001° squares, so a display position sitting on an edge (parked - * GPS jitter, the tracked reconciliation's decaying correction) flips between - * two cells frame to frame — and their floors can differ by the local grade or, - * next to a building, several metres. The contact keeps its previous cell until - * it is CELL_HYSTERESIS_DEG clear of that cell's bounds, which costs two - * comparisons and no allocation on the sticky path. - * - * @param {number} lat @param {number} lon - Displayed coordinate. - * @param {{lat: number, lon: number}|null|undefined} previousCell - The cell - * this contact read last tick, if any. - * @returns {{lat: number, lon: number}} The cell to read. - */ -export function stickyFloorCell(lat, lon, previousCell) { - if (previousCell - && Math.abs(lat - previousCell.lat) <= 0.0005 + CELL_HYSTERESIS_DEG - && Math.abs(lon - previousCell.lon) <= 0.0005 + CELL_HYSTERESIS_DEG) { - return previousCell; - } - return coarseFloorCoord(lat, lon); -} - -/** - * The coarse cells a contact's DISPLAY is about to walk through. - * - * The dead-reckoned display runs one render delay behind and advances toward - * the newest fix, so the segment [display → newest fix] is exactly the ground - * it will render over during the next poll or two. Warming only the cell it is - * standing on warms the cell it is LEAVING: at 10 m/s a contact crosses a - * ~111 m cell every ~11 s while the warm batch runs once per 30 s poll, so it - * stays permanently ahead of its own floor data and the display clamp has - * nothing to read (KAUS: sprites 2–4 m under the mesh with a cold cell for the - * whole observation). - * - * Walks in half-cell steps so no cell between the ends is skipped. The step - * COUNT follows the segment length (never a fixed cap — a capped count over a - * long segment samples sparsely and leaves holes in the middle of ground the - * contact does cross). Length is bounded instead, and honestly: the walk emits - * a GAP-FREE PREFIX from `from`, and the destination cell always takes the last - * slot. A corridor longer than CORRIDOR_MAX_CELLS is therefore truncated — - * contiguous near ground plus the far endpoint — rather than thinned; the - * ground in between is picked up on a later poll as the display advances into - * it. Loop work stays bounded because the walk exits as soon as the cell budget - * fills, whatever the segment length. - * - * Takes the display PATH as a polyline, not two endpoints: while the contact - * is extrapolating, that path is an arc (the dead-reckon integrates a - * constant-rate turn), and walking a straight chord between its ends would - * warm ground the contact never crosses while its actual arc stayed cold. - * - * @param {Array<{lat: number, lon: number}>} points - Display path, current - * position first, destination last. - * @returns {Array<{lat: number, lon: number}>} Deduped coarse cells, start - * first, destination last. - */ -export function corridorFloorCells(points) { - if (!Array.isArray(points) || !points.length) return []; - const first = points[0]; - if (!Number.isFinite(first?.lat) || !Number.isFinite(first?.lon)) return []; - const start = coarseFloorCoord(first.lat, first.lon); - const out = [start]; - const seen = new Set([`${start.lat},${start.lon}`]); - let destination = null; - for (let i = points.length - 1; i >= 1; i--) { - if (Number.isFinite(points[i]?.lat) && Number.isFinite(points[i]?.lon)) { - destination = coarseFloorCoord(points[i].lat, points[i].lon); - break; - } - } - if (!destination) return out; - const destKey = `${destination.lat},${destination.lon}`; - // One slot is reserved for the destination, appended after the prefix. - const prefixLimit = CORRIDOR_MAX_CELLS - 1; - walk: - for (let s = 0; s < points.length - 1; s++) { - const a = points[s]; - const b = points[s + 1]; - if (![a?.lat, a?.lon, b?.lat, b?.lon].every(Number.isFinite)) continue; - const steps = Math.ceil( - Math.max(Math.abs(b.lat - a.lat), Math.abs(b.lon - a.lon)) / CORRIDOR_WALK_STEP_DEG, - ); - for (let i = 1; i <= steps; i++) { - if (out.length >= prefixLimit) break walk; - const t = i / steps; - const cell = coarseFloorCoord(a.lat + (b.lat - a.lat) * t, a.lon + (b.lon - a.lon) * t); - const key = `${cell.lat},${cell.lon}`; - if (seen.has(key) || key === destKey) continue; - seen.add(key); - out.push(cell); - } - } - if (!seen.has(destKey)) out.push(destination); - return out; -} - -/** - * Shares one poll's corridor-cell budget across the contacts that need it. - * - * The first cut walked contacts in Map insertion order and decremented the - * budget BEFORE deduping, so parked contacts burned slots on cells the poll had - * already collected and the contacts actually outrunning their floor data - * starved. Three rules fix that: - * 1. Cells already collected are FREE — a parked contact, whose corridor is - * its own fix cell, never competes for budget at all. - * 2. Candidates are ordered by how many of their cells are genuinely cold, - * then by speed (whoever leaves their floor behind soonest). - * 3. Allocation runs in widening rounds — one cell each, then `fairShare` - * each, then the remainder — so no contact is left with NOTHING while - * another takes seconds. The nearest cell is the one a contact needs first - * anyway; the far end of its corridor can wait a poll. - * - * @param {Array<{cells: Array<{lat: number, lon: number}>, cold: number, speedMps: number}>} candidates - * @param {Set} seen - Keys ("lat,lon") already collected; MUTATED with - * everything this call allocates, so the caller's dedupe stays consistent. - * Ranking alone still starves a TIE longer than the budget: 80 equally needy - * movers means the same stable prefix wins every poll and the tail never moves. - * The `epoch` rotates each run of equal-ranked candidates, so a tie cycles - * round-robin across polls while a genuinely needier contact is never demoted - * below a less needy one (rotation happens WITHIN a rank, never across ranks). - * - * `seen` alone is NOT a sufficient free-list: in production it holds only the - * CURRENT poll's batch, so a cell that warmed on an earlier poll looked new and - * spent budget again, letting a contact's near cells consume its whole share - * poll after poll while the ground ahead of it stayed cold. Budget is therefore - * charged only for cells with no floor yet (`isWarm`) — but warm cells are - * still EMITTED, because the mesh sampler must see a cell again after its DEM - * prior lands or it never latches a rendered-surface height there. - * - * That gives a service bound derivable from the policy rather than a magic - * number: the first round charges at most one cell per candidate, so at least - * `min(candidates, budget)` candidates are served per poll; a candidate that - * misses out keeps its cold count while every served candidate's drops, so it - * outranks them next poll. Every candidate is therefore served within - * `ceil(candidates / budget)` polls once the warms land. - * - * @param {number} budget - Max cold cells to charge for. - * @param {number} fairShare - Per-candidate cap for the middle round. - * @param {number} [epoch] - Poll counter; rotates equal-ranked runs. - * @param {(cell: {lat: number, lon: number}) => boolean} [isWarm] - Whether a - * cell already has a floor (defaults to the shared cache). - * @returns {Array<{lat: number, lon: number}>} Cells to add, allocation order. - */ -export function allocateCorridorCells( - candidates, seen, budget, fairShare, epoch = 0, - isWarm = (cell) => cachedGroundFloor(cell.lat, cell.lon) != null, -) { - const out = []; - if (!Array.isArray(candidates) || !candidates.length || !(budget > 0)) return out; - const ranked = [...candidates].sort((a, b) => (b.cold - a.cold) || (b.speedMps - a.speedMps)); - if (Number.isFinite(epoch) && epoch !== 0) { - for (let i = 0; i < ranked.length;) { - let j = i + 1; - while (j < ranked.length - && ranked[j].cold === ranked[i].cold - && ranked[j].speedMps === ranked[i].speedMps) j += 1; - const runLength = j - i; - if (runLength > 1) { - const shift = ((epoch % runLength) + runLength) % runLength; - const run = ranked.slice(i, j); - for (let m = 0; m < runLength; m += 1) ranked[i + m] = run[(m + shift) % runLength]; - } - i = j; - } - } - // Cursor per candidate, carried ACROSS rounds: a later round resumes where - // the previous one stopped instead of re-walking cells already handled. - const cursor = new Array(ranked.length).fill(0); - let left = budget; - const round = (limit) => { - for (let i = 0; i < ranked.length; i++) { - const cells = ranked[i].cells || []; - while (cursor[i] < cells.length && cursor[i] < limit) { - const cell = cells[cursor[i]]; - const key = `${cell.lat},${cell.lon}`; - if (seen.has(key)) { cursor[i] += 1; continue; } // already in this batch - if (isWarm(cell)) { - // Free: it has a floor already. Still emitted, so the mesh sampler - // gets another look now that its DEM prior exists. - cursor[i] += 1; - seen.add(key); - out.push(cell); - continue; - } - if (left <= 0) return; - cursor[i] += 1; - seen.add(key); - out.push(cell); - left -= 1; - } - } - }; - round(1); // nobody goes home empty-handed - round(fairShare); // then a fair slice each - round(CORRIDOR_MAX_CELLS); // then the remainder, neediest first - return out; -} - -// --- Mesh-floor cells (round 4, owner-approved design) --------------------- -// The Re:Earth DEM is BARE EARTH; the visible world in the google-3d regime -// is the photogrammetric MESH, which sits above it (measured ~17 m at the -// Austin airport apron). DEM-flooring therefore still buried sprites/trails -// inside the mesh. These cells hold the RENDERED surface height, sampled -// one-shot per coarse cell near the viewer (scene.sampleHeight — the same -// source groundSnap uses, which is why 3D models always looked right) and -// reported here by meshFloorSampler.js. Design principle (owner): NEVER -// below the visible surface; slightly above is always fine. The DEM stays -// the instant global prior/fallback and the sanity gate for samples. -/** @type {Map} coarse cell key -> rendered-surface height (m, ellipsoidal). */ -const _meshCells = new Map(); -/** @type {boolean} Whether mesh cells apply — true only in the google-3d - * regime (photoreal stack). On globe stacks the rendered surface IS the - * terrain provider (Re:Earth on keyless), so the DEM is already correct - * there and mesh heights would float. Kept current by meshFloorSampler's - * 'gev:map-stack-changed' listener. Photoreal is the boot default. */ -let _meshPreferred = true; - -/** @param {boolean} preferred - google-3d regime active. */ -export function setMeshFloorPreferred(preferred) { - _meshPreferred = !!preferred; -} - -/** @returns {boolean} Whether mesh-floor cells currently apply. */ -export function meshFloorPreferred() { - return _meshPreferred; -} - -/** - * Records a sampled rendered-surface height for the cell containing the - * coordinate (one-shot latch — first accepted sample wins). - * @param {number} lat @param {number} lon @param {number} heightM - */ -export function reportMeshFloorCell(lat, lon, heightM) { - if (!Number.isFinite(lat) || !Number.isFinite(lon) || !Number.isFinite(heightM)) return; - const c = coarseFloorCoord(lat, lon); - const key = `${c.lat},${c.lon}`; - if (!_meshCells.has(key)) _meshCells.set(key, heightM); -} - -/** Return whether a rendered-mesh sample is plausible against a real DEM prior. */ -export function meshFloorSampleWithinPrior(heightM, priorM) { - return Number.isFinite(heightM) - && Number.isFinite(priorM) - && heightM >= priorM - MESH_FLOOR_BELOW_PRIOR_M - && heightM <= priorM + MESH_FLOOR_ABOVE_PRIOR_M; -} - -/** - * Validate and record a rendered-mesh floor through the single shared gate. - * Callers must not bypass this with raw `reportMeshFloorCell`: a coarse-LOD, - * rooftop, or aircraft hit would otherwise win the session-long first-write - * latch and poison every floor consumer in the cell. - * @returns {boolean} Whether the sample passed and was reported. - */ -export function reportValidatedMeshFloorCell(lat, lon, heightM) { - if (![lat, lon, heightM].every(Number.isFinite)) return false; - const cell = coarseFloorCoord(lat, lon); - const prior = cachedRealEllipsoidalGround(cell.lat, cell.lon); - if (!meshFloorSampleWithinPrior(heightM, prior)) return false; - reportMeshFloorCell(cell.lat, cell.lon, heightM); - return true; -} - -/** - * Synchronous mesh-cell read — null when the cell is unsampled OR the - * google-3d regime is inactive (globe stacks must floor on the DEM). - * @param {number} lat @param {number} lon - * @returns {number|null} - */ -export function cachedMeshFloor(lat, lon) { - if (!_meshPreferred) return null; - if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null; - const c = coarseFloorCoord(lat, lon); - const h = _meshCells.get(`${c.lat},${c.lon}`); - return h != null ? h : null; -} - -/** Test hook: clears mesh cells (never called at runtime — cells are valid all session). */ -export function _clearMeshFloorCellsForTest() { - _meshCells.clear(); -} - -/** - * Synchronous coarse-cell ground read (warm cache only — never a fetch). - * Prefers the RENDERED-surface mesh cell (google-3d regime) and falls back - * to the Re:Earth DEM cell — the single choke point every ground-adjacent - * consumer (fleet clamp, grounded surface chain, trail floors) reads, so - * they all agree on one surface. - * @param {number} lat - * @param {number} lon - * @returns {number|null} Ellipsoidal floor of the cell, or null if not warm. - */ -export function cachedGroundFloor(lat, lon) { - if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null; - const mesh = cachedMeshFloor(lat, lon); - if (mesh != null) return mesh; - const c = coarseFloorCoord(lat, lon); - return cachedRealEllipsoidalGround(c.lat, c.lon); -} - -/** - * Awaitable variant for one-shot consumers (trail backfills): dedupes the - * points to unique coarse cells and resolves them, so `cachedGroundFloor` - * reads immediately after are warm. The underlying resolver chunks (≤200/req) - * and falls back per-point, so any trace length is safe. Never throws. - * @param {Array<{lat: number, lon: number}>} points - * @returns {Promise} - */ -/** @constant {number} Max ms a one-shot consumer (trail backfill) waits for - * the floor resolve before painting with whatever cells are warm. Owner - * field test 2026-07-06 round 2: an UNBOUNDED await here held trail paints - * hostage to cold Re:Earth lookups spanning a whole flight path (seconds to - * the 30 s proxy timeout) — trails showed up late, short, or not at all. - * The resolve keeps running past the deadline and lands in the cache for - * the next paint/select. */ -export const FLOOR_RESOLVE_DEADLINE_MS = 1200; - -/** - * Bounded-latency variant of resolveGroundFloorCells: waits at most - * FLOOR_RESOLVE_DEADLINE_MS, then returns so the caller can paint with the - * cells that ARE warm. The underlying resolve continues in the background - * and fills the cache for later reads. Never throws. - * @param {Array<{lat: number, lon: number}>} points - * @returns {Promise} - */ -export function resolveGroundFloorCellsBounded(points) { - return Promise.race([ - resolveGroundFloorCells(points), - new Promise((resolve) => setTimeout(resolve, FLOOR_RESOLVE_DEADLINE_MS)), - ]); -} - -export async function resolveGroundFloorCells(points) { - if (!Array.isArray(points) || !points.length) return; - const cells = new Map(); - for (const p of points) { - if (!Number.isFinite(p?.lat) || !Number.isFinite(p?.lon)) continue; - const c = coarseFloorCoord(p.lat, p.lon); - const key = `${c.lat},${c.lon}`; - if (cells.has(key)) continue; - if (cachedRealEllipsoidalGround(c.lat, c.lon) != null) continue; - cells.set(key, c); - } - if (!cells.size) return; - try { - await resolveEllipsoidalGround([...cells.values()]); - } catch { /* best-effort — unresolved cells simply don't clamp */ } -} - -/** @type {boolean} Single-flight guard for the warm batch. */ -let _floorBatchInFlight = false; -/** @type {Map} Cells requested while a - * batch was in flight — flushed as the NEXT batch the moment it completes. - * Round 6: the old guard silently DROPPED contending calls (flights every - * 30 s, military every 15 s, trail backfills ad hoc), so arriving at a new - * airport could take minutes of lost polls before a given cell resolved — - * the owner's "you can't just go to a place and start inspecting it". */ -const _pendingFloorCells = new Map(); - -/** Kicks the resolver for a cell map, chaining any cells queued meanwhile. */ -function _resolveFloorCells(cells) { - _floorBatchInFlight = true; - resolveEllipsoidalGround([...cells.values()]) - .catch(() => { /* best-effort — unclamped this tick, floor lands later */ }) - .finally(() => { - _floorBatchInFlight = false; - if (_pendingFloorCells.size) { - const next = new Map(_pendingFloorCells); - _pendingFloorCells.clear(); - _resolveFloorCells(next); - } - }); -} - -/** - * Fire-and-forget batch warm of the coarse floor cells for the given points. - * Dedupes to unique cells, skips already-warm ones, and never blocks or - * throws into the caller. Contending calls QUEUE (never drop): their cells - * ride the next batch as soon as the in-flight one completes. Results are - * picked up by later `cachedGroundFloor` reads. - * @param {Array<{lat: number, lon: number}>} points - */ -export function warmGroundFloor(points) { - if (!Array.isArray(points) || !points.length) return; - const cells = new Map(); - for (const p of points) { - if (!Number.isFinite(p?.lat) || !Number.isFinite(p?.lon)) continue; - const c = coarseFloorCoord(p.lat, p.lon); - const key = `${c.lat},${c.lon}`; - if (cells.has(key) || _pendingFloorCells.has(key)) continue; - if (cachedRealEllipsoidalGround(c.lat, c.lon) != null) continue; // already warm - cells.set(key, c); - } - if (!cells.size) return; - if (_floorBatchInFlight) { - for (const [key, c] of cells) _pendingFloorCells.set(key, c); - return; - } - _resolveFloorCells(cells); -} +import { createGroundFloor } from '../services/groundFloor.js'; +import * as terrain from './terrainHeights.js'; +const groundFloor = createGroundFloor({ terrain }); +export const { GROUND_FLOOR_LIFT_M, MESH_FLOOR_BELOW_PRIOR_M, MESH_FLOOR_ABOVE_PRIOR_M, coarseFloorCoord, floorAltitudeM, displayFloorHeightM, FLOOR_CELL_DEG, NEIGHBOR_FLOOR_MIN_SAMPLES, neighborFloorM, CORRIDOR_MAX_CELLS, CORRIDOR_WALK_STEP_DEG, CELL_HYSTERESIS_DEG, stickyFloorCell, corridorFloorCells, allocateCorridorCells, setMeshFloorPreferred, meshFloorPreferred, reportMeshFloorCell, meshFloorSampleWithinPrior, reportValidatedMeshFloorCell, cachedMeshFloor, _clearMeshFloorCellsForTest, cachedGroundFloor, FLOOR_RESOLVE_DEADLINE_MS, resolveGroundFloorCellsBounded, resolveGroundFloorCells, warmGroundFloor } = groundFloor; diff --git a/src/data/groundSnap.js b/src/data/groundSnap.js index 4fb100c7e..673715542 100644 --- a/src/data/groundSnap.js +++ b/src/data/groundSnap.js @@ -1,352 +1,4 @@ -/** - * groundSnap.js — one-shot cached ground-height snap for MODELED grounded aircraft. - * - * Why: a parked/taxiing plane's feed altitude is last-known baro or 0 m — nowhere - * near the photoreal tile skin in ellipsoid heights (buried ~100+ m at inland - * airports like AUS, floating ~30 m at sea-level fields like SFO, where the geoid - * sits below the ellipsoid). A 3D model placed at the meta altitude therefore - * renders inside the tiles or hovering above them. Billboards dodge this with - * disableDepthTestDistance; a depth-tested glTF model cannot. - * - * Discipline (CCTV-B9b style): scene.sampleHeight forces a synchronous offscreen - * pick render, so it is NEVER called per frame. Each grounded modeled plane gets - * ONE successful sample, cached per icao; the cache stops answering directly when - * the plane moves >MOVE_INVALIDATE_M from the sampled spot (taxiing). Misses (tiles - * still streaming, OSM fallback with no tileset skin, sample outside loaded - * geometry) are a retry-later signal with per-icao exponential backoff. A - * per-window budget stops a freshly modeled airport cluster from firing dozens of - * pick renders in one fleet tick. - * - * What a caller gets while a sample is outstanding depends on whether this - * contact has ever HAD one, and the difference is the difference between two - * kinds of wrong: - * - COLD (nothing ever resolved here) — null. There is no evidence of where the - * ground is, and the only honest answer for a depth-tested model is not to - * place it at all; the caller keeps the depth-test-free billboard. - * - WARM (a snap resolved, then taxi invalidated it and the resample has not - * landed) — the last measurement, held within HELD_SNAP_MAX_DRIFT_M. Hiding - * the model here would pop a taxiing aircraft from 3D back to 2D for a whole - * retry backoff, and the ground it was measured on a few dozen metres back is - * real evidence, not a guess. Same shape as the billboard chain's - * `_heldDisplayFloorM` (flights.js): hold the last thing that resolved, - * bounded by how far the contact can have travelled from it. - * - * A hold is a MEMORY, and it is ranked accordingly: held evidence never outranks - * fresh evidence that contradicts it. Distance travelled is only a proxy for - * "does this still describe the ground here", and it is a loose one — a contact - * can taxi 200 m onto a different surface well inside the bound, and because a - * miss preserves the hold, nothing in the retry path would ever correct that. - * So where the mesh-floor cells have actually MEASURED the surface under the - * contact's current position, and that surface is ABOVE the held value by more - * than HELD_SNAP_CONTRADICTION_M, the measurement wins: the hold is dropped and - * the contact is COLD again. The fallback is the floored 2D billboard, which is - * the honest picture — a model buried in the apron is not a better one. - * - * Shared by flights.js and militaryFlights.js (one instance each — the caches - * are per-layer, keyed by that layer's icao space). - */ - -import * as Cesium from 'cesium'; -import { cachedMeshFloor, meshFloorPreferred, reportValidatedMeshFloorCell } from './groundFloor.js'; - -/** Taxi threshold: a cached snap answers directly for moves up to this far from - * the sampled spot (m). Past it the value is demoted to a held last-known and a - * resample runs. */ -const MOVE_INVALIDATE_M = 50; -/** @constant {number} How far a contact may travel from the spot its snap was - * measured at before that measurement stops describing the ground under it, and - * the hold is dropped rather than stretched. - * - * Sized to two things at once. It has to outlast a resample outage, or it buys - * nothing: RETRY_MAX_MS is 30 s of backoff, and ordinary taxi is ~10 m/s, so a - * hold that expires inside ~25 s of rolling would hand back the pop it exists to - * prevent. And it has to stay inside the grade error an airfield can hide: the - * KAUS spread this file's header cites is ~21 m across a ~4 km field, ~0.5%, so - * 250 m of taxi is ~1.3 m of vertical error — the order of the belly offsets - * already being added, and two orders under the ~100+ m burial the snap exists - * to prevent. - * - * Deliberately a quarter of the billboard chain's HELD_FLOOR_MAX_DRIFT_KM. That - * hold only ever RAISES a position (`displayFloorHeightM` never lowers), so an - * under-read there is inert; here the held value IS the placement, so the error - * shows in both directions and the bound has to be tighter. */ -const HELD_SNAP_MAX_DRIFT_M = 250; -/** @constant {number} How far a held measurement may disagree with FRESH - * measured evidence at the contact's current position before it is dropped - * rather than trusted. - * - * The drift bound above asks "how far has the contact travelled?", which is a - * proxy for "does this value still describe the ground?" — and a proxy is all - * it is. 202 m of taxi onto a different surface is inside the bound and still - * wrong, and because a miss preserves the hold, repeated misses preserve the - * error for as long as they last. So the bound is not the only guard: where - * something has actually MEASURED the surface the contact is standing on now, - * that measurement outranks the memory, and a memory that contradicts it is - * discarded rather than stretched to cover the gap. - * - * 5 m sits between the two scales this has to separate: - * - ABOVE the honest disagreement between two measurements of the SAME - * surface. A floor cell is ~111 m across, so cell grade is ~0.6 m at the - * ~0.5 % KAUS spread this file's header cites and ~3.3 m even at a steep - * 3 %; the hold's own 250 m allowance is ~1.3 m at that grade. None of - * those should cost a taxiing aircraft its model. - * - BELOW the scale at which a placement reads as wrong. The belly offsets - * this snap adds are ~2–5 m and an airliner fuselage is ~4 m across, so a - * model displaced more than ~5 m is visibly off its gear rather than - * imprecisely on it — and it is two orders under the ~100+ m burial the - * snap exists to prevent. - * - * Applied in ONE direction: the hold is released only when fresh evidence sits - * ABOVE it, which is the BURIAL case — the model sunk into the surface, which - * is the entire reason this gate exists. The other sign is deliberately left - * alone, and the asymmetry is not tidiness: - * - * - A cell reading LOW against a real sample is the EXPECTED error here, not a - * contradiction. Cells are a one-shot first-write latch over ~111 m, the - * borrowed-neighbour path leans to the LOWEST on purpose (`neighborFloorM`), - * and `displayFloorHeightM` only ever RAISES — the whole floor chain is - * built to under-read rather than to invent. Releasing on that sign would - * discard good measurements wherever the two disagree downward. Measured on - * the track-regression rig: a planted cell sat 66.7 m BELOW a real sample at - * the SAME spot, and a two-sided rule dropped a hold that was correct. - * - The residual is stated rather than hidden, and stated WITH its condition. - * A contact taxiing DOWNHILL keeps a held value above its new surface and - * its model floats. The correction is a SUCCESSFUL resample, and nothing on - * this path guarantees one: on the OSM fallback sampleHeight misses forever, - * so the retries never land and the float persists for as long as the - * contact stays inside HELD_SNAP_MAX_DRIFT_M. There is no timer beside it - * and no vertical cap of its own — only whatever the ground drops within - * that radius. The other releases (a ground flip via forget(), travelling - * past the bound, a mesh cell measuring ABOVE the hold) are not ones a - * downhill taxi is guaranteed to hit either. It is accepted over the - * alternative because it errs UPWARD and stays visible, where a buried model - * errs into the terrain and cannot be seen at all. */ -const HELD_SNAP_CONTRADICTION_M = 5; -/** First retry delay after a miss / tiles-not-ready (ms); doubles per consecutive miss. */ -const RETRY_BASE_MS = 2000; -/** Backoff cap (ms) — on the OSM fallback (no tile skin) sampleHeight misses forever. */ -const RETRY_MAX_MS = 30000; -/** Max sampleHeight calls per window across the layer (airport-cluster burst guard). */ -const SAMPLE_BUDGET_PER_WINDOW = 4; -/** Budget window length (ms). */ -const SAMPLE_WINDOW_MS = 250; - -/** - * True when the active photoreal tileset has finished streaming the tiles in - * view (cctv.js's projectionTilesReady pattern: first Cesium3DTileset found in - * scene.primitives, duck-typed via its boolean tilesLoaded). Sampling before - * that both blows the frame budget on forced synchronous loads and produces - * misses that would burn retry backoff for nothing. With NO tileset present - * (OSM fallback) this returns true so the sample path is not permanently - * blocked — the sample just misses and backs off. - * @param {Cesium.Viewer} viewer - * @returns {boolean} - */ -function _tilesReady(viewer) { - const prims = viewer?.scene?.primitives; - if (!prims) return false; - for (let i = 0; i < prims.length; i++) { - let p = null; - try { p = prims.get(i); } catch { continue; } - if (p && p.show !== false && typeof p.tilesLoaded === 'boolean') { - return p.tilesLoaded === true; - } - } - return true; -} - -/** - * Create a per-layer ground-snap cache. - * @returns {{ - * heightFor: (viewer: Cesium.Viewer, icao: string, pos: Cesium.Cartesian3, - * getExclusions?: () => Array) => number|null, - * forget: (icao: string) => void, - * clear: () => void, - * }} - */ -export function createGroundSnap() { - /** @type {Map} Per-icao snap state: - * the sampled height and the surface point it was measured at, whether that - * pair is still the fresh answer or has been demoted to a bounded last-known - * (`held`), and the retry backoff a miss earned. */ - const entries = new Map(); - let windowStartMs = 0; - let windowCount = 0; - const scratchCarto = new Cesium.Cartographic(); - const scratchSurfacePos = new Cesium.Cartesian3(); - /** Separate from `scratchCarto`: the held-evidence check runs on paths that - * return BEFORE the sample writes `scratchCarto`, and on the sampling path it - * must not disturb the cartographic that `reportValidatedMeshFloorCell` reads - * back after the sample lands. */ - const scratchHeldCarto = new Cesium.Cartographic(); - - /** - * The last measurement for a contact whose fresh snap has been demoted — it - * taxied past MOVE_INVALIDATE_M and the resample has not landed yet. - * - * Answers only while the contact is still within HELD_SNAP_MAX_DRIFT_M of the - * spot the value was measured at; past that the memory is DROPPED rather than - * stretched, and the contact is back to no evidence (null, model hidden). The - * bound is purely spatial on purpose: the ground under a contact that has not - * moved does not change with time, so what invalidates the value is the - * contact MOVING, which is exactly what this measures. - * - * @param {{h: number|null, samplePos: Cesium.Cartesian3|null, held: boolean}|undefined} entry - * @param {Cesium.Cartesian3} surfacePos - Contact's position on the ellipsoid. - * @returns {number|null} - */ - function heldSnapM(entry, surfacePos) { - if (!entry || !entry.held || entry.h == null || !entry.samplePos) return null; - if (Cesium.Cartesian3.distanceSquared(surfacePos, entry.samplePos) - > HELD_SNAP_MAX_DRIFT_M * HELD_SNAP_MAX_DRIFT_M) { - return dropHold(entry); - } - // Held memory never outranks fresh contradicting evidence. Inside the drift - // bound the contact can still have taxied onto a different surface — an - // apron edge, a ramp, the far side of a rise — and since a miss PRESERVES - // the hold, nothing else would ever correct it: the model would stay buried - // (or floating) for as long as the resample kept missing. - const fresh = freshMeasuredFloorAt(surfacePos); - if (fresh != null && fresh - entry.h > HELD_SNAP_CONTRADICTION_M) { - return dropHold(entry); - } - return entry.h; - } - - /** Forget a demoted measurement: it no longer describes anywhere this contact - * is, so the contact is COLD again (model hidden, floored billboard). */ - function dropHold(entry) { - entry.h = null; - entry.samplePos = null; - entry.held = false; - return null; - } - - /** - * Independently measured surface height at the contact's CURRENT position, or - * null where nothing has measured there yet. - * - * MESH cells only, deliberately — not `cachedGroundFloor`. A snap is a - * `sampleHeight` of the RENDERED skin, and the mesh cells are that same - * measurement through the same validation gate (written by this module's own - * successful samples and by `meshFloorSampler.js`). `cachedGroundFloor` falls - * back to the Re:Earth DEM, which is a DIFFERENT surface: the spread between - * skin and DEM is precisely what `MESH_FLOOR_BELOW/ABOVE_PRIOR_M` budgets for - * (15 m / 80 m), so testing a snap against a DEM cell would throw away good - * holds over a disagreement that says nothing about where the model sits. - * - * The contact's OWN cell only. A neighbour is ~111 m away and may be a - * terminal roof — that is why `neighborFloorM` leans to the lowest and refuses - * to answer from a single sample — and borrowing one to DISCARD a real - * measurement would let a roof cell hide a correctly placed model. - * - * @param {Cesium.Cartesian3} surfacePos - Contact's position on the ellipsoid. - * @returns {number|null} - */ - function freshMeasuredFloorAt(surfacePos) { - const carto = Cesium.Cartographic.fromCartesian( - surfacePos, Cesium.Ellipsoid.WGS84, scratchHeldCarto, - ); - if (!carto) return null; - return cachedMeshFloor( - Cesium.Math.toDegrees(carto.latitude), - Cesium.Math.toDegrees(carto.longitude), - ); - } - - /** - * Cached tile-skin height (m, ellipsoid) at the aircraft's lat/lon, or null - * when this contact has NO ground evidence at all (caller keeps the model - * hidden and calls again on a later tick — cheap map hit until a retry is due). - * A contact that HAS resolved once keeps that answer through a resample outage; - * see `heldSnapM`. - * - * @param {Cesium.Viewer} viewer - Live viewer (sampling + tiles-ready check). - * @param {string} icao - Cache key (per-layer icao space). - * @param {Cesium.Cartesian3} pos - Current display position. Cache movement - * is measured after projecting this input to the WGS84 ellipsoid, so a - * poll-time altitude/datum change cannot invalidate a stationary snap. - * @param {(() => Array)|undefined} getExclusions - Lazily builds the - * scene.sampleHeight objectsToExclude list (own billboards/models/entities — - * the vertical pick ray at a plane's OWN lat/lon lands on its icon/model - * otherwise). Only invoked when a sample actually fires. - * @returns {number|null} - */ - function heightFor(viewer, icao, pos, getExclusions) { - const surfacePos = Cesium.Ellipsoid.WGS84.scaleToGeodeticSurface(pos, scratchSurfacePos); - if (!surfacePos) return null; - const cached = entries.get(icao); - if (cached && cached.h != null && cached.samplePos && !cached.held) { - if (Cesium.Cartesian3.distanceSquared(surfacePos, cached.samplePos) <= MOVE_INVALIDATE_M * MOVE_INVALIDATE_M) { - return cached.h; - } - // Taxied away from the sampled spot. The measurement is DEMOTED to a - // bounded last-known rather than deleted, and the resample runs below: a - // miss now holds the aircraft on the ground it just left instead of - // hiding a model that was already up. - cached.held = true; - cached.nextRetryMs = 0; - cached.misses = 0; - } - const now = Date.now(); - const entry = entries.get(icao); - if (entry && now < entry.nextRetryMs) return heldSnapM(entry, surfacePos); // backoff in force - // Per-window budget: deny WITHOUT a retry stamp so the overflow simply - // tries again next tick instead of waiting out a backoff it didn't earn. - if (now - windowStartMs > SAMPLE_WINDOW_MS) { windowStartMs = now; windowCount = 0; } - if (windowCount >= SAMPLE_BUDGET_PER_WINDOW) return heldSnapM(entry, surfacePos); - const misses = entry ? entry.misses : 0; - const miss = () => { - // A held measurement survives the miss — only the retry schedule moves. - const held = !!(entry && entry.held && entry.h != null && entry.samplePos); - const next = { - h: held ? entry.h : null, - samplePos: held ? entry.samplePos : null, - held, - nextRetryMs: now + Math.min(RETRY_MAX_MS, RETRY_BASE_MS * 2 ** misses), - misses: misses + 1, - }; - entries.set(icao, next); - return heldSnapM(next, surfacePos); - }; - if (!_tilesReady(viewer)) return miss(); - windowCount += 1; - let sampled; - try { - const carto = Cesium.Cartographic.fromCartesian(pos, Cesium.Ellipsoid.WGS84, scratchCarto); - // sampleHeight throws when unsupported (no depth textures) — that's a miss. - sampled = viewer.scene.sampleHeight(carto, getExclusions ? getExclusions() : undefined); - } catch { - sampled = undefined; - } - // Same sanity floor as cctv.js's sampleGroundHeight: a hit far below the - // ellipsoid is pick garbage, not ground. - if (!Number.isFinite(sampled) || sampled < -150) return miss(); - if (meshFloorPreferred()) { - reportValidatedMeshFloorCell( - Cesium.Math.toDegrees(scratchCarto.latitude), - Cesium.Math.toDegrees(scratchCarto.longitude), - sampled, - ); - } - // A fresh sample RELEASES any hold: this is a measurement of where the - // contact is now, and it outranks a memory of where it was. - entries.set(icao, { - h: sampled, - samplePos: Cesium.Cartesian3.clone(surfacePos), - held: false, - nextRetryMs: 0, - misses: 0, - }); - return sampled; - } - - /** Drop one aircraft's snap (eviction / ground-flag flip / suppression). */ - function forget(icao) { entries.delete(icao); } - - /** Drop everything (layer destroy). */ - function clear() { entries.clear(); } - - return { heightFor, forget, clear }; -} +import { createGroundSnap as createSnap } from '../services/groundSnap.js'; +import * as groundFloor from './groundFloor.js'; +/** Compatibility constructor using the default floor cache. */ +export function createGroundSnap() { return createSnap({ groundFloor }); } diff --git a/src/data/meshFloorSampler.js b/src/data/meshFloorSampler.js index f53ddf85d..cabf25da2 100644 --- a/src/data/meshFloorSampler.js +++ b/src/data/meshFloorSampler.js @@ -1,165 +1,4 @@ -// src/data/meshFloorSampler.js — rendered-surface (mesh) floor sampling -// (field-test round 4, 2026-07-06). -// -// The visible world in the google-3d regime is the PHOTOGRAMMETRIC MESH, -// which sits above the Re:Earth bare-earth DEM (measured ~17 m at the Austin -// airport apron) — so DEM-floored sprites/trails still buried themselves in -// the mesh while 3D models (which groundSnap against the mesh) looked right. -// This module samples the mesh height ONE-SHOT per coarse cell and reports it -// to groundFloor.js, whose cachedGroundFloor() then answers mesh-first. -// -// Rationing (the CCTV loading-jitter lesson): scene.sampleHeight forces -// tile-load prioritization at the probe point, so sampling is (a) gated to -// cells NEAR the viewer — where tiles are streamed and where an error is -// visible at all, (b) capped per call, (c) one-shot per cell for the session, -// (d) skipped entirely outside the google-3d regime. A returned-undefined -// probe (tiles not streamed yet) is NOT latched — it retries on a later call. -// -// Sample acceptance is sanity-gated against the DEM prior when warm (a -// vertical probe can hit a rooftop or another aircraft instead of pavement): -// the mesh legitimately sits ABOVE bare earth, so the window is asymmetric. -import * as Cesium from 'cesium'; -import { - coarseFloorCoord, cachedMeshFloor, reportValidatedMeshFloorCell, - setMeshFloorPreferred, meshFloorPreferred, cachedGroundFloor, -} from './groundFloor.js'; - -/** @constant {number} Max scene samples per call (one call per layer poll). */ -const MAX_SAMPLES_PER_CALL = 40; -/** @constant {number} Only cells within this range of the viewer subpoint are - * sampled. Round-5 hardening: tightened 40 → 15 km — beyond the streamed - * fine-LOD area a probe returns COARSE-tile heights (not undefined), and the - * one-shot latch made those permanent (owner round 5: grounded planes stuck - * at a uniform wrong height). */ -const MAX_SAMPLE_DIST_KM = 15; -/** @constant {number} No sampling when the camera is above this height — the - * streamed LOD under a high camera is coarse everywhere, so every probe - * would latch junk. */ -const MAX_CAMERA_HEIGHT_M = 25_000; -/** @type {Cesium.Cartographic} Scratch for probe coordinates. */ -const _scratchProbe = new Cesium.Cartographic(); - -// Regime tracking: mesh cells only apply while the photoreal (google-3d) -// stack renders. main.js re-dispatches MapStackController.onChange as this -// CustomEvent; the boot default is photoreal, matching groundFloor's initial -// preference. Module-scope listener: the module only loads in the browser -// bundle, and the subscription is idempotent for the app's lifetime. -if (typeof window !== 'undefined') { - window.addEventListener('gev:map-stack-changed', (event) => { - const activeId = event?.detail?.activeId; - if (activeId) setMeshFloorPreferred(activeId === 'photoreal'); - }); -} - -/** - * True when a VISIBLE 3D tileset in the scene reports its streaming queue - * drained (tilesLoaded) — the mirror of cctv.js's projectionTilesReady. - * Walks top-level primitives only (a handful; once per poll). - * @param {Cesium.Scene} scene - * @returns {boolean} - */ -function _visibleTilesetLoaded(scene) { - try { - const prims = scene.primitives; - for (let i = 0; i < prims.length; i++) { - const p = prims.get(i); - if (p instanceof Cesium.Cesium3DTileset && p.show) { - return !!p.tilesLoaded; - } - } - } catch { /* mid-teardown */ } - return false; -} - -/** - * Equirectangular distance (km) — same approximation the flights clamp uses. - */ -function _approxKm(lat1, lon1, lat2, lon2) { - const dLat = (lat2 - lat1) * 111.32; - const dLon = (lon2 - lon1) * 111.32 * Math.cos(((lat1 + lat2) / 2) * Math.PI / 180); - return Math.hypot(dLat, dLon); -} - -/** - * Samples the rendered mesh height for the coarse cells containing `points`, - * one-shot per cell, budget-capped, viewer-proximity-gated. Call ONCE per - * layer poll (never per contact, never per frame). Synchronous — Cesium's - * sampleHeight is a CPU-side query against loaded tiles. - * - * @param {Cesium.Scene|undefined} scene - The scene (skipped when absent/torn down). - * @param {Array<{lat: number, lon: number}>} points - Contact/waypoint coords. - * @param {object} [options] - * @param {Array} [options.excludeObjects] - Own billboards/models to - * exclude from the probe (Cesium matches instances/.primitive/.id — pass - * Billboard and Model instances, NOT collections). - * @param {number} [options.viewerLat] @param {number} [options.viewerLon] - * Viewer subpoint (computed once by the caller's poll). - */ -export function sampleMeshFloorCells(scene, points, { excludeObjects = [], viewerLat, viewerLon } = {}) { - if (!meshFloorPreferred()) return; - if (!scene || typeof scene.sampleHeight !== 'function') return; - if (!Array.isArray(points) || !points.length) return; - // Round-5 hardening (the CCTV projectionTilesReady lesson, ported late): - // while tiles are MID-STREAM a probe returns coarse-LOD heights — real - // numbers, wildly wrong — and the one-shot latch made them permanent. - // Only sample when the visible Google tileset reports tilesLoaded AND the - // camera is low enough that the streamed LOD near it is fine-grained. - const camH = scene.camera?.positionCartographic?.height; - if (!Number.isFinite(camH) || camH > MAX_CAMERA_HEIGHT_M) return; - if (!_visibleTilesetLoaded(scene)) return; - let sampled = 0; - const attempted = new Set(); - for (const p of points) { - if (sampled >= MAX_SAMPLES_PER_CALL) break; - if (!Number.isFinite(p?.lat) || !Number.isFinite(p?.lon)) continue; - const cell = coarseFloorCoord(p.lat, p.lon); - const key = `${cell.lat},${cell.lon}`; - if (attempted.has(key)) continue; - attempted.add(key); - if (cachedMeshFloor(cell.lat, cell.lon) != null) continue; // one-shot latch - if (Number.isFinite(viewerLat) && Number.isFinite(viewerLon) && - _approxKm(viewerLat, viewerLon, cell.lat, cell.lon) > MAX_SAMPLE_DIST_KM) { - continue; // too far: tiles not streamed there, probe would be a guaranteed miss - } - let height; - try { - const carto = Cesium.Cartographic.fromDegrees(cell.lon, cell.lat, 0, _scratchProbe); - height = scene.sampleHeight(carto, excludeObjects); - sampled += 1; - } catch { - continue; // scene mid-teardown — try again next poll - } - if (!Number.isFinite(height)) continue; // tiles not loaded yet — no negative latch - // Round 5: a REAL Re:Earth prior is REQUIRED before any sample latches. - // - A geoid-fallback prior (cached while the proxy was failing) is a - // sea-level poison that rejected legitimate samples at elevated - // fields (AUS: mesh 138 m vs poisoned −27 m prior → dropped forever). - // - No prior at all means no way to tell a real surface from a - // coarse-LOD/rooftop/aircraft mis-hit — the old sanity-only branch - // latched a 37 m "surface" at Austin permanently. The DEM lands - // within a poll (the same warm batch that queued this cell), so - // waiting costs one poll, not correctness. - // 2026-08-21: a "provisional" tier that KEPT the reading when no prior - // existed was built here and then removed, because it was measured failing. - // Run twice against the real GPU at the same Austin apron with the proxy - // down, it recorded 122.1 m once and 20.6 m the next time — the second is a - // coarse-LOD read of ground that is really ~122 m, and it left the contact - // ~100 m under the mesh. The DEM requirement above is not a formality. - // - // Before reaching for the obvious gate: `tilesLoaded` / `allTilesLoaded` is - // NOT a resolution signal. It reports that the streaming queue has drained - // for the CURRENT camera's screen-space-error target, and a distant or high - // camera is entirely satisfied by coarse tiles — which is exactly the state - // that reported "loaded" during the measurement above. Gating harder on it - // buys nothing. A correct gate has to ask about the specific tile covering - // the sample point: walk the tileset tree to the tile containing the - // coordinate and require its `geometricError` to be below a threshold tied - // to the accuracy the caller needs, before trusting the sample. That is - // feasible and deliberately deferred — see the P2 in - // the project roadmap, which bundles it with the fly_route probe so - // one post-launch pass settles rendered-mesh sampling everywhere. - reportValidatedMeshFloorCell(cell.lat, cell.lon, height); - } -} - -export { cachedGroundFloor }; // re-export for callers that want one import site +import { createMeshFloorSampler } from '../services/meshFloorSampler.js'; +import * as groundFloor from './groundFloor.js'; +const meshFloorSampler = createMeshFloorSampler({ groundFloor, eventTarget: typeof window === 'undefined' ? null : window }); +export const { sampleMeshFloorCells, cachedGroundFloor } = meshFloorSampler; diff --git a/src/data/militaryFlights.js b/src/data/militaryFlights.js index 1c2a602ab..12119ed6c 100644 --- a/src/data/militaryFlights.js +++ b/src/data/militaryFlights.js @@ -1,9 +1,11 @@ +import { defaultSurface } from './surfaceServices.js'; import { createApplicationMilitary } from '../app/layers/militaryFlights.js'; import { createAdsbLolSource } from '../sources/live/standalone.js'; import * as militaryRegistry from './militaryRegistry.js'; const militaryFlightsLayer = createApplicationMilitary({ + surface: defaultSurface, source: createAdsbLolSource(), militaryRegistry, }); diff --git a/src/data/militaryInstallations.js b/src/data/militaryInstallations.js index c8b54eef8..86803ee1d 100644 --- a/src/data/militaryInstallations.js +++ b/src/data/militaryInstallations.js @@ -1,3 +1,4 @@ +import { defaultSurface } from './surfaceServices.js'; import { createApplicationInstallations } from '../app/layers/militaryInstallations.js'; import { createSourceSlot } from '../app/sourceSlot.js'; import { createInstallationSource } from '../layers/installations/index.js'; @@ -9,6 +10,7 @@ const sourceSlot = createSourceSlot( ); export const configureInstallationSource = sourceSlot.configure; const layer = createApplicationInstallations({ + surface: defaultSurface, source: sourceSlot.source, }); export const approximateSurfaceDistanceM = layer.approximateSurfaceDistanceM; diff --git a/src/data/radio.js b/src/data/radio.js index fa2ee01b2..1c3a52554 100644 --- a/src/data/radio.js +++ b/src/data/radio.js @@ -1,3 +1,4 @@ +import { defaultSurface } from './surfaceServices.js'; import { createApplicationRadio } from '../app/layers/radio.js'; import { createSourceSlot } from '../app/sourceSlot.js'; import { createRadioSource } from '../layers/radio/source.js'; @@ -9,6 +10,7 @@ const sourceSlot = createSourceSlot( ); export const configureRadioSource = sourceSlot.configure; const layer = createApplicationRadio({ + surface: defaultSurface, source: sourceSlot.source, }); export const radioGlobeLabel = layer.radioGlobeLabel; diff --git a/src/data/surfaceServices.js b/src/data/surfaceServices.js new file mode 100644 index 000000000..afeaf5311 --- /dev/null +++ b/src/data/surfaceServices.js @@ -0,0 +1,13 @@ +import * as terrain from './terrainHeights.js'; +import * as groundFloor from './groundFloor.js'; +import * as meshFloor from './meshFloorSampler.js'; +import * as groundSnap from './groundSnap.js'; +import * as anchors from './fireAnchors.js'; +/** Default scene services for direct data-module compatibility callers. */ +export const defaultSurface = Object.freeze({ + terrain, + groundFloor, + meshFloor, + groundSnap, + anchors, +}); diff --git a/src/data/terrainHeights.js b/src/data/terrainHeights.js index cbae46808..c71bace49 100644 --- a/src/data/terrainHeights.js +++ b/src/data/terrainHeights.js @@ -1,243 +1,4 @@ +import { createTerrainHeights } from '../services/terrainHeights.js'; import { applicationServices } from '../services/application.js'; -// src/data/terrainHeights.js — batched, cached client terrain-height resolver -// (docs/plans/2026-07-05-entity-height-datum-fix.md Task 3). -// -// Resolves ELLIPSOIDAL ground height per (lat, lon) via the server-side -// `/api/terrain/heights` proxy (Task 2 — Re:Earth `heights.json`, disk-cached, -// serve-stale). When the proxy is unreachable (or errors) AND the point isn't -// already warm in this module's in-memory cache, falls back to bundled-geoid -// math (Task 1, `src/data/geoid.js`): -// -// sourceOrthometricM (if finite) + geoidHeight(lat, lon) → source:'geoid-fallback' -// else geoidHeight(lat, lon) alone (H≈0 "at the geoid/coast" prior) → source:'geoid-fallback' -// -// Two things learned building the Task 2 proxy (see its report, and the -// ledger's T2 entry) that this module builds in: -// -// 1. The proxy's GET has a Node http header-size ceiling — empirically a -// single request much past ~700-1500 points (depending on coordinate -// precision) risks a raw socket-level 431 *before* the proxy's own -// request handler runs. So every network request this module issues is -// chunked at CHUNK_SIZE (64 points), well under that ceiling, and -// chunks are sent SEQUENTIALLY (not in parallel) to keep this client -// well-behaved against a single dev-server proxy. -// 2. The proxy's disk cache is keyed by the raw `points` query string, with -// no eviction — so a client that varies coordinate precision or point -// order defeats the warm cache and grows the cache file unbounded. This -// module rounds every coordinate to 5 decimal places (~1.1 m of -// precision at the equator — comfortably tighter than any of this app's -// placement needs) BOTH for its own in-memory cache key AND for the -// points string sent to the proxy, so repeated calls (including calls -// from a fresh page load, a different camera batch, etc.) consistently -// hit the same warm proxy cache entry. -import { ensureGeoidReady, geoidHeight } from './geoid.js'; - -/** Max points per outgoing request to `/api/terrain/heights` (see file header, point 1). */ -// Match the server's upstream batch so sequential upstream work also fits -// within this client's 30-second request deadline when Re:Earth slows. -const CHUNK_SIZE = 64; - -/** Avoid repeatedly hitting a known-failing proxy from warm fallback reads. */ -const GEOID_FALLBACK_COOLDOWN_MS = 60_000; - -/** - * In-memory cache: `"lat.toFixed(5),lon.toFixed(5)"` -> `{ellipsoid, source}`. - * Module-scoped (not exported) — the only reads are through - * `cachedEllipsoidalGround` and the internal lookup in - * `resolveEllipsoidalGround`. - * @type {Map} - */ -const cache = new Map(); - -/** - * Builds the rounded cache key shared between the in-memory cache and the - * `points` string sent to the proxy (see file header, point 2). - * @param {number} lat - * @param {number} lon - * @returns {string} - */ -function cacheKey(lat, lon) { - return `${lat.toFixed(5)},${lon.toFixed(5)}`; -} - -/** - * Synchronous read of a previously resolved ellipsoidal ground height. - * Returns null if this exact (rounded) coordinate hasn't been resolved yet - * by either the proxy path or the geoid-fallback path. - * @param {number} lat - * @param {number} lon - * @returns {number|null} - */ -export function cachedEllipsoidalGround(lat, lon) { - const entry = cache.get(cacheKey(lat, lon)); - return entry ? entry.ellipsoid : null; -} - -/** - * Field-test round 5 (2026-07-06, the "sea-level poison"): like - * cachedEllipsoidalGround but returns null for geoid-FALLBACK entries — - * only a real Re:Earth value counts. A fallback (cached when the proxy - * failed mid-burst) is the geoid surface, which at Austin sits ~165 m below - * the airport: floors built on it sank every sprite/trail, and the mesh - * sampler's sanity gate rejected REAL surface samples against it. Floor - * consumers read THIS; the plain read stays for display-only consumers. - * @param {number} lat - * @param {number} lon - * @returns {number|null} - */ -export function cachedRealEllipsoidalGround(lat, lon) { - const entry = cache.get(cacheKey(lat, lon)); - return entry && entry.source === 'reearth' ? entry.ellipsoid : null; -} - -/** - * Fetches one chunk (<=CHUNK_SIZE points) from the `/api/terrain/heights` - * proxy. Returns a Map keyed by the same rounded cache key so callers can - * look results up positionally-independent of upstream response ordering. - * Throws on any failure (non-ok response, network error, malformed body) — - * callers decide the fallback behavior per point. - * @param {Array<{key: string, lat: number, lon: number}>} chunk - * @returns {Promise>} key -> ellipsoid height (m) - */ -async function fetchChunk(chunk) { - // lon,lat order (matches the proxy's documented `points=lon,lat;…` contract - // and Task 2's implementation). - const results = await applicationServices.terrain.getHeights(chunk, { signal: AbortSignal.timeout(30000) }); - const body = { results }; - if (!Array.isArray(body?.results)) throw new Error('malformed terrain heights response (no results array)'); - if (body.results.length !== chunk.length) { - throw new Error( - `terrain heights response length mismatch (expected ${chunk.length}, got ${body.results.length})` - ); - } - const out = new Map(); - // The proxy is documented to preserve input order (Task 2 report verifies - // this against the live upstream), so map positionally rather than trust - // the response's own lon/lat fields to re-match — those are also present - // for callers who want them, but this module doesn't need them. - for (let i = 0; i < chunk.length; i += 1) { - const ellipsoid = body.results[i]?.ellipsoid; - if (!Number.isFinite(ellipsoid)) throw new Error(`non-finite ellipsoid height at index ${i}`); - out.set(chunk[i].key, ellipsoid); - } - return out; -} - -/** - * Computes the geoid-fallback ellipsoidal height for one point per the - * brief's fallback chain: `sourceOrthometricM + geoidHeight` when a finite - * orthometric source height is available, else `geoidHeight` alone (treats - * the point as if it were at the geoid, i.e. H≈0 — a coast-level prior). - * Assumes `ensureGeoidReady()` has already resolved. - * @param {number} lat - * @param {number} lon - * @param {number} [sourceOrthometricM] - * @returns {number} - */ -function geoidFallback(lat, lon, sourceOrthometricM) { - const n = geoidHeight(lat, lon); - return Number.isFinite(sourceOrthometricM) ? sourceOrthometricM + n : n; -} - -/** - * Resolves ellipsoidal ground height for a batch of coordinates, in order. - * - * - Results already warm in the in-memory cache are returned without any - * network call. - * - Remaining (deduplicated) coordinates are sent to `/api/terrain/heights` - * in sequential chunks of <=64 points. - * - If a chunk request fails (network error, non-ok HTTP, malformed body), - * every point in THAT chunk falls back to geoid math - * (`sourceOrthometricM + geoidHeight` or `geoidHeight` alone) rather than - * failing the whole batch — a transient proxy outage in the middle of a - * large batch shouldn't blank out entities whose chunk happened to land - * earlier or later. - * - Every resolved value (proxy or fallback) is written back into the - * in-memory cache before this function returns, so a later call — even - * with a real proxy round-trip pending — sees a warm hit. - * - * @param {Array<{lat:number, lon:number, sourceOrthometricM?:number}>} coords - * @returns {Promise>} - * Same length and order as `coords`. - */ -export async function resolveEllipsoidalGround(coords) { - if (!Array.isArray(coords) || coords.length === 0) return []; - - // Build the per-input work list (key + original index) up front so the - // final assembly can map back positionally regardless of how many inputs - // shared a cache key or a chunk. - const work = coords.map((c, index) => ({ - index, - lat: c.lat, - lon: c.lon, - sourceOrthometricM: c.sourceOrthometricM, - key: cacheKey(c.lat, c.lon), - })); - - // Points not yet warm in the cache, deduplicated by key (multiple inputs — - // even within the same call — naming the same rounded coordinate should - // only ever hit the network once). Round 5: a geoid-FALLBACK entry does - // NOT count as permanently warm — it was cached when the proxy failed - // mid-burst and (at elevated terrain) is a sea-level poison that sank every - // floor built on it. Give failed keys a short cooldown before re-requesting - // so warm consumers do not hammer a failing proxy; after it expires, a - // successful response replaces the fallback entry and clears the path. - const uncached = []; - const seenKeys = new Set(); - const now = Date.now(); - for (const item of work) { - const entry = cache.get(item.key); - const fallbackCooling = entry?.source === 'geoid-fallback' - && Number.isFinite(entry.retryAt) - && now < entry.retryAt; - if ((entry && entry.source === 'reearth') || fallbackCooling || seenKeys.has(item.key)) continue; - seenKeys.add(item.key); - uncached.push(item); - } - - // Resolve the network path in sequential <=CHUNK_SIZE chunks. Each chunk's - // failure is isolated to that chunk's points (geoid fallback), so a single - // bad chunk doesn't lose results for the rest of a large batch. - for (let i = 0; i < uncached.length; i += CHUNK_SIZE) { - const chunk = uncached.slice(i, i + CHUNK_SIZE); - try { - const resolved = await fetchChunk(chunk); - for (const item of chunk) { - const ellipsoid = resolved.get(item.key); - // Round 6: only a FINITE value may be cached as 'reearth'. A point - // the upstream response omitted used to cache {ellipsoid: undefined, - // source: 'reearth'} — permanently "warm" yet empty, so its floor - // read null forever and every later warm skipped it (ATL verify: - // one contact frozen at the geoid while its neighbors resolved). - // An omitted point now caches nothing and retries on the next warm. - if (Number.isFinite(ellipsoid)) { - cache.set(item.key, { ellipsoid, source: 'reearth' }); - } - } - } catch { - // Proxy down (or cold cache had nothing to serve-stale) — fall back to - // geoid math for every point in this chunk. `ensureGeoidReady()` is - // awaited lazily, only on the fallback path, so the common (proxy - // healthy) case never pays for the geoid grid's dynamic import. - await ensureGeoidReady(); - for (const item of chunk) { - const ellipsoid = geoidFallback(item.lat, item.lon, item.sourceOrthometricM); - cache.set(item.key, { - ellipsoid, - source: 'geoid-fallback', - retryAt: Date.now() + GEOID_FALLBACK_COOLDOWN_MS, - }); - } - } - } - - // Assemble the output in the original input order from the cache. A point - // the upstream omitted has NO entry (round 6 — deliberately uncached so it - // retries later): report it unresolved instead of throwing. - return work.map((item) => { - const entry = cache.get(item.key); - return entry - ? { ellipsoid: entry.ellipsoid, source: entry.source } - : { ellipsoid: null, source: 'unresolved' }; - }); -} +const terrainHeights = createTerrainHeights({ source: applicationServices.terrain }); +export const { cachedEllipsoidalGround, cachedRealEllipsoidalGround, resolveEllipsoidalGround } = terrainHeights; diff --git a/src/hud.js b/src/hud.js index 95721f9f0..9f4485162 100644 --- a/src/hud.js +++ b/src/hud.js @@ -66,7 +66,8 @@ export class IntelHUD { * @param {Cesium.Viewer} viewer - The Cesium Viewer instance used for * camera telemetry and coordinate derivation. */ - constructor(viewer, { placeSearch, summaryPolicy = {}, basemapContext = {} } = {}) { + constructor(viewer, { placeSearch, summaryPolicy = {}, basemapContext = {}, summaryService = applicationServices.summary } = {}) { + this.summaryService = summaryService; this.summaryPolicy = summaryPolicy; this.basemapContext = basemapContext; this.placeSearch = placeSearch; @@ -664,7 +665,7 @@ export class IntelHUD { this._summaryRequest = controller; try { this.summaryPolicy.onRequest?.(); - const response = await applicationServices.summary.summarize(context, { signal: controller.signal }); + const response = await this.summaryService.summarize(context, { signal: controller.signal }); const data = response.data; if (revision !== this._summaryRevision) return; if (isHudSummaryUnconfigured(response.status, data)) { diff --git a/src/keylessGeocoder.test.mjs b/src/keylessGeocoder.test.mjs index 2c9565054..2ce8a70b4 100644 --- a/src/keylessGeocoder.test.mjs +++ b/src/keylessGeocoder.test.mjs @@ -310,7 +310,7 @@ test('accents and punctuation do not change what a name compares as', () => { test('forward geocoding is composed once and consumers do not call a source directly', () => { for (const file of ['locations.js', 'annotations/annotationResolver.js', 'voice/gevActions.js']) { - const source = fs.readFileSync(path.join(ROOT, 'src', file), 'utf8'); + const source = readGeocodingConsumer(file); assert.match(source, /placeSearch\.geocode\(/); assert.doesNotMatch(source, /geocodeKeyless|geocode\/json\?address/); } @@ -325,7 +325,7 @@ test('a missing key is never a thrown error on the client', () => { // It was one, in the Radio layer: a keyless install surfaced "play radio near // Hanoi" as a failed voice turn rather than as a station it could not place. for (const file of ['locations.js', 'annotations/annotationResolver.js', 'voice/gevActions.js']) { - const body = fs.readFileSync(path.join(ROOT, 'src', file), 'utf8'); + const body = readGeocodingConsumer(file); assert.doesNotMatch(body, /throw new Error\([^)]*No Google Maps API key/, `${file} throws on a missing key`); } }); @@ -446,3 +446,9 @@ test('a biased pass that never answered leaves the whole lookup unanswered', asy assert.deepEqual(outcome, { place: null, answered: false }); assert.equal(urls.length, 2); }); + +function readGeocodingConsumer(file) { + const files = file === 'annotations/annotationResolver.js' + ? [file, 'annotations/resolver.js'] : [file]; + return files.map((name) => fs.readFileSync(path.join(ROOT, 'src', name), 'utf8')).join('\n'); +} diff --git a/src/locations.js b/src/locations.js index c248fe66a..d8af47430 100644 --- a/src/locations.js +++ b/src/locations.js @@ -794,7 +794,7 @@ export async function searchAndFlyTo(viewer, query, options = {}) { let viewport = result?.viewport || null; // Nearby landmark recovery retains precedence over a fallback geocoder hit. - const recovered = await placesNearViewRecovery( + const recovered = await (options.recoverNearView || placesNearViewRecovery)( viewer, query, result && !outcome.fallbackUsed ? { lat, lon: lng } : null, @@ -885,7 +885,7 @@ export async function searchAndFlyTo(viewer, query, options = {}) { const shouldResolveBuilding = navigationMode === 'precise-place'; const buildingBounds = shouldResolveBuilding - ? await resolveBuildingBounds(lat, lng, query) + ? await resolveBuildingBounds(lat, lng, query, options.boundaries) : null; const range = requestedRange || defaultRangeForNavigationMode(navigationMode); if (!mayFly()) return CANCELLED_SEARCH; @@ -1330,7 +1330,7 @@ function buildingPitch(bounds) { return -32; } -async function resolveBuildingBounds(lat, lon, query) { +async function resolveBuildingBounds(lat, lon, query, boundaries = applicationServices.boundaries) { const overpassQuery = ` [out:json][timeout:10]; ( @@ -1346,7 +1346,7 @@ async function resolveBuildingBounds(lat, lon, query) { const controller = new AbortController(); const timeout = window.setTimeout(() => controller.abort(), 6000); try { - const elements = await applicationServices.boundaries.query(overpassQuery, { signal: controller.signal }); + const elements = await boundaries.query(overpassQuery, { signal: controller.signal }); return selectBuildingBounds(Array.isArray(elements) ? elements : [], lat, lon, query); } catch { return null; diff --git a/src/services/groundFloor.js b/src/services/groundFloor.js new file mode 100644 index 000000000..0200455e9 --- /dev/null +++ b/src/services/groundFloor.js @@ -0,0 +1,661 @@ +/** Construct an instance-owned groundFloor service with explicit dependencies. */ +export function createGroundFloor({ terrain, signal }) { + const { cachedRealEllipsoidalGround, resolveEllipsoidalGround } = terrain; + signal?.throwIfAborted(); + // src/data/groundFloor.js — coarse ground-floor clamp for entity render + // heights (field-test round 2026-07-06). + // + // Two owner findings drove this module: + // - RS46 (military H60): baro-only low-altitude contacts near steep terrain + // render INSIDE the hillside (no alt_geom → baro+N is off by more than the + // local relief). + // - WAKE01 (military B212): trail waypoints flagged 'ground' rendered at a + // fixed 50 m ellipsoidal sentinel — ~1.5 km underground at Kirtland AFB + // (field elevation ~1590 m ellipsoidal). + // + // The fix everywhere is the same: never render below the local ellipsoidal + // surface. This module provides the shared floor: a COARSE (3-decimal, + // ~111 m) grid over Task 3's terrainHeights resolver/cache, so that moving + // aircraft and multi-hundred-point trails collapse onto a small set of cells + // with heavy cache reuse (the exact 5-decimal grid would mint a new cache key + // every fix — the taxiing-cache lesson from the adversarial review). + // A ~111 m cell means the floor can be off by the relief within the cell in + // steep terrain; the clamp is a visual floor, not a survey — lifting an icon + // a few metres short of a ridge line still beats burying it. + // + // Zero-network on the render path: reads are synchronous cache lookups; + // `warmGroundFloor` batches the misses through resolveEllipsoidalGround + // (single-flight, fire-and-forget) exactly like flights.js's grounded-surface + // warm — results land for later polls/appends. + + /** @constant {number} Metres added above the resolved floor so a clamped + * billboard/waypoint sits ON the surface instead of z-fighting it. */ + const GROUND_FLOOR_LIFT_M = 1.5; + /** Accepted rendered-mesh window around a real ellipsoidal DEM prior. */ + const MESH_FLOOR_BELOW_PRIOR_M = 15; + const MESH_FLOOR_ABOVE_PRIOR_M = 80; + + /** + * Snaps a coordinate to the coarse (3-decimal, ~111 m) floor grid. + * @param {number} lat + * @param {number} lon + * @returns {{lat: number, lon: number}} Grid-cell coordinate. + */ + function coarseFloorCoord(lat, lon) { + return { lat: Number(lat.toFixed(3)), lon: Number(lon.toFixed(3)) }; + } + + /** + * Pure floor clamp. + * - finite alt + finite ground → max(alt, ground + lift) + * - finite alt + unknown ground → alt (no data, no clamp) + * - null alt + finite ground → ground + lift (the 'ground'-waypoint case: + * the point IS on the surface; put it there) + * - null alt + unknown ground → null (caller applies its own fallback) + * @param {number|null|undefined} altM - Proposed ellipsoidal render height. + * @param {number|null|undefined} groundM - Local ellipsoidal ground, if known. + * @param {number} [liftM] - Clearance above the floor. + * @returns {number|null} + */ + function floorAltitudeM(altM, groundM, liftM = GROUND_FLOOR_LIFT_M) { + const hasAlt = Number.isFinite(altM); + const hasGround = Number.isFinite(groundM); + if (hasAlt && hasGround) return Math.max(altM, groundM + liftM); + if (hasAlt) return altM; + if (hasGround) return groundM + liftM; + return null; + } + + /** + * Floor decision at the point of DISPLAY (2026-08-19). + * + * `floorAltitudeM` above answers "what height should this FIX render at" and + * runs once per poll. But what the user sees is the DEAD-RECKONED position: + * a Cartesian interpolation between two fixes for the whole segment, and a + * velocity extrapolation of the newest fix while a contact coasts through its + * stale-feed grace. Both drift the rendered point horizontally away from the + * fix whose cell supplied the height — across a graded apron (KAUS spans + * ~119–140 m ellipsoidal) a taxiing sprite therefore renders under the mesh it + * has drifted over (measured −15.5 m; `scripts/qa-floor-verify.mjs`). + * + * This is the same "never below the visible surface" rule applied at the + * displayed coordinate instead of the fix coordinate. It returns `null` rather + * than the unchanged height whenever nothing should move, so the per-frame + * caller can skip rebuilding a Cartesian entirely — the common case by far. + * + * @param {number|null|undefined} displayHeightM - Ellipsoidal height the + * dead-reckoned display position currently carries. + * @param {number|null|undefined} floorM - Ellipsoidal floor at the DISPLAYED + * coordinate (`cachedGroundFloor`), or null when that cell is not warm. + * @param {number} [liftM] - Clearance above the floor. + * @returns {number|null} The height to render at, or null for "leave it alone". + */ + function displayFloorHeightM( + displayHeightM, + floorM, + liftM = GROUND_FLOOR_LIFT_M, + ) { + if (!Number.isFinite(displayHeightM) || !Number.isFinite(floorM)) + return null; + const lifted = floorM + liftM; + return displayHeightM < lifted ? lifted : null; + } + + /** @constant {number} Pitch of the coarse floor grid in degrees — the spacing + * `coarseFloorCoord` rounds to, and therefore the step between adjacent cells. */ + const FLOOR_CELL_DEG = 0.001; + + /** @constant {number} Resolved neighbours required before one may be borrowed. + * A single reading cannot be checked against anything: a hangar roof and an + * apron are both "a warm cell 111 m away", and there is no way to tell them + * apart from one number. Two readings at least agree or disagree. */ + const NEIGHBOR_FLOOR_MIN_SAMPLES = 2; + + /** + * A floor for `cell` borrowed from the eight cells ADJACENT to it. + * + * Used only as a stand-in while `cell` itself is unresolved (2026-08-21: the + * Re:Earth proxy timed out four times in a row and a grounded contact standing + * on a cold cell rendered at the geoid — tens of metres under the mesh it was + * parked on). A neighbour is ~111 m away; on the aprons and taxiways where + * grounded contacts live that is the same surface, and it is a MEASURED height + * rather than an invented one. + * + * Leans to the LOWEST resolved neighbour, and refuses to answer at all from + * fewer than NEIGHBOR_FLOOR_MIN_SAMPLES of them. + * + * An earlier cut leaned HIGH, reasoning from the locked "never below the + * visible surface" principle. That principle is about a contact's OWN measured + * ground; applied to a BORROWED cell it inverts, and an owner playtest found + * why — planes floating in midair at terminal gates. The two errors are not + * symmetric: + * - Too LOW is inert. `displayFloorHeightM` only ever RAISES a position, so a + * floor under the contact simply does not lift it — the same "no clamp" the + * cold-cell case already has, bounded by one cell of grade (metres). + * - Too HIGH actively invents a position. It is bounded by BUILDING HEIGHT, + * not by grade, and a parked contact holds it: measured on the A/B rig, + * a lone roof neighbour put a contact 29.5 m up and left it there + * permanently when its own cell never warmed, and 6.3 s of visible + * stair-stepping when it did. + * A plane at a gate is on the apron, never on the terminal roof, so on a + * structure edge the LOW neighbour is the answer and the high one is an + * artefact of the photogrammetric mesh. + * + * The honest residual: on a genuine SLOPE the lowest neighbour under-reads by + * the grade across ~111 m, so the clamp lifts a little less than it could. That + * shows up as no lift rather than as a wrong one, and the contact's own cell + * corrects it as soon as it warms. + * + * @param {{lat: number, lon: number}|null|undefined} cell - Coarse cell. + * @returns {number|null} Ellipsoidal floor, or null when too few neighbours are + * warm to borrow from. + */ + function neighborFloorM(cell) { + if (!Number.isFinite(cell?.lat) || !Number.isFinite(cell?.lon)) return null; + let lowest = null; + let resolved = 0; + for (let dLat = -1; dLat <= 1; dLat += 1) { + for (let dLon = -1; dLon <= 1; dLon += 1) { + if (dLat === 0 && dLon === 0) continue; + const h = cachedGroundFloor( + cell.lat + dLat * FLOOR_CELL_DEG, + cell.lon + dLon * FLOOR_CELL_DEG, + ); + if (h == null) continue; + resolved += 1; + if (lowest == null || h < lowest) lowest = h; + } + } + return resolved >= NEIGHBOR_FLOOR_MIN_SAMPLES ? lowest : null; + } + + /** @constant {number} Cap on cells one corridor may contribute — a fast rollout + * or a spliced fix must not turn one contact into an unbounded warm batch. + * Sized for the worst realistic ground segment: a 27 m/s rollout coasts ~810 m + * (≈7 cells) in one poll interval, leaving headroom plus the endpoint slot. */ + const CORRIDOR_MAX_CELLS = 12; + /** @constant {number} Step along a corridor leg when collecting cells (~14 m, + * an eighth of a cell). Half-cell steps were only cell-complete on LONG legs: + * a short leg rounds to a single step, so the walk degenerates to testing the + * leg's endpoints and a cell clipped between them is skipped. At this step any + * cell the path occupies for ~14 m of ground or more contains a sample — the + * guarantee this walk actually makes. The loop still exits the moment the cell + * budget fills, so a finer step costs iterations only on short corridors. */ + const CORRIDOR_WALK_STEP_DEG = 0.000125; + /** @constant {number} Degrees added around a cell before a contact is allowed + * to adopt the next one (~22 m). A dead-reckoned position that jitters across + * a 0.001° edge would otherwise alternate floors at fleet-tick rate and pop + * the sprite between two heights. */ + const CELL_HYSTERESIS_DEG = 0.0002; + + /** + * The floor cell a contact should read, with boundary hysteresis. + * + * Cells are 0.001° squares, so a display position sitting on an edge (parked + * GPS jitter, the tracked reconciliation's decaying correction) flips between + * two cells frame to frame — and their floors can differ by the local grade or, + * next to a building, several metres. The contact keeps its previous cell until + * it is CELL_HYSTERESIS_DEG clear of that cell's bounds, which costs two + * comparisons and no allocation on the sticky path. + * + * @param {number} lat @param {number} lon - Displayed coordinate. + * @param {{lat: number, lon: number}|null|undefined} previousCell - The cell + * this contact read last tick, if any. + * @returns {{lat: number, lon: number}} The cell to read. + */ + function stickyFloorCell(lat, lon, previousCell) { + if ( + previousCell && + Math.abs(lat - previousCell.lat) <= 0.0005 + CELL_HYSTERESIS_DEG && + Math.abs(lon - previousCell.lon) <= 0.0005 + CELL_HYSTERESIS_DEG + ) { + return previousCell; + } + return coarseFloorCoord(lat, lon); + } + + /** + * The coarse cells a contact's DISPLAY is about to walk through. + * + * The dead-reckoned display runs one render delay behind and advances toward + * the newest fix, so the segment [display → newest fix] is exactly the ground + * it will render over during the next poll or two. Warming only the cell it is + * standing on warms the cell it is LEAVING: at 10 m/s a contact crosses a + * ~111 m cell every ~11 s while the warm batch runs once per 30 s poll, so it + * stays permanently ahead of its own floor data and the display clamp has + * nothing to read (KAUS: sprites 2–4 m under the mesh with a cold cell for the + * whole observation). + * + * Walks in half-cell steps so no cell between the ends is skipped. The step + * COUNT follows the segment length (never a fixed cap — a capped count over a + * long segment samples sparsely and leaves holes in the middle of ground the + * contact does cross). Length is bounded instead, and honestly: the walk emits + * a GAP-FREE PREFIX from `from`, and the destination cell always takes the last + * slot. A corridor longer than CORRIDOR_MAX_CELLS is therefore truncated — + * contiguous near ground plus the far endpoint — rather than thinned; the + * ground in between is picked up on a later poll as the display advances into + * it. Loop work stays bounded because the walk exits as soon as the cell budget + * fills, whatever the segment length. + * + * Takes the display PATH as a polyline, not two endpoints: while the contact + * is extrapolating, that path is an arc (the dead-reckon integrates a + * constant-rate turn), and walking a straight chord between its ends would + * warm ground the contact never crosses while its actual arc stayed cold. + * + * @param {Array<{lat: number, lon: number}>} points - Display path, current + * position first, destination last. + * @returns {Array<{lat: number, lon: number}>} Deduped coarse cells, start + * first, destination last. + */ + function corridorFloorCells(points) { + if (!Array.isArray(points) || !points.length) return []; + const first = points[0]; + if (!Number.isFinite(first?.lat) || !Number.isFinite(first?.lon)) return []; + const start = coarseFloorCoord(first.lat, first.lon); + const out = [start]; + const seen = new Set([`${start.lat},${start.lon}`]); + let destination = null; + for (let i = points.length - 1; i >= 1; i--) { + if (Number.isFinite(points[i]?.lat) && Number.isFinite(points[i]?.lon)) { + destination = coarseFloorCoord(points[i].lat, points[i].lon); + break; + } + } + if (!destination) return out; + const destKey = `${destination.lat},${destination.lon}`; + // One slot is reserved for the destination, appended after the prefix. + const prefixLimit = CORRIDOR_MAX_CELLS - 1; + walk: for (let s = 0; s < points.length - 1; s++) { + const a = points[s]; + const b = points[s + 1]; + if (![a?.lat, a?.lon, b?.lat, b?.lon].every(Number.isFinite)) continue; + const steps = Math.ceil( + Math.max(Math.abs(b.lat - a.lat), Math.abs(b.lon - a.lon)) / + CORRIDOR_WALK_STEP_DEG, + ); + for (let i = 1; i <= steps; i++) { + if (out.length >= prefixLimit) break walk; + const t = i / steps; + const cell = coarseFloorCoord( + a.lat + (b.lat - a.lat) * t, + a.lon + (b.lon - a.lon) * t, + ); + const key = `${cell.lat},${cell.lon}`; + if (seen.has(key) || key === destKey) continue; + seen.add(key); + out.push(cell); + } + } + if (!seen.has(destKey)) out.push(destination); + return out; + } + + /** + * Shares one poll's corridor-cell budget across the contacts that need it. + * + * The first cut walked contacts in Map insertion order and decremented the + * budget BEFORE deduping, so parked contacts burned slots on cells the poll had + * already collected and the contacts actually outrunning their floor data + * starved. Three rules fix that: + * 1. Cells already collected are FREE — a parked contact, whose corridor is + * its own fix cell, never competes for budget at all. + * 2. Candidates are ordered by how many of their cells are genuinely cold, + * then by speed (whoever leaves their floor behind soonest). + * 3. Allocation runs in widening rounds — one cell each, then `fairShare` + * each, then the remainder — so no contact is left with NOTHING while + * another takes seconds. The nearest cell is the one a contact needs first + * anyway; the far end of its corridor can wait a poll. + * + * @param {Array<{cells: Array<{lat: number, lon: number}>, cold: number, speedMps: number}>} candidates + * @param {Set} seen - Keys ("lat,lon") already collected; MUTATED with + * everything this call allocates, so the caller's dedupe stays consistent. + * Ranking alone still starves a TIE longer than the budget: 80 equally needy + * movers means the same stable prefix wins every poll and the tail never moves. + * The `epoch` rotates each run of equal-ranked candidates, so a tie cycles + * round-robin across polls while a genuinely needier contact is never demoted + * below a less needy one (rotation happens WITHIN a rank, never across ranks). + * + * `seen` alone is NOT a sufficient free-list: in production it holds only the + * CURRENT poll's batch, so a cell that warmed on an earlier poll looked new and + * spent budget again, letting a contact's near cells consume its whole share + * poll after poll while the ground ahead of it stayed cold. Budget is therefore + * charged only for cells with no floor yet (`isWarm`) — but warm cells are + * still EMITTED, because the mesh sampler must see a cell again after its DEM + * prior lands or it never latches a rendered-surface height there. + * + * That gives a service bound derivable from the policy rather than a magic + * number: the first round charges at most one cell per candidate, so at least + * `min(candidates, budget)` candidates are served per poll; a candidate that + * misses out keeps its cold count while every served candidate's drops, so it + * outranks them next poll. Every candidate is therefore served within + * `ceil(candidates / budget)` polls once the warms land. + * + * @param {number} budget - Max cold cells to charge for. + * @param {number} fairShare - Per-candidate cap for the middle round. + * @param {number} [epoch] - Poll counter; rotates equal-ranked runs. + * @param {(cell: {lat: number, lon: number}) => boolean} [isWarm] - Whether a + * cell already has a floor (defaults to the shared cache). + * @returns {Array<{lat: number, lon: number}>} Cells to add, allocation order. + */ + function allocateCorridorCells( + candidates, + seen, + budget, + fairShare, + epoch = 0, + isWarm = (cell) => cachedGroundFloor(cell.lat, cell.lon) != null, + ) { + const out = []; + if (!Array.isArray(candidates) || !candidates.length || !(budget > 0)) + return out; + const ranked = [...candidates].sort( + (a, b) => b.cold - a.cold || b.speedMps - a.speedMps, + ); + if (Number.isFinite(epoch) && epoch !== 0) { + for (let i = 0; i < ranked.length;) { + let j = i + 1; + while ( + j < ranked.length && + ranked[j].cold === ranked[i].cold && + ranked[j].speedMps === ranked[i].speedMps + ) + j += 1; + const runLength = j - i; + if (runLength > 1) { + const shift = ((epoch % runLength) + runLength) % runLength; + const run = ranked.slice(i, j); + for (let m = 0; m < runLength; m += 1) + ranked[i + m] = run[(m + shift) % runLength]; + } + i = j; + } + } + // Cursor per candidate, carried ACROSS rounds: a later round resumes where + // the previous one stopped instead of re-walking cells already handled. + const cursor = new Array(ranked.length).fill(0); + let left = budget; + const round = (limit) => { + for (let i = 0; i < ranked.length; i++) { + const cells = ranked[i].cells || []; + while (cursor[i] < cells.length && cursor[i] < limit) { + const cell = cells[cursor[i]]; + const key = `${cell.lat},${cell.lon}`; + if (seen.has(key)) { + cursor[i] += 1; + continue; + } // already in this batch + if (isWarm(cell)) { + // Free: it has a floor already. Still emitted, so the mesh sampler + // gets another look now that its DEM prior exists. + cursor[i] += 1; + seen.add(key); + out.push(cell); + continue; + } + if (left <= 0) return; + cursor[i] += 1; + seen.add(key); + out.push(cell); + left -= 1; + } + } + }; + round(1); // nobody goes home empty-handed + round(fairShare); // then a fair slice each + round(CORRIDOR_MAX_CELLS); // then the remainder, neediest first + return out; + } + + // --- Mesh-floor cells (round 4, owner-approved design) --------------------- + // The Re:Earth DEM is BARE EARTH; the visible world in the google-3d regime + // is the photogrammetric MESH, which sits above it (measured ~17 m at the + // Austin airport apron). DEM-flooring therefore still buried sprites/trails + // inside the mesh. These cells hold the RENDERED surface height, sampled + // one-shot per coarse cell near the viewer (scene.sampleHeight — the same + // source groundSnap uses, which is why 3D models always looked right) and + // reported here by meshFloorSampler.js. Design principle (owner): NEVER + // below the visible surface; slightly above is always fine. The DEM stays + // the instant global prior/fallback and the sanity gate for samples. + /** @type {Map} coarse cell key -> rendered-surface height (m, ellipsoidal). */ + const _meshCells = new Map(); + /** @type {boolean} Whether mesh cells apply — true only in the google-3d + * regime (photoreal stack). On globe stacks the rendered surface IS the + * terrain provider (Re:Earth on keyless), so the DEM is already correct + * there and mesh heights would float. Kept current by meshFloorSampler's + * 'gev:map-stack-changed' listener. Photoreal is the boot default. */ + let _meshPreferred = true; + + /** @param {boolean} preferred - google-3d regime active. */ + function setMeshFloorPreferred(preferred) { + _meshPreferred = !!preferred; + } + + /** @returns {boolean} Whether mesh-floor cells currently apply. */ + function meshFloorPreferred() { + return _meshPreferred; + } + + /** + * Records a sampled rendered-surface height for the cell containing the + * coordinate (one-shot latch — first accepted sample wins). + * @param {number} lat @param {number} lon @param {number} heightM + */ + function reportMeshFloorCell(lat, lon, heightM) { + if ( + !Number.isFinite(lat) || + !Number.isFinite(lon) || + !Number.isFinite(heightM) + ) + return; + const c = coarseFloorCoord(lat, lon); + const key = `${c.lat},${c.lon}`; + if (!_meshCells.has(key)) _meshCells.set(key, heightM); + } + + /** Return whether a rendered-mesh sample is plausible against a real DEM prior. */ + function meshFloorSampleWithinPrior(heightM, priorM) { + return ( + Number.isFinite(heightM) && + Number.isFinite(priorM) && + heightM >= priorM - MESH_FLOOR_BELOW_PRIOR_M && + heightM <= priorM + MESH_FLOOR_ABOVE_PRIOR_M + ); + } + + /** + * Validate and record a rendered-mesh floor through the single shared gate. + * Callers must not bypass this with raw `reportMeshFloorCell`: a coarse-LOD, + * rooftop, or aircraft hit would otherwise win the session-long first-write + * latch and poison every floor consumer in the cell. + * @returns {boolean} Whether the sample passed and was reported. + */ + function reportValidatedMeshFloorCell(lat, lon, heightM) { + if (![lat, lon, heightM].every(Number.isFinite)) return false; + const cell = coarseFloorCoord(lat, lon); + const prior = cachedRealEllipsoidalGround(cell.lat, cell.lon); + if (!meshFloorSampleWithinPrior(heightM, prior)) return false; + reportMeshFloorCell(cell.lat, cell.lon, heightM); + return true; + } + + /** + * Synchronous mesh-cell read — null when the cell is unsampled OR the + * google-3d regime is inactive (globe stacks must floor on the DEM). + * @param {number} lat @param {number} lon + * @returns {number|null} + */ + function cachedMeshFloor(lat, lon) { + if (!_meshPreferred) return null; + if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null; + const c = coarseFloorCoord(lat, lon); + const h = _meshCells.get(`${c.lat},${c.lon}`); + return h != null ? h : null; + } + + /** Test hook: clears mesh cells (never called at runtime — cells are valid all session). */ + function _clearMeshFloorCellsForTest() { + _meshCells.clear(); + } + + /** + * Synchronous coarse-cell ground read (warm cache only — never a fetch). + * Prefers the RENDERED-surface mesh cell (google-3d regime) and falls back + * to the Re:Earth DEM cell — the single choke point every ground-adjacent + * consumer (fleet clamp, grounded surface chain, trail floors) reads, so + * they all agree on one surface. + * @param {number} lat + * @param {number} lon + * @returns {number|null} Ellipsoidal floor of the cell, or null if not warm. + */ + function cachedGroundFloor(lat, lon) { + if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null; + const mesh = cachedMeshFloor(lat, lon); + if (mesh != null) return mesh; + const c = coarseFloorCoord(lat, lon); + return cachedRealEllipsoidalGround(c.lat, c.lon); + } + + /** + * Awaitable variant for one-shot consumers (trail backfills): dedupes the + * points to unique coarse cells and resolves them, so `cachedGroundFloor` + * reads immediately after are warm. The underlying resolver chunks (≤200/req) + * and falls back per-point, so any trace length is safe. Never throws. + * @param {Array<{lat: number, lon: number}>} points + * @returns {Promise} + */ + /** @constant {number} Max ms a one-shot consumer (trail backfill) waits for + * the floor resolve before painting with whatever cells are warm. Owner + * field test 2026-07-06 round 2: an UNBOUNDED await here held trail paints + * hostage to cold Re:Earth lookups spanning a whole flight path (seconds to + * the 30 s proxy timeout) — trails showed up late, short, or not at all. + * The resolve keeps running past the deadline and lands in the cache for + * the next paint/select. */ + const FLOOR_RESOLVE_DEADLINE_MS = 1200; + + /** + * Bounded-latency variant of resolveGroundFloorCells: waits at most + * FLOOR_RESOLVE_DEADLINE_MS, then returns so the caller can paint with the + * cells that ARE warm. The underlying resolve continues in the background + * and fills the cache for later reads. Never throws. + * @param {Array<{lat: number, lon: number}>} points + * @returns {Promise} + */ + function resolveGroundFloorCellsBounded(points) { + return Promise.race([ + resolveGroundFloorCells(points), + new Promise((resolve) => setTimeout(resolve, FLOOR_RESOLVE_DEADLINE_MS)), + ]); + } + + async function resolveGroundFloorCells(points) { + if (!Array.isArray(points) || !points.length) return; + const cells = new Map(); + for (const p of points) { + if (!Number.isFinite(p?.lat) || !Number.isFinite(p?.lon)) continue; + const c = coarseFloorCoord(p.lat, p.lon); + const key = `${c.lat},${c.lon}`; + if (cells.has(key)) continue; + if (cachedRealEllipsoidalGround(c.lat, c.lon) != null) continue; + cells.set(key, c); + } + if (!cells.size) return; + try { + await resolveEllipsoidalGround([...cells.values()]); + } catch { + /* best-effort — unresolved cells simply don't clamp */ + } + } + + /** @type {boolean} Single-flight guard for the warm batch. */ + let _floorBatchInFlight = false; + /** @type {Map} Cells requested while a + * batch was in flight — flushed as the NEXT batch the moment it completes. + * Round 6: the old guard silently DROPPED contending calls (flights every + * 30 s, military every 15 s, trail backfills ad hoc), so arriving at a new + * airport could take minutes of lost polls before a given cell resolved — + * the owner's "you can't just go to a place and start inspecting it". */ + const _pendingFloorCells = new Map(); + + /** Kicks the resolver for a cell map, chaining any cells queued meanwhile. */ + function _resolveFloorCells(cells) { + _floorBatchInFlight = true; + resolveEllipsoidalGround([...cells.values()]) + .catch(() => { + /* best-effort — unclamped this tick, floor lands later */ + }) + .finally(() => { + _floorBatchInFlight = false; + if (!signal?.aborted && _pendingFloorCells.size) { + const next = new Map(_pendingFloorCells); + _pendingFloorCells.clear(); + _resolveFloorCells(next); + } + }); + } + + /** + * Fire-and-forget batch warm of the coarse floor cells for the given points. + * Dedupes to unique cells, skips already-warm ones, and never blocks or + * throws into the caller. Contending calls QUEUE (never drop): their cells + * ride the next batch as soon as the in-flight one completes. Results are + * picked up by later `cachedGroundFloor` reads. + * @param {Array<{lat: number, lon: number}>} points + */ + function warmGroundFloor(points) { + if (signal?.aborted) return; + if (!Array.isArray(points) || !points.length) return; + const cells = new Map(); + for (const p of points) { + if (!Number.isFinite(p?.lat) || !Number.isFinite(p?.lon)) continue; + const c = coarseFloorCoord(p.lat, p.lon); + const key = `${c.lat},${c.lon}`; + if (cells.has(key) || _pendingFloorCells.has(key)) continue; + if (cachedRealEllipsoidalGround(c.lat, c.lon) != null) continue; // already warm + cells.set(key, c); + } + if (!cells.size) return; + if (_floorBatchInFlight) { + for (const [key, c] of cells) _pendingFloorCells.set(key, c); + return; + } + _resolveFloorCells(cells); + } + + signal?.addEventListener( + 'abort', + () => { + _meshCells.clear(); + _pendingFloorCells.clear(); + }, + { once: true }, + ); + return { + GROUND_FLOOR_LIFT_M, + MESH_FLOOR_BELOW_PRIOR_M, + MESH_FLOOR_ABOVE_PRIOR_M, + coarseFloorCoord, + floorAltitudeM, + displayFloorHeightM, + FLOOR_CELL_DEG, + NEIGHBOR_FLOOR_MIN_SAMPLES, + neighborFloorM, + CORRIDOR_MAX_CELLS, + CORRIDOR_WALK_STEP_DEG, + CELL_HYSTERESIS_DEG, + stickyFloorCell, + corridorFloorCells, + allocateCorridorCells, + setMeshFloorPreferred, + meshFloorPreferred, + reportMeshFloorCell, + meshFloorSampleWithinPrior, + reportValidatedMeshFloorCell, + cachedMeshFloor, + _clearMeshFloorCellsForTest, + cachedGroundFloor, + FLOOR_RESOLVE_DEADLINE_MS, + resolveGroundFloorCellsBounded, + resolveGroundFloorCells, + warmGroundFloor, + }; +} diff --git a/src/services/groundSnap.js b/src/services/groundSnap.js new file mode 100644 index 000000000..159cd66fd --- /dev/null +++ b/src/services/groundSnap.js @@ -0,0 +1,388 @@ +/** + * groundSnap.js — one-shot cached ground-height snap for MODELED grounded aircraft. + * + * Why: a parked/taxiing plane's feed altitude is last-known baro or 0 m — nowhere + * near the photoreal tile skin in ellipsoid heights (buried ~100+ m at inland + * airports like AUS, floating ~30 m at sea-level fields like SFO, where the geoid + * sits below the ellipsoid). A 3D model placed at the meta altitude therefore + * renders inside the tiles or hovering above them. Billboards dodge this with + * disableDepthTestDistance; a depth-tested glTF model cannot. + * + * Discipline (CCTV-B9b style): scene.sampleHeight forces a synchronous offscreen + * pick render, so it is NEVER called per frame. Each grounded modeled plane gets + * ONE successful sample, cached per icao; the cache stops answering directly when + * the plane moves >MOVE_INVALIDATE_M from the sampled spot (taxiing). Misses (tiles + * still streaming, OSM fallback with no tileset skin, sample outside loaded + * geometry) are a retry-later signal with per-icao exponential backoff. A + * per-window budget stops a freshly modeled airport cluster from firing dozens of + * pick renders in one fleet tick. + * + * What a caller gets while a sample is outstanding depends on whether this + * contact has ever HAD one, and the difference is the difference between two + * kinds of wrong: + * - COLD (nothing ever resolved here) — null. There is no evidence of where the + * ground is, and the only honest answer for a depth-tested model is not to + * place it at all; the caller keeps the depth-test-free billboard. + * - WARM (a snap resolved, then taxi invalidated it and the resample has not + * landed) — the last measurement, held within HELD_SNAP_MAX_DRIFT_M. Hiding + * the model here would pop a taxiing aircraft from 3D back to 2D for a whole + * retry backoff, and the ground it was measured on a few dozen metres back is + * real evidence, not a guess. Same shape as the billboard chain's + * `_heldDisplayFloorM` (flights.js): hold the last thing that resolved, + * bounded by how far the contact can have travelled from it. + * + * A hold is a MEMORY, and it is ranked accordingly: held evidence never outranks + * fresh evidence that contradicts it. Distance travelled is only a proxy for + * "does this still describe the ground here", and it is a loose one — a contact + * can taxi 200 m onto a different surface well inside the bound, and because a + * miss preserves the hold, nothing in the retry path would ever correct that. + * So where the mesh-floor cells have actually MEASURED the surface under the + * contact's current position, and that surface is ABOVE the held value by more + * than HELD_SNAP_CONTRADICTION_M, the measurement wins: the hold is dropped and + * the contact is COLD again. The fallback is the floored 2D billboard, which is + * the honest picture — a model buried in the apron is not a better one. + * + * Shared by flights.js and militaryFlights.js (one instance each — the caches + * are per-layer, keyed by that layer's icao space). + */ + +import * as Cesium from 'cesium'; + +/** Taxi threshold: a cached snap answers directly for moves up to this far from + * the sampled spot (m). Past it the value is demoted to a held last-known and a + * resample runs. */ +const MOVE_INVALIDATE_M = 50; +/** @constant {number} How far a contact may travel from the spot its snap was + * measured at before that measurement stops describing the ground under it, and + * the hold is dropped rather than stretched. + * + * Sized to two things at once. It has to outlast a resample outage, or it buys + * nothing: RETRY_MAX_MS is 30 s of backoff, and ordinary taxi is ~10 m/s, so a + * hold that expires inside ~25 s of rolling would hand back the pop it exists to + * prevent. And it has to stay inside the grade error an airfield can hide: the + * KAUS spread this file's header cites is ~21 m across a ~4 km field, ~0.5%, so + * 250 m of taxi is ~1.3 m of vertical error — the order of the belly offsets + * already being added, and two orders under the ~100+ m burial the snap exists + * to prevent. + * + * Deliberately a quarter of the billboard chain's HELD_FLOOR_MAX_DRIFT_KM. That + * hold only ever RAISES a position (`displayFloorHeightM` never lowers), so an + * under-read there is inert; here the held value IS the placement, so the error + * shows in both directions and the bound has to be tighter. */ +const HELD_SNAP_MAX_DRIFT_M = 250; +/** @constant {number} How far a held measurement may disagree with FRESH + * measured evidence at the contact's current position before it is dropped + * rather than trusted. + * + * The drift bound above asks "how far has the contact travelled?", which is a + * proxy for "does this value still describe the ground?" — and a proxy is all + * it is. 202 m of taxi onto a different surface is inside the bound and still + * wrong, and because a miss preserves the hold, repeated misses preserve the + * error for as long as they last. So the bound is not the only guard: where + * something has actually MEASURED the surface the contact is standing on now, + * that measurement outranks the memory, and a memory that contradicts it is + * discarded rather than stretched to cover the gap. + * + * 5 m sits between the two scales this has to separate: + * - ABOVE the honest disagreement between two measurements of the SAME + * surface. A floor cell is ~111 m across, so cell grade is ~0.6 m at the + * ~0.5 % KAUS spread this file's header cites and ~3.3 m even at a steep + * 3 %; the hold's own 250 m allowance is ~1.3 m at that grade. None of + * those should cost a taxiing aircraft its model. + * - BELOW the scale at which a placement reads as wrong. The belly offsets + * this snap adds are ~2–5 m and an airliner fuselage is ~4 m across, so a + * model displaced more than ~5 m is visibly off its gear rather than + * imprecisely on it — and it is two orders under the ~100+ m burial the + * snap exists to prevent. + * + * Applied in ONE direction: the hold is released only when fresh evidence sits + * ABOVE it, which is the BURIAL case — the model sunk into the surface, which + * is the entire reason this gate exists. The other sign is deliberately left + * alone, and the asymmetry is not tidiness: + * + * - A cell reading LOW against a real sample is the EXPECTED error here, not a + * contradiction. Cells are a one-shot first-write latch over ~111 m, the + * borrowed-neighbour path leans to the LOWEST on purpose (`neighborFloorM`), + * and `displayFloorHeightM` only ever RAISES — the whole floor chain is + * built to under-read rather than to invent. Releasing on that sign would + * discard good measurements wherever the two disagree downward. Measured on + * the track-regression rig: a planted cell sat 66.7 m BELOW a real sample at + * the SAME spot, and a two-sided rule dropped a hold that was correct. + * - The residual is stated rather than hidden, and stated WITH its condition. + * A contact taxiing DOWNHILL keeps a held value above its new surface and + * its model floats. The correction is a SUCCESSFUL resample, and nothing on + * this path guarantees one: on the OSM fallback sampleHeight misses forever, + * so the retries never land and the float persists for as long as the + * contact stays inside HELD_SNAP_MAX_DRIFT_M. There is no timer beside it + * and no vertical cap of its own — only whatever the ground drops within + * that radius. The other releases (a ground flip via forget(), travelling + * past the bound, a mesh cell measuring ABOVE the hold) are not ones a + * downhill taxi is guaranteed to hit either. It is accepted over the + * alternative because it errs UPWARD and stays visible, where a buried model + * errs into the terrain and cannot be seen at all. */ +const HELD_SNAP_CONTRADICTION_M = 5; +/** First retry delay after a miss / tiles-not-ready (ms); doubles per consecutive miss. */ +const RETRY_BASE_MS = 2000; +/** Backoff cap (ms) — on the OSM fallback (no tile skin) sampleHeight misses forever. */ +const RETRY_MAX_MS = 30000; +/** Max sampleHeight calls per window across the layer (airport-cluster burst guard). */ +const SAMPLE_BUDGET_PER_WINDOW = 4; +/** Budget window length (ms). */ +const SAMPLE_WINDOW_MS = 250; + +/** + * True when the active photoreal tileset has finished streaming the tiles in + * view (cctv.js's projectionTilesReady pattern: first Cesium3DTileset found in + * scene.primitives, duck-typed via its boolean tilesLoaded). Sampling before + * that both blows the frame budget on forced synchronous loads and produces + * misses that would burn retry backoff for nothing. With NO tileset present + * (OSM fallback) this returns true so the sample path is not permanently + * blocked — the sample just misses and backs off. + * @param {Cesium.Viewer} viewer + * @returns {boolean} + */ +function _tilesReady(viewer) { + const prims = viewer?.scene?.primitives; + if (!prims) return false; + for (let i = 0; i < prims.length; i++) { + let p = null; + try { + p = prims.get(i); + } catch { + continue; + } + if (p && p.show !== false && typeof p.tilesLoaded === 'boolean') { + return p.tilesLoaded === true; + } + } + return true; +} + +/** + * Create a per-layer ground-snap cache. + * @returns {{ + * heightFor: (viewer: Cesium.Viewer, icao: string, pos: Cesium.Cartesian3, + * getExclusions?: () => Array) => number|null, + * forget: (icao: string) => void, + * clear: () => void, + * }} + */ +export function createGroundSnap({ groundFloor }) { + const { cachedMeshFloor, meshFloorPreferred, reportValidatedMeshFloorCell } = + groundFloor; + /** @type {Map} Per-icao snap state: + * the sampled height and the surface point it was measured at, whether that + * pair is still the fresh answer or has been demoted to a bounded last-known + * (`held`), and the retry backoff a miss earned. */ + const entries = new Map(); + let windowStartMs = 0; + let windowCount = 0; + const scratchCarto = new Cesium.Cartographic(); + const scratchSurfacePos = new Cesium.Cartesian3(); + /** Separate from `scratchCarto`: the held-evidence check runs on paths that + * return BEFORE the sample writes `scratchCarto`, and on the sampling path it + * must not disturb the cartographic that `reportValidatedMeshFloorCell` reads + * back after the sample lands. */ + const scratchHeldCarto = new Cesium.Cartographic(); + + /** + * The last measurement for a contact whose fresh snap has been demoted — it + * taxied past MOVE_INVALIDATE_M and the resample has not landed yet. + * + * Answers only while the contact is still within HELD_SNAP_MAX_DRIFT_M of the + * spot the value was measured at; past that the memory is DROPPED rather than + * stretched, and the contact is back to no evidence (null, model hidden). The + * bound is purely spatial on purpose: the ground under a contact that has not + * moved does not change with time, so what invalidates the value is the + * contact MOVING, which is exactly what this measures. + * + * @param {{h: number|null, samplePos: Cesium.Cartesian3|null, held: boolean}|undefined} entry + * @param {Cesium.Cartesian3} surfacePos - Contact's position on the ellipsoid. + * @returns {number|null} + */ + function heldSnapM(entry, surfacePos) { + if (!entry || !entry.held || entry.h == null || !entry.samplePos) + return null; + if ( + Cesium.Cartesian3.distanceSquared(surfacePos, entry.samplePos) > + HELD_SNAP_MAX_DRIFT_M * HELD_SNAP_MAX_DRIFT_M + ) { + return dropHold(entry); + } + // Held memory never outranks fresh contradicting evidence. Inside the drift + // bound the contact can still have taxied onto a different surface — an + // apron edge, a ramp, the far side of a rise — and since a miss PRESERVES + // the hold, nothing else would ever correct it: the model would stay buried + // (or floating) for as long as the resample kept missing. + const fresh = freshMeasuredFloorAt(surfacePos); + if (fresh != null && fresh - entry.h > HELD_SNAP_CONTRADICTION_M) { + return dropHold(entry); + } + return entry.h; + } + + /** Forget a demoted measurement: it no longer describes anywhere this contact + * is, so the contact is COLD again (model hidden, floored billboard). */ + function dropHold(entry) { + entry.h = null; + entry.samplePos = null; + entry.held = false; + return null; + } + + /** + * Independently measured surface height at the contact's CURRENT position, or + * null where nothing has measured there yet. + * + * MESH cells only, deliberately — not `cachedGroundFloor`. A snap is a + * `sampleHeight` of the RENDERED skin, and the mesh cells are that same + * measurement through the same validation gate (written by this module's own + * successful samples and by `meshFloorSampler.js`). `cachedGroundFloor` falls + * back to the Re:Earth DEM, which is a DIFFERENT surface: the spread between + * skin and DEM is precisely what `MESH_FLOOR_BELOW/ABOVE_PRIOR_M` budgets for + * (15 m / 80 m), so testing a snap against a DEM cell would throw away good + * holds over a disagreement that says nothing about where the model sits. + * + * The contact's OWN cell only. A neighbour is ~111 m away and may be a + * terminal roof — that is why `neighborFloorM` leans to the lowest and refuses + * to answer from a single sample — and borrowing one to DISCARD a real + * measurement would let a roof cell hide a correctly placed model. + * + * @param {Cesium.Cartesian3} surfacePos - Contact's position on the ellipsoid. + * @returns {number|null} + */ + function freshMeasuredFloorAt(surfacePos) { + const carto = Cesium.Cartographic.fromCartesian( + surfacePos, + Cesium.Ellipsoid.WGS84, + scratchHeldCarto, + ); + if (!carto) return null; + return cachedMeshFloor( + Cesium.Math.toDegrees(carto.latitude), + Cesium.Math.toDegrees(carto.longitude), + ); + } + + /** + * Cached tile-skin height (m, ellipsoid) at the aircraft's lat/lon, or null + * when this contact has NO ground evidence at all (caller keeps the model + * hidden and calls again on a later tick — cheap map hit until a retry is due). + * A contact that HAS resolved once keeps that answer through a resample outage; + * see `heldSnapM`. + * + * @param {Cesium.Viewer} viewer - Live viewer (sampling + tiles-ready check). + * @param {string} icao - Cache key (per-layer icao space). + * @param {Cesium.Cartesian3} pos - Current display position. Cache movement + * is measured after projecting this input to the WGS84 ellipsoid, so a + * poll-time altitude/datum change cannot invalidate a stationary snap. + * @param {(() => Array)|undefined} getExclusions - Lazily builds the + * scene.sampleHeight objectsToExclude list (own billboards/models/entities — + * the vertical pick ray at a plane's OWN lat/lon lands on its icon/model + * otherwise). Only invoked when a sample actually fires. + * @returns {number|null} + */ + function heightFor(viewer, icao, pos, getExclusions) { + const surfacePos = Cesium.Ellipsoid.WGS84.scaleToGeodeticSurface( + pos, + scratchSurfacePos, + ); + if (!surfacePos) return null; + const cached = entries.get(icao); + if (cached && cached.h != null && cached.samplePos && !cached.held) { + if ( + Cesium.Cartesian3.distanceSquared(surfacePos, cached.samplePos) <= + MOVE_INVALIDATE_M * MOVE_INVALIDATE_M + ) { + return cached.h; + } + // Taxied away from the sampled spot. The measurement is DEMOTED to a + // bounded last-known rather than deleted, and the resample runs below: a + // miss now holds the aircraft on the ground it just left instead of + // hiding a model that was already up. + cached.held = true; + cached.nextRetryMs = 0; + cached.misses = 0; + } + const now = Date.now(); + const entry = entries.get(icao); + if (entry && now < entry.nextRetryMs) return heldSnapM(entry, surfacePos); // backoff in force + // Per-window budget: deny WITHOUT a retry stamp so the overflow simply + // tries again next tick instead of waiting out a backoff it didn't earn. + if (now - windowStartMs > SAMPLE_WINDOW_MS) { + windowStartMs = now; + windowCount = 0; + } + if (windowCount >= SAMPLE_BUDGET_PER_WINDOW) + return heldSnapM(entry, surfacePos); + const misses = entry ? entry.misses : 0; + const miss = () => { + // A held measurement survives the miss — only the retry schedule moves. + const held = !!( + entry && + entry.held && + entry.h != null && + entry.samplePos + ); + const next = { + h: held ? entry.h : null, + samplePos: held ? entry.samplePos : null, + held, + nextRetryMs: now + Math.min(RETRY_MAX_MS, RETRY_BASE_MS * 2 ** misses), + misses: misses + 1, + }; + entries.set(icao, next); + return heldSnapM(next, surfacePos); + }; + if (!_tilesReady(viewer)) return miss(); + windowCount += 1; + let sampled; + try { + const carto = Cesium.Cartographic.fromCartesian( + pos, + Cesium.Ellipsoid.WGS84, + scratchCarto, + ); + // sampleHeight throws when unsupported (no depth textures) — that's a miss. + sampled = viewer.scene.sampleHeight( + carto, + getExclusions ? getExclusions() : undefined, + ); + } catch { + sampled = undefined; + } + // Same sanity floor as cctv.js's sampleGroundHeight: a hit far below the + // ellipsoid is pick garbage, not ground. + if (!Number.isFinite(sampled) || sampled < -150) return miss(); + if (meshFloorPreferred()) { + reportValidatedMeshFloorCell( + Cesium.Math.toDegrees(scratchCarto.latitude), + Cesium.Math.toDegrees(scratchCarto.longitude), + sampled, + ); + } + // A fresh sample RELEASES any hold: this is a measurement of where the + // contact is now, and it outranks a memory of where it was. + entries.set(icao, { + h: sampled, + samplePos: Cesium.Cartesian3.clone(surfacePos), + held: false, + nextRetryMs: 0, + misses: 0, + }); + return sampled; + } + + /** Drop one aircraft's snap (eviction / ground-flag flip / suppression). */ + function forget(icao) { + entries.delete(icao); + } + + /** Drop everything (layer destroy). */ + function clear() { + entries.clear(); + } + + return { heightFor, forget, clear }; +} diff --git a/src/services/meshFloorSampler.js b/src/services/meshFloorSampler.js new file mode 100644 index 000000000..0faee939d --- /dev/null +++ b/src/services/meshFloorSampler.js @@ -0,0 +1,199 @@ +import * as Cesium from 'cesium'; + +/** Construct an instance-owned meshFloorSampler service with explicit dependencies. */ +export function createMeshFloorSampler({ + groundFloor, + signal, + eventTarget = null, +}) { + const { + coarseFloorCoord, + cachedMeshFloor, + reportValidatedMeshFloorCell, + setMeshFloorPreferred, + meshFloorPreferred, + cachedGroundFloor, + } = groundFloor; + signal?.throwIfAborted(); + // src/data/meshFloorSampler.js — rendered-surface (mesh) floor sampling + // (field-test round 4, 2026-07-06). + // + // The visible world in the google-3d regime is the PHOTOGRAMMETRIC MESH, + // which sits above the Re:Earth bare-earth DEM (measured ~17 m at the Austin + // airport apron) — so DEM-floored sprites/trails still buried themselves in + // the mesh while 3D models (which groundSnap against the mesh) looked right. + // This module samples the mesh height ONE-SHOT per coarse cell and reports it + // to groundFloor.js, whose cachedGroundFloor() then answers mesh-first. + // + // Rationing (the CCTV loading-jitter lesson): scene.sampleHeight forces + // tile-load prioritization at the probe point, so sampling is (a) gated to + // cells NEAR the viewer — where tiles are streamed and where an error is + // visible at all, (b) capped per call, (c) one-shot per cell for the session, + // (d) skipped entirely outside the google-3d regime. A returned-undefined + // probe (tiles not streamed yet) is NOT latched — it retries on a later call. + // + // Sample acceptance is sanity-gated against the DEM prior when warm (a + // vertical probe can hit a rooftop or another aircraft instead of pavement): + // the mesh legitimately sits ABOVE bare earth, so the window is asymmetric. + + /** @constant {number} Max scene samples per call (one call per layer poll). */ + const MAX_SAMPLES_PER_CALL = 40; + /** @constant {number} Only cells within this range of the viewer subpoint are + * sampled. Round-5 hardening: tightened 40 → 15 km — beyond the streamed + * fine-LOD area a probe returns COARSE-tile heights (not undefined), and the + * one-shot latch made those permanent (owner round 5: grounded planes stuck + * at a uniform wrong height). */ + const MAX_SAMPLE_DIST_KM = 15; + /** @constant {number} No sampling when the camera is above this height — the + * streamed LOD under a high camera is coarse everywhere, so every probe + * would latch junk. */ + const MAX_CAMERA_HEIGHT_M = 25_000; + /** @type {Cesium.Cartographic} Scratch for probe coordinates. */ + const _scratchProbe = new Cesium.Cartographic(); + + // Regime tracking: mesh cells only apply while the photoreal (google-3d) + // stack renders. main.js re-dispatches MapStackController.onChange as this + // CustomEvent; the boot default is photoreal, matching groundFloor's initial + // preference. Module-scope listener: the module only loads in the browser + // bundle, and the subscription is idempotent for the app's lifetime. + + /** + * True when a VISIBLE 3D tileset in the scene reports its streaming queue + * drained (tilesLoaded) — the mirror of cctv.js's projectionTilesReady. + * Walks top-level primitives only (a handful; once per poll). + * @param {Cesium.Scene} scene + * @returns {boolean} + */ + function _visibleTilesetLoaded(scene) { + try { + const prims = scene.primitives; + for (let i = 0; i < prims.length; i++) { + const p = prims.get(i); + if (p instanceof Cesium.Cesium3DTileset && p.show) { + return !!p.tilesLoaded; + } + } + } catch { + /* mid-teardown */ + } + return false; + } + + /** + * Equirectangular distance (km) — same approximation the flights clamp uses. + */ + function _approxKm(lat1, lon1, lat2, lon2) { + const dLat = (lat2 - lat1) * 111.32; + const dLon = + (lon2 - lon1) * 111.32 * Math.cos((((lat1 + lat2) / 2) * Math.PI) / 180); + return Math.hypot(dLat, dLon); + } + + /** + * Samples the rendered mesh height for the coarse cells containing `points`, + * one-shot per cell, budget-capped, viewer-proximity-gated. Call ONCE per + * layer poll (never per contact, never per frame). Synchronous — Cesium's + * sampleHeight is a CPU-side query against loaded tiles. + * + * @param {Cesium.Scene|undefined} scene - The scene (skipped when absent/torn down). + * @param {Array<{lat: number, lon: number}>} points - Contact/waypoint coords. + * @param {object} [options] + * @param {Array} [options.excludeObjects] - Own billboards/models to + * exclude from the probe (Cesium matches instances/.primitive/.id — pass + * Billboard and Model instances, NOT collections). + * @param {number} [options.viewerLat] @param {number} [options.viewerLon] + * Viewer subpoint (computed once by the caller's poll). + */ + function sampleMeshFloorCells( + scene, + points, + { excludeObjects = [], viewerLat, viewerLon } = {}, + ) { + if (!meshFloorPreferred()) return; + if (!scene || typeof scene.sampleHeight !== 'function') return; + if (!Array.isArray(points) || !points.length) return; + // Round-5 hardening (the CCTV projectionTilesReady lesson, ported late): + // while tiles are MID-STREAM a probe returns coarse-LOD heights — real + // numbers, wildly wrong — and the one-shot latch made them permanent. + // Only sample when the visible Google tileset reports tilesLoaded AND the + // camera is low enough that the streamed LOD near it is fine-grained. + const camH = scene.camera?.positionCartographic?.height; + if (!Number.isFinite(camH) || camH > MAX_CAMERA_HEIGHT_M) return; + if (!_visibleTilesetLoaded(scene)) return; + let sampled = 0; + const attempted = new Set(); + for (const p of points) { + if (sampled >= MAX_SAMPLES_PER_CALL) break; + if (!Number.isFinite(p?.lat) || !Number.isFinite(p?.lon)) continue; + const cell = coarseFloorCoord(p.lat, p.lon); + const key = `${cell.lat},${cell.lon}`; + if (attempted.has(key)) continue; + attempted.add(key); + if (cachedMeshFloor(cell.lat, cell.lon) != null) continue; // one-shot latch + if ( + Number.isFinite(viewerLat) && + Number.isFinite(viewerLon) && + _approxKm(viewerLat, viewerLon, cell.lat, cell.lon) > MAX_SAMPLE_DIST_KM + ) { + continue; // too far: tiles not streamed there, probe would be a guaranteed miss + } + let height; + try { + const carto = Cesium.Cartographic.fromDegrees( + cell.lon, + cell.lat, + 0, + _scratchProbe, + ); + height = scene.sampleHeight(carto, excludeObjects); + sampled += 1; + } catch { + continue; // scene mid-teardown — try again next poll + } + if (!Number.isFinite(height)) continue; // tiles not loaded yet — no negative latch + // Round 5: a REAL Re:Earth prior is REQUIRED before any sample latches. + // - A geoid-fallback prior (cached while the proxy was failing) is a + // sea-level poison that rejected legitimate samples at elevated + // fields (AUS: mesh 138 m vs poisoned −27 m prior → dropped forever). + // - No prior at all means no way to tell a real surface from a + // coarse-LOD/rooftop/aircraft mis-hit — the old sanity-only branch + // latched a 37 m "surface" at Austin permanently. The DEM lands + // within a poll (the same warm batch that queued this cell), so + // waiting costs one poll, not correctness. + // 2026-08-21: a "provisional" tier that KEPT the reading when no prior + // existed was built here and then removed, because it was measured failing. + // Run twice against the real GPU at the same Austin apron with the proxy + // down, it recorded 122.1 m once and 20.6 m the next time — the second is a + // coarse-LOD read of ground that is really ~122 m, and it left the contact + // ~100 m under the mesh. The DEM requirement above is not a formality. + // + // Before reaching for the obvious gate: `tilesLoaded` / `allTilesLoaded` is + // NOT a resolution signal. It reports that the streaming queue has drained + // for the CURRENT camera's screen-space-error target, and a distant or high + // camera is entirely satisfied by coarse tiles — which is exactly the state + // that reported "loaded" during the measurement above. Gating harder on it + // buys nothing. A correct gate has to ask about the specific tile covering + // the sample point: walk the tileset tree to the tile containing the + // coordinate and require its `geometricError` to be below a threshold tied + // to the accuracy the caller needs, before trusting the sample. That is + // feasible and deliberately deferred — see the P2 in + // the project roadmap, which bundles it with the fly_route probe so + // one post-launch pass settles rendered-mesh sampling everywhere. + reportValidatedMeshFloorCell(cell.lat, cell.lon, height); + } + } + + // re-export for callers that want one import site + + const onStack = (event) => { + const id = event?.detail?.activeId; + if (id) setMeshFloorPreferred(id === 'photoreal'); + }; + eventTarget?.addEventListener('gev:map-stack-changed', onStack); + signal?.addEventListener( + 'abort', + () => eventTarget?.removeEventListener('gev:map-stack-changed', onStack), + { once: true }, + ); + return { sampleMeshFloorCells, cachedGroundFloor }; +} diff --git a/src/services/terrainHeights.js b/src/services/terrainHeights.js new file mode 100644 index 000000000..4366d632a --- /dev/null +++ b/src/services/terrainHeights.js @@ -0,0 +1,276 @@ +import { ensureGeoidReady, geoidHeight } from '../data/geoid.js'; + +/** Construct an instance-owned terrainHeights service with explicit dependencies. */ +export function createTerrainHeights({ source, signal }) { + if (typeof source?.getHeights !== 'function') + throw new TypeError('Terrain heights require a source'); + signal?.throwIfAborted(); + // src/data/terrainHeights.js — batched, cached client terrain-height resolver + // (docs/plans/2026-07-05-entity-height-datum-fix.md Task 3). + // + // Resolves ELLIPSOIDAL ground height per (lat, lon) via the server-side + // `/api/terrain/heights` proxy (Task 2 — Re:Earth `heights.json`, disk-cached, + // serve-stale). When the proxy is unreachable (or errors) AND the point isn't + // already warm in this module's in-memory cache, falls back to bundled-geoid + // math (Task 1, `src/data/geoid.js`): + // + // sourceOrthometricM (if finite) + geoidHeight(lat, lon) → source:'geoid-fallback' + // else geoidHeight(lat, lon) alone (H≈0 "at the geoid/coast" prior) → source:'geoid-fallback' + // + // Two things learned building the Task 2 proxy (see its report, and the + // ledger's T2 entry) that this module builds in: + // + // 1. The proxy's GET has a Node http header-size ceiling — empirically a + // single request much past ~700-1500 points (depending on coordinate + // precision) risks a raw socket-level 431 *before* the proxy's own + // request handler runs. So every network request this module issues is + // chunked at CHUNK_SIZE (64 points), well under that ceiling, and + // chunks are sent SEQUENTIALLY (not in parallel) to keep this client + // well-behaved against a single dev-server proxy. + // 2. The proxy's disk cache is keyed by the raw `points` query string, with + // no eviction — so a client that varies coordinate precision or point + // order defeats the warm cache and grows the cache file unbounded. This + // module rounds every coordinate to 5 decimal places (~1.1 m of + // precision at the equator — comfortably tighter than any of this app's + // placement needs) BOTH for its own in-memory cache key AND for the + // points string sent to the proxy, so repeated calls (including calls + // from a fresh page load, a different camera batch, etc.) consistently + // hit the same warm proxy cache entry. + + /** Max points per outgoing request to `/api/terrain/heights` (see file header, point 1). */ + // Match the server's upstream batch so sequential upstream work also fits + // within this client's 30-second request deadline when Re:Earth slows. + const CHUNK_SIZE = 64; + + /** Avoid repeatedly hitting a known-failing proxy from warm fallback reads. */ + const GEOID_FALLBACK_COOLDOWN_MS = 60_000; + + /** + * In-memory cache: `"lat.toFixed(5),lon.toFixed(5)"` -> `{ellipsoid, source}`. + * Module-scoped (not exported) — the only reads are through + * `cachedEllipsoidalGround` and the internal lookup in + * `resolveEllipsoidalGround`. + * @type {Map} + */ + const cache = new Map(); + + /** + * Builds the rounded cache key shared between the in-memory cache and the + * `points` string sent to the proxy (see file header, point 2). + * @param {number} lat + * @param {number} lon + * @returns {string} + */ + function cacheKey(lat, lon) { + return `${lat.toFixed(5)},${lon.toFixed(5)}`; + } + + /** + * Synchronous read of a previously resolved ellipsoidal ground height. + * Returns null if this exact (rounded) coordinate hasn't been resolved yet + * by either the proxy path or the geoid-fallback path. + * @param {number} lat + * @param {number} lon + * @returns {number|null} + */ + function cachedEllipsoidalGround(lat, lon) { + const entry = cache.get(cacheKey(lat, lon)); + return entry ? entry.ellipsoid : null; + } + + /** + * Field-test round 5 (2026-07-06, the "sea-level poison"): like + * cachedEllipsoidalGround but returns null for geoid-FALLBACK entries — + * only a real Re:Earth value counts. A fallback (cached when the proxy + * failed mid-burst) is the geoid surface, which at Austin sits ~165 m below + * the airport: floors built on it sank every sprite/trail, and the mesh + * sampler's sanity gate rejected REAL surface samples against it. Floor + * consumers read THIS; the plain read stays for display-only consumers. + * @param {number} lat + * @param {number} lon + * @returns {number|null} + */ + function cachedRealEllipsoidalGround(lat, lon) { + const entry = cache.get(cacheKey(lat, lon)); + return entry && entry.source === 'reearth' ? entry.ellipsoid : null; + } + + /** + * Fetches one chunk (<=CHUNK_SIZE points) from the `/api/terrain/heights` + * proxy. Returns a Map keyed by the same rounded cache key so callers can + * look results up positionally-independent of upstream response ordering. + * Throws on any failure (non-ok response, network error, malformed body) — + * callers decide the fallback behavior per point. + * @param {Array<{key: string, lat: number, lon: number}>} chunk + * @returns {Promise>} key -> ellipsoid height (m) + */ + async function fetchChunk(chunk) { + // lon,lat order (matches the proxy's documented `points=lon,lat;…` contract + // and Task 2's implementation). + const results = await source.getHeights(chunk, { + signal: signal + ? AbortSignal.any([signal, AbortSignal.timeout(30000)]) + : AbortSignal.timeout(30000), + }); + const body = { results }; + if (!Array.isArray(body?.results)) + throw new Error('malformed terrain heights response (no results array)'); + if (body.results.length !== chunk.length) { + throw new Error( + `terrain heights response length mismatch (expected ${chunk.length}, got ${body.results.length})`, + ); + } + const out = new Map(); + // The proxy is documented to preserve input order (Task 2 report verifies + // this against the live upstream), so map positionally rather than trust + // the response's own lon/lat fields to re-match — those are also present + // for callers who want them, but this module doesn't need them. + for (let i = 0; i < chunk.length; i += 1) { + const ellipsoid = body.results[i]?.ellipsoid; + if (!Number.isFinite(ellipsoid)) + throw new Error(`non-finite ellipsoid height at index ${i}`); + out.set(chunk[i].key, ellipsoid); + } + return out; + } + + /** + * Computes the geoid-fallback ellipsoidal height for one point per the + * brief's fallback chain: `sourceOrthometricM + geoidHeight` when a finite + * orthometric source height is available, else `geoidHeight` alone (treats + * the point as if it were at the geoid, i.e. H≈0 — a coast-level prior). + * Assumes `ensureGeoidReady()` has already resolved. + * @param {number} lat + * @param {number} lon + * @param {number} [sourceOrthometricM] + * @returns {number} + */ + function geoidFallback(lat, lon, sourceOrthometricM) { + const n = geoidHeight(lat, lon); + return Number.isFinite(sourceOrthometricM) ? sourceOrthometricM + n : n; + } + + /** + * Resolves ellipsoidal ground height for a batch of coordinates, in order. + * + * - Results already warm in the in-memory cache are returned without any + * network call. + * - Remaining (deduplicated) coordinates are sent to `/api/terrain/heights` + * in sequential chunks of <=64 points. + * - If a chunk request fails (network error, non-ok HTTP, malformed body), + * every point in THAT chunk falls back to geoid math + * (`sourceOrthometricM + geoidHeight` or `geoidHeight` alone) rather than + * failing the whole batch — a transient proxy outage in the middle of a + * large batch shouldn't blank out entities whose chunk happened to land + * earlier or later. + * - Every resolved value (proxy or fallback) is written back into the + * in-memory cache before this function returns, so a later call — even + * with a real proxy round-trip pending — sees a warm hit. + * + * @param {Array<{lat:number, lon:number, sourceOrthometricM?:number}>} coords + * @returns {Promise>} + * Same length and order as `coords`. + */ + async function resolveEllipsoidalGround(coords) { + signal?.throwIfAborted(); + if (!Array.isArray(coords) || coords.length === 0) return []; + + // Build the per-input work list (key + original index) up front so the + // final assembly can map back positionally regardless of how many inputs + // shared a cache key or a chunk. + const work = coords.map((c, index) => ({ + index, + lat: c.lat, + lon: c.lon, + sourceOrthometricM: c.sourceOrthometricM, + key: cacheKey(c.lat, c.lon), + })); + + // Points not yet warm in the cache, deduplicated by key (multiple inputs — + // even within the same call — naming the same rounded coordinate should + // only ever hit the network once). Round 5: a geoid-FALLBACK entry does + // NOT count as permanently warm — it was cached when the proxy failed + // mid-burst and (at elevated terrain) is a sea-level poison that sank every + // floor built on it. Give failed keys a short cooldown before re-requesting + // so warm consumers do not hammer a failing proxy; after it expires, a + // successful response replaces the fallback entry and clears the path. + const uncached = []; + const seenKeys = new Set(); + const now = Date.now(); + for (const item of work) { + const entry = cache.get(item.key); + const fallbackCooling = + entry?.source === 'geoid-fallback' && + Number.isFinite(entry.retryAt) && + now < entry.retryAt; + if ( + (entry && entry.source === 'reearth') || + fallbackCooling || + seenKeys.has(item.key) + ) + continue; + seenKeys.add(item.key); + uncached.push(item); + } + + // Resolve the network path in sequential <=CHUNK_SIZE chunks. Each chunk's + // failure is isolated to that chunk's points (geoid fallback), so a single + // bad chunk doesn't lose results for the rest of a large batch. + for (let i = 0; i < uncached.length; i += CHUNK_SIZE) { + const chunk = uncached.slice(i, i + CHUNK_SIZE); + try { + const resolved = await fetchChunk(chunk); + signal?.throwIfAborted(); + for (const item of chunk) { + const ellipsoid = resolved.get(item.key); + // Round 6: only a FINITE value may be cached as 'reearth'. A point + // the upstream response omitted used to cache {ellipsoid: undefined, + // source: 'reearth'} — permanently "warm" yet empty, so its floor + // read null forever and every later warm skipped it (ATL verify: + // one contact frozen at the geoid while its neighbors resolved). + // An omitted point now caches nothing and retries on the next warm. + if (Number.isFinite(ellipsoid)) { + cache.set(item.key, { ellipsoid, source: 'reearth' }); + } + } + } catch { + signal?.throwIfAborted(); + // Proxy down (or cold cache had nothing to serve-stale) — fall back to + // geoid math for every point in this chunk. `ensureGeoidReady()` is + // awaited lazily, only on the fallback path, so the common (proxy + // healthy) case never pays for the geoid grid's dynamic import. + await ensureGeoidReady(); + signal?.throwIfAborted(); + for (const item of chunk) { + const ellipsoid = geoidFallback( + item.lat, + item.lon, + item.sourceOrthometricM, + ); + cache.set(item.key, { + ellipsoid, + source: 'geoid-fallback', + retryAt: Date.now() + GEOID_FALLBACK_COOLDOWN_MS, + }); + } + } + } + + // Assemble the output in the original input order from the cache. A point + // the upstream omitted has NO entry (round 6 — deliberately uncached so it + // retries later): report it unresolved instead of throwing. + return work.map((item) => { + const entry = cache.get(item.key); + return entry + ? { ellipsoid: entry.ellipsoid, source: entry.source } + : { ellipsoid: null, source: 'unresolved' }; + }); + } + + signal?.addEventListener('abort', () => cache.clear(), { once: true }); + return { + cachedEllipsoidalGround, + cachedRealEllipsoidalGround, + resolveEllipsoidalGround, + }; +} diff --git a/src/standalone/application.js b/src/standalone/application.js index 450a534c4..45cd9e721 100644 --- a/src/standalone/application.js +++ b/src/standalone/application.js @@ -25,19 +25,23 @@ export function createStandaloneApplication({ let placeSearch; let catalog; return createApplication({ - createScene: (context) => { - catalog = createStandaloneCatalog({ signal: context.signal }); + createScene: async (context) => { placeSearch = createStandalonePlaceSearch({ ...geospatial, resolveApiKey: () => googleApiKey, signal: context.signal, }); - return createStandaloneScene({ + const scene = await createStandaloneScene({ ...context, googleApiKey, cesiumToken, loaderStatus, }); + catalog = createStandaloneCatalog({ + signal: context.signal, + surface: scene.operations.surface, + }); + return scene; }, createControls: (context) => createStandaloneControls({ diff --git a/src/standalone/catalog.js b/src/standalone/catalog.js index 8394c0905..eda304975 100644 --- a/src/standalone/catalog.js +++ b/src/standalone/catalog.js @@ -1,3 +1,5 @@ +import { createSurfaceServices } from '../app/surfaceServices.js'; +import { createApplicationRequestServices } from '../services/requests.js'; import { createApplicationCatalog } from '../app/constructCatalog.js'; import { createStandaloneLayerSources } from './layerSources.js'; export { createStandaloneReferenceSources } from './layerSources.js'; @@ -5,8 +7,13 @@ export { createStandaloneReferenceSources } from './layerSources.js'; /** Create fresh layer instances using the existing standalone source choices. */ export function createStandaloneCatalog({ signal = new AbortController().signal, + surface = createSurfaceServices({ + terrainSource: createApplicationRequestServices().terrain, + signal, + }), } = {}) { return createApplicationCatalog({ + surface, sources: createStandaloneLayerSources(), signal, vesselOptions: { diff --git a/src/standalone/scene.js b/src/standalone/scene.js index ae464d751..b4970655a 100644 --- a/src/standalone/scene.js +++ b/src/standalone/scene.js @@ -1 +1,9 @@ -export { createApplicationScene as createStandaloneScene } from '../app/scene.js'; +import { createApplicationScene } from '../app/scene.js'; +import { createApplicationRequestServices } from '../services/requests.js'; +/** Supply standalone request services when directly constructing a scene. */ +export function createStandaloneScene(options) { + return createApplicationScene({ + requestServices: createApplicationRequestServices(), + ...options, + }); +} diff --git a/src/ui.js b/src/ui.js index 450979d9b..5142301a3 100644 --- a/src/ui.js +++ b/src/ui.js @@ -8,6 +8,7 @@ export class StyleManager extends ApplicationControls { ...options, services: { ...catalogControlServices(getStandaloneCatalog()), + ...getStandaloneCatalog().surface.controlServices, ...options.services, }, }); diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index e54b7bf25..293b9d8e3 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -148,7 +148,7 @@ export class StyleManager { */ constructor( viewer, - { mapStackController = null, placeSearch, services } = {}, + { mapStackController = null, placeSearch, services, requestServices } = {}, ) { const { IntelHUD, @@ -293,7 +293,10 @@ export class StyleManager { this._orbitIndicator = null; // Intel HUD - this.hud = new IntelHUD(viewer, { placeSearch }); + this.hud = new IntelHUD(viewer, { + placeSearch, + summaryService: requestServices?.summary, + }); this._recording.hud = this.hud; this._cockpitVisionMode = 'optical'; this._cockpitVisionRestore = null; diff --git a/src/ui/composition.js b/src/ui/composition.js index 5db8ebe47..6f8ac6bc5 100644 --- a/src/ui/composition.js +++ b/src/ui/composition.js @@ -41,14 +41,6 @@ import { setDetectionTuning, } from '../data/detection.js'; import { isTr3b, toggleTr3b } from '../data/tr3bRegistry.js'; -import { - cachedGroundFloor, - cachedMeshFloor, - GROUND_FLOOR_LIFT_M, - meshFloorPreferred, - warmGroundFloor, -} from '../data/groundFloor.js'; -import { sampleMeshFloorCells } from '../data/meshFloorSampler.js'; import { holdContinuousRender, releaseContinuousRender, @@ -105,12 +97,6 @@ export class StyleManager extends ApplicationShell { setDetectionTuning, isTr3b, toggleTr3b, - cachedGroundFloor, - cachedMeshFloor, - GROUND_FLOOR_LIFT_M, - meshFloorPreferred, - warmGroundFloor, - sampleMeshFloorCells, holdContinuousRender, releaseContinuousRender, governorRequestRender, diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 323a7c915..e9f313ce1 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -13,10 +13,10 @@ import { contextModeWord } from '../contextModePolicy.js'; import { createAnalystEngine } from '../data/analystEngine.js'; import { layerFeedState } from '../data/manager.js'; import { initCameraVerbs, moveCamera, flyRoute, interruptCameraMotion, adjustOrbitRange } from '../cameraVerbs.js'; -import { cachedGroundFloor, warmGroundFloor } from '../data/groundFloor.js'; +import * as defaultFloorServices from '../data/groundFloor.js'; import { isPickedWorldPosition } from '../data/scenePick.js'; import { unavailablePlaceSearch } from '../search/placeSearch.js'; -import { resolveRegionRingForQuery } from '../annotations/annotationResolver.js'; +import * as defaultAnnotationResolver from '../annotations/annotationResolver.js'; import { normalizeRadioCountryInput } from '../data/radioCountry.js'; import { TR3B_CLASS } from '../data/tr3bRegistry.js'; @@ -263,7 +263,11 @@ const BASEMAP_CONTEXT_WAIT_MS = 1500; const viewTargetCache = new WeakMap(); /** Create application actions over the supplied scene and services. */ -export function createGevActionRunner({ viewer, styleManager, dataManager, sceneDirector = null, annotations = null, placeSearch = unavailablePlaceSearch }) { +export function createGevActionRunner({ viewer, styleManager, dataManager, sceneDirector = null, annotations = null, placeSearch = unavailablePlaceSearch, floorServices = defaultFloorServices, annotationResolver = defaultAnnotationResolver, searchNavigation = searchAndFlyTo }) { + // Voice enable times and analyst follow-up memory belong to this runner. + const _layerEnabledAt = new Map(); + let analystEngine; + const resolveRegionRing = (name) => annotationResolver.resolveRegionRingForQuery(name, undefined, placeSearch); installViewTargetPrewarm(viewer); initCameraVerbs(viewer, getViewTargetCartesian); return async function runGevAction(name, rawArgs = {}, runOptions = {}) { @@ -486,7 +490,7 @@ export function createGevActionRunner({ viewer, styleManager, dataManager, scene }; const nearest = await createAnalystEngine(analystProviders(viewer, dataManager, { - recordLimitByLayer: { [layerId]: Number.MAX_SAFE_INTEGER }, placeSearch, + recordLimitByLayer: { [layerId]: Number.MAX_SAFE_INTEGER }, placeSearch, resolveRegionRing, })).query({ layers: [layerId], scope: { kind: 'view' }, @@ -745,7 +749,7 @@ export function createGevActionRunner({ viewer, styleManager, dataManager, scene if (name === 'fly_to_location') { return flyToRequestedLocation(viewer, args, { - placeSearch, signal: runOptions.signal, + placeSearch, searchNavigation, signal: runOptions.signal, runImmediate: typeof styleManager?.runImmediateLocationNavigation === 'function' ? (navigate) => styleManager.runImmediateLocationNavigation(navigate) : null, @@ -791,7 +795,8 @@ export function createGevActionRunner({ viewer, styleManager, dataManager, scene } if (name === 'analyst_query') { - return runAnalystQuery(viewer, dataManager, args, placeSearch); + analystEngine ||= createAnalystEngine(analystProviders(viewer, dataManager, { placeSearch, resolveRegionRing })); + return runAnalystQuery(analystEngine, dataManager, args, _layerEnabledAt); } if (name === 'move_camera') { @@ -804,9 +809,9 @@ export function createGevActionRunner({ viewer, styleManager, dataManager, scene return flyRoute( annotations?.list?.() || [], args, - (lat, lon) => cachedGroundFloor(lat, lon), + (lat, lon) => floorServices.cachedGroundFloor(lat, lon), (navigate) => runManagedVoiceNavigation(styleManager, 'route', 'fly_route', navigate), - (cells) => warmGroundFloor(cells), + (cells) => floorServices.warmGroundFloor(cells), ); } @@ -2166,7 +2171,7 @@ function normalizeStyle(value) { } async function flyToRequestedLocation(viewer, args, { - placeSearch = unavailablePlaceSearch, signal, + placeSearch = unavailablePlaceSearch, searchNavigation = searchAndFlyTo, signal, onStart = null, runImmediate = null, beginDeferred = null, @@ -2281,7 +2286,7 @@ async function flyToRequestedLocation(viewer, args, { const generation = typeof beginDeferred === 'function' ? beginDeferred() : null; if (generation === false) return cancelled(query); const managedDeferred = typeof reassertDeferred === 'function'; - const destination = await searchAndFlyTo(viewer, query, { + const destination = await searchNavigation(viewer, query, { placeSearch, signal, ...(rangeM ? { range: rangeM } : {}), forceClose: args.viewMode === 'close', @@ -3218,9 +3223,6 @@ function clampNumber(value, min, max, fallback) { * the voice payload. One engine per runner keeps follow-up memory * ("which of those is closest?") scoped to the session. */ -/** layerId → epoch ms of last voice-driven enable (analyst warm-up honesty). */ -const _layerEnabledAt = new Map(); -let _analystEngine = null; /** Layers whose loaded set follows the camera, so a loaded count is not a world count. */ const VIEWPORT_LOADED_LAYERS = new Set(['flights']); @@ -3239,7 +3241,7 @@ function activeContactsWindow(dataManager) { } } -function analystProviders(viewer, dataManager, { recordLimitByLayer = null, placeSearch = unavailablePlaceSearch } = {}) { +function analystProviders(viewer, dataManager, { recordLimitByLayer = null, placeSearch = unavailablePlaceSearch, resolveRegionRing = (name) => defaultAnnotationResolver.resolveRegionRingForQuery(name, undefined, placeSearch) } = {}) { return { getRecords(layerKey) { const layer = dataManager.layers.get(layerKey); @@ -3251,7 +3253,7 @@ function analystProviders(viewer, dataManager, { recordLimitByLayer = null, plac ? (mod.getAnalystRecords(requestedLimit) || []) : (mod.getAnalystRecords() || []); }, - resolveRegionRing: (name) => resolveRegionRingForQuery(name, undefined, placeSearch), + resolveRegionRing, /** * The active Contacts subject, when there is one — the centre the operator * is reasoning about while Contacts is up. Null whenever Contacts is off, @@ -3285,9 +3287,8 @@ function analystProviders(viewer, dataManager, { recordLimitByLayer = null, plac }; } -async function runAnalystQuery(viewer, dataManager, args = {}, placeSearch = unavailablePlaceSearch) { - if (!_analystEngine) _analystEngine = createAnalystEngine(analystProviders(viewer, dataManager, { placeSearch })); - const result = await _analystEngine.query({ +async function runAnalystQuery(analystEngine, dataManager, args = {}, _layerEnabledAt) { + const result = await analystEngine.query({ layers: Array.isArray(args.layers) ? args.layers : undefined, scope: args.scope, filters: Array.isArray(args.filters) ? args.filters : [],