Something went wrong. Try again.
A wayfinder inspired map plugin for obisidian
Something went wrong. Try again.
12 kB · 401 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402/** * 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<typeof setTimeout> | null = null; private destroyed = false; private lastSelectedStartLine: number | null = null; private inFlightRefresh: Promise<void> | null = null;
constructor(leaf: WorkspaceLeaf) { super(leaf); }
getViewType(): string { return VIEW_TYPE; }
getDisplayText(): string { return "Map"; }
getIcon(): string { return "map-pin"; }
async onOpen(): Promise<void> { 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<void> { 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<void> { // 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<void> { 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<string, Place>(); 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([]); } }}