// Place names for a coordinate. The phone app asks the OS geocoder, and a // browser has no twin of that, so the lookup lives here: one HTTP call behind a // cache, and the same naming rule the app uses, so a stamp reads the same on // both. import { findPlace, savePlace } from './db'; // ~11m at the equator: fixes from the same spot share a cache row. const key = (lat: number, lng: number) => `${lat.toFixed(4)},${lng.toFixed(4)}`; // Nominatim (OpenStreetMap). Its usage policy asks for one request per second, // a caller that identifies itself, and answers that are kept — hence the queue // below and the SQLite row every answer leaves behind. const ENDPOINT = 'https://nominatim.openstreetmap.org/reverse'; const AGENT = 'RecipesCam/1.2 (https://recipescam.labz.io.vn)'; const GAP_MS = 1100; const TIMEOUT_MS = 8000; // One lookup at a time, never closer together than GAP_MS. let queue: Promise = Promise.resolve(); // Android answers with the commune in `subregion` and the city in `region`; // Nominatim says the same thing with `suburb` and `city`. Same rule as the app's // localityName: "COMMUNE, CITY", and a name that fills both slots prints once. export function nameOf(address: Record): string | null { const district = address.suburb || address.city_district || address.district || address.quarter || address.neighbourhood || address.county; const city = address.city || address.town || address.municipality || address.village || address.state; const parts = district && district !== city ? [district, city] : [district || city]; const name = parts.filter(Boolean).join(', '); return name ? name.toUpperCase() : null; } export async function placeName(lat: number, lng: number): Promise { const cached = findPlace(key(lat, lng)); if (cached !== undefined) return cached || null; const call = queue.then(async () => { await new Promise((r) => setTimeout(r, GAP_MS)); const url = `${ENDPOINT}?format=jsonv2&zoom=14&addressdetails=1&lat=${lat}&lon=${lng}`; const res = await fetch(url, { headers: { 'user-agent': AGENT, accept: 'application/json' }, signal: AbortSignal.timeout(TIMEOUT_MS), }); if (!res.ok) throw new Error(`geocoder HTTP ${res.status}`); const body = (await res.json()) as { address?: Record }; return body.address ? nameOf(body.address) : null; }); // The queue must not inherit a rejection, or every later lookup would fail // with it; the caller gets the failure through `call` itself. queue = call.catch(() => undefined); try { const name = await call; // Only an answer that came back is worth keeping — a timeout or an outage // must not pin "no name here" on the coordinate for good. savePlace(key(lat, lng), name ?? ''); return name; } catch { return null; } }