179 lines
5.1 KiB
JavaScript
179 lines
5.1 KiB
JavaScript
|
|
const DEFAULT_OVERPASS_URL = 'https://overpass-api.de/api/interpreter';
|
||
|
|
const METERS_PER_MILE = 1609.344;
|
||
|
|
const MIN_RADIUS_MILES = 1;
|
||
|
|
const MAX_RADIUS_MILES = 50;
|
||
|
|
const DEFAULT_RADIUS_MILES = 25;
|
||
|
|
|
||
|
|
const ALLOWED_HOSTS = new Set(['overpass-api.de', 'overpass.kumi.systems']);
|
||
|
|
|
||
|
|
function assertAllowedHost(url) {
|
||
|
|
const host = new URL(url).hostname.toLowerCase();
|
||
|
|
if (!ALLOWED_HOSTS.has(host)) {
|
||
|
|
throw new Error(`[overpass] Host not allowlisted: ${host}`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @param {number} miles
|
||
|
|
* @returns {number}
|
||
|
|
*/
|
||
|
|
export function clampRadiusMiles(miles) {
|
||
|
|
const value = Number(miles);
|
||
|
|
if (!Number.isFinite(value)) {
|
||
|
|
return DEFAULT_RADIUS_MILES;
|
||
|
|
}
|
||
|
|
return Math.min(MAX_RADIUS_MILES, Math.max(MIN_RADIUS_MILES, value));
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @param {number} miles
|
||
|
|
* @returns {number}
|
||
|
|
*/
|
||
|
|
export function milesToMeters(miles) {
|
||
|
|
return Math.round(clampRadiusMiles(miles) * METERS_PER_MILE);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Build Overpass QL from validated numeric inputs only — never user strings.
|
||
|
|
* @param {{ lat: number, lng: number, radiusMeters: number }} params
|
||
|
|
* @returns {string}
|
||
|
|
*/
|
||
|
|
export function buildOverpassQuery({ lat, lng, radiusMeters }) {
|
||
|
|
const safeLat = Number(lat);
|
||
|
|
const safeLng = Number(lng);
|
||
|
|
const safeRadius = Math.round(Number(radiusMeters));
|
||
|
|
|
||
|
|
if (!Number.isFinite(safeLat) || !Number.isFinite(safeLng) || !Number.isFinite(safeRadius)) {
|
||
|
|
throw new Error('[overpass] Invalid numeric query parameters');
|
||
|
|
}
|
||
|
|
if (safeRadius < 1 || safeRadius > Math.round(MAX_RADIUS_MILES * METERS_PER_MILE)) {
|
||
|
|
throw new Error('[overpass] Radius out of bounds');
|
||
|
|
}
|
||
|
|
|
||
|
|
return `[out:json][timeout:25];
|
||
|
|
(
|
||
|
|
node["shop"="games"](around:${safeRadius},${safeLat},${safeLng});
|
||
|
|
node["shop"="hobby"](around:${safeRadius},${safeLat},${safeLng});
|
||
|
|
way["shop"="games"](around:${safeRadius},${safeLat},${safeLng});
|
||
|
|
way["shop"="hobby"](around:${safeRadius},${safeLat},${safeLng});
|
||
|
|
);
|
||
|
|
out center tags;`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function formatAddress(tags) {
|
||
|
|
const parts = [
|
||
|
|
tags['addr:housenumber'],
|
||
|
|
tags['addr:street'],
|
||
|
|
tags['addr:city'],
|
||
|
|
tags['addr:state'],
|
||
|
|
tags['addr:postcode'],
|
||
|
|
].filter(Boolean);
|
||
|
|
if (parts.length > 0) {
|
||
|
|
return parts.join(', ');
|
||
|
|
}
|
||
|
|
return tags['addr:full'] ?? '';
|
||
|
|
}
|
||
|
|
|
||
|
|
function elementCoords(element) {
|
||
|
|
if (element.type === 'node' && element.lat != null && element.lon != null) {
|
||
|
|
return { lat: Number(element.lat), lng: Number(element.lon) };
|
||
|
|
}
|
||
|
|
if (element.center?.lat != null && element.center?.lon != null) {
|
||
|
|
return { lat: Number(element.center.lat), lng: Number(element.center.lon) };
|
||
|
|
}
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
function osmUrl(element) {
|
||
|
|
const type = element.type === 'node' ? 'node' : 'way';
|
||
|
|
return `https://www.openstreetmap.org/${type}/${element.id}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @param {unknown} data
|
||
|
|
* @param {{ originLat: number, originLng: number }} origin
|
||
|
|
* @returns {Array<{
|
||
|
|
* id: string,
|
||
|
|
* name: string,
|
||
|
|
* address: string,
|
||
|
|
* lat: number,
|
||
|
|
* lng: number,
|
||
|
|
* distanceMiles: number,
|
||
|
|
* phone: string | null,
|
||
|
|
* website: string | null,
|
||
|
|
* osmUrl: string,
|
||
|
|
* }>}
|
||
|
|
*/
|
||
|
|
export function parseOverpassResponse(data, { originLat, originLng }) {
|
||
|
|
const elements = data?.elements;
|
||
|
|
if (!Array.isArray(elements)) {
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
|
||
|
|
const stores = [];
|
||
|
|
|
||
|
|
for (const element of elements) {
|
||
|
|
const tags = element.tags ?? {};
|
||
|
|
const name = tags.name;
|
||
|
|
if (!name) continue;
|
||
|
|
|
||
|
|
const coords = elementCoords(element);
|
||
|
|
if (!coords || !Number.isFinite(coords.lat) || !Number.isFinite(coords.lng)) {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
stores.push({
|
||
|
|
id: `${element.type}/${element.id}`,
|
||
|
|
name,
|
||
|
|
address: formatAddress(tags),
|
||
|
|
lat: coords.lat,
|
||
|
|
lng: coords.lng,
|
||
|
|
distanceMiles: haversineMiles(originLat, originLng, coords.lat, coords.lng),
|
||
|
|
phone: tags.phone ?? tags['contact:phone'] ?? null,
|
||
|
|
website: tags.website ?? tags['contact:website'] ?? null,
|
||
|
|
osmUrl: osmUrl(element),
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
stores.sort((a, b) => a.distanceMiles - b.distanceMiles);
|
||
|
|
return stores;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @param {number} lat
|
||
|
|
* @param {number} lng
|
||
|
|
* @param {number} radiusMeters
|
||
|
|
* @param {{ fetchImpl?: typeof fetch }} [options]
|
||
|
|
* @returns {Promise<ReturnType<typeof parseOverpassResponse>>}
|
||
|
|
*/
|
||
|
|
export async function fetchGameStores(lat, lng, radiusMeters, { fetchImpl = fetch } = {}) {
|
||
|
|
const endpoint = process.env.OVERPASS_URL ?? DEFAULT_OVERPASS_URL;
|
||
|
|
assertAllowedHost(endpoint);
|
||
|
|
|
||
|
|
const query = buildOverpassQuery({ lat, lng, radiusMeters });
|
||
|
|
const response = await fetchImpl(endpoint, {
|
||
|
|
method: 'POST',
|
||
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
|
|
body: `data=${encodeURIComponent(query)}`,
|
||
|
|
});
|
||
|
|
|
||
|
|
if (!response.ok) {
|
||
|
|
throw new Error(`[overpass] HTTP ${response.status}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
const data = await response.json();
|
||
|
|
return parseOverpassResponse(data, { originLat: lat, originLng: lng });
|
||
|
|
}
|
||
|
|
|
||
|
|
function haversineMiles(lat1, lng1, lat2, lng2) {
|
||
|
|
const toRad = (deg) => (deg * Math.PI) / 180;
|
||
|
|
const dLat = toRad(lat2 - lat1);
|
||
|
|
const dLng = toRad(lng2 - lng1);
|
||
|
|
const a =
|
||
|
|
Math.sin(dLat / 2) ** 2 +
|
||
|
|
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
|
||
|
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||
|
|
const earthRadiusMiles = 3958.8;
|
||
|
|
return Math.round(c * earthRadiusMiles * 10) / 10;
|
||
|
|
}
|