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}
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}