refactor: separate map feature queries and source records

This commit is contained in:
Sameh Khamis committed 2026-09-15 08:10:32 -07:00
1 parent a8125ad917
commit bf1d50f29b
28 files changed
+1128 -675

No files matched your search

+6 -2
View File
@@ -1,17 +1,21 @@
import { normalizeOverpassFeatures } from '../sources/overpassFeaturesRecords.js';
import { createAnnotationResolver } from './resolver.js';
import { applicationServices } from '../services/application.js';
const resolver = createAnnotationResolver({
boundarySource: applicationServices.boundaries,
featureSource: applicationServices.features,
});
export const {
resolveAnnotationTarget,
refineScope,
isRateLimitedOutcome,
isGroundsLikeAsk,
selectFootprint,
viewportBias,
placesNearViewRecovery,
resolveRegionRingForQuery,
pickWorldFromScreen,
sampleGroundHeight,
} = resolver;
/** Compatibility adapter for callers supplying raw OSM fixtures. */
export const selectFootprint = (elements, ...args) =>
resolver.selectFootprint(normalizeOverpassFeatures(elements), ...args);
+83 -485
View File
@@ -1,3 +1,16 @@
import { requireFeatureSource } from '../sources/featureSource.js';
import { createOverpassFeatureSource } from '../sources/overpassFeatures.js';
import {
ringAreaM2,
stitchLine,
bufferCorridor,
simplifyRing,
closeRing,
ringCentroid,
approximateAreaM2,
approximateDistanceM,
pointInPolygon,
} from '../sources/featureGeometry.js';
import { defaultGeospatial } from '../search/defaults.js';
import * as Cesium from 'cesium';
import { lookupNeighborhoodRing } from '../data/neighborhoodPolygons.js';
@@ -12,13 +25,16 @@ import {
import { unavailablePlaceSearch } from '../search/placeSearch.js';
import { isPickedWorldPosition } from '../data/scenePick.js';
/** Own annotation lookup caches and the supplied boundary query service. */
/** Own annotation lookup caches and ranking of supplied feature candidates. */
export function createAnnotationResolver({
boundarySource,
signal: lifetime,
featureSource = createOverpassFeatureSource({
boundarySource,
signal: lifetime,
}),
} = {}) {
if (typeof boundarySource?.query !== 'function')
throw new TypeError('Annotation boundaries require a query service');
requireFeatureSource(featureSource);
lifetime?.throwIfAborted();
/**
@@ -764,20 +780,6 @@ export function createAnnotationResolver({
}
/** Shoelace area (m²) of a [[lon,lat], ...] ring in a local equirectangular projection. */
function ringAreaM2(ring) {
if (!Array.isArray(ring) || ring.length < 3) return 0;
const mLat = 111_320;
const mLon = mLat * Math.cos(Cesium.Math.toRadians(ring[0][1]));
let area = 0;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
const xi = ring[i][0] * mLon;
const yi = ring[i][1] * mLat;
const xj = ring[j][0] * mLon;
const yj = ring[j][1] * mLat;
area += xj * yi - xi * yj;
}
return Math.abs(area) / 2;
}
/** Resolve a name through the supplied service; geometry selection stays here. */
async function geocodePlace(query, biasRect, signal, placeSearch) {
@@ -979,25 +981,6 @@ export function createAnnotationResolver({
return value?.rateLimited === true;
}
/** POST an Overpass QL query and return elements, a transient null, or a throttle object. */
async function overpassJson(query, timeoutMs = 14000, signal) {
const controller = new AbortController();
const detach = linkAbort(controller, signal);
const timer = window.setTimeout(() => controller.abort(), timeoutMs);
try {
return await boundarySource.query(query, {
signal: lifetime
? AbortSignal.any([lifetime, controller.signal])
: controller.signal,
});
} catch {
return null;
} finally {
window.clearTimeout(timer);
detach();
}
}
/**
* Resolve an administrative boundary (country/state/county/city/neighborhood).
* `is_in(point)` returns every admin area containing the point at all levels;
@@ -1014,10 +997,9 @@ export function createAnnotationResolver({
const cachedFp = cacheRead(footprintCache, cacheKey);
if (cachedFp !== undefined) return cachedFp;
const candidates = await overpassJson(
`[out:json][timeout:25];is_in(${lat},${lon})->.a;area.a["boundary"="administrative"]["admin_level"];out tags;`,
14000,
signal,
const candidates = await featureSource.getAdministrativeAreas(
{ lat, lon },
{ signal: signal },
);
if (isRateLimitedOutcome(candidates)) return candidates;
if (!candidates) return undefined; // transient upstream failure (vs null = definitive miss)
@@ -1025,17 +1007,17 @@ export function createAnnotationResolver({
const queryWords = normalizedWords(query);
const scored = [];
for (const el of candidates) {
if (el.type !== 'area' || !el.tags) continue;
if (el.category !== 'administrative') continue;
// Match against the FULL name set (incl. official_name) so the query can still hit a
// verbose official name — but score COMPLETENESS against the CORE name only. A long
// official_name ("City and County of San Francisco") otherwise DILUTES the real
// boundary's completeness and lets a less-specific duplicate ("San Francisco County")
// win — which then has no backing relation and collapses the whole resolution to a dot.
const coreWords = normalizedWords(
[el.tags.name, el.tags['name:en']].filter(Boolean).join(' '),
[el.names.primary, el.names.english].filter(Boolean).join(' '),
);
const fullWords = normalizedWords(
[el.tags.name, el.tags['name:en'], el.tags.official_name]
[el.names.primary, el.names.english, el.names.official]
.filter(Boolean)
.join(' '),
);
@@ -1053,7 +1035,7 @@ export function createAnnotationResolver({
// one (decisively outranks a "<name> County" style duplicate).
const exactName =
coreWords.size === queryWords.size && coreOverlap === queryWords.size;
const level = Number(el.tags.admin_level) || 99;
const level = Number(el.level) || 99;
// Bias toward the scope's specificity: city/neighborhood prefer the MORE specific
// (higher admin_level) match; state/country prefer the broader one.
let levelBias;
@@ -1109,19 +1091,17 @@ export function createAnnotationResolver({
// 16 s turned finishable region pivots (Sicilia's dense coastline) into permanent
// "transients" — every retry died the same death (field test 2026-07-23). The
// outline is progressive, so a long budget blocks nothing; repeats are disk-cached.
const relEls = await overpassJson(
`[out:json][timeout:25];area(${cand.el.id})->.x;rel(pivot.x);out geom;`,
28000,
signal,
);
const relEls = await featureSource.getAreaGeometry(cand.el.id, {
signal: signal,
});
if (isRateLimitedOutcome(relEls)) return relEls;
if (relEls === null) {
transient = true;
break;
} // network blip — don't definitively fail
const relEl = relEls.find((e) => e.type === 'relation');
const relEl = relEls[0];
if (!relEl) continue; // no relation backing this area — try the next candidate
const coords = elementCoordinates(relEl);
const coords = relEl.coordinates;
if (coords.length < 3) continue; // incomplete geometry — try the next
let ring = closeRing(coords.map((p) => [p.lon, p.lat]));
@@ -1184,25 +1164,20 @@ export function createAnnotationResolver({
*/
async function fetchPlaceArea(lat, lon, query, signal) {
const queryWords = normalizedWords(query);
const els = await overpassJson(
`[out:json][timeout:20];(` +
`way(around:1500,${lat},${lon})["place"~"neighbourhood|suburb|quarter|borough"]["name"];` +
`relation(around:1500,${lat},${lon})["place"~"neighbourhood|suburb|quarter|borough"]["name"];` +
`relation(around:1500,${lat},${lon})["boundary"="place"]["name"];` +
`);out tags geom;`,
14000,
signal,
const els = await featureSource.getNeighborhoodAreas(
{ lat, lon },
{ signal: signal },
);
if (isRateLimitedOutcome(els)) return els;
if (els === null) return undefined; // transient upstream failure (vs [] = no match)
let bestRing = null;
let bestScore = 0;
for (const el of els) {
const coords = elementCoordinates(el);
const coords = el.coordinates;
if (coords.length < 3) continue;
const tags = el.tags || {};
const names = el.names;
const nameWords = normalizedWords(
[tags.name, tags['name:en']].filter(Boolean).join(' '),
[names.primary, names.english].filter(Boolean).join(' '),
);
const overlap = wordOverlap(queryWords, nameWords);
if (!overlap) continue;
@@ -1238,26 +1213,20 @@ export function createAnnotationResolver({
const queryWords = normalizedWords(query);
// Tier C — a same-named district / quarter / named commercial area.
const areaEls = await overpassJson(
`[out:json][timeout:20];(` +
`way(around:450,${lat},${lon})["place"~"quarter|neighbourhood|suburb|city_block"];` +
`relation(around:450,${lat},${lon})["place"~"quarter|neighbourhood|suburb"];` +
`way(around:450,${lat},${lon})["landuse"~"commercial|retail"]["name"];` +
`relation(around:450,${lat},${lon})["landuse"~"commercial|retail"]["name"]["type"="multipolygon"];` +
`);out tags geom;`,
14000,
signal,
const areaEls = await featureSource.getStreetAreas(
{ lat, lon },
{ signal: signal },
);
if (isRateLimitedOutcome(areaEls)) return areaEls;
if (areaEls) {
let bestRing = null;
let bestScore = 0;
for (const el of areaEls) {
const coords = elementCoordinates(el);
const coords = el.coordinates;
if (coords.length < 3) continue;
const tags = el.tags || {};
const names = el.names;
const nameWords = normalizedWords(
[tags.name, tags['name:en']].filter(Boolean).join(' '),
[names.primary, names.english].filter(Boolean).join(' '),
);
const overlap = wordOverlap(queryWords, nameWords);
if (!overlap) continue;
@@ -1277,23 +1246,20 @@ export function createAnnotationResolver({
}
// Tier F — buffer the matching centerline into a corridor ribbon (workhorse).
const wayEls = await overpassJson(
`[out:json][timeout:20];way(around:320,${lat},${lon})["highway"]["name"];out geom;`,
14000,
signal,
const wayEls = await featureSource.getStreetLines(
{ lat, lon },
{ signal: signal },
);
if (isRateLimitedOutcome(wayEls)) return wayEls;
if (wayEls) {
const segments = [];
for (const el of wayEls) {
const tags = el.tags || {};
const names = el.names;
const nameWords = normalizedWords(
[tags.name, tags['name:en']].filter(Boolean).join(' '),
[names.primary, names.english].filter(Boolean).join(' '),
);
if (!wordOverlap(queryWords, nameWords)) continue;
const geom = (el.geometry || []).filter(
(p) => Number.isFinite(p?.lat) && Number.isFinite(p?.lon),
);
const geom = el.coordinates;
if (geom.length >= 2) segments.push(geom.map((p) => [p.lon, p.lat]));
}
if (segments.length) {
@@ -1317,119 +1283,6 @@ export function createAnnotationResolver({
return definitive ? null : undefined;
}
/** Chain street way-segments into one contiguous polyline by endpoint matching. */
function stitchLine(segments) {
const same = (a, b) => approximateDistanceM(a[1], a[0], b[1], b[0]) < 2;
const remaining = segments.map((s) => s.slice());
let line = remaining.shift();
let advanced = true;
while (advanced && remaining.length) {
advanced = false;
for (let i = 0; i < remaining.length; i++) {
const s = remaining[i];
if (same(line[line.length - 1], s[0])) line = line.concat(s.slice(1));
else if (same(line[line.length - 1], s[s.length - 1]))
line = line.concat(s.slice(0, -1).reverse());
else if (same(line[0], s[s.length - 1]))
line = s.slice(0, -1).concat(line);
else if (same(line[0], s[0])) line = s.slice(1).reverse().concat(line);
else continue;
remaining.splice(i, 1);
advanced = true;
break;
}
}
return line;
}
/** Offset a centerline into a closed corridor ring (~2*halfWidthM wide). */
function bufferCorridor(line, halfWidthM) {
const lat0 = line[0][1];
const mLon = 111320 * Math.cos((lat0 * Math.PI) / 180);
const mLat = 111320;
const P = line.map(([lon, lat]) => [lon * mLon, lat * mLat]);
const left = [];
const right = [];
for (let i = 0; i < P.length; i++) {
const a = P[Math.max(0, i - 1)];
const b = P[Math.min(P.length - 1, i + 1)];
let dx = b[0] - a[0];
let dy = b[1] - a[1];
const len = Math.hypot(dx, dy) || 1;
dx /= len;
dy /= len;
const nx = -dy;
const ny = dx;
left.push([P[i][0] + nx * halfWidthM, P[i][1] + ny * halfWidthM]);
right.push([P[i][0] - nx * halfWidthM, P[i][1] - ny * halfWidthM]);
}
const ring = [...left, ...right.reverse(), left[0]];
return ring.map(([x, y]) => [x / mLon, y / mLat]);
}
/** Douglas–Peucker ring simplification with a metres tolerance. Pre-decimates
* very large rings (state/country) to keep the recursion shallow. */
function simplifyRing(ring, tolM) {
if (ring.length <= 24) return ring;
let pts = ring;
// Hard-cap the input to Douglas-Peucker so a pathological 50k-point country
// boundary can't dominate a frame, even with the iterative implementation.
if (pts.length > 4000) {
const step = Math.ceil(pts.length / 4000);
pts = pts.filter((_, i) => i % step === 0 || i === ring.length - 1);
}
const lat0 = pts[0][1];
const tol = tolM / (111320 * Math.cos((lat0 * Math.PI) / 180));
const out = douglasPeucker(pts, tol);
return out.length >= 4 ? out : ring;
}
function douglasPeucker(points, tol) {
const n = points.length;
if (n < 3) return points.slice();
// Iterative, index-based (no per-call array slicing / recursion), so a large
// state/country boundary can't blow the call stack or thrash GC and freeze the
// main thread during a voice turn.
const keep = new Uint8Array(n);
keep[0] = 1;
keep[n - 1] = 1;
const stack = [[0, n - 1]];
while (stack.length) {
const seg = stack.pop();
const first = seg[0];
const last = seg[1];
let index = -1;
let maxD = 0;
const a = points[first];
const b = points[last];
for (let i = first + 1; i < last; i += 1) {
const dist = perpDistance(points[i], a, b);
if (dist > maxD) {
maxD = dist;
index = i;
}
}
if (maxD > tol && index > first) {
keep[index] = 1;
stack.push([first, index]);
stack.push([index, last]);
}
}
const out = [];
for (let i = 0; i < n; i += 1) if (keep[i]) out.push(points[i]);
return out;
}
function perpDistance(p, a, b) {
const dx = b[0] - a[0];
const dy = b[1] - a[1];
const len2 = dx * dx + dy * dy;
if (len2 === 0) return Math.hypot(p[0] - a[0], p[1] - a[1]);
let t = ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2;
t = Math.max(0, Math.min(1, t));
return Math.hypot(p[0] - (a[0] + t * dx), p[1] - (a[1] + t * dy));
}
/**
* Fetch the best-matching OSM polygon near a point and classify it as a single
* `building` (small footprint, gets extruded into a volume) or an `area` (a
@@ -1466,30 +1319,10 @@ export function createAnnotationResolver({
const cachedFp = cacheRead(footprintCache, cacheKey);
if (cachedFp !== undefined) return cachedFp;
// `out geom` (not `out tags geom`) so relation members come back WITH their
// way geometry — needed to trace multipolygon compounds like the Presidio.
const overpassQuery = `
[out:json][timeout:25];
(
way(around:320,${lat},${lon})["building"];
relation(around:320,${lat},${lon})["building"];
way(around:1800,${lat},${lon})["landuse"];
relation(around:1800,${lat},${lon})["landuse"];
way(around:1800,${lat},${lon})["leisure"];
relation(around:1800,${lat},${lon})["leisure"];
way(around:1800,${lat},${lon})["aeroway"="aerodrome"];
relation(around:1800,${lat},${lon})["aeroway"="aerodrome"];
way(around:1800,${lat},${lon})["natural"="water"]["name"];
relation(around:1800,${lat},${lon})["natural"="water"]["name"]["type"="multipolygon"];
way(around:1200,${lat},${lon})["shop"="mall"];
relation(around:1200,${lat},${lon})["shop"="mall"];
way(around:800,${lat},${lon})["amenity"];
way(around:800,${lat},${lon})["tourism"];
const elements = await featureSource.getFootprints(
{ lat, lon },
{ signal: signal },
);
out geom;
`;
const elements = await overpassJson(overpassQuery, 12000, signal);
if (isRateLimitedOutcome(elements)) return elements;
if (elements === null || signal?.aborted) return undefined;
const fp = selectFootprint(elements, lat, lon, query, mode);
@@ -1501,8 +1334,6 @@ export function createAnnotationResolver({
return null;
}
const ENCLOSING_RADIUS_M = 600; // sweep this far for an enclosing named non-building polygon
/**
* Find the REAL enclosing "grounds / compound / campus" polygon containing a point
* — the smallest NAMED, NON-building `leisure` / `landuse` / `boundary` / `amenity`
@@ -1532,30 +1363,14 @@ export function createAnnotationResolver({
const cached = cacheRead(enclosingAreaCache, cacheKey);
if (cached !== undefined) return cached;
// Named non-building polygons near the point. `out geom` returns inline geometry so it
// stitches with elementCoordinates/stitchRing (multipolygon outers). Buildings are EXCLUDED
// in JS below (a tag filter can't drop a relation whose members include a building).
const q = `
[out:json][timeout:25];
(
way(around:${ENCLOSING_RADIUS_M},${lat},${lon})["leisure"]["name"];
relation(around:${ENCLOSING_RADIUS_M},${lat},${lon})["leisure"]["name"]["type"="multipolygon"];
way(around:${ENCLOSING_RADIUS_M},${lat},${lon})["landuse"]["name"];
relation(around:${ENCLOSING_RADIUS_M},${lat},${lon})["landuse"]["name"]["type"="multipolygon"];
way(around:${ENCLOSING_RADIUS_M},${lat},${lon})["boundary"]["name"];
relation(around:${ENCLOSING_RADIUS_M},${lat},${lon})["boundary"]["name"];
way(around:${ENCLOSING_RADIUS_M},${lat},${lon})["amenity"]["name"];
relation(around:${ENCLOSING_RADIUS_M},${lat},${lon})["amenity"]["name"]["type"="multipolygon"];
way(around:${ENCLOSING_RADIUS_M},${lat},${lon})["natural"="water"]["name"];
relation(around:${ENCLOSING_RADIUS_M},${lat},${lon})["natural"="water"]["name"]["type"="multipolygon"];
);
out geom;
`;
// Fail-fast budget: an opportunistic enrichment, not worth blocking on a hung Overpass —
// but generous enough (12 s ≈ the footprint fetch budget) that an ordinary slow mirror
// doesn't strand a grounds mark as a point. overpassJson returns null on a transient
// doesn't strand a grounds mark as a point. The feature source returns null on a transient
// failure (timeout/502).
const elements = await overpassJson(q, 12000, signal);
const elements = await featureSource.getEnclosingAreas(
{ lat, lon },
{ signal: signal },
);
if (isRateLimitedOutcome(elements)) return elements;
if (elements === null) return undefined; // transient upstream failure (vs [] = definitive no-match)
@@ -1564,8 +1379,8 @@ export function createAnnotationResolver({
let bestArea = Infinity;
let bestNameMatch = false;
for (const el of elements) {
if (el.tags?.building) continue; // exclude buildings (the exact feature, never the grounds)
const coords = elementCoordinates(el);
if (el.building) continue; // exclude buildings (the exact feature, never the grounds)
const coords = el.coordinates;
if (coords.length < 3) continue;
if (!pointInPolygon(lon, lat, coords)) continue; // require genuine containment
const ringLonLat = closeRing(coords.map((p) => [p.lon, p.lat]));
@@ -1577,10 +1392,10 @@ export function createAnnotationResolver({
// makes the Capitol land on "Capitol Square" even though the user said "grounds".
const nameWords = normalizedWords(
[
el.tags?.name,
el.tags?.['name:en'],
el.tags?.official_name,
el.tags?.alt_name,
el.names.primary,
el.names.english,
el.names.official,
el.names.alternate,
]
.filter(Boolean)
.join(' '),
@@ -1606,7 +1421,6 @@ export function createAnnotationResolver({
return null;
}
const MONUMENT_RADIUS_M = 2500; // search this far from the view centre for a named monument
/** A target that reads like a fine-grained monument/marker (vs a building/district). */
function isMonumentLikeQuery(query) {
return /\b(monument|memorial|statue|sculpture|fountain|cenotaph|obelisk|plaque|bust)\b/i.test(
@@ -1675,18 +1489,10 @@ export function createAnnotationResolver({
// timeout, and one caller's clear() must not kill the others' snap).
let pending = monumentInflight.get(centerKey);
if (!pending) {
const q = `
[out:json][timeout:20];
(
nwr(around:${MONUMENT_RADIUS_M},${lat},${lon})["historic"~"memorial|monument|statue|tomb"];
nwr(around:${MONUMENT_RADIUS_M},${lat},${lon})["tourism"="artwork"]["name"];
nwr(around:${MONUMENT_RADIUS_M},${lat},${lon})["memorial"]["name"];
);
out center tags;
`;
// Short timeout + fail-fast: this is an opportunistic snap, not worth blocking narration
// on a slow/overloaded Overpass. overpassJson returns null on a transient failure.
pending = overpassJson(q, 6000)
// on a slow/overloaded Overpass. The feature source returns null on a transient failure.
pending = featureSource
.getMonuments({ lat, lon }, { signal: undefined })
.then((elements) => {
if (elements === null || isRateLimitedOutcome(elements))
return null; // transient — NEVER cached (a poisoned bucket
@@ -1694,16 +1500,12 @@ export function createAnnotationResolver({
const feats = [];
for (const el of elements) {
const name =
el.tags?.name ||
el.tags?.['name:en'] ||
el.tags?.official_name ||
el.tags?.alt_name;
el.names.primary ||
el.names.english ||
el.names.official ||
el.names.alternate;
if (!name) continue;
const p = Number.isFinite(el.lat)
? { lat: el.lat, lon: el.lon }
: el.center
? { lat: el.center.lat, lon: el.center.lon }
: null;
const p = el.point;
if (!p) continue;
feats.push({
name,
@@ -1767,20 +1569,20 @@ export function createAnnotationResolver({
let bestScore = -Infinity;
for (const element of elements) {
const coords = elementCoordinates(element);
const coords = element.coordinates;
if (coords.length < 3) continue;
const tags = element.tags || {};
const isBuilding = Boolean(tags.building);
const names = element.names;
const isBuilding = element.building;
const areaM2 = approximateAreaM2(coords);
const nameWords = normalizedWords(
[
tags.name,
tags['name:en'],
tags.official_name,
tags.alt_name,
tags.short_name,
names.primary,
names.english,
names.official,
names.alternate,
names.short,
]
.filter(Boolean)
.join(' '),
@@ -1848,7 +1650,10 @@ export function createAnnotationResolver({
best = {
ring: closeRing(coords.map((p) => [p.lon, p.lat])),
kind: isBuilding ? 'building' : 'area',
heightM: isBuilding ? buildingHeightFromTags(tags, areaM2) : null,
heightM: isBuilding
? (element.heightM ??
Math.max(10, Math.min(70, Math.sqrt(Math.max(1, areaM2)) * 0.6)))
: null,
};
}
}
@@ -1856,213 +1661,6 @@ export function createAnnotationResolver({
return best;
}
/** Estimate a building's height (m) from OSM tags, falling back to footprint size. */
function buildingHeightFromTags(tags, areaM2) {
const explicit = parseMeters(tags.height || tags['building:height']);
if (explicit) return explicit;
const levels = Number.parseFloat(tags['building:levels']);
const roof = parseMeters(tags['roof:height']) || 0;
if (Number.isFinite(levels) && levels > 0) return levels * 3.3 + roof;
const side = Math.sqrt(Math.max(1, areaM2));
return Math.max(10, Math.min(70, side * 0.6));
}
function parseMeters(value) {
if (value == null) return 0;
const n = Number.parseFloat(String(value).replace(',', '.'));
if (!Number.isFinite(n) || n <= 0) return 0;
return /\b(ft|feet|foot)\b/i.test(String(value)) ? n * 0.3048 : n;
}
// --- geometry helpers -------------------------------------------------------
function elementCoordinates(element) {
if (Array.isArray(element.geometry)) {
return element.geometry.filter(
(p) => Number.isFinite(p?.lat) && Number.isFinite(p?.lon),
);
}
if (!Array.isArray(element.members)) return [];
// For a multipolygon relation the boundary is split across several outer
// ways (the Presidio has 8), so chain them into one ring by matching
// endpoints rather than taking a single segment.
const outerWays = element.members
.filter(
(m) => (m.role === 'outer' || !m.role) && Array.isArray(m.geometry),
)
.map((m) =>
m.geometry.filter(
(p) => Number.isFinite(p?.lat) && Number.isFinite(p?.lon),
),
)
.filter((w) => w.length >= 2);
return stitchRing(outerWays);
}
/**
* Stitch relation outer ways into a footprint ring. Builds ALL connected
* components (a multipolygon outer boundary is split across several ways), then
* returns the largest component whose endpoints actually meet — a true closed
* ring. If no component closes cleanly, it accepts the largest only when the
* endpoint gap is small relative to its own span (a minor seam); otherwise the
* relation geometry is incomplete and it returns [] rather than fabricate a
* straight chord across the gap (the reported bay-spanning blob).
*/
function stitchRing(ways) {
if (!ways.length) return [];
const components = buildRingComponents(ways);
if (!components.length) return [];
const CLOSE_TOL_M = 30; // endpoints within 30 m → a genuinely closed ring
const closed = components.filter((c) => endpointGapM(c) <= CLOSE_TOL_M);
if (closed.length) {
// Pick by projected AREA, not vertex count — a small, highly-detailed island
// must not beat the large simple mainland. (For a disjoint multipolygon this
// returns only the largest closed outer ring; full multi-ring rendering is a
// renderer change tracked separately.)
return largestByArea(closed);
}
// Nothing closes exactly. Bridge a seam ONLY when the gap is BOTH a small
// fraction of the feature's own span (so half a compound's boundary missing is
// rejected) AND under a hard absolute ceiling (so a state/country can never get a
// kilometres-long chord — the bay blob). A complete relation closes at ~0; a
// genuine seam (e.g. the Presidio's ~few-hundred-m coastline gap) bridges; a
// truly incomplete relation is rejected → point fallback.
const largest = largestByArea(components);
const allow = Math.min(ringSpanM(largest) * 0.15, 1200);
return endpointGapM(largest) <= allow ? largest : [];
}
/** Largest component by projected area (each component is an array of {lat,lon}). */
function largestByArea(components) {
let best = components[0];
let bestArea = approximateAreaM2(best);
for (let i = 1; i < components.length; i += 1) {
const area = approximateAreaM2(components[i]);
if (area > bestArea) {
bestArea = area;
best = components[i];
}
}
return best;
}
/** Rough diameter (m) of a chain's bounding box. */
function ringSpanM(chain) {
let minLat = Infinity;
let maxLat = -Infinity;
let minLon = Infinity;
let maxLon = -Infinity;
for (const p of chain) {
if (p.lat < minLat) minLat = p.lat;
if (p.lat > maxLat) maxLat = p.lat;
if (p.lon < minLon) minLon = p.lon;
if (p.lon > maxLon) maxLon = p.lon;
}
return approximateDistanceM(minLat, minLon, maxLat, maxLon);
}
/** Chain ways into maximal connected components by endpoint matching. */
function buildRingComponents(ways) {
const same = (a, b) =>
Math.abs(a.lon - b.lon) < 1e-7 && Math.abs(a.lat - b.lat) < 1e-7;
const remaining = ways.map((w) => w.slice());
const components = [];
while (remaining.length) {
let chain = remaining.shift();
let grew = true;
while (grew) {
grew = false;
const head = chain[0];
const tail = chain[chain.length - 1];
for (let i = 0; i < remaining.length; i += 1) {
const w = remaining[i];
const ws = w[0];
const we = w[w.length - 1];
if (same(tail, ws)) chain = chain.concat(w.slice(1));
else if (same(tail, we))
chain = chain.concat(w.slice(0, -1).reverse());
else if (same(head, we)) chain = w.slice(0, -1).concat(chain);
else if (same(head, ws)) chain = w.slice(1).reverse().concat(chain);
else continue;
remaining.splice(i, 1);
grew = true;
break;
}
}
components.push(chain);
}
return components;
}
/** Great-circle distance (m) between a chain's first and last vertex. */
function endpointGapM(chain) {
if (!chain || chain.length < 2) return Infinity;
const a = chain[0];
const b = chain[chain.length - 1];
return approximateDistanceM(a.lat, a.lon, b.lat, b.lon);
}
function closeRing(ring) {
if (ring.length < 3) return ring;
const [fx, fy] = ring[0];
const [lx, ly] = ring[ring.length - 1];
if (fx !== lx || fy !== ly) ring.push([fx, fy]);
return ring;
}
function ringCentroid(ring) {
if (!ring || ring.length < 3) return null;
let sumLat = 0;
let sumLon = 0;
for (const [lon, lat] of ring) {
sumLat += lat;
sumLon += lon;
}
return { lat: sumLat / ring.length, lon: sumLon / ring.length };
}
function approximateAreaM2(coords) {
// Shoelace in a local equirectangular projection (good enough for buildings).
if (coords.length < 3) return 0;
const lat0 = coords[0].lat;
const mPerDegLat = 111_320;
const mPerDegLon = mPerDegLat * Math.cos(Cesium.Math.toRadians(lat0));
let area = 0;
for (let i = 0, j = coords.length - 1; i < coords.length; j = i++) {
const xi = coords[i].lon * mPerDegLon;
const yi = coords[i].lat * mPerDegLat;
const xj = coords[j].lon * mPerDegLon;
const yj = coords[j].lat * mPerDegLat;
area += xj * yi - xi * yj;
}
return Math.abs(area) / 2;
}
function approximateDistanceM(latA, lonA, latB, lonB) {
const latScale = 111_320;
const lonScale =
latScale * Math.cos(Cesium.Math.toRadians((latA + latB) / 2));
return Math.hypot((latB - latA) * latScale, (lonB - lonA) * lonScale);
}
function pointInPolygon(lon, lat, coords) {
let inside = false;
for (let i = 0, j = coords.length - 1; i < coords.length; j = i++) {
const a = coords[i];
const b = coords[j];
const intersects =
a.lat > lat !== b.lat > lat &&
lon <
((b.lon - a.lon) * (lat - a.lat)) /
(b.lat - a.lat || Number.EPSILON) +
a.lon;
if (intersects) inside = !inside;
}
return inside;
}
function normalizedWords(value) {
return new Set(
String(value || '')
+13 -3
View File
@@ -1,3 +1,4 @@
import { createOverpassFeatureSource } from '../sources/overpassFeatures.js';
import { createSurfaceServices } from './surfaceServices.js';
import { createAnnotationResolver } from '../annotations/resolver.js';
import { searchAndFlyTo } from '../locations.js';
@@ -5,7 +6,6 @@ import { searchAndFlyTo } from '../locations.js';
/** Assemble application operations from the caller's request services. */
export function createApplicationOperations({ requests, signal, eventTarget }) {
for (const [name, method] of Object.entries({
boundaries: 'query',
terrain: 'getHeights',
regional: 'getBrief',
weather: 'getConditions',
@@ -19,8 +19,14 @@ export function createApplicationOperations({ requests, signal, eventTarget }) {
signal,
eventTarget,
});
const features =
requests.features ??
createOverpassFeatureSource({
boundarySource: requests.boundaries,
signal,
});
const annotationResolver = createAnnotationResolver({
boundarySource: requests.boundaries,
featureSource: features,
signal,
});
return Object.freeze({
@@ -30,7 +36,11 @@ export function createApplicationOperations({ requests, signal, eventTarget }) {
searchAndFlyTo: (viewer, query, options = {}) =>
searchAndFlyTo(viewer, query, {
...options,
boundaries: requests.boundaries,
features,
signal:
signal && options.signal
? AbortSignal.any([signal, options.signal])
: signal || options.signal,
recoverNearView: annotationResolver.placesNearViewRecovery,
}),
});
+3 -9
View File
@@ -1,5 +1,3 @@
import { normalizeMilitaryInstallations } from '../../data/militaryInstallationData.js';
export function createIngestion({
state: layerState,
services,
@@ -58,19 +56,15 @@ export function createIngestion({
// A SATURATED snapped tile was truncated upstream, so features from the
// snap's extra ring may have crowded out sites actually on screen. Re-ask
// for the exact viewport (separately keyed and cached) before rendering.
let saturated = parts.model.installationResponseSaturated(payload);
let saturated = payload.saturated === true;
if (saturated) {
payload = await fetchInstallations(true);
saturated = parts.model.installationResponseSaturated(payload);
saturated = payload.saturated === true;
}
const normalized = normalizeMilitaryInstallations(
payload,
payload.retrievedAt || new Date().toISOString(),
);
// The proxy answers a bbox at least as large as the viewport; keep only what
// was actually asked for so nothing off-screen reaches the map or the
// "current viewport only" context claim.
const records = normalized.records.filter((record) =>
const records = payload.records.filter((record) =>
parts.model.installationWithinViewport(record, box),
);
let placesError = null;
+1 -17
View File
@@ -1,3 +1,4 @@
import { installationResponseSaturated } from './source.js';
import * as Cesium from 'cesium';
import {
EARTH_MEAN_RADIUS_M,
@@ -137,23 +138,6 @@ export function createModel({ state: layerState, services, parts, source }) {
return record.osmType !== 'node';
}
/**
* Whether a response was truncated at the upstream element cap.
*
* The proxy states this outright, but a `saturated`-less payload is NOT
* evidence of a complete answer: entries cached before the saturation guard
* shipped predate the field and live for 30 days. Fall back to deriving it from
* the element count against the cap the payload itself reports.
* @param {{saturated?: boolean, elements?: Array, elementCap?: number}} payload
* @returns {boolean}
*/
function installationResponseSaturated(payload) {
if (typeof payload?.saturated === 'boolean') return payload.saturated;
const cap = Number(payload?.elementCap);
if (!Number.isFinite(cap) || cap <= 0) return false;
return Array.isArray(payload?.elements) && payload.elements.length >= cap;
}
return {
approximateSurfaceDistanceM,
colorFor,
+18 -1
View File
@@ -1,3 +1,13 @@
import { normalizeMilitaryInstallations } from '../../data/militaryInstallationData.js';
/** Preserve legacy cache admission even when the explicit saturation flag is absent. */
export function installationResponseSaturated(payload) {
if (typeof payload?.saturated === 'boolean') return payload.saturated;
const cap = Number(payload?.elementCap);
if (!Number.isFinite(cap) || cap <= 0) return false;
return Array.isArray(payload?.elements) && payload.elements.length >= cap;
}
/** Read mapped installations and explicit nearby-place searches through fixed endpoints. */
export function createInstallationSource({
fetchImpl = (...args) => globalThis.fetch(...args),
@@ -43,7 +53,14 @@ export function createInstallationSource({
);
if (!Array.isArray(body?.elements))
throw new Error('Malformed installation snapshot');
return body;
return {
...normalizeMilitaryInstallations(
body,
body.retrievedAt || new Date().toISOString(),
),
status: body.status,
saturated: installationResponseSaturated(body),
};
},
async searchNearby({ latitude, longitude, radiusM }, { signal } = {}) {
if (
+27
View File
@@ -57,3 +57,30 @@ test('installation response parsing respects cancellation before any follow-on s
{ name: 'AbortError' },
);
});
test('mapped-site sources normalize records and derive saturation from legacy cache metadata', async () => {
const source = createInstallationSource({
fetchImpl: async () =>
Response.json({
elements: [
{
type: 'node',
id: 1,
lat: 30.2,
lon: -97.7,
tags: { military: 'airfield', name: 'Fixture Field' },
},
],
elementCap: 1,
status: 'stale',
retrievedAt: '2026-01-01T00:00:00Z',
}),
});
const payload = await source.getMappedSites(box);
assert.equal(payload.records[0].name, 'Fixture Field');
assert.equal(payload.records[0].latitude, 30.2);
assert.equal(payload.records[0].retrievedAt, '2026-01-01T00:00:00Z');
assert.equal(payload.saturated, true);
assert.equal(payload.status, 'stale');
assert.equal('elements' in payload, false);
});
+2 -3
View File
@@ -73,7 +73,7 @@ export function createIngestion({
if (!state) {
const data = await response.json();
signal?.throwIfAborted();
if (!Array.isArray(data?.elements))
if (!Array.isArray(data?.roads))
throw new Error('Malformed road snapshot');
return data;
}
@@ -100,8 +100,7 @@ export function createIngestion({
}
const data = await response.json();
signal?.throwIfAborted();
if (!Array.isArray(data?.elements))
throw new Error('Malformed road snapshot');
if (!Array.isArray(data?.roads)) throw new Error('Malformed road snapshot');
if (state) {
const responseEnd = parts.timing.trafficTimingMark(
state,
+10 -58
View File
@@ -13,52 +13,17 @@ import {
} from './policy.js';
export function createModel({ state: layerState, services, parts, source }) {
// ─── Overpass API ──────────────────────────────────────────
/**
* Build an Overpass QL query string to fetch road ways within a bounding box.
*
* The query uses a highway tag regex filter. When `majorOnly` is true, only
* motorway/trunk/primary/secondary are included; otherwise all drivable road
* classes are fetched. The `out geom qt;` suffix returns inline geometry
* (lat/lon per node) sorted by quadtile for faster server response.
*
* @param {number} south - Southern latitude bound (degrees).
* @param {number} west - Western longitude bound (degrees).
* @param {number} north - Northern latitude bound (degrees).
* @param {number} east - Eastern longitude bound (degrees).
* @param {Object} [opts]
* @param {boolean} [opts.majorOnly=false] - Restrict to major highway classes only.
* @param {number} [opts.timeoutSec=25] - Overpass server-side timeout.
* @returns {string} Overpass QL query body.
*/
/**
* Parse an Overpass `out geom;` JSON response into internal road objects.
*
* Each OSM `way` element carries an inline `geometry` array of `{lat, lon}`
* objects, so no separate node look-up or osmtogeojson conversion is needed.
*
* Processing per way:
* 1. Extract [lon, lat] coordinate pairs.
* 2. Sub-sample long polylines to at most MAX_WAYPOINTS_PER_ROAD vertices.
* 3. Sample terrain height once at the first vertex (avoids per-vertex cost).
* 4. Convert to Cartesian3 waypoints and pre-compute inter-vertex distances.
*
* @param {Object} overpassData - Raw JSON response from the Overpass API.
* @param {Array} overpassData.elements - Array of OSM elements.
* @returns {Array<{coords:number[][], type:string, waypoints:Cesium.Cartesian3[], segmentDist:number[]}>}
* Parsed road objects ready for dot spawning.
*/
function parseRoads(overpassData) {
if (!overpassData || !overpassData.elements) return [];
/** Build scene waypoints from source records; thinning and terrain remain rendering policy. */
function parseRoads(roadData) {
if (!roadData || !roadData.roads) {
return [];
}
const roads = [];
for (const el of overpassData.elements) {
if (el.type !== 'way' || !el.geometry || el.geometry.length < 2) continue;
for (const road of roadData.roads) {
if (!road.coordinates || road.coordinates.length < 2) continue;
const rawCoords = el.geometry.map((g) => [g.lon, g.lat]);
const rawCoords = road.coordinates;
// Sub-sample long polylines: keep every Nth vertex to stay within budget
const simplifyStep =
@@ -79,21 +44,8 @@ export function createModel({ state: layerState, services, parts, source }) {
if (coords.length < 2) continue;
const type = el.tags?.highway || 'unclassified';
// One-way capture (field-test round 1: "a car would never go in
// reverse"): dots on one-way roads all travel the legal direction.
// OSM: oneway=yes/1/true → digitization order; '-1' → reversed;
// roundabouts are one-way by definition. 0 = two-way (alternate).
const onewayTag = el.tags?.oneway;
const oneway =
onewayTag === 'yes' ||
onewayTag === '1' ||
onewayTag === 'true' ||
el.tags?.junction === 'roundabout'
? 1
: onewayTag === '-1'
? -1
: 0;
const type = road.type;
const oneway = road.oneway;
// Sample terrain height once at the road start to avoid per-vertex cost
let baseHeight = 0;
+6 -6
View File
@@ -82,13 +82,13 @@ function roads(bounds) {
return {
ok: true,
json: async () => ({
elements: [
roads: [
{
type: 'way',
tags: { highway: 'primary' },
geometry: [
{ lat: bounds.south, lon: bounds.west },
{ lat: bounds.south + 0.005, lon: bounds.west + 0.005 },
type: 'primary',
oneway: 0,
coordinates: [
[bounds.west, bounds.south],
[bounds.west + 0.005, bounds.south + 0.005],
],
},
],
+5 -1
View File
@@ -1,3 +1,5 @@
import { normalizeOverpassRoads } from '../../sources/overpassRoads.js';
export { normalizeOverpassRoads } from '../../sources/overpassRoads.js';
import { createFlowTileSource } from './flowSource.js';
function buildOverpassQuery(
south,
@@ -58,7 +60,9 @@ export function createTrafficSource({
async json() {
const body = await response.json();
signal?.throwIfAborted();
return body;
if (!Array.isArray(body?.elements))
throw new Error('Malformed road snapshot');
return { roads: normalizeOverpassRoads(body) };
},
};
},
+38
View File
@@ -127,3 +127,41 @@ test('road body parsing retains the source request cancellation signal', async (
});
await assert.rejects(response.json(), { name: 'AbortError' });
});
test('road sources decode direction and coordinates before scene construction', async () => {
const geometry = [
{ lat: 30, lon: -97 },
{ lat: 30.001, lon: -97.001 },
];
const source = createTrafficSource({
fetchImpl: async () =>
Response.json({
elements: [
{ type: 'node', id: 1 },
{ type: 'way', geometry, tags: { highway: 'primary', oneway: '-1' } },
{ type: 'way', geometry, tags: { junction: 'roundabout' } },
{ type: 'way', geometry: [geometry[0]] },
],
}),
});
assert.deepEqual(await (await source.requestRoads(bounds)).json(), {
roads: [
{
coordinates: [
[-97, 30],
[-97.001, 30.001],
],
type: 'primary',
oneway: -1,
},
{
coordinates: [
[-97, 30],
[-97.001, 30.001],
],
type: 'unclassified',
oneway: 1,
},
],
});
});
+7 -16
View File
@@ -193,7 +193,7 @@ export function createTiming({ state: layerState, services, parts, source }) {
* waypoint-materialization time independently.
*/
function parseRoadsTimed(overpassData, trace) {
function parseRoadsTimed(roadData, trace) {
/* TRACE_ONLY_BEGIN */
const _trafficTimingState = trafficTimingPass(
trace,
@@ -207,7 +207,7 @@ export function createTiming({ state: layerState, services, parts, source }) {
_trafficTimingParseStartTime,
);
/* TRACE_ONLY_END */
if (!overpassData || !overpassData.elements) {
if (!roadData || !roadData.roads) {
/* TRACE_ONLY_BEGIN */
const _trafficTimingParseEnd = trafficTimingMark(
_trafficTimingState,
@@ -251,10 +251,10 @@ export function createTiming({ state: layerState, services, parts, source }) {
let _trafficTimingSampleHeightMs = 0;
let _trafficTimingWaypointMaterializationMs = 0;
/* TRACE_ONLY_END */
for (const el of overpassData.elements) {
if (el.type !== 'way' || !el.geometry || el.geometry.length < 2) continue;
for (const road of roadData.roads) {
if (!road.coordinates || road.coordinates.length < 2) continue;
const rawCoords = el.geometry.map((g) => [g.lon, g.lat]);
const rawCoords = road.coordinates;
const simplifyStep =
rawCoords.length > MAX_WAYPOINTS_PER_ROAD
? Math.ceil(rawCoords.length / MAX_WAYPOINTS_PER_ROAD)
@@ -271,17 +271,8 @@ export function createTiming({ state: layerState, services, parts, source }) {
}
if (coords.length < 2) continue;
const type = el.tags?.highway || 'unclassified';
const onewayTag = el.tags?.oneway;
const oneway =
onewayTag === 'yes' ||
onewayTag === '1' ||
onewayTag === 'true' ||
el.tags?.junction === 'roundabout'
? 1
: onewayTag === '-1'
? -1
: 0;
const type = road.type;
const oneway = road.oneway;
let baseHeight = 0;
const firstCoord = coords[0];
+29 -61
View File
@@ -1,3 +1,4 @@
import { createOverpassFeatureSource } from './sources/overpassFeatures.js';
import { applicationServices } from './services/application.js';
import * as Cesium from 'cesium';
import {
@@ -891,7 +892,18 @@ export async function searchAndFlyTo(viewer, query, options = {}) {
const shouldResolveBuilding = navigationMode === 'precise-place';
const buildingBounds = shouldResolveBuilding
? await resolveBuildingBounds(lat, lng, query, options.boundaries)
? await resolveBuildingBounds(
lat,
lng,
query,
options.features ??
(options.boundaries
? createOverpassFeatureSource({
boundarySource: options.boundaries,
})
: undefined),
options.signal,
)
: null;
const range = requestedRange || defaultRangeForNavigationMode(navigationMode);
if (!mayFly()) return CANCELLED_SEARCH;
@@ -1340,36 +1352,22 @@ async function resolveBuildingBounds(
lat,
lon,
query,
boundaries = applicationServices.boundaries,
features = applicationServices.features,
signal,
) {
const overpassQuery = `
[out:json][timeout:10];
(
way(around:180,${lat},${lon})["building"];
relation(around:180,${lat},${lon})["building"];
way(around:180,${lat},${lon})["man_made"];
relation(around:180,${lat},${lon})["man_made"];
way(around:180,${lat},${lon})["tourism"="attraction"];
relation(around:180,${lat},${lon})["tourism"="attraction"];
);
out tags center geom;
`;
const controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), 6000);
try {
const elements = await boundaries.query(overpassQuery, {
signal: controller.signal,
});
const candidates = await features.getFocusFootprints(
{ lat, lon },
{ signal },
);
return selectBuildingBounds(
Array.isArray(elements) ? elements : [],
Array.isArray(candidates) ? candidates : [],
lat,
lon,
query,
);
} catch {
return null;
} finally {
window.clearTimeout(timeout);
}
}
@@ -1377,11 +1375,11 @@ function selectBuildingBounds(elements, targetLat, targetLon, query) {
const queryWords = normalizedWords(query);
const candidates = [];
for (const element of elements) {
const coordinates = elementCoordinates(element);
const coordinates = element.coordinates;
if (coordinates.length < 3) continue;
const bounds = coordinateBounds(coordinates, targetLat);
if (!bounds || bounds.width < 2 || bounds.depth < 2) continue;
const tags = element.tags || {};
const names = element.names;
const center = element.center || averageCoordinate(coordinates);
const distanceM = approximateDistanceM(
targetLat,
@@ -1390,22 +1388,24 @@ function selectBuildingBounds(elements, targetLat, targetLon, query) {
center.lon,
);
const nameWords = normalizedWords(
[tags.name, tags['name:en'], tags.official_name, tags.alt_name]
[names.primary, names.english, names.official, names.alternate]
.filter(Boolean)
.join(' '),
);
const nameScore = wordOverlap(queryWords, nameWords);
const containsTarget = pointInPolygon(targetLon, targetLat, coordinates);
const height = buildingHeightFromTags(tags, bounds);
const height =
element.heightM ??
Math.max(12, Math.min(80, Math.max(bounds.width, bounds.depth) * 0.8));
candidates.push({
lat: center.lat,
lon: center.lon,
height,
width: bounds.width,
depth: bounds.depth,
osmName: tags.name || tags['name:en'] || null,
osmType: element.type,
osmId: element.id,
osmName: names.primary || names.english || null,
osmType: element.provenance?.type,
osmId: element.provenance?.id,
score: nameScore * 1000 + (containsTarget ? 500 : 0) - distanceM,
});
}
@@ -1415,22 +1415,6 @@ function selectBuildingBounds(elements, targetLat, targetLon, query) {
return best;
}
function elementCoordinates(element) {
if (Array.isArray(element.geometry)) {
return element.geometry.filter(
(point) => Number.isFinite(point?.lat) && Number.isFinite(point?.lon),
);
}
if (!Array.isArray(element.members)) return [];
return element.members.flatMap((member) =>
Array.isArray(member.geometry)
? member.geometry.filter(
(point) => Number.isFinite(point?.lat) && Number.isFinite(point?.lon),
)
: [],
);
}
function coordinateBounds(coordinates, latitude) {
const latitudes = coordinates.map((point) => point.lat);
const longitudes = coordinates.map((point) => point.lon);
@@ -1444,22 +1428,6 @@ function coordinateBounds(coordinates, latitude) {
};
}
function buildingHeightFromTags(tags, bounds) {
const explicitHeight = parseMeters(tags.height || tags['building:height']);
if (explicitHeight) return explicitHeight;
const levels = Number.parseFloat(tags['building:levels']);
const roofHeight = parseMeters(tags['roof:height']) || 0;
if (Number.isFinite(levels) && levels > 0) return levels * 3.3 + roofHeight;
return Math.max(12, Math.min(80, Math.max(bounds.width, bounds.depth) * 0.8));
}
function parseMeters(value) {
if (value == null) return 0;
const number = Number.parseFloat(String(value).replace(',', '.'));
if (!Number.isFinite(number) || number <= 0) return 0;
return /\b(ft|feet|foot)\b/i.test(String(value)) ? number * 0.3048 : number;
}
function averageCoordinate(coordinates) {
const total = coordinates.reduce(
(sum, point) => ({
+7
View File
@@ -1,3 +1,5 @@
import { createOverpassFeatureSource } from '../sources/overpassFeatures.js';
import { FEATURE_SOURCE_METHODS } from '../sources/featureSource.js';
import { createSourceSlot } from '../sources/sourceSlot.js';
import { createApplicationRequestServices } from './requests.js';
@@ -15,6 +17,11 @@ const slots = Object.fromEntries(
createSourceSlot(defaults[name], methods, `${name} service`),
]),
);
slots.features = createSourceSlot(
createOverpassFeatureSource({ boundarySource: slots.boundaries.source }),
FEATURE_SOURCE_METHODS,
'features service',
);
export const applicationServices = Object.freeze(
Object.fromEntries(
Object.entries(slots).map(([name, slot]) => [name, slot.source]),
+10 -1
View File
@@ -1,3 +1,4 @@
import { createOverpassFeatureSource } from '../sources/overpassFeatures.js';
/** Parse bounded retry information from a service response. */
function retryAfterMs(value) {
if (value == null || String(value).trim() === '') return null;
@@ -13,6 +14,7 @@ export function createApplicationRequestServices({
fetchImpl = (...args) => fetch(...args),
signal: lifetime,
endpoints = {},
features,
} = {}) {
const urls = {
boundaries: '/api/overpass',
@@ -62,7 +64,7 @@ export function createApplicationRequestServices({
throw new Error(`${label} unavailable (${response.status})`);
return response.data;
}
return {
const services = {
boundaries: {
async query(query, { signal } = {}) {
const response = await request(urls.boundaries, {
@@ -125,4 +127,11 @@ export function createApplicationRequestServices({
},
},
};
services.features =
features ??
createOverpassFeatureSource({
boundarySource: services.boundaries,
signal: lifetime,
});
return services;
}
+272
View File
@@ -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;
}
+20
View File
@@ -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;
}
+191
View File
@@ -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,
);
},
};
}
+192
View File
@@ -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);
});
+76
View File
@@ -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 },
};
});
}
+27
View File
@@ -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;
}