Files
gods-eye-view/src/sources/overpassFeatures.js
T
manjunath 578ebdc030 merge main f13c60c into feat/voice-outlines
reconcile voice outlines with main f13c60c, retaining German/Japanese,
Operator HUD retirement and Context/Radio cleanup, named-building
containment, courtyard-hole, non-Latin identity, attribution, cancellation
and antimeridian repairs

keep outline callouts above the open voice preview when an above-panel
slot fits without colliding with another callout,
and clip the compact dock location label on narrow screens
2026-10-30 19:02:47 +05:30

236 lines
8.5 KiB
JavaScript

import { isUnavailableCapability } from './capability.js';
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');
}
/**
* Output box for `out geom(s,w,n,e)`: `out geom` prints every member of a
* matched relation, and one borough can run to tens of thousands of nodes.
* Geometry is printed only inside this box; a relation cut by it no longer
* closes and is rejected by ring stitching, so nothing partial is drawn.
* @param {number} lat
* @param {number} lon
* @param {number} halfSizeM - Half the box side in metres.
* @returns {string} `(south,west,north,east)` in degrees, 5 decimals.
*/
export function geometryOutputBox(lat, lon, halfSizeM) {
const dLat = halfSizeM / 111320;
const dLon =
halfSizeM / (111320 * Math.max(0.01, Math.cos((lat * Math.PI) / 180)));
const f = (value) => Number(value.toFixed(5));
return `(${f(Math.max(-90, lat - dLat))},${f(Math.max(-180, lon - dLon))},${f(Math.min(90, lat + dLat))},${f(Math.min(180, lon + dLon))})`;
}
/** Query bounded feature candidates; ranking and rendering belong to callers.
* Array = definitive response (possibly empty), null = retryable failure,
* {rateLimited, retryAfterMs} = admission delay; {unavailable, retryable:false} = no capability.
*/
export function createOverpassFeatureSource({
boundarySource,
signal: lifetime,
} = {}) {
if (typeof boundarySource?.query !== 'function')
throw new TypeError('A boundary query transport is required');
let unavailable = null;
async function query(
text,
timeoutMs,
{ signal, focus = false, relationsOnly = false } = {},
) {
if (unavailable) return unavailable;
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();
if (isUnavailableCapability(elements)) {
unavailable = {
unavailable: true,
code: elements.code || 'OVERPASS_NOT_CONFIGURED',
retryable: false,
};
return unavailable;
}
return Array.isArray(elements)
? normalizeOverpassFeatures(
relationsOnly
? elements.filter((element) => element.type === 'relation')
: elements,
{ focus },
)
: elements;
} catch (error) {
if (isUnavailableCapability(error)) {
unavailable = {
unavailable: true,
code: error.code || 'OVERPASS_NOT_CONFIGURED',
retryable: false,
};
return unavailable;
}
return null;
} finally {
clearTimeout(timer);
}
}
return {
// Only this concrete OSM adapter declares outline origin; generic injected
// feature sources do not acquire attribution from their feature IDs.
outlineCredit: 'osm',
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 center geom${geometryOutputBox(lat, lon, 1500)};
`,
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 },
);
},
// Polygon lookups print with `out geom`, not `out tags geom`: at `tags`
// verbosity a relation carries no members, so it arrives with bounds and
// no outline and is dropped. The output box bounds member geometry (6 km
// for neighbourhoods, 3 km for street areas, 1.5 km for focus footprints).
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 geom${geometryOutputBox(lat, lon, 6000)};`,
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 geom${geometryOutputBox(lat, lon, 3000)};`,
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,
);
},
};
}