mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +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
@@ -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