deckhearth/lib/pricing/overpass.js

179 lines
5.1 KiB
JavaScript
Raw Permalink Normal View History

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;
}