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