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