/** * mapView.ts — Sidebar View (Effectful/Obsidian API) * * Obsidian ItemView subclass that reads the active note, parses places, * manages geocoding, writes geo data back to the note, and synchronizes * cursor position with map markers. */ import { ItemView, MarkdownView, Notice } from "obsidian"; import type { WorkspaceLeaf, TAbstractFile, TFile } from "obsidian"; import { parsePlaces, GEO_LINE_RE } from "./parser"; import type { Place } from "./parser"; import { geocodePlaces } from "./geocoder"; import { createMap } from "./mapRenderer"; import type { MapController } from "./mapRenderer"; export const VIEW_TYPE = "map-viewer"; const DEBOUNCE_MS = 300; // Grace period after vault.process() completes before re-enabling modify // event handling. Obsidian fires modify events asynchronously after writes; // this window suppresses self-triggered refreshes. Trade-off: legitimate // external edits within this window are also suppressed. const WRITE_GUARD_MS = 500; // Obsidian has no first-class cursor-move event. CodeMirror's cursorActivity // extension requires @codemirror/view (external). Polling is the standard // approach for Obsidian plugins; 200ms balances responsiveness vs cost. const CURSOR_POLL_MS = 200; function computeFingerprint(places: Place[]): string { return places .map((p) => `${p.name}::${p.lat}::${p.lng}::${p.url ?? ""}::${p.startLine}`) .join("|"); } export class MapViewerView extends ItemView { private mapController: MapController | null = null; private currentPlaces: Place[] = []; private lastFingerprint: string | null = null; private writeGuardCounter = 0; private currentAbortController: AbortController | null = null; private debounceTimer: ReturnType | null = null; private destroyed = false; private lastSelectedStartLine: number | null = null; private inFlightRefresh: Promise | null = null; constructor(leaf: WorkspaceLeaf) { super(leaf); } getViewType(): string { return VIEW_TYPE; } getDisplayText(): string { return "Map"; } getIcon(): string { return "map-pin"; } async onOpen(): Promise { const container = document.createElement("div"); container.className = "map-viewer-container"; this.contentEl.appendChild(container); this.mapController = createMap(container, [], { onPlaceSelect: (places: Place[]) => this.handlePlaceSelect(places), }); this.registerEvents(); this.startCursorSync(); this.scheduleRefresh(); } async onClose(): Promise { this.destroyed = true; if (this.debounceTimer !== null) { clearTimeout(this.debounceTimer); this.debounceTimer = null; } if (this.currentAbortController) { this.currentAbortController.abort(); this.currentAbortController = null; } if (this.mapController) { this.mapController.destroy(); this.mapController = null; } } // NOTE: main.ts also registers active-leaf-change and modify handlers for // plugin-level concerns (lastActiveFilePath dedup, active file check). Both // sets of handlers may fire; scheduleRefresh() debounce collapses duplicates. // The view's handlers add view-specific logic: clearMap on non-MarkdownView // leaf change, and write-guard checks on modify. private registerEvents(): void { this.registerEvent( this.app.vault.on("modify", (file: TAbstractFile) => this.onFileModify(file as TFile)) ); this.registerEvent( this.app.workspace.on( "active-leaf-change", (leaf) => this.onActiveLeafChange(leaf) ) ); } private onFileModify(file: TFile): void { if (this.writeGuardCounter > 0) return; // Only refresh if the modified file is the currently active file const mdView = this.app.workspace.getActiveViewOfType(MarkdownView); if (!mdView?.file || mdView.file !== file) return; this.scheduleRefresh(); } private onActiveLeafChange(_leaf: WorkspaceLeaf | null): void { const mdView = this.app.workspace.getActiveViewOfType(MarkdownView); if (!mdView) { this.clearMap(); return; } this.scheduleRefresh(); } /** * Public entry point for external callers (e.g., main.ts event handlers). * Delegates to the debounced scheduleRefresh(). */ refresh(): void { this.scheduleRefresh(); } private scheduleRefresh(): void { if (this.debounceTimer !== null) { clearTimeout(this.debounceTimer); } this.debounceTimer = setTimeout(() => { this.debounceTimer = null; const promise = this.doRefresh().catch((err) => { if (!this.destroyed) { console.warn("[MapViewer] Refresh failed:", err); } }); this.inFlightRefresh = promise; promise.finally(() => { if (this.inFlightRefresh === promise) { this.inFlightRefresh = null; } }); }, DEBOUNCE_MS); } private async doRefresh(): Promise { // Abort any in-flight operation from a previous refresh if (this.currentAbortController) { this.currentAbortController.abort(); this.currentAbortController = null; } // Create an abort controller for the entire refresh lifecycle const abortController = new AbortController(); this.currentAbortController = abortController; if (this.destroyed) return; const mdView = this.app.workspace.getActiveViewOfType(MarkdownView); if (!mdView || !mdView.file) { this.clearMap(); return; } const file = mdView.file; const content = await this.app.vault.cachedRead(file); // Check for cancellation after async boundary if (abortController.signal.aborted || this.destroyed) return; const places = parsePlaces(content); const fingerprint = computeFingerprint(places); // Always store fresh places (fresh line ranges for cursor sync) this.currentPlaces = places; // Reset cursor sync state — line numbers may have shifted after re-parse this.lastSelectedStartLine = null; if (this.lastFingerprint === null || fingerprint !== this.lastFingerprint) { this.lastFingerprint = fingerprint; if (this.mapController) { this.mapController.updateMarkers(places); } } // Only geocode places that don't already have coordinates const placesToGeocode = places.filter( (p) => p.lat == null || p.lng == null ); if (placesToGeocode.length > 0) { await this.geocodeAndWriteBack( places, placesToGeocode, file, abortController ); } } private async geocodeAndWriteBack( allPlaces: Place[], placesToGeocode: Place[], file: TFile, abortController: AbortController ): Promise { try { // Pass only the places that need geocoding. The geocoder mutates in-place. await geocodePlaces(placesToGeocode, { onNotice: (msg) => new Notice(msg), }, abortController.signal); if (abortController.signal.aborted || this.destroyed) return; // Check only the places that were ATTEMPTED for geocoding const successfullyGeocoded = placesToGeocode.filter( (p) => p.lat != null && p.lng != null ); if (successfullyGeocoded.length === 0) { new Notice("Map Viewer: No places could be geocoded"); console.warn("[MapViewer] Geocoding produced zero results"); return; } // geocodePlaces() mutates in-place. placesToGeocode holds the same object // references as allPlaces (via Array.filter), so allPlaces already has the // geocoded coordinates. No explicit copy-back needed. // Only write back newly geocoded places, not pre-existing ones this.writeGuardCounter++; try { await this.app.vault.process(file, (currentContent: string) => { return this.applyGeoWriteBack(currentContent, successfullyGeocoded); }); } finally { setTimeout(() => { this.writeGuardCounter--; }, WRITE_GUARD_MS); } if (abortController.signal.aborted || this.destroyed) return; // Update map with new coordinates (allPlaces now has geocoded coords) if (this.mapController) { const newFingerprint = computeFingerprint(allPlaces); if (newFingerprint !== this.lastFingerprint) { this.lastFingerprint = newFingerprint; this.mapController.updateMarkers(allPlaces); } } } catch (err) { if (!abortController.signal.aborted && !this.destroyed) { console.warn("[MapViewer] Geocoding failed:", err); } } } private applyGeoWriteBack( currentContent: string, geocodedPlaces: Place[] ): string { const currentPlaces = parsePlaces(currentContent); const lines = currentContent.split("\n"); // Build lookup: normalized name -> geocoded Place (first occurrence wins for coords) const geocodedByName = new Map(); for (const p of geocodedPlaces) { if (p.lat != null && p.lng != null) { const key = p.name.trim().toLowerCase(); if (!geocodedByName.has(key)) { geocodedByName.set(key, p); } } } const operations: Array<{ type: "insert" | "replace"; lineIndex: number; content: string; }> = []; for (const currentPlace of currentPlaces) { const normalizedName = currentPlace.name.trim().toLowerCase(); const geocoded = geocodedByName.get(normalizedName); if (!geocoded || geocoded.lat == null || geocoded.lng == null) continue; const geoLine = `\t* geo: ${geocoded.lat.toFixed(6)},${geocoded.lng.toFixed(6)}`; let existingGeoLine = -1; for (let i = currentPlace.startLine + 1; i <= currentPlace.endLine; i++) { if (GEO_LINE_RE.test(lines[i])) { existingGeoLine = i; break; } } if (existingGeoLine >= 0) { operations.push({ type: "replace", lineIndex: existingGeoLine, content: geoLine, }); } else { operations.push({ type: "insert", lineIndex: currentPlace.endLine, content: geoLine, }); } } operations.sort((a, b) => b.lineIndex - a.lineIndex); for (const op of operations) { if (op.type === "replace") { lines[op.lineIndex] = op.content; } else { lines.splice(op.lineIndex + 1, 0, op.content); } } return lines.join("\n"); } private startCursorSync(): void { const intervalId = window.setInterval(() => { this.pollCursorPosition(); }, CURSOR_POLL_MS); this.registerInterval(intervalId); } private pollCursorPosition(): void { if (!this.mapController || this.currentPlaces.length === 0) return; const mdView = this.app.workspace.getActiveViewOfType(MarkdownView); if (!mdView || !mdView.editor) return; const cursor = mdView.editor.getCursor(); const cursorLine = cursor.line; for (const place of this.currentPlaces) { if (cursorLine >= place.startLine && cursorLine <= place.endLine) { // Skip redundant selectPlace calls when cursor hasn't moved to a new place if (this.lastSelectedStartLine === place.startLine) return; this.lastSelectedStartLine = place.startLine; this.mapController.selectPlace(place); return; } } // Deselect — cursor is in a dead zone if (this.lastSelectedStartLine !== null) { this.lastSelectedStartLine = null; this.mapController.selectPlace(null); } } private handlePlaceSelect(places: Place[]): void { if (!places || places.length === 0) return; const mdView = this.app.workspace.getActiveViewOfType(MarkdownView); if (!mdView || !mdView.editor) return; const firstPlace = places[0]; mdView.editor.setCursor({ line: firstPlace.startLine, ch: 0 }); mdView.editor.scrollIntoView( { from: { line: firstPlace.startLine, ch: 0 }, to: { line: firstPlace.startLine, ch: 0 }, }, true ); } private clearMap(): void { this.currentPlaces = []; this.lastFingerprint = null; this.lastSelectedStartLine = null; if (this.mapController) { this.mapController.updateMarkers([]); } } }