A wayfinder inspired map plugin for obisidian
at main 401 lines 12 kB view raw
1/** 2 * mapView.ts — Sidebar View (Effectful/Obsidian API) 3 * 4 * Obsidian ItemView subclass that reads the active note, parses places, 5 * manages geocoding, writes geo data back to the note, and synchronizes 6 * cursor position with map markers. 7 */ 8 9import { ItemView, MarkdownView, Notice } from "obsidian"; 10import type { WorkspaceLeaf, TAbstractFile, TFile } from "obsidian"; 11import { parsePlaces, GEO_LINE_RE } from "./parser"; 12import type { Place } from "./parser"; 13import { geocodePlaces } from "./geocoder"; 14import { createMap } from "./mapRenderer"; 15import type { MapController } from "./mapRenderer"; 16 17export const VIEW_TYPE = "map-viewer"; 18 19const DEBOUNCE_MS = 300; 20// Grace period after vault.process() completes before re-enabling modify 21// event handling. Obsidian fires modify events asynchronously after writes; 22// this window suppresses self-triggered refreshes. Trade-off: legitimate 23// external edits within this window are also suppressed. 24const WRITE_GUARD_MS = 500; 25// Obsidian has no first-class cursor-move event. CodeMirror's cursorActivity 26// extension requires @codemirror/view (external). Polling is the standard 27// approach for Obsidian plugins; 200ms balances responsiveness vs cost. 28const CURSOR_POLL_MS = 200; 29 30function computeFingerprint(places: Place[]): string { 31 return places 32 .map((p) => `${p.name}::${p.lat}::${p.lng}::${p.url ?? ""}::${p.startLine}`) 33 .join("|"); 34} 35 36export class MapViewerView extends ItemView { 37 private mapController: MapController | null = null; 38 private currentPlaces: Place[] = []; 39 private lastFingerprint: string | null = null; 40 private writeGuardCounter = 0; 41 private currentAbortController: AbortController | null = null; 42 private debounceTimer: ReturnType<typeof setTimeout> | null = null; 43 private destroyed = false; 44 private lastSelectedStartLine: number | null = null; 45 private inFlightRefresh: Promise<void> | null = null; 46 47 constructor(leaf: WorkspaceLeaf) { 48 super(leaf); 49 } 50 51 getViewType(): string { 52 return VIEW_TYPE; 53 } 54 55 getDisplayText(): string { 56 return "Map"; 57 } 58 59 getIcon(): string { 60 return "map-pin"; 61 } 62 63 async onOpen(): Promise<void> { 64 const container = document.createElement("div"); 65 container.className = "map-viewer-container"; 66 this.contentEl.appendChild(container); 67 68 this.mapController = createMap(container, [], { 69 onPlaceSelect: (places: Place[]) => this.handlePlaceSelect(places), 70 }); 71 72 this.registerEvents(); 73 this.startCursorSync(); 74 this.scheduleRefresh(); 75 } 76 77 async onClose(): Promise<void> { 78 this.destroyed = true; 79 80 if (this.debounceTimer !== null) { 81 clearTimeout(this.debounceTimer); 82 this.debounceTimer = null; 83 } 84 85 if (this.currentAbortController) { 86 this.currentAbortController.abort(); 87 this.currentAbortController = null; 88 } 89 90 if (this.mapController) { 91 this.mapController.destroy(); 92 this.mapController = null; 93 } 94 } 95 96 // NOTE: main.ts also registers active-leaf-change and modify handlers for 97 // plugin-level concerns (lastActiveFilePath dedup, active file check). Both 98 // sets of handlers may fire; scheduleRefresh() debounce collapses duplicates. 99 // The view's handlers add view-specific logic: clearMap on non-MarkdownView 100 // leaf change, and write-guard checks on modify. 101 private registerEvents(): void { 102 this.registerEvent( 103 this.app.vault.on("modify", (file: TAbstractFile) => this.onFileModify(file as TFile)) 104 ); 105 106 this.registerEvent( 107 this.app.workspace.on( 108 "active-leaf-change", 109 (leaf) => this.onActiveLeafChange(leaf) 110 ) 111 ); 112 } 113 114 private onFileModify(file: TFile): void { 115 if (this.writeGuardCounter > 0) return; 116 117 // Only refresh if the modified file is the currently active file 118 const mdView = this.app.workspace.getActiveViewOfType(MarkdownView); 119 if (!mdView?.file || mdView.file !== file) return; 120 121 this.scheduleRefresh(); 122 } 123 124 private onActiveLeafChange(_leaf: WorkspaceLeaf | null): void { 125 const mdView = this.app.workspace.getActiveViewOfType(MarkdownView); 126 if (!mdView) { 127 this.clearMap(); 128 return; 129 } 130 this.scheduleRefresh(); 131 } 132 133 /** 134 * Public entry point for external callers (e.g., main.ts event handlers). 135 * Delegates to the debounced scheduleRefresh(). 136 */ 137 refresh(): void { 138 this.scheduleRefresh(); 139 } 140 141 private scheduleRefresh(): void { 142 if (this.debounceTimer !== null) { 143 clearTimeout(this.debounceTimer); 144 } 145 146 this.debounceTimer = setTimeout(() => { 147 this.debounceTimer = null; 148 const promise = this.doRefresh().catch((err) => { 149 if (!this.destroyed) { 150 console.warn("[MapViewer] Refresh failed:", err); 151 } 152 }); 153 this.inFlightRefresh = promise; 154 promise.finally(() => { 155 if (this.inFlightRefresh === promise) { 156 this.inFlightRefresh = null; 157 } 158 }); 159 }, DEBOUNCE_MS); 160 } 161 162 private async doRefresh(): Promise<void> { 163 // Abort any in-flight operation from a previous refresh 164 if (this.currentAbortController) { 165 this.currentAbortController.abort(); 166 this.currentAbortController = null; 167 } 168 169 // Create an abort controller for the entire refresh lifecycle 170 const abortController = new AbortController(); 171 this.currentAbortController = abortController; 172 173 if (this.destroyed) return; 174 175 const mdView = this.app.workspace.getActiveViewOfType(MarkdownView); 176 if (!mdView || !mdView.file) { 177 this.clearMap(); 178 return; 179 } 180 181 const file = mdView.file; 182 const content = await this.app.vault.cachedRead(file); 183 184 // Check for cancellation after async boundary 185 if (abortController.signal.aborted || this.destroyed) return; 186 187 const places = parsePlaces(content); 188 const fingerprint = computeFingerprint(places); 189 190 // Always store fresh places (fresh line ranges for cursor sync) 191 this.currentPlaces = places; 192 // Reset cursor sync state — line numbers may have shifted after re-parse 193 this.lastSelectedStartLine = null; 194 195 if (this.lastFingerprint === null || fingerprint !== this.lastFingerprint) { 196 this.lastFingerprint = fingerprint; 197 if (this.mapController) { 198 this.mapController.updateMarkers(places); 199 } 200 } 201 202 // Only geocode places that don't already have coordinates 203 const placesToGeocode = places.filter( 204 (p) => p.lat == null || p.lng == null 205 ); 206 207 if (placesToGeocode.length > 0) { 208 await this.geocodeAndWriteBack( 209 places, 210 placesToGeocode, 211 file, 212 abortController 213 ); 214 } 215 } 216 217 private async geocodeAndWriteBack( 218 allPlaces: Place[], 219 placesToGeocode: Place[], 220 file: TFile, 221 abortController: AbortController 222 ): Promise<void> { 223 try { 224 // Pass only the places that need geocoding. The geocoder mutates in-place. 225 await geocodePlaces(placesToGeocode, { 226 onNotice: (msg) => new Notice(msg), 227 }, abortController.signal); 228 229 if (abortController.signal.aborted || this.destroyed) return; 230 231 // Check only the places that were ATTEMPTED for geocoding 232 const successfullyGeocoded = placesToGeocode.filter( 233 (p) => p.lat != null && p.lng != null 234 ); 235 236 if (successfullyGeocoded.length === 0) { 237 new Notice("Map Viewer: No places could be geocoded"); 238 console.warn("[MapViewer] Geocoding produced zero results"); 239 return; 240 } 241 242 // geocodePlaces() mutates in-place. placesToGeocode holds the same object 243 // references as allPlaces (via Array.filter), so allPlaces already has the 244 // geocoded coordinates. No explicit copy-back needed. 245 246 // Only write back newly geocoded places, not pre-existing ones 247 this.writeGuardCounter++; 248 249 try { 250 await this.app.vault.process(file, (currentContent: string) => { 251 return this.applyGeoWriteBack(currentContent, successfullyGeocoded); 252 }); 253 } finally { 254 setTimeout(() => { 255 this.writeGuardCounter--; 256 }, WRITE_GUARD_MS); 257 } 258 259 if (abortController.signal.aborted || this.destroyed) return; 260 261 // Update map with new coordinates (allPlaces now has geocoded coords) 262 if (this.mapController) { 263 const newFingerprint = computeFingerprint(allPlaces); 264 if (newFingerprint !== this.lastFingerprint) { 265 this.lastFingerprint = newFingerprint; 266 this.mapController.updateMarkers(allPlaces); 267 } 268 } 269 } catch (err) { 270 if (!abortController.signal.aborted && !this.destroyed) { 271 console.warn("[MapViewer] Geocoding failed:", err); 272 } 273 } 274 } 275 276 private applyGeoWriteBack( 277 currentContent: string, 278 geocodedPlaces: Place[] 279 ): string { 280 const currentPlaces = parsePlaces(currentContent); 281 const lines = currentContent.split("\n"); 282 283 // Build lookup: normalized name -> geocoded Place (first occurrence wins for coords) 284 const geocodedByName = new Map<string, Place>(); 285 for (const p of geocodedPlaces) { 286 if (p.lat != null && p.lng != null) { 287 const key = p.name.trim().toLowerCase(); 288 if (!geocodedByName.has(key)) { 289 geocodedByName.set(key, p); 290 } 291 } 292 } 293 294 const operations: Array<{ 295 type: "insert" | "replace"; 296 lineIndex: number; 297 content: string; 298 }> = []; 299 300 for (const currentPlace of currentPlaces) { 301 const normalizedName = currentPlace.name.trim().toLowerCase(); 302 const geocoded = geocodedByName.get(normalizedName); 303 if (!geocoded || geocoded.lat == null || geocoded.lng == null) continue; 304 305 const geoLine = `\t* geo: ${geocoded.lat.toFixed(6)},${geocoded.lng.toFixed(6)}`; 306 307 let existingGeoLine = -1; 308 for (let i = currentPlace.startLine + 1; i <= currentPlace.endLine; i++) { 309 if (GEO_LINE_RE.test(lines[i])) { 310 existingGeoLine = i; 311 break; 312 } 313 } 314 315 if (existingGeoLine >= 0) { 316 operations.push({ 317 type: "replace", 318 lineIndex: existingGeoLine, 319 content: geoLine, 320 }); 321 } else { 322 operations.push({ 323 type: "insert", 324 lineIndex: currentPlace.endLine, 325 content: geoLine, 326 }); 327 } 328 } 329 330 operations.sort((a, b) => b.lineIndex - a.lineIndex); 331 332 for (const op of operations) { 333 if (op.type === "replace") { 334 lines[op.lineIndex] = op.content; 335 } else { 336 lines.splice(op.lineIndex + 1, 0, op.content); 337 } 338 } 339 340 return lines.join("\n"); 341 } 342 343 private startCursorSync(): void { 344 const intervalId = window.setInterval(() => { 345 this.pollCursorPosition(); 346 }, CURSOR_POLL_MS); 347 this.registerInterval(intervalId); 348 } 349 350 private pollCursorPosition(): void { 351 if (!this.mapController || this.currentPlaces.length === 0) return; 352 353 const mdView = this.app.workspace.getActiveViewOfType(MarkdownView); 354 if (!mdView || !mdView.editor) return; 355 356 const cursor = mdView.editor.getCursor(); 357 const cursorLine = cursor.line; 358 359 for (const place of this.currentPlaces) { 360 if (cursorLine >= place.startLine && cursorLine <= place.endLine) { 361 // Skip redundant selectPlace calls when cursor hasn't moved to a new place 362 if (this.lastSelectedStartLine === place.startLine) return; 363 this.lastSelectedStartLine = place.startLine; 364 this.mapController.selectPlace(place); 365 return; 366 } 367 } 368 369 // Deselect — cursor is in a dead zone 370 if (this.lastSelectedStartLine !== null) { 371 this.lastSelectedStartLine = null; 372 this.mapController.selectPlace(null); 373 } 374 } 375 376 private handlePlaceSelect(places: Place[]): void { 377 if (!places || places.length === 0) return; 378 379 const mdView = this.app.workspace.getActiveViewOfType(MarkdownView); 380 if (!mdView || !mdView.editor) return; 381 382 const firstPlace = places[0]; 383 mdView.editor.setCursor({ line: firstPlace.startLine, ch: 0 }); 384 mdView.editor.scrollIntoView( 385 { 386 from: { line: firstPlace.startLine, ch: 0 }, 387 to: { line: firstPlace.startLine, ch: 0 }, 388 }, 389 true 390 ); 391 } 392 393 private clearMap(): void { 394 this.currentPlaces = []; 395 this.lastFingerprint = null; 396 this.lastSelectedStartLine = null; 397 if (this.mapController) { 398 this.mapController.updateMarkers([]); 399 } 400 } 401}