A wayfinder inspired map plugin for obisidian
at main 243 lines 7.6 kB view raw
1/** 2 * geocoder.ts — Nominatim Geocoder (Effectful) 3 * 4 * Convert place names to lat/lng coordinates via OpenStreetMap Nominatim API. 5 * Handles rate limiting, deduplication, cancellation, and progressive updates. 6 */ 7 8import type { Place } from "./parser"; 9 10export interface GeoResult { 11 lat: number; 12 lng: number; 13} 14 15export interface GeocodeCallbacks { 16 onProgress?: (place: Place, result: GeoResult | null) => void; 17 /** Called when the user should be shown a notice (e.g., repeated geocoding failures). */ 18 onNotice?: (message: string) => void; 19} 20 21/** Minimum delay between sequential Nominatim requests (ms). */ 22const RATE_LIMIT_MS = 1100; 23 24/** Per-request fetch timeout (ms). */ 25const FETCH_TIMEOUT_MS = 10_000; 26 27/** Consecutive failure threshold for showing an Obsidian Notice. */ 28const CONSECUTIVE_FAILURE_NOTICE_THRESHOLD = 3; 29 30const NOMINATIM_BASE = "https://nominatim.openstreetmap.org/search"; 31const USER_AGENT = "ObsidianMapViewer/1.0"; 32 33/** 34 * Geocode an array of places via Nominatim. 35 * 36 * **Mutates** the input `places` array in-place — successfully geocoded places 37 * have their `lat` and `lng` properties set directly on the original objects. 38 * Returns the same array reference (not a copy). 39 * 40 * - Only geocodes places where lat AND lng are both undefined/null. 41 * - Deduplicates by case-insensitive trimmed name. 42 * - Rate-limits to 1100ms between sequential requests. 43 * - Supports external cancellation via AbortSignal. 44 * - Reports progress via onProgress callback (once per unique geocode, not per duplicate). 45 */ 46export async function geocodePlaces( 47 places: Place[], 48 callbacks?: GeocodeCallbacks, 49 signal?: AbortSignal 50): Promise<Place[]> { 51 // Contract 13: empty array short-circuit 52 if (places.length === 0) return []; 53 54 // Build dedup map: normalized name -> { queryName, indices } 55 const dedupMap = new Map< 56 string, 57 { queryName: string; indices: number[] } 58 >(); 59 60 // Collect which places need geocoding and build dedup groups 61 const geocodeOrder: string[] = []; // order of unique normalized names to geocode 62 63 for (let i = 0; i < places.length; i++) { 64 const place = places[i]; 65 66 // Contract 1: skip places with existing coordinates 67 if (place.lat != null && place.lng != null) { 68 continue; 69 } 70 71 const normalizedName = place.name.trim().toLowerCase(); 72 73 if (dedupMap.has(normalizedName)) { 74 // Contract 5: deduplication — add index to existing group 75 dedupMap.get(normalizedName)!.indices.push(i); 76 } else { 77 // First encounter of this name 78 dedupMap.set(normalizedName, { 79 queryName: place.name.trim(), 80 indices: [i], 81 }); 82 geocodeOrder.push(normalizedName); 83 } 84 } 85 86 let consecutiveFailures = 0; 87 88 // Process each unique name sequentially 89 for (let g = 0; g < geocodeOrder.length; g++) { 90 const normalizedName = geocodeOrder[g]; 91 const group = dedupMap.get(normalizedName)!; 92 93 // Contract 11: check external abort signal before making request 94 if (signal?.aborted) { 95 break; 96 } 97 98 // Contract 4: rate limiting — wait 1100ms between requests (not before first) 99 // Contract 11: delay is cancellable — signal aborts immediately, not after full delay 100 if (g > 0) { 101 await delay(RATE_LIMIT_MS, signal); 102 if (signal?.aborted) { 103 break; 104 } 105 } 106 107 // Perform the geocode 108 const result = await fetchGeocode(group.queryName, signal); 109 110 if (result === null) { 111 consecutiveFailures++; 112 // Contract 12: Notice on exactly the 3rd consecutive failure (not every subsequent one) 113 if (consecutiveFailures === CONSECUTIVE_FAILURE_NOTICE_THRESHOLD) { 114 callbacks?.onNotice?.( 115 "Map Viewer: Geocoding issues — check your network connection" 116 ); 117 } 118 } else { 119 consecutiveFailures = 0; 120 } 121 122 // Contract 7 & 9: apply result to all places in this dedup group 123 for (const idx of group.indices) { 124 if (result) { 125 places[idx].lat = result.lat; 126 places[idx].lng = result.lng; 127 } 128 } 129 130 // Contract 7: call onProgress for the first place in the group 131 if (callbacks?.onProgress) { 132 const firstIdx = group.indices[0]; 133 callbacks.onProgress(places[firstIdx], result); 134 } 135 } 136 137 return places; 138} 139 140/** 141 * Fetch geocode result for a single place name from Nominatim. 142 * Returns GeoResult on success, null on failure/empty/timeout. 143 */ 144async function fetchGeocode( 145 name: string, 146 externalSignal?: AbortSignal 147): Promise<GeoResult | null> { 148 // Contract 10: 10-second timeout via AbortController 149 const timeoutController = new AbortController(); 150 const timeoutId = setTimeout(() => timeoutController.abort(), FETCH_TIMEOUT_MS); 151 152 // Combine external signal and timeout signal 153 const combinedController = new AbortController(); 154 155 // Named handlers for cleanup — pass reason for debuggability 156 const onExternalAbort = () => combinedController.abort("cancelled"); 157 const onTimeoutAbort = () => combinedController.abort("timeout"); 158 159 // If external signal aborts, abort combined 160 if (externalSignal) { 161 if (externalSignal.aborted) { 162 clearTimeout(timeoutId); 163 return null; 164 } 165 externalSignal.addEventListener("abort", onExternalAbort); 166 } 167 168 // If timeout aborts, abort combined 169 timeoutController.signal.addEventListener("abort", onTimeoutAbort); 170 171 // Contract 2: build Nominatim URL 172 const url = `${NOMINATIM_BASE}?format=json&limit=1&q=${encodeURIComponent(name)}`; 173 174 try { 175 // Contract 6: User-Agent header 176 const response = await fetch(url, { 177 headers: { "User-Agent": USER_AGENT }, 178 signal: combinedController.signal, 179 }); 180 181 if (!response.ok) { 182 console.warn("[MapViewer] HTTP error for:", name, response.status); 183 return null; 184 } 185 186 const data = await response.json(); 187 188 // Empty results 189 if (!Array.isArray(data) || data.length === 0) { 190 // Contract 12: warn for empty results 191 console.warn("[MapViewer] No results for:", name); 192 return null; 193 } 194 195 const lat = parseFloat(data[0].lat); 196 const lng = parseFloat(data[0].lon); 197 198 if (isNaN(lat) || isNaN(lng) || lat < -90 || lat > 90 || lng < -180 || lng > 180) { 199 console.warn("[MapViewer] Invalid coordinates for:", name, { lat, lng }); 200 return null; 201 } 202 203 return { lat, lng }; 204 } catch (err) { 205 // Contract 8 & 12: network failures logged, place skipped 206 if (err instanceof DOMException && err.name === "AbortError") { 207 const reason = combinedController.signal.reason; 208 if (reason === "timeout") { 209 console.warn("[MapViewer] Geocode timed out for:", name); 210 } else { 211 console.warn("[MapViewer] Geocode cancelled for:", name); 212 } 213 } else { 214 console.warn("[MapViewer] Geocode failed for:", name, err); 215 } 216 return null; 217 } finally { 218 clearTimeout(timeoutId); 219 timeoutController.signal.removeEventListener("abort", onTimeoutAbort); 220 if (externalSignal) { 221 externalSignal.removeEventListener("abort", onExternalAbort); 222 } 223 } 224} 225 226/** Promise-based delay that can be cancelled via AbortSignal. */ 227function delay(ms: number, signal?: AbortSignal): Promise<void> { 228 return new Promise((resolve) => { 229 if (signal?.aborted) { 230 resolve(); 231 return; 232 } 233 const onAbort = () => { 234 clearTimeout(timerId); 235 resolve(); 236 }; 237 const timerId = setTimeout(() => { 238 signal?.removeEventListener("abort", onAbort); 239 resolve(); 240 }, ms); 241 signal?.addEventListener("abort", onAbort, { once: true }); 242 }); 243}