← back to Charge And Explore
backend/src/providers/overpass-places.ts
206 lines
// "Stuff to do near the charger" via OpenStreetMap Overpass — free, no key.
// Returns nearby POIs (food, cafe, park, shops, cinema) with walking distance.
// Cached in-memory per rounded coordinate so we don't hammer public Overpass.
//
// NOTE: public Overpass has no SLA and rate-limits; fine for on-demand lookups
// with caching. A production build should self-host Overpass or use a licensed
// places provider (Google/Yelp/HERE) — the PlacesProvider seam allows the swap.
import type { GeoPoint } from "../core/types.ts";
// Kamatera can't reach overpass-api.de (blocked); these mirrors respond. Tried in order.
const OVERPASS_ENDPOINTS = [
"https://overpass.kumi.systems/api/interpreter",
"https://overpass.private.coffee/api/interpreter",
"https://overpass-api.de/api/interpreter",
];
export interface Activity {
id: string;
name: string;
category: string;
lat: number;
lon: number;
distanceMeters: number;
/** True for food places (restaurants/cafes) — used for UI labeling. */
food: boolean;
/** Google rating (0–5) when from Places; null otherwise. */
rating?: number | null;
/** Place website (menu/site) when from Places; else null. */
website?: string | null;
/** Google Places photo resource name ("places/…/photos/…") — resolve via /api/places/photo. */
photoRef?: string | null;
/** Direct thumbnail URL (Wikipedia page image); null when only photoRef applies. */
photoUrl?: string | null;
}
const cache = new Map<string, { at: number; items: Activity[] }>();
const TTL_MS = 24 * 60 * 60 * 1000;
function haversine(a: GeoPoint, b: GeoPoint): number {
const R = 6371000, toRad = (d: number) => (d * Math.PI) / 180;
const dLat = toRad(b.latitude - a.latitude), dLon = toRad(b.longitude - a.longitude);
const s = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(a.latitude)) * Math.cos(toRad(b.latitude)) * Math.sin(dLon / 2) ** 2;
return Math.round(R * 2 * Math.asin(Math.sqrt(s)));
}
const FOOD = new Set(["restaurant", "fast_food", "cafe", "food_court", "ice_cream", "pub", "bar"]);
export async function activitiesNear(point: GeoPoint, radiusMeters = 1500): Promise<Activity[]> {
const cacheKey = `${point.latitude.toFixed(3)},${point.longitude.toFixed(3)},${radiusMeters}`;
const hit = cache.get(cacheKey);
if (hit && Date.now() - hit.at < TTL_MS) return hit.items;
const gkey = process.env.GOOGLE_PLACES_API_KEY;
// Run Google (rated/photo'd, but hard-capped at 20) AND Overpass (every tagged
// business in the radius, no cap) in parallel, then merge — so a dense retail
// strip like Rodeo Dr returns hundreds of shops, not just 20 restaurants.
const [google, osm] = await Promise.all([
gkey ? viaGooglePlaces(point, radiusMeters, gkey).catch(() => []) : Promise.resolve<Activity[]>([]),
viaOverpass(point, radiusMeters).catch(() => []),
]);
let items = mergeDedupe(google, osm);
if (!items.length) { try { items = await viaWikipedia(point, radiusMeters); } catch { items = []; } }
cache.set(cacheKey, { at: Date.now(), items });
return items;
}
// Merge two sources, preferring the rated/photo'd Google entry when the same
// place appears in both (same normalized name within ~45m). Keeps everything else.
function mergeDedupe(primary: Activity[], extra: Activity[]): Activity[] {
const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, "");
const seen = primary.map((p) => ({ n: norm(p.name), lat: p.lat, lon: p.lon }));
const out = [...primary];
for (const e of extra) {
const en = norm(e.name);
const dup = seen.some((s) => s.n === en && haversine({ latitude: s.lat, longitude: s.lon }, { latitude: e.lat, longitude: e.lon }) < 45);
if (!dup) out.push(e);
}
return out.sort((a, b) => a.distanceMeters - b.distanceMeters).slice(0, 200);
}
// Overpass (OpenStreetMap) — EVERY tagged business/shop/food/attraction in the
// radius, no result cap. Free, no key. Tries the mirrors in order (Kamatera can't
// reach overpass-api.de directly). This is what makes "load all businesses" real.
async function viaOverpass(point: GeoPoint, radiusMeters: number): Promise<Activity[]> {
const r = Math.min(4000, Math.max(300, radiusMeters));
const c = `${point.latitude},${point.longitude}`;
const q = `[out:json][timeout:25];(` +
`nwr["shop"](around:${r},${c});` +
`nwr["amenity"~"^(restaurant|cafe|fast_food|bar|pub|ice_cream|food_court|nightclub|marketplace|cinema|theatre)$"](around:${r},${c});` +
`nwr["leisure"~"^(park|garden|fitness_centre|sports_centre|bowling_alley)$"](around:${r},${c});` +
`nwr["tourism"~"^(museum|gallery|attraction|artwork|viewpoint|hotel)$"](around:${r},${c});` +
`);out center 400;`;
for (const ep of OVERPASS_ENDPOINTS) {
try {
const res = await fetch(ep, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: "data=" + encodeURIComponent(q),
signal: AbortSignal.timeout(20000),
});
if (!res.ok) continue;
const j = (await res.json()) as { elements?: any[] };
const items = (j.elements ?? [])
.map((e) => {
const lat = e.lat ?? e.center?.lat, lon = e.lon ?? e.center?.lon;
const t = e.tags ?? {};
if (!t.name || lat == null || lon == null) return null;
const cat = t.shop ?? t.amenity ?? t.leisure ?? t.tourism ?? "place";
return {
id: `osm:${e.type}:${e.id}`, name: String(t.name),
category: String(cat).replace(/_/g, " "),
lat, lon, distanceMeters: haversine(point, { latitude: lat, longitude: lon }),
food: FOOD.has(t.amenity), rating: null,
website: t.website || t["contact:website"] || null,
photoRef: null, photoUrl: null,
} as Activity;
})
.filter((x): x is Activity => x !== null)
.sort((a, b) => a.distanceMeters - b.distanceMeters);
if (items.length) return items;
} catch { /* try next mirror */ }
}
return [];
}
// Google Places API (New) Nearby Search — real restaurants/cafés/shops/attractions
// with rating + website (menu). Server-side key only; cached by activitiesNear.
// Nearby Search hard-caps at 20 results, so we run one query per TYPE GROUP in
// parallel and merge — ~20 food + ~20 retail + ~20 attractions instead of 20 total.
// On a dense retail street (Rodeo Dr) that's the difference between a handful and ~60.
const GOOGLE_GROUPS: string[][] = [
["restaurant", "cafe", "bakery", "bar", "coffee_shop", "meal_takeaway", "ice_cream_shop"],
["store", "clothing_store", "jewelry_store", "shoe_store", "department_store", "book_store", "home_goods_store", "furniture_store", "gift_shop", "shopping_mall", "supermarket"],
["tourist_attraction", "park", "museum", "art_gallery", "movie_theater", "spa"],
];
async function viaGooglePlaces(point: GeoPoint, radiusMeters: number, key: string): Promise<Activity[]> {
const groups = await Promise.all(GOOGLE_GROUPS.map((types) => googleNearby(point, radiusMeters, key, types).catch(() => [] as Activity[])));
const byId = new Map<string, Activity>();
for (const a of groups.flat()) if (!byId.has(a.id)) byId.set(a.id, a);
return [...byId.values()].sort((a, b) => a.distanceMeters - b.distanceMeters);
}
async function googleNearby(point: GeoPoint, radiusMeters: number, key: string, includedTypes: string[]): Promise<Activity[]> {
const body = {
includedTypes, maxResultCount: 20, rankPreference: "DISTANCE",
locationRestriction: { circle: { center: { latitude: point.latitude, longitude: point.longitude }, radius: Math.min(50000, Math.max(200, radiusMeters)) } },
};
const res = await fetch("https://places.googleapis.com/v1/places:searchNearby", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Goog-Api-Key": key,
"X-Goog-FieldMask": "places.id,places.displayName,places.location,places.primaryTypeDisplayName,places.types,places.rating,places.websiteUri,places.googleMapsUri,places.photos",
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(10000),
});
if (!res.ok) throw new Error(`places ${res.status}`);
const j = (await res.json()) as { places?: any[] };
return (j.places ?? [])
.filter((p) => p.displayName?.text && p.location)
.map((p) => ({
id: `g:${p.id}`,
name: p.displayName.text as string,
category: p.primaryTypeDisplayName?.text || String(p.types?.[0] ?? "place").replace(/_/g, " "),
lat: p.location.latitude, lon: p.location.longitude,
distanceMeters: haversine(point, { latitude: p.location.latitude, longitude: p.location.longitude }),
food: (p.types ?? []).some((t: string) => /restaurant|cafe|bakery|bar|food|meal|coffee/.test(t)),
rating: p.rating ?? null,
website: p.websiteUri || p.googleMapsUri || null,
photoRef: p.photos?.[0]?.name ?? null,
photoUrl: null,
}));
}
// Fallback: Wikipedia GeoSearch (free, no key) — notable nearby places.
// generator=geosearch + prop=pageimages folds the page thumbnail into the
// same single request, so the popup card gets a real photo without a 2nd call.
async function viaWikipedia(point: GeoPoint, radiusMeters: number): Promise<Activity[]> {
const radius = Math.min(10000, Math.max(500, radiusMeters));
const url = `https://en.wikipedia.org/w/api.php?action=query&generator=geosearch` +
`&ggscoord=${point.latitude}%7C${point.longitude}&ggsradius=${radius}&ggslimit=40` +
`&prop=coordinates|pageimages&pithumbsize=560&pilicense=any&format=json&formatversion=2`;
const res = await fetch(url, { headers: { "user-agent": "ChargeAndExplore/1.0 (chargeandexplore.agentabrams.com)" }, signal: AbortSignal.timeout(10000) });
if (!res.ok) throw new Error(`wiki geosearch ${res.status}`);
const j = (await res.json()) as { query?: { pages?: any[] } };
return (j.query?.pages ?? [])
.filter((g) => g.title && g.coordinates?.[0])
.map((g) => {
const c = g.coordinates[0];
return {
id: `wiki:${g.pageid}`, name: g.title as string, category: "point of interest",
lat: c.lat, lon: c.lon,
distanceMeters: haversine(point, { latitude: c.lat, longitude: c.lon }),
food: false, rating: null,
website: `https://en.wikipedia.org/?curid=${g.pageid}`,
photoRef: null,
photoUrl: g.thumbnail?.source ?? null,
};
})
.sort((a, b) => a.distanceMeters - b.distanceMeters)
.slice(0, 40);
}