mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
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
236 lines
8.5 KiB
JavaScript
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,
|
|
);
|
|
},
|
|
};
|
|
}
|