mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
refactor: separate map feature queries and source records
This commit is contained in:
1 parent
a8125ad917
commit
bf1d50f29b
28 files changed
+1128
-675
No files matched your search
@@ -1,5 +1,6 @@
|
|||||||
# Changelog
|
# 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.
|
- 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.
|
- 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.
|
||||||
|
|||||||
+39
-3
@@ -589,9 +589,9 @@ installation footprints, and annotation geometry (administrative boundaries,
|
|||||||
neighborhoods, streets, building/grounds outlines and monument candidates).
|
neighborhoods, streets, building/grounds outlines and monument candidates).
|
||||||
Nominatim forward/reverse lookup does not replace those queries. Layer source
|
Nominatim forward/reverse lookup does not replace those queries. Layer source
|
||||||
interfaces select traffic, camera and installation ingestion separately;
|
interfaces select traffic, camera and installation ingestion separately;
|
||||||
annotation `boundaries.query` still uses Overpass QL and OSM-shaped elements.
|
annotation and camera-framing consumers use the feature operations documented
|
||||||
Replacing that service with a different query language requires operation-level
|
below. The Overpass adapter translates those operations and normalizes records;
|
||||||
feature/geometry adapters, not just changing its URL. Rendering and geometry
|
an alternate backend implements that source interface. Rendering and geometry
|
||||||
selection remain consumers of those results.
|
selection remain consumers of those results.
|
||||||
|
|
||||||
`./sources/nominatim` exports the lower-level JSONv2 client and normalizers for
|
`./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
|
files under the configured owned roots. Tests retain explicit adoption. Git and
|
||||||
Prettier exclusions keep local/generated data out; resolved paths are validated
|
Prettier exclusions keep local/generated data out; resolved paths are validated
|
||||||
before any writes. New runtime modules do not need another scope-list entry.
|
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.
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
# God's Eye View Current State
|
# 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.
|
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.
|
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.
|
||||||
|
|||||||
+2
-1
@@ -239,6 +239,7 @@
|
|||||||
"./layers/transit": "./src/layers/transit/index.js",
|
"./layers/transit": "./src/layers/transit/index.js",
|
||||||
"./layers/transit/standalone": "./src/data/transit.js",
|
"./layers/transit/standalone": "./src/data/transit.js",
|
||||||
"./layers/transit/source": "./src/layers/transit/source.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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -724,5 +724,6 @@
|
|||||||
"src/tooling/transitQa.test.mjs",
|
"src/tooling/transitQa.test.mjs",
|
||||||
"scripts/qa-transit-browser.mjs",
|
"scripts/qa-transit-browser.mjs",
|
||||||
"scripts/format-runtime.json",
|
"scripts/format-runtime.json",
|
||||||
"src/tooling/transitService.test.mjs"
|
"src/tooling/transitService.test.mjs",
|
||||||
|
"src/sources/overpassFeatures.test.mjs"
|
||||||
]
|
]
|
||||||
@@ -905,7 +905,8 @@
|
|||||||
"src/layers/traffic/state.js",
|
"src/layers/traffic/state.js",
|
||||||
"src/layers/traffic/style.js",
|
"src/layers/traffic/style.js",
|
||||||
"src/layers/traffic/timing.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"]
|
"external": ["cesium", "pbf", "@mapbox/vector-tile"]
|
||||||
},
|
},
|
||||||
@@ -1672,7 +1673,12 @@
|
|||||||
"src/layers/transit/movement.js",
|
"src/layers/transit/movement.js",
|
||||||
"src/layers/transit/qaMetrics.js",
|
"src/layers/transit/qaMetrics.js",
|
||||||
"src/layers/transit/source.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": [
|
"external": [
|
||||||
"@mapbox/vector-tile",
|
"@mapbox/vector-tile",
|
||||||
@@ -2055,7 +2061,12 @@
|
|||||||
"src/layers/transit/movement.js",
|
"src/layers/transit/movement.js",
|
||||||
"src/layers/transit/qaMetrics.js",
|
"src/layers/transit/qaMetrics.js",
|
||||||
"src/layers/transit/source.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": [
|
"external": [
|
||||||
"cesium",
|
"cesium",
|
||||||
@@ -2104,7 +2115,11 @@
|
|||||||
"src/nominatimGeocode.js",
|
"src/nominatimGeocode.js",
|
||||||
"src/data/regionalModel.js",
|
"src/data/regionalModel.js",
|
||||||
"src/sources/httpBody.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": [
|
"external": [
|
||||||
"cesium",
|
"cesium",
|
||||||
@@ -2305,7 +2320,10 @@
|
|||||||
},
|
},
|
||||||
"installations-source": {
|
"installations-source": {
|
||||||
"exports": ["./layers/installations/source"],
|
"exports": ["./layers/installations/source"],
|
||||||
"modules": ["src/layers/installations/source.js"],
|
"modules": [
|
||||||
|
"src/layers/installations/source.js",
|
||||||
|
"src/data/militaryInstallationData.js"
|
||||||
|
],
|
||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"launches-source": {
|
"launches-source": {
|
||||||
@@ -2329,7 +2347,8 @@
|
|||||||
"src/data/tomtomTiles.js",
|
"src/data/tomtomTiles.js",
|
||||||
"src/layers/traffic/flowDecode.js",
|
"src/layers/traffic/flowDecode.js",
|
||||||
"src/layers/traffic/flowSource.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"]
|
"external": ["@mapbox/vector-tile", "pbf"]
|
||||||
},
|
},
|
||||||
@@ -2458,7 +2477,11 @@
|
|||||||
"src/services/terrainHeights.js",
|
"src/services/terrainHeights.js",
|
||||||
"src/sources/sourceSlot.js",
|
"src/sources/sourceSlot.js",
|
||||||
"src/layers/transit/source.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"]
|
"external": ["cesium", "egm96-universal", "pbf"]
|
||||||
},
|
},
|
||||||
@@ -2520,5 +2543,15 @@
|
|||||||
"src/sources/rateLimit.js"
|
"src/sources/rateLimit.js"
|
||||||
],
|
],
|
||||||
"external": ["pbf"]
|
"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": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,17 +1,21 @@
|
|||||||
|
import { normalizeOverpassFeatures } from '../sources/overpassFeaturesRecords.js';
|
||||||
import { createAnnotationResolver } from './resolver.js';
|
import { createAnnotationResolver } from './resolver.js';
|
||||||
import { applicationServices } from '../services/application.js';
|
import { applicationServices } from '../services/application.js';
|
||||||
const resolver = createAnnotationResolver({
|
const resolver = createAnnotationResolver({
|
||||||
boundarySource: applicationServices.boundaries,
|
featureSource: applicationServices.features,
|
||||||
});
|
});
|
||||||
export const {
|
export const {
|
||||||
resolveAnnotationTarget,
|
resolveAnnotationTarget,
|
||||||
refineScope,
|
refineScope,
|
||||||
isRateLimitedOutcome,
|
isRateLimitedOutcome,
|
||||||
isGroundsLikeAsk,
|
isGroundsLikeAsk,
|
||||||
selectFootprint,
|
|
||||||
viewportBias,
|
viewportBias,
|
||||||
placesNearViewRecovery,
|
placesNearViewRecovery,
|
||||||
resolveRegionRingForQuery,
|
resolveRegionRingForQuery,
|
||||||
pickWorldFromScreen,
|
pickWorldFromScreen,
|
||||||
sampleGroundHeight,
|
sampleGroundHeight,
|
||||||
} = resolver;
|
} = resolver;
|
||||||
|
|
||||||
|
/** Compatibility adapter for callers supplying raw OSM fixtures. */
|
||||||
|
export const selectFootprint = (elements, ...args) =>
|
||||||
|
resolver.selectFootprint(normalizeOverpassFeatures(elements), ...args);
|
||||||
+83
-485
@@ -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 { defaultGeospatial } from '../search/defaults.js';
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { lookupNeighborhoodRing } from '../data/neighborhoodPolygons.js';
|
import { lookupNeighborhoodRing } from '../data/neighborhoodPolygons.js';
|
||||||
@@ -12,13 +25,16 @@ import {
|
|||||||
import { unavailablePlaceSearch } from '../search/placeSearch.js';
|
import { unavailablePlaceSearch } from '../search/placeSearch.js';
|
||||||
import { isPickedWorldPosition } from '../data/scenePick.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({
|
export function createAnnotationResolver({
|
||||||
boundarySource,
|
boundarySource,
|
||||||
signal: lifetime,
|
signal: lifetime,
|
||||||
|
featureSource = createOverpassFeatureSource({
|
||||||
|
boundarySource,
|
||||||
|
signal: lifetime,
|
||||||
|
}),
|
||||||
} = {}) {
|
} = {}) {
|
||||||
if (typeof boundarySource?.query !== 'function')
|
requireFeatureSource(featureSource);
|
||||||
throw new TypeError('Annotation boundaries require a query service');
|
|
||||||
lifetime?.throwIfAborted();
|
lifetime?.throwIfAborted();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -764,20 +780,6 @@ export function createAnnotationResolver({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Shoelace area (m²) of a [[lon,lat], ...] ring in a local equirectangular projection. */
|
/** 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. */
|
/** Resolve a name through the supplied service; geometry selection stays here. */
|
||||||
async function geocodePlace(query, biasRect, signal, placeSearch) {
|
async function geocodePlace(query, biasRect, signal, placeSearch) {
|
||||||
@@ -979,25 +981,6 @@ export function createAnnotationResolver({
|
|||||||
return value?.rateLimited === true;
|
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).
|
* Resolve an administrative boundary (country/state/county/city/neighborhood).
|
||||||
* `is_in(point)` returns every admin area containing the point at all levels;
|
* `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);
|
const cachedFp = cacheRead(footprintCache, cacheKey);
|
||||||
if (cachedFp !== undefined) return cachedFp;
|
if (cachedFp !== undefined) return cachedFp;
|
||||||
|
|
||||||
const candidates = await overpassJson(
|
const candidates = await featureSource.getAdministrativeAreas(
|
||||||
`[out:json][timeout:25];is_in(${lat},${lon})->.a;area.a["boundary"="administrative"]["admin_level"];out tags;`,
|
{ lat, lon },
|
||||||
14000,
|
{ signal: signal },
|
||||||
signal,
|
|
||||||
);
|
);
|
||||||
if (isRateLimitedOutcome(candidates)) return candidates;
|
if (isRateLimitedOutcome(candidates)) return candidates;
|
||||||
if (!candidates) return undefined; // transient upstream failure (vs null = definitive miss)
|
if (!candidates) return undefined; // transient upstream failure (vs null = definitive miss)
|
||||||
@@ -1025,17 +1007,17 @@ export function createAnnotationResolver({
|
|||||||
const queryWords = normalizedWords(query);
|
const queryWords = normalizedWords(query);
|
||||||
const scored = [];
|
const scored = [];
|
||||||
for (const el of candidates) {
|
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
|
// 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
|
// 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
|
// official_name ("City and County of San Francisco") otherwise DILUTES the real
|
||||||
// boundary's completeness and lets a less-specific duplicate ("San Francisco County")
|
// 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.
|
// win — which then has no backing relation and collapses the whole resolution to a dot.
|
||||||
const coreWords = normalizedWords(
|
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(
|
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)
|
.filter(Boolean)
|
||||||
.join(' '),
|
.join(' '),
|
||||||
);
|
);
|
||||||
@@ -1053,7 +1035,7 @@ export function createAnnotationResolver({
|
|||||||
// one (decisively outranks a "<name> County" style duplicate).
|
// one (decisively outranks a "<name> County" style duplicate).
|
||||||
const exactName =
|
const exactName =
|
||||||
coreWords.size === queryWords.size && coreOverlap === queryWords.size;
|
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
|
// Bias toward the scope's specificity: city/neighborhood prefer the MORE specific
|
||||||
// (higher admin_level) match; state/country prefer the broader one.
|
// (higher admin_level) match; state/country prefer the broader one.
|
||||||
let levelBias;
|
let levelBias;
|
||||||
@@ -1109,19 +1091,17 @@ export function createAnnotationResolver({
|
|||||||
// 16 s turned finishable region pivots (Sicilia's dense coastline) into permanent
|
// 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
|
// "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.
|
// outline is progressive, so a long budget blocks nothing; repeats are disk-cached.
|
||||||
const relEls = await overpassJson(
|
const relEls = await featureSource.getAreaGeometry(cand.el.id, {
|
||||||
`[out:json][timeout:25];area(${cand.el.id})->.x;rel(pivot.x);out geom;`,
|
signal: signal,
|
||||||
28000,
|
});
|
||||||
signal,
|
|
||||||
);
|
|
||||||
if (isRateLimitedOutcome(relEls)) return relEls;
|
if (isRateLimitedOutcome(relEls)) return relEls;
|
||||||
if (relEls === null) {
|
if (relEls === null) {
|
||||||
transient = true;
|
transient = true;
|
||||||
break;
|
break;
|
||||||
} // network blip — don't definitively fail
|
} // 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
|
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
|
if (coords.length < 3) continue; // incomplete geometry — try the next
|
||||||
|
|
||||||
let ring = closeRing(coords.map((p) => [p.lon, p.lat]));
|
let ring = closeRing(coords.map((p) => [p.lon, p.lat]));
|
||||||
@@ -1184,25 +1164,20 @@ export function createAnnotationResolver({
|
|||||||
*/
|
*/
|
||||||
async function fetchPlaceArea(lat, lon, query, signal) {
|
async function fetchPlaceArea(lat, lon, query, signal) {
|
||||||
const queryWords = normalizedWords(query);
|
const queryWords = normalizedWords(query);
|
||||||
const els = await overpassJson(
|
const els = await featureSource.getNeighborhoodAreas(
|
||||||
`[out:json][timeout:20];(` +
|
{ lat, lon },
|
||||||
`way(around:1500,${lat},${lon})["place"~"neighbourhood|suburb|quarter|borough"]["name"];` +
|
{ signal: signal },
|
||||||
`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 (isRateLimitedOutcome(els)) return els;
|
||||||
if (els === null) return undefined; // transient upstream failure (vs [] = no match)
|
if (els === null) return undefined; // transient upstream failure (vs [] = no match)
|
||||||
let bestRing = null;
|
let bestRing = null;
|
||||||
let bestScore = 0;
|
let bestScore = 0;
|
||||||
for (const el of els) {
|
for (const el of els) {
|
||||||
const coords = elementCoordinates(el);
|
const coords = el.coordinates;
|
||||||
if (coords.length < 3) continue;
|
if (coords.length < 3) continue;
|
||||||
const tags = el.tags || {};
|
const names = el.names;
|
||||||
const nameWords = normalizedWords(
|
const nameWords = normalizedWords(
|
||||||
[tags.name, tags['name:en']].filter(Boolean).join(' '),
|
[names.primary, names.english].filter(Boolean).join(' '),
|
||||||
);
|
);
|
||||||
const overlap = wordOverlap(queryWords, nameWords);
|
const overlap = wordOverlap(queryWords, nameWords);
|
||||||
if (!overlap) continue;
|
if (!overlap) continue;
|
||||||
@@ -1238,26 +1213,20 @@ export function createAnnotationResolver({
|
|||||||
const queryWords = normalizedWords(query);
|
const queryWords = normalizedWords(query);
|
||||||
|
|
||||||
// Tier C — a same-named district / quarter / named commercial area.
|
// Tier C — a same-named district / quarter / named commercial area.
|
||||||
const areaEls = await overpassJson(
|
const areaEls = await featureSource.getStreetAreas(
|
||||||
`[out:json][timeout:20];(` +
|
{ lat, lon },
|
||||||
`way(around:450,${lat},${lon})["place"~"quarter|neighbourhood|suburb|city_block"];` +
|
{ signal: signal },
|
||||||
`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 (isRateLimitedOutcome(areaEls)) return areaEls;
|
||||||
if (areaEls) {
|
if (areaEls) {
|
||||||
let bestRing = null;
|
let bestRing = null;
|
||||||
let bestScore = 0;
|
let bestScore = 0;
|
||||||
for (const el of areaEls) {
|
for (const el of areaEls) {
|
||||||
const coords = elementCoordinates(el);
|
const coords = el.coordinates;
|
||||||
if (coords.length < 3) continue;
|
if (coords.length < 3) continue;
|
||||||
const tags = el.tags || {};
|
const names = el.names;
|
||||||
const nameWords = normalizedWords(
|
const nameWords = normalizedWords(
|
||||||
[tags.name, tags['name:en']].filter(Boolean).join(' '),
|
[names.primary, names.english].filter(Boolean).join(' '),
|
||||||
);
|
);
|
||||||
const overlap = wordOverlap(queryWords, nameWords);
|
const overlap = wordOverlap(queryWords, nameWords);
|
||||||
if (!overlap) continue;
|
if (!overlap) continue;
|
||||||
@@ -1277,23 +1246,20 @@ export function createAnnotationResolver({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Tier F — buffer the matching centerline into a corridor ribbon (workhorse).
|
// Tier F — buffer the matching centerline into a corridor ribbon (workhorse).
|
||||||
const wayEls = await overpassJson(
|
const wayEls = await featureSource.getStreetLines(
|
||||||
`[out:json][timeout:20];way(around:320,${lat},${lon})["highway"]["name"];out geom;`,
|
{ lat, lon },
|
||||||
14000,
|
{ signal: signal },
|
||||||
signal,
|
|
||||||
);
|
);
|
||||||
if (isRateLimitedOutcome(wayEls)) return wayEls;
|
if (isRateLimitedOutcome(wayEls)) return wayEls;
|
||||||
if (wayEls) {
|
if (wayEls) {
|
||||||
const segments = [];
|
const segments = [];
|
||||||
for (const el of wayEls) {
|
for (const el of wayEls) {
|
||||||
const tags = el.tags || {};
|
const names = el.names;
|
||||||
const nameWords = normalizedWords(
|
const nameWords = normalizedWords(
|
||||||
[tags.name, tags['name:en']].filter(Boolean).join(' '),
|
[names.primary, names.english].filter(Boolean).join(' '),
|
||||||
);
|
);
|
||||||
if (!wordOverlap(queryWords, nameWords)) continue;
|
if (!wordOverlap(queryWords, nameWords)) continue;
|
||||||
const geom = (el.geometry || []).filter(
|
const geom = el.coordinates;
|
||||||
(p) => Number.isFinite(p?.lat) && Number.isFinite(p?.lon),
|
|
||||||
);
|
|
||||||
if (geom.length >= 2) segments.push(geom.map((p) => [p.lon, p.lat]));
|
if (geom.length >= 2) segments.push(geom.map((p) => [p.lon, p.lat]));
|
||||||
}
|
}
|
||||||
if (segments.length) {
|
if (segments.length) {
|
||||||
@@ -1317,119 +1283,6 @@ export function createAnnotationResolver({
|
|||||||
return definitive ? null : undefined;
|
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
|
* 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
|
* `building` (small footprint, gets extruded into a volume) or an `area` (a
|
||||||
@@ -1466,30 +1319,10 @@ export function createAnnotationResolver({
|
|||||||
const cachedFp = cacheRead(footprintCache, cacheKey);
|
const cachedFp = cacheRead(footprintCache, cacheKey);
|
||||||
if (cachedFp !== undefined) return cachedFp;
|
if (cachedFp !== undefined) return cachedFp;
|
||||||
|
|
||||||
// `out geom` (not `out tags geom`) so relation members come back WITH their
|
const elements = await featureSource.getFootprints(
|
||||||
// way geometry — needed to trace multipolygon compounds like the Presidio.
|
{ lat, lon },
|
||||||
const overpassQuery = `
|
{ signal: signal },
|
||||||
[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 (isRateLimitedOutcome(elements)) return elements;
|
||||||
if (elements === null || signal?.aborted) return undefined;
|
if (elements === null || signal?.aborted) return undefined;
|
||||||
const fp = selectFootprint(elements, lat, lon, query, mode);
|
const fp = selectFootprint(elements, lat, lon, query, mode);
|
||||||
@@ -1501,8 +1334,6 @@ export function createAnnotationResolver({
|
|||||||
return null;
|
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
|
* Find the REAL enclosing "grounds / compound / campus" polygon containing a point
|
||||||
* — the smallest NAMED, NON-building `leisure` / `landuse` / `boundary` / `amenity`
|
* — the smallest NAMED, NON-building `leisure` / `landuse` / `boundary` / `amenity`
|
||||||
@@ -1532,30 +1363,14 @@ export function createAnnotationResolver({
|
|||||||
const cached = cacheRead(enclosingAreaCache, cacheKey);
|
const cached = cacheRead(enclosingAreaCache, cacheKey);
|
||||||
if (cached !== undefined) return cached;
|
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 —
|
// 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
|
// 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).
|
// 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 (isRateLimitedOutcome(elements)) return elements;
|
||||||
if (elements === null) return undefined; // transient upstream failure (vs [] = definitive no-match)
|
if (elements === null) return undefined; // transient upstream failure (vs [] = definitive no-match)
|
||||||
|
|
||||||
@@ -1564,8 +1379,8 @@ export function createAnnotationResolver({
|
|||||||
let bestArea = Infinity;
|
let bestArea = Infinity;
|
||||||
let bestNameMatch = false;
|
let bestNameMatch = false;
|
||||||
for (const el of elements) {
|
for (const el of elements) {
|
||||||
if (el.tags?.building) continue; // exclude buildings (the exact feature, never the grounds)
|
if (el.building) continue; // exclude buildings (the exact feature, never the grounds)
|
||||||
const coords = elementCoordinates(el);
|
const coords = el.coordinates;
|
||||||
if (coords.length < 3) continue;
|
if (coords.length < 3) continue;
|
||||||
if (!pointInPolygon(lon, lat, coords)) continue; // require genuine containment
|
if (!pointInPolygon(lon, lat, coords)) continue; // require genuine containment
|
||||||
const ringLonLat = closeRing(coords.map((p) => [p.lon, p.lat]));
|
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".
|
// makes the Capitol land on "Capitol Square" even though the user said "grounds".
|
||||||
const nameWords = normalizedWords(
|
const nameWords = normalizedWords(
|
||||||
[
|
[
|
||||||
el.tags?.name,
|
el.names.primary,
|
||||||
el.tags?.['name:en'],
|
el.names.english,
|
||||||
el.tags?.official_name,
|
el.names.official,
|
||||||
el.tags?.alt_name,
|
el.names.alternate,
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' '),
|
.join(' '),
|
||||||
@@ -1606,7 +1421,6 @@ export function createAnnotationResolver({
|
|||||||
return null;
|
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). */
|
/** A target that reads like a fine-grained monument/marker (vs a building/district). */
|
||||||
function isMonumentLikeQuery(query) {
|
function isMonumentLikeQuery(query) {
|
||||||
return /\b(monument|memorial|statue|sculpture|fountain|cenotaph|obelisk|plaque|bust)\b/i.test(
|
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).
|
// timeout, and one caller's clear() must not kill the others' snap).
|
||||||
let pending = monumentInflight.get(centerKey);
|
let pending = monumentInflight.get(centerKey);
|
||||||
if (!pending) {
|
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
|
// 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.
|
// on a slow/overloaded Overpass. The feature source returns null on a transient failure.
|
||||||
pending = overpassJson(q, 6000)
|
pending = featureSource
|
||||||
|
.getMonuments({ lat, lon }, { signal: undefined })
|
||||||
.then((elements) => {
|
.then((elements) => {
|
||||||
if (elements === null || isRateLimitedOutcome(elements))
|
if (elements === null || isRateLimitedOutcome(elements))
|
||||||
return null; // transient — NEVER cached (a poisoned bucket
|
return null; // transient — NEVER cached (a poisoned bucket
|
||||||
@@ -1694,16 +1500,12 @@ export function createAnnotationResolver({
|
|||||||
const feats = [];
|
const feats = [];
|
||||||
for (const el of elements) {
|
for (const el of elements) {
|
||||||
const name =
|
const name =
|
||||||
el.tags?.name ||
|
el.names.primary ||
|
||||||
el.tags?.['name:en'] ||
|
el.names.english ||
|
||||||
el.tags?.official_name ||
|
el.names.official ||
|
||||||
el.tags?.alt_name;
|
el.names.alternate;
|
||||||
if (!name) continue;
|
if (!name) continue;
|
||||||
const p = Number.isFinite(el.lat)
|
const p = el.point;
|
||||||
? { lat: el.lat, lon: el.lon }
|
|
||||||
: el.center
|
|
||||||
? { lat: el.center.lat, lon: el.center.lon }
|
|
||||||
: null;
|
|
||||||
if (!p) continue;
|
if (!p) continue;
|
||||||
feats.push({
|
feats.push({
|
||||||
name,
|
name,
|
||||||
@@ -1767,20 +1569,20 @@ export function createAnnotationResolver({
|
|||||||
let bestScore = -Infinity;
|
let bestScore = -Infinity;
|
||||||
|
|
||||||
for (const element of elements) {
|
for (const element of elements) {
|
||||||
const coords = elementCoordinates(element);
|
const coords = element.coordinates;
|
||||||
if (coords.length < 3) continue;
|
if (coords.length < 3) continue;
|
||||||
|
|
||||||
const tags = element.tags || {};
|
const names = element.names;
|
||||||
const isBuilding = Boolean(tags.building);
|
const isBuilding = element.building;
|
||||||
const areaM2 = approximateAreaM2(coords);
|
const areaM2 = approximateAreaM2(coords);
|
||||||
|
|
||||||
const nameWords = normalizedWords(
|
const nameWords = normalizedWords(
|
||||||
[
|
[
|
||||||
tags.name,
|
names.primary,
|
||||||
tags['name:en'],
|
names.english,
|
||||||
tags.official_name,
|
names.official,
|
||||||
tags.alt_name,
|
names.alternate,
|
||||||
tags.short_name,
|
names.short,
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' '),
|
.join(' '),
|
||||||
@@ -1848,7 +1650,10 @@ export function createAnnotationResolver({
|
|||||||
best = {
|
best = {
|
||||||
ring: closeRing(coords.map((p) => [p.lon, p.lat])),
|
ring: closeRing(coords.map((p) => [p.lon, p.lat])),
|
||||||
kind: isBuilding ? 'building' : 'area',
|
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;
|
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) {
|
function normalizedWords(value) {
|
||||||
return new Set(
|
return new Set(
|
||||||
String(value || '')
|
String(value || '')
|
||||||
|
|||||||
+13
-3
@@ -1,3 +1,4 @@
|
|||||||
|
import { createOverpassFeatureSource } from '../sources/overpassFeatures.js';
|
||||||
import { createSurfaceServices } from './surfaceServices.js';
|
import { createSurfaceServices } from './surfaceServices.js';
|
||||||
import { createAnnotationResolver } from '../annotations/resolver.js';
|
import { createAnnotationResolver } from '../annotations/resolver.js';
|
||||||
import { searchAndFlyTo } from '../locations.js';
|
import { searchAndFlyTo } from '../locations.js';
|
||||||
@@ -5,7 +6,6 @@ import { searchAndFlyTo } from '../locations.js';
|
|||||||
/** Assemble application operations from the caller's request services. */
|
/** Assemble application operations from the caller's request services. */
|
||||||
export function createApplicationOperations({ requests, signal, eventTarget }) {
|
export function createApplicationOperations({ requests, signal, eventTarget }) {
|
||||||
for (const [name, method] of Object.entries({
|
for (const [name, method] of Object.entries({
|
||||||
boundaries: 'query',
|
|
||||||
terrain: 'getHeights',
|
terrain: 'getHeights',
|
||||||
regional: 'getBrief',
|
regional: 'getBrief',
|
||||||
weather: 'getConditions',
|
weather: 'getConditions',
|
||||||
@@ -19,8 +19,14 @@ export function createApplicationOperations({ requests, signal, eventTarget }) {
|
|||||||
signal,
|
signal,
|
||||||
eventTarget,
|
eventTarget,
|
||||||
});
|
});
|
||||||
|
const features =
|
||||||
|
requests.features ??
|
||||||
|
createOverpassFeatureSource({
|
||||||
|
boundarySource: requests.boundaries,
|
||||||
|
signal,
|
||||||
|
});
|
||||||
const annotationResolver = createAnnotationResolver({
|
const annotationResolver = createAnnotationResolver({
|
||||||
boundarySource: requests.boundaries,
|
featureSource: features,
|
||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
@@ -30,7 +36,11 @@ export function createApplicationOperations({ requests, signal, eventTarget }) {
|
|||||||
searchAndFlyTo: (viewer, query, options = {}) =>
|
searchAndFlyTo: (viewer, query, options = {}) =>
|
||||||
searchAndFlyTo(viewer, query, {
|
searchAndFlyTo(viewer, query, {
|
||||||
...options,
|
...options,
|
||||||
boundaries: requests.boundaries,
|
features,
|
||||||
|
signal:
|
||||||
|
signal && options.signal
|
||||||
|
? AbortSignal.any([signal, options.signal])
|
||||||
|
: signal || options.signal,
|
||||||
recoverNearView: annotationResolver.placesNearViewRecovery,
|
recoverNearView: annotationResolver.placesNearViewRecovery,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { normalizeMilitaryInstallations } from '../../data/militaryInstallationData.js';
|
|
||||||
|
|
||||||
export function createIngestion({
|
export function createIngestion({
|
||||||
state: layerState,
|
state: layerState,
|
||||||
services,
|
services,
|
||||||
@@ -58,19 +56,15 @@ export function createIngestion({
|
|||||||
// A SATURATED snapped tile was truncated upstream, so features from the
|
// 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
|
// snap's extra ring may have crowded out sites actually on screen. Re-ask
|
||||||
// for the exact viewport (separately keyed and cached) before rendering.
|
// for the exact viewport (separately keyed and cached) before rendering.
|
||||||
let saturated = parts.model.installationResponseSaturated(payload);
|
let saturated = payload.saturated === true;
|
||||||
if (saturated) {
|
if (saturated) {
|
||||||
payload = await fetchInstallations(true);
|
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
|
// 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
|
// was actually asked for so nothing off-screen reaches the map or the
|
||||||
// "current viewport only" context claim.
|
// "current viewport only" context claim.
|
||||||
const records = normalized.records.filter((record) =>
|
const records = payload.records.filter((record) =>
|
||||||
parts.model.installationWithinViewport(record, box),
|
parts.model.installationWithinViewport(record, box),
|
||||||
);
|
);
|
||||||
let placesError = null;
|
let placesError = null;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { installationResponseSaturated } from './source.js';
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import {
|
import {
|
||||||
EARTH_MEAN_RADIUS_M,
|
EARTH_MEAN_RADIUS_M,
|
||||||
@@ -137,23 +138,6 @@ export function createModel({ state: layerState, services, parts, source }) {
|
|||||||
return record.osmType !== 'node';
|
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 {
|
return {
|
||||||
approximateSurfaceDistanceM,
|
approximateSurfaceDistanceM,
|
||||||
colorFor,
|
colorFor,
|
||||||
|
|||||||
@@ -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. */
|
/** Read mapped installations and explicit nearby-place searches through fixed endpoints. */
|
||||||
export function createInstallationSource({
|
export function createInstallationSource({
|
||||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||||
@@ -43,7 +53,14 @@ export function createInstallationSource({
|
|||||||
);
|
);
|
||||||
if (!Array.isArray(body?.elements))
|
if (!Array.isArray(body?.elements))
|
||||||
throw new Error('Malformed installation snapshot');
|
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 } = {}) {
|
async searchNearby({ latitude, longitude, radiusM }, { signal } = {}) {
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -57,3 +57,30 @@ test('installation response parsing respects cancellation before any follow-on s
|
|||||||
{ name: 'AbortError' },
|
{ 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);
|
||||||
|
});
|
||||||
@@ -73,7 +73,7 @@ export function createIngestion({
|
|||||||
if (!state) {
|
if (!state) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
signal?.throwIfAborted();
|
signal?.throwIfAborted();
|
||||||
if (!Array.isArray(data?.elements))
|
if (!Array.isArray(data?.roads))
|
||||||
throw new Error('Malformed road snapshot');
|
throw new Error('Malformed road snapshot');
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
@@ -100,8 +100,7 @@ export function createIngestion({
|
|||||||
}
|
}
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
signal?.throwIfAborted();
|
signal?.throwIfAborted();
|
||||||
if (!Array.isArray(data?.elements))
|
if (!Array.isArray(data?.roads)) throw new Error('Malformed road snapshot');
|
||||||
throw new Error('Malformed road snapshot');
|
|
||||||
if (state) {
|
if (state) {
|
||||||
const responseEnd = parts.timing.trafficTimingMark(
|
const responseEnd = parts.timing.trafficTimingMark(
|
||||||
state,
|
state,
|
||||||
|
|||||||
+10
-58
@@ -13,52 +13,17 @@ import {
|
|||||||
} from './policy.js';
|
} from './policy.js';
|
||||||
|
|
||||||
export function createModel({ state: layerState, services, parts, source }) {
|
export function createModel({ state: layerState, services, parts, source }) {
|
||||||
// ─── Overpass API ──────────────────────────────────────────
|
/** Build scene waypoints from source records; thinning and terrain remain rendering policy. */
|
||||||
|
function parseRoads(roadData) {
|
||||||
/**
|
if (!roadData || !roadData.roads) {
|
||||||
* Build an Overpass QL query string to fetch road ways within a bounding box.
|
return [];
|
||||||
*
|
}
|
||||||
* 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 [];
|
|
||||||
|
|
||||||
const roads = [];
|
const roads = [];
|
||||||
for (const el of overpassData.elements) {
|
for (const road of roadData.roads) {
|
||||||
if (el.type !== 'way' || !el.geometry || el.geometry.length < 2) continue;
|
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
|
// Sub-sample long polylines: keep every Nth vertex to stay within budget
|
||||||
const simplifyStep =
|
const simplifyStep =
|
||||||
@@ -79,21 +44,8 @@ export function createModel({ state: layerState, services, parts, source }) {
|
|||||||
|
|
||||||
if (coords.length < 2) continue;
|
if (coords.length < 2) continue;
|
||||||
|
|
||||||
const type = el.tags?.highway || 'unclassified';
|
const type = road.type;
|
||||||
// One-way capture (field-test round 1: "a car would never go in
|
const oneway = road.oneway;
|
||||||
// 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;
|
|
||||||
|
|
||||||
// Sample terrain height once at the road start to avoid per-vertex cost
|
// Sample terrain height once at the road start to avoid per-vertex cost
|
||||||
let baseHeight = 0;
|
let baseHeight = 0;
|
||||||
|
|||||||
@@ -82,13 +82,13 @@ function roads(bounds) {
|
|||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({
|
json: async () => ({
|
||||||
elements: [
|
roads: [
|
||||||
{
|
{
|
||||||
type: 'way',
|
type: 'primary',
|
||||||
tags: { highway: 'primary' },
|
oneway: 0,
|
||||||
geometry: [
|
coordinates: [
|
||||||
{ lat: bounds.south, lon: bounds.west },
|
[bounds.west, bounds.south],
|
||||||
{ lat: bounds.south + 0.005, lon: bounds.west + 0.005 },
|
[bounds.west + 0.005, bounds.south + 0.005],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { normalizeOverpassRoads } from '../../sources/overpassRoads.js';
|
||||||
|
export { normalizeOverpassRoads } from '../../sources/overpassRoads.js';
|
||||||
import { createFlowTileSource } from './flowSource.js';
|
import { createFlowTileSource } from './flowSource.js';
|
||||||
function buildOverpassQuery(
|
function buildOverpassQuery(
|
||||||
south,
|
south,
|
||||||
@@ -58,7 +60,9 @@ export function createTrafficSource({
|
|||||||
async json() {
|
async json() {
|
||||||
const body = await response.json();
|
const body = await response.json();
|
||||||
signal?.throwIfAborted();
|
signal?.throwIfAborted();
|
||||||
return body;
|
if (!Array.isArray(body?.elements))
|
||||||
|
throw new Error('Malformed road snapshot');
|
||||||
|
return { roads: normalizeOverpassRoads(body) };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -127,3 +127,41 @@ test('road body parsing retains the source request cancellation signal', async (
|
|||||||
});
|
});
|
||||||
await assert.rejects(response.json(), { name: 'AbortError' });
|
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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -193,7 +193,7 @@ export function createTiming({ state: layerState, services, parts, source }) {
|
|||||||
* waypoint-materialization time independently.
|
* waypoint-materialization time independently.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function parseRoadsTimed(overpassData, trace) {
|
function parseRoadsTimed(roadData, trace) {
|
||||||
/* TRACE_ONLY_BEGIN */
|
/* TRACE_ONLY_BEGIN */
|
||||||
const _trafficTimingState = trafficTimingPass(
|
const _trafficTimingState = trafficTimingPass(
|
||||||
trace,
|
trace,
|
||||||
@@ -207,7 +207,7 @@ export function createTiming({ state: layerState, services, parts, source }) {
|
|||||||
_trafficTimingParseStartTime,
|
_trafficTimingParseStartTime,
|
||||||
);
|
);
|
||||||
/* TRACE_ONLY_END */
|
/* TRACE_ONLY_END */
|
||||||
if (!overpassData || !overpassData.elements) {
|
if (!roadData || !roadData.roads) {
|
||||||
/* TRACE_ONLY_BEGIN */
|
/* TRACE_ONLY_BEGIN */
|
||||||
const _trafficTimingParseEnd = trafficTimingMark(
|
const _trafficTimingParseEnd = trafficTimingMark(
|
||||||
_trafficTimingState,
|
_trafficTimingState,
|
||||||
@@ -251,10 +251,10 @@ export function createTiming({ state: layerState, services, parts, source }) {
|
|||||||
let _trafficTimingSampleHeightMs = 0;
|
let _trafficTimingSampleHeightMs = 0;
|
||||||
let _trafficTimingWaypointMaterializationMs = 0;
|
let _trafficTimingWaypointMaterializationMs = 0;
|
||||||
/* TRACE_ONLY_END */
|
/* TRACE_ONLY_END */
|
||||||
for (const el of overpassData.elements) {
|
for (const road of roadData.roads) {
|
||||||
if (el.type !== 'way' || !el.geometry || el.geometry.length < 2) continue;
|
if (!road.coordinates || road.coordinates.length < 2) continue;
|
||||||
|
|
||||||
const rawCoords = el.geometry.map((g) => [g.lon, g.lat]);
|
const rawCoords = road.coordinates;
|
||||||
const simplifyStep =
|
const simplifyStep =
|
||||||
rawCoords.length > MAX_WAYPOINTS_PER_ROAD
|
rawCoords.length > MAX_WAYPOINTS_PER_ROAD
|
||||||
? Math.ceil(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;
|
if (coords.length < 2) continue;
|
||||||
|
|
||||||
const type = el.tags?.highway || 'unclassified';
|
const type = road.type;
|
||||||
const onewayTag = el.tags?.oneway;
|
const oneway = road.oneway;
|
||||||
const oneway =
|
|
||||||
onewayTag === 'yes' ||
|
|
||||||
onewayTag === '1' ||
|
|
||||||
onewayTag === 'true' ||
|
|
||||||
el.tags?.junction === 'roundabout'
|
|
||||||
? 1
|
|
||||||
: onewayTag === '-1'
|
|
||||||
? -1
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
let baseHeight = 0;
|
let baseHeight = 0;
|
||||||
const firstCoord = coords[0];
|
const firstCoord = coords[0];
|
||||||
|
|||||||
+29
-61
@@ -1,3 +1,4 @@
|
|||||||
|
import { createOverpassFeatureSource } from './sources/overpassFeatures.js';
|
||||||
import { applicationServices } from './services/application.js';
|
import { applicationServices } from './services/application.js';
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import {
|
import {
|
||||||
@@ -891,7 +892,18 @@ export async function searchAndFlyTo(viewer, query, options = {}) {
|
|||||||
|
|
||||||
const shouldResolveBuilding = navigationMode === 'precise-place';
|
const shouldResolveBuilding = navigationMode === 'precise-place';
|
||||||
const buildingBounds = shouldResolveBuilding
|
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;
|
: null;
|
||||||
const range = requestedRange || defaultRangeForNavigationMode(navigationMode);
|
const range = requestedRange || defaultRangeForNavigationMode(navigationMode);
|
||||||
if (!mayFly()) return CANCELLED_SEARCH;
|
if (!mayFly()) return CANCELLED_SEARCH;
|
||||||
@@ -1340,36 +1352,22 @@ async function resolveBuildingBounds(
|
|||||||
lat,
|
lat,
|
||||||
lon,
|
lon,
|
||||||
query,
|
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 {
|
try {
|
||||||
const elements = await boundaries.query(overpassQuery, {
|
const candidates = await features.getFocusFootprints(
|
||||||
signal: controller.signal,
|
{ lat, lon },
|
||||||
});
|
{ signal },
|
||||||
|
);
|
||||||
return selectBuildingBounds(
|
return selectBuildingBounds(
|
||||||
Array.isArray(elements) ? elements : [],
|
Array.isArray(candidates) ? candidates : [],
|
||||||
lat,
|
lat,
|
||||||
lon,
|
lon,
|
||||||
query,
|
query,
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
|
||||||
window.clearTimeout(timeout);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1377,11 +1375,11 @@ function selectBuildingBounds(elements, targetLat, targetLon, query) {
|
|||||||
const queryWords = normalizedWords(query);
|
const queryWords = normalizedWords(query);
|
||||||
const candidates = [];
|
const candidates = [];
|
||||||
for (const element of elements) {
|
for (const element of elements) {
|
||||||
const coordinates = elementCoordinates(element);
|
const coordinates = element.coordinates;
|
||||||
if (coordinates.length < 3) continue;
|
if (coordinates.length < 3) continue;
|
||||||
const bounds = coordinateBounds(coordinates, targetLat);
|
const bounds = coordinateBounds(coordinates, targetLat);
|
||||||
if (!bounds || bounds.width < 2 || bounds.depth < 2) continue;
|
if (!bounds || bounds.width < 2 || bounds.depth < 2) continue;
|
||||||
const tags = element.tags || {};
|
const names = element.names;
|
||||||
const center = element.center || averageCoordinate(coordinates);
|
const center = element.center || averageCoordinate(coordinates);
|
||||||
const distanceM = approximateDistanceM(
|
const distanceM = approximateDistanceM(
|
||||||
targetLat,
|
targetLat,
|
||||||
@@ -1390,22 +1388,24 @@ function selectBuildingBounds(elements, targetLat, targetLon, query) {
|
|||||||
center.lon,
|
center.lon,
|
||||||
);
|
);
|
||||||
const nameWords = normalizedWords(
|
const nameWords = normalizedWords(
|
||||||
[tags.name, tags['name:en'], tags.official_name, tags.alt_name]
|
[names.primary, names.english, names.official, names.alternate]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' '),
|
.join(' '),
|
||||||
);
|
);
|
||||||
const nameScore = wordOverlap(queryWords, nameWords);
|
const nameScore = wordOverlap(queryWords, nameWords);
|
||||||
const containsTarget = pointInPolygon(targetLon, targetLat, coordinates);
|
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({
|
candidates.push({
|
||||||
lat: center.lat,
|
lat: center.lat,
|
||||||
lon: center.lon,
|
lon: center.lon,
|
||||||
height,
|
height,
|
||||||
width: bounds.width,
|
width: bounds.width,
|
||||||
depth: bounds.depth,
|
depth: bounds.depth,
|
||||||
osmName: tags.name || tags['name:en'] || null,
|
osmName: names.primary || names.english || null,
|
||||||
osmType: element.type,
|
osmType: element.provenance?.type,
|
||||||
osmId: element.id,
|
osmId: element.provenance?.id,
|
||||||
score: nameScore * 1000 + (containsTarget ? 500 : 0) - distanceM,
|
score: nameScore * 1000 + (containsTarget ? 500 : 0) - distanceM,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1415,22 +1415,6 @@ function selectBuildingBounds(elements, targetLat, targetLon, query) {
|
|||||||
return best;
|
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) {
|
function coordinateBounds(coordinates, latitude) {
|
||||||
const latitudes = coordinates.map((point) => point.lat);
|
const latitudes = coordinates.map((point) => point.lat);
|
||||||
const longitudes = coordinates.map((point) => point.lon);
|
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) {
|
function averageCoordinate(coordinates) {
|
||||||
const total = coordinates.reduce(
|
const total = coordinates.reduce(
|
||||||
(sum, point) => ({
|
(sum, point) => ({
|
||||||
|
|||||||
@@ -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 { createSourceSlot } from '../sources/sourceSlot.js';
|
||||||
import { createApplicationRequestServices } from './requests.js';
|
import { createApplicationRequestServices } from './requests.js';
|
||||||
|
|
||||||
@@ -15,6 +17,11 @@ const slots = Object.fromEntries(
|
|||||||
createSourceSlot(defaults[name], methods, `${name} service`),
|
createSourceSlot(defaults[name], methods, `${name} service`),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
slots.features = createSourceSlot(
|
||||||
|
createOverpassFeatureSource({ boundarySource: slots.boundaries.source }),
|
||||||
|
FEATURE_SOURCE_METHODS,
|
||||||
|
'features service',
|
||||||
|
);
|
||||||
export const applicationServices = Object.freeze(
|
export const applicationServices = Object.freeze(
|
||||||
Object.fromEntries(
|
Object.fromEntries(
|
||||||
Object.entries(slots).map(([name, slot]) => [name, slot.source]),
|
Object.entries(slots).map(([name, slot]) => [name, slot.source]),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { createOverpassFeatureSource } from '../sources/overpassFeatures.js';
|
||||||
/** Parse bounded retry information from a service response. */
|
/** Parse bounded retry information from a service response. */
|
||||||
function retryAfterMs(value) {
|
function retryAfterMs(value) {
|
||||||
if (value == null || String(value).trim() === '') return null;
|
if (value == null || String(value).trim() === '') return null;
|
||||||
@@ -13,6 +14,7 @@ export function createApplicationRequestServices({
|
|||||||
fetchImpl = (...args) => fetch(...args),
|
fetchImpl = (...args) => fetch(...args),
|
||||||
signal: lifetime,
|
signal: lifetime,
|
||||||
endpoints = {},
|
endpoints = {},
|
||||||
|
features,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const urls = {
|
const urls = {
|
||||||
boundaries: '/api/overpass',
|
boundaries: '/api/overpass',
|
||||||
@@ -62,7 +64,7 @@ export function createApplicationRequestServices({
|
|||||||
throw new Error(`${label} unavailable (${response.status})`);
|
throw new Error(`${label} unavailable (${response.status})`);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
return {
|
const services = {
|
||||||
boundaries: {
|
boundaries: {
|
||||||
async query(query, { signal } = {}) {
|
async query(query, { signal } = {}) {
|
||||||
const response = await request(urls.boundaries, {
|
const response = await request(urls.boundaries, {
|
||||||
@@ -125,4 +127,11 @@ export function createApplicationRequestServices({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
services.features =
|
||||||
|
features ??
|
||||||
|
createOverpassFeatureSource({
|
||||||
|
boundarySource: services.boundaries,
|
||||||
|
signal: lifetime,
|
||||||
|
});
|
||||||
|
return services;
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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 },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user