From bf1d50f29b3daa27a7746a62b41e80f47eba9f57 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Tue, 15 Sep 2026 08:08:18 -0700 Subject: [PATCH] refactor: separate map feature queries and source records --- CHANGELOG.md | 1 + docs/CODE-BOUNDARIES.md | 42 +- docs/CURRENT-STATE.md | 1 + package.json | 3 +- scripts/format-scope.json | 3 +- scripts/package-boundaries.json | 47 +- src/annotations/annotationResolver.js | 8 +- src/annotations/resolver.js | 568 ++++------------------- src/app/operations.js | 16 +- src/layers/installations/ingestion.js | 12 +- src/layers/installations/model.js | 18 +- src/layers/installations/source.js | 19 +- src/layers/installations/source.test.mjs | 27 ++ src/layers/traffic/ingestion.js | 5 +- src/layers/traffic/model.js | 68 +-- src/layers/traffic/navigation.test.mjs | 12 +- src/layers/traffic/source.js | 6 +- src/layers/traffic/source.test.mjs | 38 ++ src/layers/traffic/timing.js | 23 +- src/locations.js | 90 ++-- src/services/application.js | 7 + src/services/requests.js | 11 +- src/sources/featureGeometry.js | 272 +++++++++++ src/sources/featureSource.js | 20 + src/sources/overpassFeatures.js | 191 ++++++++ src/sources/overpassFeatures.test.mjs | 192 ++++++++ src/sources/overpassFeaturesRecords.js | 76 +++ src/sources/overpassRoads.js | 27 ++ 28 files changed, 1128 insertions(+), 675 deletions(-) create mode 100644 src/sources/featureGeometry.js create mode 100644 src/sources/featureSource.js create mode 100644 src/sources/overpassFeatures.js create mode 100644 src/sources/overpassFeatures.test.mjs create mode 100644 src/sources/overpassFeaturesRecords.js create mode 100644 src/sources/overpassRoads.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f86a298c..f3696f7ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Changelog +- Separate map-feature acquisition from annotation/search selection. Move road and mapped-installation decoding into source adapters while preserving geometry policy, cancellation, retry outcomes and compatibility exports. - Separate transit snapshot/history acquisition from the layer and expose its bounded request service independently of Vite. Preserve feed selection, playback, cache policy and compatibility exports. - Widen opaque sensor halos to 3 display pixels with a 30 px fleet core while retaining at least 60% opaque core coverage, adding contrast margin through thermal blur and bloom on bright roofs. diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index 06e391adf..35409e1d4 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -589,9 +589,9 @@ installation footprints, and annotation geometry (administrative boundaries, neighborhoods, streets, building/grounds outlines and monument candidates). Nominatim forward/reverse lookup does not replace those queries. Layer source interfaces select traffic, camera and installation ingestion separately; -annotation `boundaries.query` still uses Overpass QL and OSM-shaped elements. -Replacing that service with a different query language requires operation-level -feature/geometry adapters, not just changing its URL. Rendering and geometry +annotation and camera-framing consumers use the feature operations documented +below. The Overpass adapter translates those operations and normalizes records; +an alternate backend implements that source interface. Rendering and geometry selection remain consumers of those results. `./sources/nominatim` exports the lower-level JSONv2 client and normalizers for @@ -606,3 +606,39 @@ Runtime formatting discovers tracked and new non-ignored `.js`, `.mjs` and `.cjs files under the configured owned roots. Tests retain explicit adoption. Git and Prettier exclusions keep local/generated data out; resolved paths are validated before any writes. New runtime modules do not need another scope-list entry. + +## Map-feature sources + +Application request services expose `features`. Callers may supply another source +with the same operations; the default is `createOverpassFeatureSource` from +`sources/map-features`. Legacy boundary-query configuration remains supported. +Search and annotation code request candidates and rank them without constructing +query-language strings or decoding backend tags and relation members. + +| Operation | Result used by the caller | +| --------------------------------------------------------------------- | ------------------------------------------------------- | +| `getAdministrativeAreas(point, options)` | Named administrative candidates and geometry references | +| `getAreaGeometry(id, options)` | Geometry for one selected administrative reference | +| `getNeighborhoodAreas(point, options)` | Neighborhood polygon candidates | +| `getStreetAreas(point, options)` / `getStreetLines(point, options)` | Named areas and road lines for street annotations | +| `getFootprints(point, options)` / `getEnclosingAreas(point, options)` | Building and surrounding-ground candidates | +| `getMonuments(point, options)` | Named point candidates | +| `getFocusFootprints(point, options)` | Building/landmark candidates for camera framing | + +Points use `{lat, lon}`. Options carry `signal`. A successful array, including an +empty array, is definitive; `null` means a transient failure; +`{rateLimited: true, retryAfterMs}` retains a provider's retry delay. Candidates +carry `id`, `names` (`primary`, `english`, `official`, `alternate`, `short`), +`coordinates` (objects with `lat` and `lon`), `building` and optional `heightM`, +`center` and `point`. Administrative candidates use `category: 'administrative'` +and a numeric `level`. Provenance may retain compatibility metadata, but ranking +does not use backend tags. The Overpass implementation retains its existing +operation-specific query radii and deadlines. + +Traffic's source response decodes to `{roads}`. Each road supplies longitude/latitude +`coordinates`, a road-class `type` and `oneway` (`-1`, `0` or `1`). Scene construction +still owns thinning, terrain sampling and Cartesian waypoints. Installation +sources supply `{records, droppedCount, status, saturated}`; viewport filtering, +exact-bound retry and rendering stay in the layer. Legacy cache saturation is +decoded in the source. Source and geometry modules remain independent of Cesium, +DOM and platform middleware. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 9fc311be7..836d7e45a 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,6 @@ # God's Eye View Current State +Search framing and annotations request semantic map features from an explicit source. The Overpass adapter owns bounded queries, member/tag decoding and request deadlines; callers retain candidate ranking, outline caching, deferred retries and scene placement. Empty, transient and throttled outcomes remain distinct. Traffic sources return road records and installation sources return mapped records with freshness/saturation metadata, so their layers no longer decode upstream elements. ALPR already normalizes its records in the source. Default providers, footprints, road directions, exact-viewport retries and source attribution are unchanged. Transit snapshot and selected-history reads use an explicit source interface. The standalone source selects the existing routes; rendering, playback and selection retain their existing owners. A portable request service owns registered-feed admission, bounded decoding, cache/revalidation, backoff and history; development and preview use the same thin adapter. Compatibility exports and source attribution are preserved. Place search accepts an explicit Nominatim provider with independently configured search and reverse endpoints. The default offline/Google/Photon/local-fallback order is unchanged when no provider is selected. Provider adapters share normalized coordinates, viewport framing and reverse labels; roads and boundary geometry remain separate services. Portable capped-response and Overpass lexical helpers are exported independently of the Node server. diff --git a/package.json b/package.json index 144665a2f..a919399a1 100644 --- a/package.json +++ b/package.json @@ -239,6 +239,7 @@ "./layers/transit": "./src/layers/transit/index.js", "./layers/transit/standalone": "./src/data/transit.js", "./layers/transit/source": "./src/layers/transit/source.js", - "./sources/transit-service": "./src/sources/transitService.js" + "./sources/transit-service": "./src/sources/transitService.js", + "./sources/map-features": "./src/sources/overpassFeatures.js" } } diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 1d1351bf5..b86632108 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -724,5 +724,6 @@ "src/tooling/transitQa.test.mjs", "scripts/qa-transit-browser.mjs", "scripts/format-runtime.json", - "src/tooling/transitService.test.mjs" + "src/tooling/transitService.test.mjs", + "src/sources/overpassFeatures.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 8a0b6f7b9..87e7a8c41 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -905,7 +905,8 @@ "src/layers/traffic/state.js", "src/layers/traffic/style.js", "src/layers/traffic/timing.js", - "src/layers/traffic/viewport.js" + "src/layers/traffic/viewport.js", + "src/sources/overpassRoads.js" ], "external": ["cesium", "pbf", "@mapbox/vector-tile"] }, @@ -1672,7 +1673,12 @@ "src/layers/transit/movement.js", "src/layers/transit/qaMetrics.js", "src/layers/transit/source.js", - "src/sources/transitHistory.js" + "src/sources/transitHistory.js", + "src/sources/featureGeometry.js", + "src/sources/overpassFeaturesRecords.js", + "src/sources/overpassFeatures.js", + "src/sources/featureSource.js", + "src/sources/overpassRoads.js" ], "external": [ "@mapbox/vector-tile", @@ -2055,7 +2061,12 @@ "src/layers/transit/movement.js", "src/layers/transit/qaMetrics.js", "src/layers/transit/source.js", - "src/sources/transitHistory.js" + "src/sources/transitHistory.js", + "src/sources/featureGeometry.js", + "src/sources/overpassFeaturesRecords.js", + "src/sources/overpassFeatures.js", + "src/sources/featureSource.js", + "src/sources/overpassRoads.js" ], "external": [ "cesium", @@ -2104,7 +2115,11 @@ "src/nominatimGeocode.js", "src/data/regionalModel.js", "src/sources/httpBody.js", - "src/sources/nominatim.js" + "src/sources/nominatim.js", + "src/sources/featureGeometry.js", + "src/sources/overpassFeaturesRecords.js", + "src/sources/overpassFeatures.js", + "src/sources/featureSource.js" ], "external": [ "cesium", @@ -2305,7 +2320,10 @@ }, "installations-source": { "exports": ["./layers/installations/source"], - "modules": ["src/layers/installations/source.js"], + "modules": [ + "src/layers/installations/source.js", + "src/data/militaryInstallationData.js" + ], "external": [] }, "launches-source": { @@ -2329,7 +2347,8 @@ "src/data/tomtomTiles.js", "src/layers/traffic/flowDecode.js", "src/layers/traffic/flowSource.js", - "src/layers/traffic/source.js" + "src/layers/traffic/source.js", + "src/sources/overpassRoads.js" ], "external": ["@mapbox/vector-tile", "pbf"] }, @@ -2458,7 +2477,11 @@ "src/services/terrainHeights.js", "src/sources/sourceSlot.js", "src/layers/transit/source.js", - "src/sources/transitHistory.js" + "src/sources/transitHistory.js", + "src/sources/featureGeometry.js", + "src/sources/overpassFeaturesRecords.js", + "src/sources/overpassFeatures.js", + "src/sources/featureSource.js" ], "external": ["cesium", "egm96-universal", "pbf"] }, @@ -2520,5 +2543,15 @@ "src/sources/rateLimit.js" ], "external": ["pbf"] + }, + "map-feature-source": { + "exports": ["./sources/map-features"], + "modules": [ + "src/sources/featureGeometry.js", + "src/sources/overpassFeaturesRecords.js", + "src/sources/overpassFeatures.js", + "src/sources/featureSource.js" + ], + "external": [] } } diff --git a/src/annotations/annotationResolver.js b/src/annotations/annotationResolver.js index a2b833672..6e6a10264 100644 --- a/src/annotations/annotationResolver.js +++ b/src/annotations/annotationResolver.js @@ -1,17 +1,21 @@ +import { normalizeOverpassFeatures } from '../sources/overpassFeaturesRecords.js'; import { createAnnotationResolver } from './resolver.js'; import { applicationServices } from '../services/application.js'; const resolver = createAnnotationResolver({ - boundarySource: applicationServices.boundaries, + featureSource: applicationServices.features, }); export const { resolveAnnotationTarget, refineScope, isRateLimitedOutcome, isGroundsLikeAsk, - selectFootprint, viewportBias, placesNearViewRecovery, resolveRegionRingForQuery, pickWorldFromScreen, sampleGroundHeight, } = resolver; + +/** Compatibility adapter for callers supplying raw OSM fixtures. */ +export const selectFootprint = (elements, ...args) => + resolver.selectFootprint(normalizeOverpassFeatures(elements), ...args); diff --git a/src/annotations/resolver.js b/src/annotations/resolver.js index 7f0807d83..3f757159e 100644 --- a/src/annotations/resolver.js +++ b/src/annotations/resolver.js @@ -1,3 +1,16 @@ +import { requireFeatureSource } from '../sources/featureSource.js'; +import { createOverpassFeatureSource } from '../sources/overpassFeatures.js'; +import { + ringAreaM2, + stitchLine, + bufferCorridor, + simplifyRing, + closeRing, + ringCentroid, + approximateAreaM2, + approximateDistanceM, + pointInPolygon, +} from '../sources/featureGeometry.js'; import { defaultGeospatial } from '../search/defaults.js'; import * as Cesium from 'cesium'; import { lookupNeighborhoodRing } from '../data/neighborhoodPolygons.js'; @@ -12,13 +25,16 @@ import { import { unavailablePlaceSearch } from '../search/placeSearch.js'; import { isPickedWorldPosition } from '../data/scenePick.js'; -/** Own annotation lookup caches and the supplied boundary query service. */ +/** Own annotation lookup caches and ranking of supplied feature candidates. */ export function createAnnotationResolver({ boundarySource, signal: lifetime, + featureSource = createOverpassFeatureSource({ + boundarySource, + signal: lifetime, + }), } = {}) { - if (typeof boundarySource?.query !== 'function') - throw new TypeError('Annotation boundaries require a query service'); + requireFeatureSource(featureSource); lifetime?.throwIfAborted(); /** @@ -764,20 +780,6 @@ export function createAnnotationResolver({ } /** 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) { @@ -979,25 +981,6 @@ export function createAnnotationResolver({ 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; @@ -1014,10 +997,9 @@ export function createAnnotationResolver({ 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, + const candidates = await featureSource.getAdministrativeAreas( + { lat, lon }, + { signal: signal }, ); if (isRateLimitedOutcome(candidates)) return candidates; if (!candidates) return undefined; // transient upstream failure (vs null = definitive miss) @@ -1025,17 +1007,17 @@ export function createAnnotationResolver({ const queryWords = normalizedWords(query); const scored = []; for (const el of candidates) { - if (el.type !== 'area' || !el.tags) continue; + if (el.category !== 'administrative') continue; // Match against the FULL name set (incl. official_name) so the query can still hit a // verbose official name — but score COMPLETENESS against the CORE name only. A long // official_name ("City and County of San Francisco") otherwise DILUTES the real // boundary's completeness and lets a less-specific duplicate ("San Francisco County") // win — which then has no backing relation and collapses the whole resolution to a dot. const coreWords = normalizedWords( - [el.tags.name, el.tags['name:en']].filter(Boolean).join(' '), + [el.names.primary, el.names.english].filter(Boolean).join(' '), ); const fullWords = normalizedWords( - [el.tags.name, el.tags['name:en'], el.tags.official_name] + [el.names.primary, el.names.english, el.names.official] .filter(Boolean) .join(' '), ); @@ -1053,7 +1035,7 @@ export function createAnnotationResolver({ // one (decisively outranks a " County" style duplicate). const exactName = coreWords.size === queryWords.size && coreOverlap === queryWords.size; - const level = Number(el.tags.admin_level) || 99; + const level = Number(el.level) || 99; // Bias toward the scope's specificity: city/neighborhood prefer the MORE specific // (higher admin_level) match; state/country prefer the broader one. let levelBias; @@ -1109,19 +1091,17 @@ export function createAnnotationResolver({ // 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, - ); + const relEls = await featureSource.getAreaGeometry(cand.el.id, { + signal: 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'); + const relEl = relEls[0]; if (!relEl) continue; // no relation backing this area — try the next candidate - const coords = elementCoordinates(relEl); + const coords = relEl.coordinates; if (coords.length < 3) continue; // incomplete geometry — try the next let ring = closeRing(coords.map((p) => [p.lon, p.lat])); @@ -1184,25 +1164,20 @@ export function createAnnotationResolver({ */ 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, + const els = await featureSource.getNeighborhoodAreas( + { lat, lon }, + { signal: 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); + const coords = el.coordinates; if (coords.length < 3) continue; - const tags = el.tags || {}; + const names = el.names; const nameWords = normalizedWords( - [tags.name, tags['name:en']].filter(Boolean).join(' '), + [names.primary, names.english].filter(Boolean).join(' '), ); const overlap = wordOverlap(queryWords, nameWords); if (!overlap) continue; @@ -1238,26 +1213,20 @@ export function createAnnotationResolver({ 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, + const areaEls = await featureSource.getStreetAreas( + { lat, lon }, + { signal: signal }, ); if (isRateLimitedOutcome(areaEls)) return areaEls; if (areaEls) { let bestRing = null; let bestScore = 0; for (const el of areaEls) { - const coords = elementCoordinates(el); + const coords = el.coordinates; if (coords.length < 3) continue; - const tags = el.tags || {}; + const names = el.names; const nameWords = normalizedWords( - [tags.name, tags['name:en']].filter(Boolean).join(' '), + [names.primary, names.english].filter(Boolean).join(' '), ); const overlap = wordOverlap(queryWords, nameWords); if (!overlap) continue; @@ -1277,23 +1246,20 @@ export function createAnnotationResolver({ } // 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, + const wayEls = await featureSource.getStreetLines( + { lat, lon }, + { signal: signal }, ); if (isRateLimitedOutcome(wayEls)) return wayEls; if (wayEls) { const segments = []; for (const el of wayEls) { - const tags = el.tags || {}; + const names = el.names; const nameWords = normalizedWords( - [tags.name, tags['name:en']].filter(Boolean).join(' '), + [names.primary, names.english].filter(Boolean).join(' '), ); if (!wordOverlap(queryWords, nameWords)) continue; - const geom = (el.geometry || []).filter( - (p) => Number.isFinite(p?.lat) && Number.isFinite(p?.lon), - ); + const geom = el.coordinates; if (geom.length >= 2) segments.push(geom.map((p) => [p.lon, p.lat])); } if (segments.length) { @@ -1317,119 +1283,6 @@ export function createAnnotationResolver({ 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 @@ -1466,30 +1319,10 @@ export function createAnnotationResolver({ 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"]; + const elements = await featureSource.getFootprints( + { lat, lon }, + { signal: signal }, ); - 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); @@ -1501,8 +1334,6 @@ export function createAnnotationResolver({ 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` @@ -1532,30 +1363,14 @@ export function createAnnotationResolver({ 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 + // doesn't strand a grounds mark as a point. The feature source returns null on a transient // failure (timeout/502). - const elements = await overpassJson(q, 12000, signal); + const elements = await featureSource.getEnclosingAreas( + { lat, lon }, + { signal: signal }, + ); if (isRateLimitedOutcome(elements)) return elements; if (elements === null) return undefined; // transient upstream failure (vs [] = definitive no-match) @@ -1564,8 +1379,8 @@ export function createAnnotationResolver({ 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 (el.building) continue; // exclude buildings (the exact feature, never the grounds) + const coords = el.coordinates; if (coords.length < 3) continue; if (!pointInPolygon(lon, lat, coords)) continue; // require genuine containment const ringLonLat = closeRing(coords.map((p) => [p.lon, p.lat])); @@ -1577,10 +1392,10 @@ export function createAnnotationResolver({ // 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, + el.names.primary, + el.names.english, + el.names.official, + el.names.alternate, ] .filter(Boolean) .join(' '), @@ -1606,7 +1421,6 @@ export function createAnnotationResolver({ 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( @@ -1675,18 +1489,10 @@ export function createAnnotationResolver({ // 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) + // on a slow/overloaded Overpass. The feature source returns null on a transient failure. + pending = featureSource + .getMonuments({ lat, lon }, { signal: undefined }) .then((elements) => { if (elements === null || isRateLimitedOutcome(elements)) return null; // transient — NEVER cached (a poisoned bucket @@ -1694,16 +1500,12 @@ export function createAnnotationResolver({ const feats = []; for (const el of elements) { const name = - el.tags?.name || - el.tags?.['name:en'] || - el.tags?.official_name || - el.tags?.alt_name; + el.names.primary || + el.names.english || + el.names.official || + el.names.alternate; 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; + const p = el.point; if (!p) continue; feats.push({ name, @@ -1767,20 +1569,20 @@ export function createAnnotationResolver({ let bestScore = -Infinity; for (const element of elements) { - const coords = elementCoordinates(element); + const coords = element.coordinates; if (coords.length < 3) continue; - const tags = element.tags || {}; - const isBuilding = Boolean(tags.building); + const names = element.names; + const isBuilding = element.building; const areaM2 = approximateAreaM2(coords); const nameWords = normalizedWords( [ - tags.name, - tags['name:en'], - tags.official_name, - tags.alt_name, - tags.short_name, + names.primary, + names.english, + names.official, + names.alternate, + names.short, ] .filter(Boolean) .join(' '), @@ -1848,7 +1650,10 @@ export function createAnnotationResolver({ best = { ring: closeRing(coords.map((p) => [p.lon, p.lat])), kind: isBuilding ? 'building' : 'area', - heightM: isBuilding ? buildingHeightFromTags(tags, areaM2) : null, + heightM: isBuilding + ? (element.heightM ?? + Math.max(10, Math.min(70, Math.sqrt(Math.max(1, areaM2)) * 0.6))) + : null, }; } } @@ -1856,213 +1661,6 @@ export function createAnnotationResolver({ 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 || '') diff --git a/src/app/operations.js b/src/app/operations.js index d284ff210..f5e93c78c 100644 --- a/src/app/operations.js +++ b/src/app/operations.js @@ -1,3 +1,4 @@ +import { createOverpassFeatureSource } from '../sources/overpassFeatures.js'; import { createSurfaceServices } from './surfaceServices.js'; import { createAnnotationResolver } from '../annotations/resolver.js'; import { searchAndFlyTo } from '../locations.js'; @@ -5,7 +6,6 @@ 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', @@ -19,8 +19,14 @@ export function createApplicationOperations({ requests, signal, eventTarget }) { signal, eventTarget, }); + const features = + requests.features ?? + createOverpassFeatureSource({ + boundarySource: requests.boundaries, + signal, + }); const annotationResolver = createAnnotationResolver({ - boundarySource: requests.boundaries, + featureSource: features, signal, }); return Object.freeze({ @@ -30,7 +36,11 @@ export function createApplicationOperations({ requests, signal, eventTarget }) { searchAndFlyTo: (viewer, query, options = {}) => searchAndFlyTo(viewer, query, { ...options, - boundaries: requests.boundaries, + features, + signal: + signal && options.signal + ? AbortSignal.any([signal, options.signal]) + : signal || options.signal, recoverNearView: annotationResolver.placesNearViewRecovery, }), }); diff --git a/src/layers/installations/ingestion.js b/src/layers/installations/ingestion.js index 7564b6025..0f8453873 100644 --- a/src/layers/installations/ingestion.js +++ b/src/layers/installations/ingestion.js @@ -1,5 +1,3 @@ -import { normalizeMilitaryInstallations } from '../../data/militaryInstallationData.js'; - export function createIngestion({ state: layerState, services, @@ -58,19 +56,15 @@ export function createIngestion({ // A SATURATED snapped tile was truncated upstream, so features from the // snap's extra ring may have crowded out sites actually on screen. Re-ask // for the exact viewport (separately keyed and cached) before rendering. - let saturated = parts.model.installationResponseSaturated(payload); + let saturated = payload.saturated === true; if (saturated) { payload = await fetchInstallations(true); - saturated = parts.model.installationResponseSaturated(payload); + saturated = payload.saturated === true; } - const normalized = normalizeMilitaryInstallations( - payload, - payload.retrievedAt || new Date().toISOString(), - ); // The proxy answers a bbox at least as large as the viewport; keep only what // was actually asked for so nothing off-screen reaches the map or the // "current viewport only" context claim. - const records = normalized.records.filter((record) => + const records = payload.records.filter((record) => parts.model.installationWithinViewport(record, box), ); let placesError = null; diff --git a/src/layers/installations/model.js b/src/layers/installations/model.js index dc72898ed..28ff5f7f8 100644 --- a/src/layers/installations/model.js +++ b/src/layers/installations/model.js @@ -1,3 +1,4 @@ +import { installationResponseSaturated } from './source.js'; import * as Cesium from 'cesium'; import { EARTH_MEAN_RADIUS_M, @@ -137,23 +138,6 @@ export function createModel({ state: layerState, services, parts, source }) { return record.osmType !== 'node'; } - /** - * Whether a response was truncated at the upstream element cap. - * - * The proxy states this outright, but a `saturated`-less payload is NOT - * evidence of a complete answer: entries cached before the saturation guard - * shipped predate the field and live for 30 days. Fall back to deriving it from - * the element count against the cap the payload itself reports. - * @param {{saturated?: boolean, elements?: Array, elementCap?: number}} payload - * @returns {boolean} - */ - - function installationResponseSaturated(payload) { - if (typeof payload?.saturated === 'boolean') return payload.saturated; - const cap = Number(payload?.elementCap); - if (!Number.isFinite(cap) || cap <= 0) return false; - return Array.isArray(payload?.elements) && payload.elements.length >= cap; - } return { approximateSurfaceDistanceM, colorFor, diff --git a/src/layers/installations/source.js b/src/layers/installations/source.js index ccbb799d4..b8c06de20 100644 --- a/src/layers/installations/source.js +++ b/src/layers/installations/source.js @@ -1,3 +1,13 @@ +import { normalizeMilitaryInstallations } from '../../data/militaryInstallationData.js'; + +/** Preserve legacy cache admission even when the explicit saturation flag is absent. */ +export function installationResponseSaturated(payload) { + if (typeof payload?.saturated === 'boolean') return payload.saturated; + const cap = Number(payload?.elementCap); + if (!Number.isFinite(cap) || cap <= 0) return false; + return Array.isArray(payload?.elements) && payload.elements.length >= cap; +} + /** Read mapped installations and explicit nearby-place searches through fixed endpoints. */ export function createInstallationSource({ fetchImpl = (...args) => globalThis.fetch(...args), @@ -43,7 +53,14 @@ export function createInstallationSource({ ); if (!Array.isArray(body?.elements)) throw new Error('Malformed installation snapshot'); - return body; + return { + ...normalizeMilitaryInstallations( + body, + body.retrievedAt || new Date().toISOString(), + ), + status: body.status, + saturated: installationResponseSaturated(body), + }; }, async searchNearby({ latitude, longitude, radiusM }, { signal } = {}) { if ( diff --git a/src/layers/installations/source.test.mjs b/src/layers/installations/source.test.mjs index 3aa54bbef..e00e29e08 100644 --- a/src/layers/installations/source.test.mjs +++ b/src/layers/installations/source.test.mjs @@ -57,3 +57,30 @@ test('installation response parsing respects cancellation before any follow-on s { name: 'AbortError' }, ); }); + +test('mapped-site sources normalize records and derive saturation from legacy cache metadata', async () => { + const source = createInstallationSource({ + fetchImpl: async () => + Response.json({ + elements: [ + { + type: 'node', + id: 1, + lat: 30.2, + lon: -97.7, + tags: { military: 'airfield', name: 'Fixture Field' }, + }, + ], + elementCap: 1, + status: 'stale', + retrievedAt: '2026-01-01T00:00:00Z', + }), + }); + const payload = await source.getMappedSites(box); + assert.equal(payload.records[0].name, 'Fixture Field'); + assert.equal(payload.records[0].latitude, 30.2); + assert.equal(payload.records[0].retrievedAt, '2026-01-01T00:00:00Z'); + assert.equal(payload.saturated, true); + assert.equal(payload.status, 'stale'); + assert.equal('elements' in payload, false); +}); diff --git a/src/layers/traffic/ingestion.js b/src/layers/traffic/ingestion.js index b7d48597f..de5b56d9f 100644 --- a/src/layers/traffic/ingestion.js +++ b/src/layers/traffic/ingestion.js @@ -73,7 +73,7 @@ export function createIngestion({ if (!state) { const data = await response.json(); signal?.throwIfAborted(); - if (!Array.isArray(data?.elements)) + if (!Array.isArray(data?.roads)) throw new Error('Malformed road snapshot'); return data; } @@ -100,8 +100,7 @@ export function createIngestion({ } const data = await response.json(); signal?.throwIfAborted(); - if (!Array.isArray(data?.elements)) - throw new Error('Malformed road snapshot'); + if (!Array.isArray(data?.roads)) throw new Error('Malformed road snapshot'); if (state) { const responseEnd = parts.timing.trafficTimingMark( state, diff --git a/src/layers/traffic/model.js b/src/layers/traffic/model.js index db8f9536b..281bacb9b 100644 --- a/src/layers/traffic/model.js +++ b/src/layers/traffic/model.js @@ -13,52 +13,17 @@ import { } from './policy.js'; export function createModel({ state: layerState, services, parts, source }) { - // ─── Overpass API ────────────────────────────────────────── - - /** - * Build an Overpass QL query string to fetch road ways within a bounding box. - * - * The query uses a highway tag regex filter. When `majorOnly` is true, only - * motorway/trunk/primary/secondary are included; otherwise all drivable road - * classes are fetched. The `out geom qt;` suffix returns inline geometry - * (lat/lon per node) sorted by quadtile for faster server response. - * - * @param {number} south - Southern latitude bound (degrees). - * @param {number} west - Western longitude bound (degrees). - * @param {number} north - Northern latitude bound (degrees). - * @param {number} east - Eastern longitude bound (degrees). - * @param {Object} [opts] - * @param {boolean} [opts.majorOnly=false] - Restrict to major highway classes only. - * @param {number} [opts.timeoutSec=25] - Overpass server-side timeout. - * @returns {string} Overpass QL query body. - */ - - /** - * Parse an Overpass `out geom;` JSON response into internal road objects. - * - * Each OSM `way` element carries an inline `geometry` array of `{lat, lon}` - * objects, so no separate node look-up or osmtogeojson conversion is needed. - * - * Processing per way: - * 1. Extract [lon, lat] coordinate pairs. - * 2. Sub-sample long polylines to at most MAX_WAYPOINTS_PER_ROAD vertices. - * 3. Sample terrain height once at the first vertex (avoids per-vertex cost). - * 4. Convert to Cartesian3 waypoints and pre-compute inter-vertex distances. - * - * @param {Object} overpassData - Raw JSON response from the Overpass API. - * @param {Array} overpassData.elements - Array of OSM elements. - * @returns {Array<{coords:number[][], type:string, waypoints:Cesium.Cartesian3[], segmentDist:number[]}>} - * Parsed road objects ready for dot spawning. - */ - - function parseRoads(overpassData) { - if (!overpassData || !overpassData.elements) return []; + /** Build scene waypoints from source records; thinning and terrain remain rendering policy. */ + function parseRoads(roadData) { + if (!roadData || !roadData.roads) { + return []; + } const roads = []; - for (const el of overpassData.elements) { - if (el.type !== 'way' || !el.geometry || el.geometry.length < 2) continue; + for (const road of roadData.roads) { + if (!road.coordinates || road.coordinates.length < 2) continue; - const rawCoords = el.geometry.map((g) => [g.lon, g.lat]); + const rawCoords = road.coordinates; // Sub-sample long polylines: keep every Nth vertex to stay within budget const simplifyStep = @@ -79,21 +44,8 @@ export function createModel({ state: layerState, services, parts, source }) { if (coords.length < 2) continue; - const type = el.tags?.highway || 'unclassified'; - // One-way capture (field-test round 1: "a car would never go in - // reverse"): dots on one-way roads all travel the legal direction. - // OSM: oneway=yes/1/true → digitization order; '-1' → reversed; - // roundabouts are one-way by definition. 0 = two-way (alternate). - const onewayTag = el.tags?.oneway; - const oneway = - onewayTag === 'yes' || - onewayTag === '1' || - onewayTag === 'true' || - el.tags?.junction === 'roundabout' - ? 1 - : onewayTag === '-1' - ? -1 - : 0; + const type = road.type; + const oneway = road.oneway; // Sample terrain height once at the road start to avoid per-vertex cost let baseHeight = 0; diff --git a/src/layers/traffic/navigation.test.mjs b/src/layers/traffic/navigation.test.mjs index 3d594c4e5..e7bd4cc62 100644 --- a/src/layers/traffic/navigation.test.mjs +++ b/src/layers/traffic/navigation.test.mjs @@ -82,13 +82,13 @@ function roads(bounds) { return { ok: true, json: async () => ({ - elements: [ + roads: [ { - type: 'way', - tags: { highway: 'primary' }, - geometry: [ - { lat: bounds.south, lon: bounds.west }, - { lat: bounds.south + 0.005, lon: bounds.west + 0.005 }, + type: 'primary', + oneway: 0, + coordinates: [ + [bounds.west, bounds.south], + [bounds.west + 0.005, bounds.south + 0.005], ], }, ], diff --git a/src/layers/traffic/source.js b/src/layers/traffic/source.js index 616f4d0c7..d499a4b7b 100644 --- a/src/layers/traffic/source.js +++ b/src/layers/traffic/source.js @@ -1,3 +1,5 @@ +import { normalizeOverpassRoads } from '../../sources/overpassRoads.js'; +export { normalizeOverpassRoads } from '../../sources/overpassRoads.js'; import { createFlowTileSource } from './flowSource.js'; function buildOverpassQuery( south, @@ -58,7 +60,9 @@ export function createTrafficSource({ async json() { const body = await response.json(); signal?.throwIfAborted(); - return body; + if (!Array.isArray(body?.elements)) + throw new Error('Malformed road snapshot'); + return { roads: normalizeOverpassRoads(body) }; }, }; }, diff --git a/src/layers/traffic/source.test.mjs b/src/layers/traffic/source.test.mjs index 9be3315ba..763dfe7bf 100644 --- a/src/layers/traffic/source.test.mjs +++ b/src/layers/traffic/source.test.mjs @@ -127,3 +127,41 @@ test('road body parsing retains the source request cancellation signal', async ( }); await assert.rejects(response.json(), { name: 'AbortError' }); }); + +test('road sources decode direction and coordinates before scene construction', async () => { + const geometry = [ + { lat: 30, lon: -97 }, + { lat: 30.001, lon: -97.001 }, + ]; + const source = createTrafficSource({ + fetchImpl: async () => + Response.json({ + elements: [ + { type: 'node', id: 1 }, + { type: 'way', geometry, tags: { highway: 'primary', oneway: '-1' } }, + { type: 'way', geometry, tags: { junction: 'roundabout' } }, + { type: 'way', geometry: [geometry[0]] }, + ], + }), + }); + assert.deepEqual(await (await source.requestRoads(bounds)).json(), { + roads: [ + { + coordinates: [ + [-97, 30], + [-97.001, 30.001], + ], + type: 'primary', + oneway: -1, + }, + { + coordinates: [ + [-97, 30], + [-97.001, 30.001], + ], + type: 'unclassified', + oneway: 1, + }, + ], + }); +}); diff --git a/src/layers/traffic/timing.js b/src/layers/traffic/timing.js index 59cb2d2c0..2c9a03ce4 100644 --- a/src/layers/traffic/timing.js +++ b/src/layers/traffic/timing.js @@ -193,7 +193,7 @@ export function createTiming({ state: layerState, services, parts, source }) { * waypoint-materialization time independently. */ - function parseRoadsTimed(overpassData, trace) { + function parseRoadsTimed(roadData, trace) { /* TRACE_ONLY_BEGIN */ const _trafficTimingState = trafficTimingPass( trace, @@ -207,7 +207,7 @@ export function createTiming({ state: layerState, services, parts, source }) { _trafficTimingParseStartTime, ); /* TRACE_ONLY_END */ - if (!overpassData || !overpassData.elements) { + if (!roadData || !roadData.roads) { /* TRACE_ONLY_BEGIN */ const _trafficTimingParseEnd = trafficTimingMark( _trafficTimingState, @@ -251,10 +251,10 @@ export function createTiming({ state: layerState, services, parts, source }) { let _trafficTimingSampleHeightMs = 0; let _trafficTimingWaypointMaterializationMs = 0; /* TRACE_ONLY_END */ - for (const el of overpassData.elements) { - if (el.type !== 'way' || !el.geometry || el.geometry.length < 2) continue; + for (const road of roadData.roads) { + if (!road.coordinates || road.coordinates.length < 2) continue; - const rawCoords = el.geometry.map((g) => [g.lon, g.lat]); + const rawCoords = road.coordinates; const simplifyStep = rawCoords.length > MAX_WAYPOINTS_PER_ROAD ? Math.ceil(rawCoords.length / MAX_WAYPOINTS_PER_ROAD) @@ -271,17 +271,8 @@ export function createTiming({ state: layerState, services, parts, source }) { } if (coords.length < 2) continue; - const type = el.tags?.highway || 'unclassified'; - const onewayTag = el.tags?.oneway; - const oneway = - onewayTag === 'yes' || - onewayTag === '1' || - onewayTag === 'true' || - el.tags?.junction === 'roundabout' - ? 1 - : onewayTag === '-1' - ? -1 - : 0; + const type = road.type; + const oneway = road.oneway; let baseHeight = 0; const firstCoord = coords[0]; diff --git a/src/locations.js b/src/locations.js index f8d334749..d0f90ede6 100644 --- a/src/locations.js +++ b/src/locations.js @@ -1,3 +1,4 @@ +import { createOverpassFeatureSource } from './sources/overpassFeatures.js'; import { applicationServices } from './services/application.js'; import * as Cesium from 'cesium'; import { @@ -891,7 +892,18 @@ export async function searchAndFlyTo(viewer, query, options = {}) { const shouldResolveBuilding = navigationMode === 'precise-place'; const buildingBounds = shouldResolveBuilding - ? await resolveBuildingBounds(lat, lng, query, options.boundaries) + ? await resolveBuildingBounds( + lat, + lng, + query, + options.features ?? + (options.boundaries + ? createOverpassFeatureSource({ + boundarySource: options.boundaries, + }) + : undefined), + options.signal, + ) : null; const range = requestedRange || defaultRangeForNavigationMode(navigationMode); if (!mayFly()) return CANCELLED_SEARCH; @@ -1340,36 +1352,22 @@ async function resolveBuildingBounds( lat, lon, query, - boundaries = applicationServices.boundaries, + features = applicationServices.features, + signal, ) { - const overpassQuery = ` - [out:json][timeout:10]; - ( - way(around:180,${lat},${lon})["building"]; - relation(around:180,${lat},${lon})["building"]; - way(around:180,${lat},${lon})["man_made"]; - relation(around:180,${lat},${lon})["man_made"]; - way(around:180,${lat},${lon})["tourism"="attraction"]; - relation(around:180,${lat},${lon})["tourism"="attraction"]; - ); - out tags center geom; - `; - const controller = new AbortController(); - const timeout = window.setTimeout(() => controller.abort(), 6000); try { - const elements = await boundaries.query(overpassQuery, { - signal: controller.signal, - }); + const candidates = await features.getFocusFootprints( + { lat, lon }, + { signal }, + ); return selectBuildingBounds( - Array.isArray(elements) ? elements : [], + Array.isArray(candidates) ? candidates : [], lat, lon, query, ); } catch { return null; - } finally { - window.clearTimeout(timeout); } } @@ -1377,11 +1375,11 @@ function selectBuildingBounds(elements, targetLat, targetLon, query) { const queryWords = normalizedWords(query); const candidates = []; for (const element of elements) { - const coordinates = elementCoordinates(element); + const coordinates = element.coordinates; if (coordinates.length < 3) continue; const bounds = coordinateBounds(coordinates, targetLat); if (!bounds || bounds.width < 2 || bounds.depth < 2) continue; - const tags = element.tags || {}; + const names = element.names; const center = element.center || averageCoordinate(coordinates); const distanceM = approximateDistanceM( targetLat, @@ -1390,22 +1388,24 @@ function selectBuildingBounds(elements, targetLat, targetLon, query) { center.lon, ); const nameWords = normalizedWords( - [tags.name, tags['name:en'], tags.official_name, tags.alt_name] + [names.primary, names.english, names.official, names.alternate] .filter(Boolean) .join(' '), ); const nameScore = wordOverlap(queryWords, nameWords); const containsTarget = pointInPolygon(targetLon, targetLat, coordinates); - const height = buildingHeightFromTags(tags, bounds); + const height = + element.heightM ?? + Math.max(12, Math.min(80, Math.max(bounds.width, bounds.depth) * 0.8)); candidates.push({ lat: center.lat, lon: center.lon, height, width: bounds.width, depth: bounds.depth, - osmName: tags.name || tags['name:en'] || null, - osmType: element.type, - osmId: element.id, + osmName: names.primary || names.english || null, + osmType: element.provenance?.type, + osmId: element.provenance?.id, score: nameScore * 1000 + (containsTarget ? 500 : 0) - distanceM, }); } @@ -1415,22 +1415,6 @@ function selectBuildingBounds(elements, targetLat, targetLon, query) { return best; } -function elementCoordinates(element) { - if (Array.isArray(element.geometry)) { - return element.geometry.filter( - (point) => Number.isFinite(point?.lat) && Number.isFinite(point?.lon), - ); - } - if (!Array.isArray(element.members)) return []; - return element.members.flatMap((member) => - Array.isArray(member.geometry) - ? member.geometry.filter( - (point) => Number.isFinite(point?.lat) && Number.isFinite(point?.lon), - ) - : [], - ); -} - function coordinateBounds(coordinates, latitude) { const latitudes = coordinates.map((point) => point.lat); const longitudes = coordinates.map((point) => point.lon); @@ -1444,22 +1428,6 @@ function coordinateBounds(coordinates, latitude) { }; } -function buildingHeightFromTags(tags, bounds) { - const explicitHeight = parseMeters(tags.height || tags['building:height']); - if (explicitHeight) return explicitHeight; - const levels = Number.parseFloat(tags['building:levels']); - const roofHeight = parseMeters(tags['roof:height']) || 0; - if (Number.isFinite(levels) && levels > 0) return levels * 3.3 + roofHeight; - return Math.max(12, Math.min(80, Math.max(bounds.width, bounds.depth) * 0.8)); -} - -function parseMeters(value) { - if (value == null) return 0; - const number = Number.parseFloat(String(value).replace(',', '.')); - if (!Number.isFinite(number) || number <= 0) return 0; - return /\b(ft|feet|foot)\b/i.test(String(value)) ? number * 0.3048 : number; -} - function averageCoordinate(coordinates) { const total = coordinates.reduce( (sum, point) => ({ diff --git a/src/services/application.js b/src/services/application.js index 98804e89b..30c1c1aed 100644 --- a/src/services/application.js +++ b/src/services/application.js @@ -1,3 +1,5 @@ +import { createOverpassFeatureSource } from '../sources/overpassFeatures.js'; +import { FEATURE_SOURCE_METHODS } from '../sources/featureSource.js'; import { createSourceSlot } from '../sources/sourceSlot.js'; import { createApplicationRequestServices } from './requests.js'; @@ -15,6 +17,11 @@ const slots = Object.fromEntries( createSourceSlot(defaults[name], methods, `${name} service`), ]), ); +slots.features = createSourceSlot( + createOverpassFeatureSource({ boundarySource: slots.boundaries.source }), + FEATURE_SOURCE_METHODS, + 'features service', +); export const applicationServices = Object.freeze( Object.fromEntries( Object.entries(slots).map(([name, slot]) => [name, slot.source]), diff --git a/src/services/requests.js b/src/services/requests.js index f82fa8eeb..181c6c439 100644 --- a/src/services/requests.js +++ b/src/services/requests.js @@ -1,3 +1,4 @@ +import { createOverpassFeatureSource } from '../sources/overpassFeatures.js'; /** Parse bounded retry information from a service response. */ function retryAfterMs(value) { if (value == null || String(value).trim() === '') return null; @@ -13,6 +14,7 @@ export function createApplicationRequestServices({ fetchImpl = (...args) => fetch(...args), signal: lifetime, endpoints = {}, + features, } = {}) { const urls = { boundaries: '/api/overpass', @@ -62,7 +64,7 @@ export function createApplicationRequestServices({ throw new Error(`${label} unavailable (${response.status})`); return response.data; } - return { + const services = { boundaries: { async query(query, { signal } = {}) { const response = await request(urls.boundaries, { @@ -125,4 +127,11 @@ export function createApplicationRequestServices({ }, }, }; + services.features = + features ?? + createOverpassFeatureSource({ + boundarySource: services.boundaries, + signal: lifetime, + }); + return services; } diff --git a/src/sources/featureGeometry.js b/src/sources/featureGeometry.js new file mode 100644 index 000000000..429dbe826 --- /dev/null +++ b/src/sources/featureGeometry.js @@ -0,0 +1,272 @@ +/** Portable polygon and corridor operations; coordinates remain longitude/latitude. */ +export function ringAreaM2(ring) { + if (!Array.isArray(ring) || ring.length < 3) return 0; + const mLat = 111_320; + const mLon = mLat * Math.cos(ring[0][1] * (Math.PI / 180)); + 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; +} + +export 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; +} + +export 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]); +} + +export 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; +} + +export 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; +} + +export 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)); +} + +export 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 : []; +} + +export 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; +} + +export 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); +} + +export 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; +} + +export 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); +} + +export 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; +} + +export 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 }; +} + +export 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(lat0 * (Math.PI / 180)); + 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; +} + +export function approximateDistanceM(latA, lonA, latB, lonB) { + const latScale = 111_320; + const lonScale = latScale * Math.cos(((latA + latB) / 2) * (Math.PI / 180)); + return Math.hypot((latB - latA) * latScale, (lonB - lonA) * lonScale); +} + +export 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; +} diff --git a/src/sources/featureSource.js b/src/sources/featureSource.js new file mode 100644 index 000000000..f2a655772 --- /dev/null +++ b/src/sources/featureSource.js @@ -0,0 +1,20 @@ +/** Semantic candidate operations supplied to search and annotation selection. */ +export const FEATURE_SOURCE_METHODS = Object.freeze([ + 'getAdministrativeAreas', + 'getAreaGeometry', + 'getNeighborhoodAreas', + 'getStreetAreas', + 'getStreetLines', + 'getFootprints', + 'getEnclosingAreas', + 'getMonuments', + 'getFocusFootprints', +]); + +export function requireFeatureSource(source) { + for (const method of FEATURE_SOURCE_METHODS) { + if (typeof source?.[method] !== 'function') + throw new TypeError('Missing feature operation: ' + method); + } + return source; +} diff --git a/src/sources/overpassFeatures.js b/src/sources/overpassFeatures.js new file mode 100644 index 000000000..228be344a --- /dev/null +++ b/src/sources/overpassFeatures.js @@ -0,0 +1,191 @@ +export { + FEATURE_SOURCE_METHODS, + requireFeatureSource, +} from './featureSource.js'; +import { normalizeOverpassFeatures } from './overpassFeaturesRecords.js'; + +function validPoint(lat, lon) { + if ( + !Number.isFinite(lat) || + !Number.isFinite(lon) || + Math.abs(lat) > 90 || + Math.abs(lon) > 180 + ) + throw new TypeError('Valid feature coordinates are required'); +} + +/** Query bounded feature candidates; ranking and rendering belong to callers. + * Array = definitive response (possibly empty), null = retryable failure, + * {rateLimited, retryAfterMs} = admission delay. + */ +export function createOverpassFeatureSource({ + boundarySource, + signal: lifetime, +} = {}) { + if (typeof boundarySource?.query !== 'function') + throw new TypeError('A boundary query transport is required'); + async function query( + text, + timeoutMs, + { signal, focus = false, relationsOnly = false } = {}, + ) { + const controller = new AbortController(); + const signals = [lifetime, signal, controller.signal].filter(Boolean); + const combined = AbortSignal.any(signals); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + combined.throwIfAborted(); + const elements = await boundarySource.query(text, { signal: combined }); + combined.throwIfAborted(); + return Array.isArray(elements) + ? normalizeOverpassFeatures( + relationsOnly + ? elements.filter((element) => element.type === 'relation') + : elements, + { focus }, + ) + : elements; + } catch { + return null; + } finally { + clearTimeout(timer); + } + } + return { + getFocusFootprints({ lat, lon }, options = {}) { + validPoint(lat, lon); + return query( + ` + [out:json][timeout:10]; + ( + way(around:180,${lat},${lon})["building"]; + relation(around:180,${lat},${lon})["building"]; + way(around:180,${lat},${lon})["man_made"]; + relation(around:180,${lat},${lon})["man_made"]; + way(around:180,${lat},${lon})["tourism"="attraction"]; + relation(around:180,${lat},${lon})["tourism"="attraction"]; + ); + out tags center geom; + `, + 6000, + { ...options, focus: true }, + ); + }, + getAdministrativeAreas({ lat, lon }, options = {}) { + validPoint(lat, lon); + return query( + `[out:json][timeout:25];is_in(${lat},${lon})->.a;area.a["boundary"="administrative"]["admin_level"];out tags;`, + 14000, + options, + ); + }, + getAreaGeometry(id, options = {}) { + if (!Number.isSafeInteger(id) || id <= 0) + throw new TypeError('Invalid feature geometry reference'); + return query( + `[out:json][timeout:25];area(${id})->.x;rel(pivot.x);out geom;`, + 28000, + { ...options, relationsOnly: true }, + ); + }, + getNeighborhoodAreas({ lat, lon }, options = {}) { + validPoint(lat, lon); + return query( + `[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, + options, + ); + }, + getStreetAreas({ lat, lon }, options = {}) { + validPoint(lat, lon); + return query( + `[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, + options, + ); + }, + getStreetLines({ lat, lon }, options = {}) { + validPoint(lat, lon); + return query( + `[out:json][timeout:20];way(around:320,${lat},${lon})["highway"]["name"];out geom;`, + 14000, + options, + ); + }, + getFootprints({ lat, lon }, options = {}) { + validPoint(lat, lon); + return query( + ` + [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; + `, + 12000, + options, + ); + }, + getEnclosingAreas({ lat, lon }, options = {}) { + validPoint(lat, lon); + return query( + ` + [out:json][timeout:25]; + ( + way(around:${600},${lat},${lon})["leisure"]["name"]; + relation(around:${600},${lat},${lon})["leisure"]["name"]["type"="multipolygon"]; + way(around:${600},${lat},${lon})["landuse"]["name"]; + relation(around:${600},${lat},${lon})["landuse"]["name"]["type"="multipolygon"]; + way(around:${600},${lat},${lon})["boundary"]["name"]; + relation(around:${600},${lat},${lon})["boundary"]["name"]; + way(around:${600},${lat},${lon})["amenity"]["name"]; + relation(around:${600},${lat},${lon})["amenity"]["name"]["type"="multipolygon"]; + way(around:${600},${lat},${lon})["natural"="water"]["name"]; + relation(around:${600},${lat},${lon})["natural"="water"]["name"]["type"="multipolygon"]; + ); + out geom; + `, + 12000, + options, + ); + }, + getMonuments({ lat, lon }, options = {}) { + validPoint(lat, lon); + return query( + ` + [out:json][timeout:20]; + ( + nwr(around:${2500},${lat},${lon})["historic"~"memorial|monument|statue|tomb"]; + nwr(around:${2500},${lat},${lon})["tourism"="artwork"]["name"]; + nwr(around:${2500},${lat},${lon})["memorial"]["name"]; + ); + out center tags; + `, + 6000, + options, + ); + }, + }; +} diff --git a/src/sources/overpassFeatures.test.mjs b/src/sources/overpassFeatures.test.mjs new file mode 100644 index 000000000..45c233919 --- /dev/null +++ b/src/sources/overpassFeatures.test.mjs @@ -0,0 +1,192 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + createOverpassFeatureSource, + FEATURE_SOURCE_METHODS, +} from './overpassFeatures.js'; +import { normalizeOverpassFeatures } from './overpassFeaturesRecords.js'; +import { createAnnotationResolver } from '../annotations/resolver.js'; + +const geometry = [ + { lat: 30, lon: -97 }, + { lat: 30, lon: -96.999 }, + { lat: 30.001, lon: -96.999 }, + { lat: 30.001, lon: -97 }, + { lat: 30, lon: -97 }, +]; +const raw = { + type: 'way', + id: 42, + tags: { name: 'Fixture Tower', building: 'yes', height: '100 ft' }, + geometry, +}; +const candidate = { + id: 42, + category: 'feature', + names: { primary: 'Fixture Tower' }, + building: true, + heightM: 30.48, + coordinates: geometry, +}; +const featureSource = (overrides) => + Object.assign( + Object.fromEntries( + FEATURE_SOURCE_METHODS.map((method) => [method, async () => []]), + ), + overrides, + ); + +test('Overpass adapter owns all feature query expressions and normalizes returned records', async () => { + const calls = []; + const source = createOverpassFeatureSource({ + boundarySource: { + query: async (query, options) => { + calls.push({ query, options }); + return [raw, { ...raw, type: 'relation' }]; + }, + }, + }); + for (const method of FEATURE_SOURCE_METHODS) { + const records = await source[method]( + method === 'getAreaGeometry' ? 3600000042 : { lat: 30, lon: -97 }, + ); + assert.equal(records[0].names.primary, 'Fixture Tower'); + assert.equal(records[0].heightM, 30.48); + assert.deepEqual(records[0].coordinates, geometry); + assert.equal('tags' in records[0], false); + assert.equal(records.length, method === 'getAreaGeometry' ? 1 : 2); + } + assert.equal(calls.length, 9); + assert.ok( + calls.every( + ({ query, options }) => + query.includes('[out:json]') && options.signal instanceof AbortSignal, + ), + ); + assert.match(calls[1].query, /rel\(pivot.x\)/); + assert.match(calls[4].query, /\["highway"\]/); + assert.match(calls[7].query, /memorial\|monument/); + assert.match(calls[8].query, /around:180,/); + assert.throws( + () => source.getFootprints({ lat: '30);out;', lon: -97 }), + /coordinates/, + ); + assert.throws(() => source.getAreaGeometry('1);out;'), /reference/); + assert.equal(calls.length, 9); +}); + +test('provider preserves empty, transient and throttled responses and rejects late canceled data', async () => { + const lifetime = new AbortController(); + let value = []; + const source = createOverpassFeatureSource({ + signal: lifetime.signal, + boundarySource: { query: async () => value }, + }); + assert.deepEqual(await source.getFootprints({ lat: 30, lon: -97 }), []); + value = null; + assert.equal(await source.getFootprints({ lat: 30, lon: -97 }), null); + value = { rateLimited: true, retryAfterMs: 1500 }; + assert.deepEqual(await source.getFootprints({ lat: 30, lon: -97 }), value); + let respond, signal; + const pendingSource = createOverpassFeatureSource({ + signal: lifetime.signal, + boundarySource: { + query: async (_q, options) => { + signal = options.signal; + return new Promise((resolve) => { + respond = resolve; + }); + }, + }, + }); + const pending = pendingSource.getFootprints({ lat: 30, lon: -97 }); + lifetime.abort(); + respond([raw]); + assert.equal(signal.aborted, true); + assert.equal(await pending, null); +}); + +test('relation normalization keeps the largest closed outer and rejects a missing boundary', () => { + const relation = { + type: 'relation', + id: 1, + tags: { name: 'Region' }, + members: [ + { role: 'outer', geometry: geometry.slice(0, 3) }, + { role: 'outer', geometry: geometry.slice(2) }, + { + role: 'inner', + geometry: geometry.map((p) => ({ lat: p.lat + 1, lon: p.lon + 1 })), + }, + ], + }; + assert.deepEqual( + normalizeOverpassFeatures([relation])[0].coordinates, + geometry, + ); + relation.members = [{ role: 'outer', geometry: geometry.slice(0, 3) }]; + assert.deepEqual(normalizeOverpassFeatures([relation])[0].coordinates, []); + assert.deepEqual( + normalizeOverpassFeatures([{ ...raw, geometry: undefined, members: {} }], { + focus: true, + })[0].coordinates, + [], + ); +}); + +test('annotation selection accepts another feature provider with no query transport', async () => { + let calls = 0; + const resolver = createAnnotationResolver({ + featureSource: featureSource({ + getFootprints: async () => { + calls++; + return [candidate]; + }, + }), + }); + const args = { + latitude: 30.0005, + longitude: -96.9995, + target: 'Fixture Tower', + entityKind: 'building', + footprint: true, + }; + const first = await resolver.resolveAnnotationTarget(args); + const second = await resolver.resolveAnnotationTarget(args); + assert.equal(first.footprintKind, 'building'); + assert.equal(first.buildingHeight, 30.48); + assert.deepEqual( + first.ring, + geometry.map((p) => [p.lon, p.lat]), + ); + assert.deepEqual(second.ring, first.ring); + assert.equal( + calls, + 1, + 'ranking cache is independent of the provider implementation', + ); +}); + +test('annotation retries transient feature failures but caches definitive misses', async () => { + let calls = 0; + const resolver = createAnnotationResolver({ + featureSource: featureSource({ + getFootprints: async () => { + calls++; + return calls === 1 ? null : []; + }, + }), + }); + const anchor = await resolver.resolveAnnotationTarget({ + latitude: 30.0005, + longitude: -96.9995, + target: 'Missing Tower', + entityKind: 'building', + footprint: true, + deferFootprint: true, + }); + assert.equal(await anchor.resolveOutline(), undefined); + assert.equal(await anchor.resolveOutline(), null); + assert.equal(await anchor.resolveOutline(), null); + assert.equal(calls, 2); +}); diff --git a/src/sources/overpassFeaturesRecords.js b/src/sources/overpassFeaturesRecords.js new file mode 100644 index 000000000..ae23d00b3 --- /dev/null +++ b/src/sources/overpassFeaturesRecords.js @@ -0,0 +1,76 @@ +import { stitchRing } from './featureGeometry.js'; + +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); +} + +function buildingHeightFromTags(tags) { + 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; + return null; +} + +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; +} + +/** Normalize backend tags and member geometry before feature selection. */ +export function normalizeOverpassFeatures(elements, { focus = false } = {}) { + return elements.map((element) => { + const tags = element.tags || {}; + const coordinates = + focus && !Array.isArray(element.geometry) + ? (Array.isArray(element.members) ? element.members : []).flatMap( + (member) => + (Array.isArray(member.geometry) ? member.geometry : []).filter( + (p) => Number.isFinite(p?.lat) && Number.isFinite(p?.lon), + ), + ) + : elementCoordinates(element); + return { + id: element.id, + category: element.type === 'area' ? 'administrative' : 'feature', + names: { + primary: tags.name, + english: tags['name:en'], + official: tags.official_name, + alternate: tags.alt_name, + short: tags.short_name, + }, + level: Number(tags.admin_level) || 99, + building: Boolean(tags.building), + heightM: buildingHeightFromTags(tags), + coordinates, + center: element.center || null, + point: Number.isFinite(element.lat) + ? { lat: element.lat, lon: element.lon } + : element.center + ? { lat: element.center.lat, lon: element.center.lon } + : null, + provenance: { type: element.type, id: element.id }, + }; + }); +} diff --git a/src/sources/overpassRoads.js b/src/sources/overpassRoads.js new file mode 100644 index 000000000..576704508 --- /dev/null +++ b/src/sources/overpassRoads.js @@ -0,0 +1,27 @@ +/** Decode OSM ways into portable road coordinates, classes and travel direction. */ +export function normalizeOverpassRoads(payload) { + const roads = []; + for (const element of payload?.elements || []) { + if ( + element.type !== 'way' || + !element.geometry || + element.geometry.length < 2 + ) + continue; + const oneway = element.tags?.oneway; + roads.push({ + coordinates: element.geometry.map((point) => [point.lon, point.lat]), + type: element.tags?.highway || 'unclassified', + oneway: + oneway === 'yes' || + oneway === '1' || + oneway === 'true' || + element.tags?.junction === 'roundabout' + ? 1 + : oneway === '-1' + ? -1 + : 0, + }); + } + return roads; +}