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