Something went wrong. Try again.
A wayfinder inspired map plugin for obisidian
Something went wrong. Try again.
7.6 kB · 243 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244/** * 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<Place[]> { // 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<GeoResult | null> { // 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<void> { 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 }); });}