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