/** * geocoder.ts — Nominatim Geocoder (Effectful) * * Convert place names to lat/lng coordinates via OpenStreetMap Nominatim API. * Handles rate limiting, deduplication, cancellation, and progressive updates. */ import type { Place } from "./parser"; export interface GeoResult { lat: number; lng: number; } export interface GeocodeCallbacks { onProgress?: (place: Place, result: GeoResult | null) => void; /** Called when the user should be shown a notice (e.g., repeated geocoding failures). */ onNotice?: (message: string) => void; } /** Minimum delay between sequential Nominatim requests (ms). */ const RATE_LIMIT_MS = 1100; /** Per-request fetch timeout (ms). */ const FETCH_TIMEOUT_MS = 10_000; /** Consecutive failure threshold for showing an Obsidian Notice. */ const CONSECUTIVE_FAILURE_NOTICE_THRESHOLD = 3; const NOMINATIM_BASE = "https://nominatim.openstreetmap.org/search"; const USER_AGENT = "ObsidianMapViewer/1.0"; /** * Geocode an array of places via Nominatim. * * **Mutates** the input `places` array in-place — successfully geocoded places * have their `lat` and `lng` properties set directly on the original objects. * Returns the same array reference (not a copy). * * - Only geocodes places where lat AND lng are both undefined/null. * - Deduplicates by case-insensitive trimmed name. * - Rate-limits to 1100ms between sequential requests. * - Supports external cancellation via AbortSignal. * - Reports progress via onProgress callback (once per unique geocode, not per duplicate). */ export async function geocodePlaces( places: Place[], callbacks?: GeocodeCallbacks, signal?: AbortSignal ): Promise { // Contract 13: empty array short-circuit if (places.length === 0) return []; // Build dedup map: normalized name -> { queryName, indices } const dedupMap = new Map< string, { queryName: string; indices: number[] } >(); // Collect which places need geocoding and build dedup groups const geocodeOrder: string[] = []; // order of unique normalized names to geocode for (let i = 0; i < places.length; i++) { const place = places[i]; // Contract 1: skip places with existing coordinates if (place.lat != null && place.lng != null) { continue; } const normalizedName = place.name.trim().toLowerCase(); if (dedupMap.has(normalizedName)) { // Contract 5: deduplication — add index to existing group dedupMap.get(normalizedName)!.indices.push(i); } else { // First encounter of this name dedupMap.set(normalizedName, { queryName: place.name.trim(), indices: [i], }); geocodeOrder.push(normalizedName); } } let consecutiveFailures = 0; // Process each unique name sequentially for (let g = 0; g < geocodeOrder.length; g++) { const normalizedName = geocodeOrder[g]; const group = dedupMap.get(normalizedName)!; // Contract 11: check external abort signal before making request if (signal?.aborted) { break; } // Contract 4: rate limiting — wait 1100ms between requests (not before first) // Contract 11: delay is cancellable — signal aborts immediately, not after full delay if (g > 0) { await delay(RATE_LIMIT_MS, signal); if (signal?.aborted) { break; } } // Perform the geocode const result = await fetchGeocode(group.queryName, signal); if (result === null) { consecutiveFailures++; // Contract 12: Notice on exactly the 3rd consecutive failure (not every subsequent one) if (consecutiveFailures === CONSECUTIVE_FAILURE_NOTICE_THRESHOLD) { callbacks?.onNotice?.( "Map Viewer: Geocoding issues — check your network connection" ); } } else { consecutiveFailures = 0; } // Contract 7 & 9: apply result to all places in this dedup group for (const idx of group.indices) { if (result) { places[idx].lat = result.lat; places[idx].lng = result.lng; } } // Contract 7: call onProgress for the first place in the group if (callbacks?.onProgress) { const firstIdx = group.indices[0]; callbacks.onProgress(places[firstIdx], result); } } return places; } /** * Fetch geocode result for a single place name from Nominatim. * Returns GeoResult on success, null on failure/empty/timeout. */ async function fetchGeocode( name: string, externalSignal?: AbortSignal ): Promise { // Contract 10: 10-second timeout via AbortController const timeoutController = new AbortController(); const timeoutId = setTimeout(() => timeoutController.abort(), FETCH_TIMEOUT_MS); // Combine external signal and timeout signal const combinedController = new AbortController(); // Named handlers for cleanup — pass reason for debuggability const onExternalAbort = () => combinedController.abort("cancelled"); const onTimeoutAbort = () => combinedController.abort("timeout"); // If external signal aborts, abort combined if (externalSignal) { if (externalSignal.aborted) { clearTimeout(timeoutId); return null; } externalSignal.addEventListener("abort", onExternalAbort); } // If timeout aborts, abort combined timeoutController.signal.addEventListener("abort", onTimeoutAbort); // Contract 2: build Nominatim URL const url = `${NOMINATIM_BASE}?format=json&limit=1&q=${encodeURIComponent(name)}`; try { // Contract 6: User-Agent header const response = await fetch(url, { headers: { "User-Agent": USER_AGENT }, signal: combinedController.signal, }); if (!response.ok) { console.warn("[MapViewer] HTTP error for:", name, response.status); return null; } const data = await response.json(); // Empty results if (!Array.isArray(data) || data.length === 0) { // Contract 12: warn for empty results console.warn("[MapViewer] No results for:", name); return null; } const lat = parseFloat(data[0].lat); const lng = parseFloat(data[0].lon); if (isNaN(lat) || isNaN(lng) || lat < -90 || lat > 90 || lng < -180 || lng > 180) { console.warn("[MapViewer] Invalid coordinates for:", name, { lat, lng }); return null; } return { lat, lng }; } catch (err) { // Contract 8 & 12: network failures logged, place skipped if (err instanceof DOMException && err.name === "AbortError") { const reason = combinedController.signal.reason; if (reason === "timeout") { console.warn("[MapViewer] Geocode timed out for:", name); } else { console.warn("[MapViewer] Geocode cancelled for:", name); } } else { console.warn("[MapViewer] Geocode failed for:", name, err); } return null; } finally { clearTimeout(timeoutId); timeoutController.signal.removeEventListener("abort", onTimeoutAbort); if (externalSignal) { externalSignal.removeEventListener("abort", onExternalAbort); } } } /** Promise-based delay that can be cancelled via AbortSignal. */ function delay(ms: number, signal?: AbortSignal): Promise { return new Promise((resolve) => { if (signal?.aborted) { resolve(); return; } const onAbort = () => { clearTimeout(timerId); resolve(); }; const timerId = setTimeout(() => { signal?.removeEventListener("abort", onAbort); resolve(); }, ms); signal?.addEventListener("abort", onAbort, { once: true }); }); }