A wayfinder inspired map plugin for obisidian
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}