A wayfinder inspired map plugin for obisidian
1/**
2 * mapRenderer.ts — Leaflet Map (Effectful/DOM)
3 *
4 * Create and manage a Leaflet map with place markers, selection highlighting,
5 * and popups. Uses Stadia Maps Watercolor + CartoDB Light Labels tiles.
6 */
7
8import * as L from "leaflet";
9import type { Place } from "./parser";
10
11// ─── Types ────────────────────────────────────────────────────────────
12
13export interface MapCallbacks {
14 onPlaceSelect?: (places: Place[]) => void;
15}
16
17export interface MapController {
18 updateMarkers(places: Place[], fitBounds?: boolean): void;
19 selectPlace(place: Place | null): void;
20 fitBounds(): void;
21 invalidateSize(): void;
22 destroy(): void;
23}
24
25// ─── Constants ────────────────────────────────────────────────────────
26
27// Stamen Watercolor tiles hosted by the Smithsonian / Cooper Hewitt (free, no API key)
28const WATERCOLOR_URL =
29 "https://watercolormaps.collection.cooperhewitt.org/tile/watercolor/{z}/{x}/{y}.jpg";
30// CartoDB light labels overlay (free, no API key)
31const LABELS_URL =
32 "https://{s}.basemaps.cartocdn.com/light_only_labels/{z}/{x}/{y}{r}.png";
33
34const DEFAULT_CENTER: L.LatLngExpression = [20, 0];
35const DEFAULT_ZOOM = 2;
36const SINGLE_MARKER_ZOOM = 13;
37
38// ─── Leaflet CSS Injection ────────────────────────────────────────────
39
40/** Leaflet CSS as a minimal inline string — injected into document.head once. */
41const LEAFLET_CSS_ID = "leaflet-css";
42
43function injectLeafletCSS(): void {
44 if (document.getElementById(LEAFLET_CSS_ID)) return;
45
46 const style = document.createElement("style");
47 style.id = LEAFLET_CSS_ID;
48 // Structural Leaflet CSS only — positioning, z-index, overflow, cursors.
49 // All cosmetic/visual styles (colors, backgrounds, shadows, animations)
50 // are owned by styles.css for Obsidian theme compatibility.
51 style.textContent = `
52 .leaflet-pane,
53 .leaflet-tile,
54 .leaflet-marker-icon,
55 .leaflet-marker-shadow,
56 .leaflet-tile-container,
57 .leaflet-pane > svg,
58 .leaflet-pane > canvas,
59 .leaflet-zoom-box,
60 .leaflet-image-layer,
61 .leaflet-layer { position: absolute; left: 0; top: 0; }
62 .leaflet-container { overflow: hidden; -webkit-tap-highlight-color: transparent; }
63 .leaflet-tile, .leaflet-marker-icon, .leaflet-marker-shadow { user-select: none; -webkit-user-select: none; }
64 .leaflet-tile::selection { background: transparent; }
65 .leaflet-safari .leaflet-tile { image-rendering: -webkit-optimize-contrast; }
66 .leaflet-tile { filter: inherit; visibility: hidden; }
67 .leaflet-tile-loaded { visibility: inherit; }
68 .leaflet-zoom-box { width: 0; height: 0; box-sizing: border-box; z-index: 800; }
69 .leaflet-overlay-pane svg { -moz-user-select: none; }
70 .leaflet-pane { z-index: 400; }
71 .leaflet-tile-pane { z-index: 200; }
72 .leaflet-overlay-pane { z-index: 400; }
73 .leaflet-shadow-pane { z-index: 500; }
74 .leaflet-marker-pane { z-index: 600; }
75 .leaflet-tooltip-pane { z-index: 650; }
76 .leaflet-popup-pane { z-index: 700; }
77 .leaflet-map-pane canvas { z-index: 100; }
78 .leaflet-map-pane svg { z-index: 200; }
79 .leaflet-control { position: relative; z-index: 800; pointer-events: visiblePainted; pointer-events: auto; }
80 .leaflet-top, .leaflet-bottom { position: absolute; z-index: 1000; pointer-events: none; }
81 .leaflet-top { top: 0; }
82 .leaflet-right { right: 0; }
83 .leaflet-bottom { bottom: 0; }
84 .leaflet-left { left: 0; }
85 .leaflet-popup-content-wrapper { padding: 1px; text-align: left; }
86 .leaflet-popup-tip-container { width: 40px; height: 20px; position: absolute; left: 50%; margin-left: -20px; overflow: hidden; pointer-events: none; }
87 .leaflet-popup-tip { width: 17px; height: 17px; padding: 1px; margin: -10px auto 0; transform: rotate(45deg); }
88 .leaflet-popup-close-button { position: absolute; top: 0; right: 0; border: none; text-align: center; width: 24px; height: 24px; font: 16px/24px Tahoma, Verdana, sans-serif; text-decoration: none; background: transparent; cursor: pointer; }
89 .leaflet-popup { position: absolute; text-align: center; margin-bottom: 20px; }
90 .leaflet-control-zoom a { width: 30px; height: 30px; line-height: 30px; display: block; text-align: center; text-decoration: none; font: bold 18px 'Lucida Console', Monaco, monospace; cursor: pointer; }
91 .leaflet-control-zoom-in { border-top-left-radius: 2px; border-top-right-radius: 2px; }
92 .leaflet-control-zoom-out { border-bottom-left-radius: 2px; border-bottom-right-radius: 2px; }
93 .leaflet-control-attribution { padding: 0 5px; }
94 .leaflet-control-attribution a { text-decoration: none; }
95 .leaflet-grab { cursor: grab; }
96 .leaflet-dragging .leaflet-grab { cursor: grabbing; }
97 .leaflet-fade-anim .leaflet-popup { opacity: 1; transition: opacity 0.2s linear; }
98 .leaflet-zoom-anim .leaflet-zoom-animated { transition: transform 0.25s cubic-bezier(0,0,0.25,1); }
99 `;
100 document.head.appendChild(style);
101}
102
103// ─── Color Utilities ──────────────────────────────────────────────────
104
105/**
106 * Parse any CSS color string to RGB components using a canvas 2d context.
107 * Handles hex, rgb(), rgba(), hsl(), hsla(), oklch(), named colors, etc.
108 * Falls back to regex parsing if canvas is unavailable (e.g., in tests).
109 * Returns null if the color cannot be parsed.
110 */
111function parseColorToRgb(color: string): { r: number; g: number; b: number } | null {
112 const trimmed = color.trim();
113 if (!trimmed) return null;
114
115 // Try canvas-based parsing first (handles all CSS color formats)
116 try {
117 const ctx = document.createElement("canvas").getContext("2d");
118 if (ctx) {
119 ctx.fillStyle = "#000000"; // reset to known value
120 ctx.fillStyle = trimmed;
121 // If the browser didn't recognize the color, fillStyle stays "#000000"
122 const result = ctx.fillStyle;
123 if (result === "#000000" && trimmed.toLowerCase() !== "#000000" && trimmed.toLowerCase() !== "black") {
124 // Color was not recognized — fall through to regex
125 } else {
126 // Parse the result (always #rrggbb or an rgb()/rgba() string)
127 const hexMatch = result.match(/^#([0-9a-f]{6})$/i);
128 if (hexMatch) {
129 return {
130 r: parseInt(hexMatch[1].substring(0, 2), 16),
131 g: parseInt(hexMatch[1].substring(2, 4), 16),
132 b: parseInt(hexMatch[1].substring(4, 6), 16),
133 };
134 }
135 const rgbMatch = result.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)/);
136 if (rgbMatch) {
137 return {
138 r: parseInt(rgbMatch[1], 10),
139 g: parseInt(rgbMatch[2], 10),
140 b: parseInt(rgbMatch[3], 10),
141 };
142 }
143 }
144 }
145 } catch {
146 // Canvas unavailable — fall through to regex
147 }
148
149 // Regex fallback for environments without canvas (tests, SSR)
150 const hex6Match = trimmed.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i);
151 if (hex6Match) {
152 return {
153 r: parseInt(hex6Match[1], 16),
154 g: parseInt(hex6Match[2], 16),
155 b: parseInt(hex6Match[3], 16),
156 };
157 }
158
159 const hex3Match = trimmed.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/i);
160 if (hex3Match) {
161 return {
162 r: parseInt(hex3Match[1] + hex3Match[1], 16),
163 g: parseInt(hex3Match[2] + hex3Match[2], 16),
164 b: parseInt(hex3Match[3] + hex3Match[3], 16),
165 };
166 }
167
168 const rgbMatch = trimmed.match(/^rgb\(\s*(\d{1,3})\s*[,\s]\s*(\d{1,3})\s*[,\s]\s*(\d{1,3})\s*\)$/i);
169 if (rgbMatch) {
170 const r = parseInt(rgbMatch[1], 10);
171 const g = parseInt(rgbMatch[2], 10);
172 const b = parseInt(rgbMatch[3], 10);
173 if (r <= 255 && g <= 255 && b <= 255) {
174 return { r, g, b };
175 }
176 }
177
178 return null;
179}
180
181/**
182 * Convert RGB to hex string.
183 */
184function rgbToHex(r: number, g: number, b: number): string {
185 const toHex = (n: number) =>
186 Math.max(0, Math.min(255, Math.round(n)))
187 .toString(16)
188 .padStart(2, "0");
189 return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
190}
191
192const FALLBACK_ACCENT = "#7b6cd9";
193
194/**
195 * Darken a CSS color by a percentage (0-100).
196 * Falls back to darkening the default accent if the color can't be parsed.
197 */
198function darkenColor(color: string, percent: number): string {
199 const rgb = parseColorToRgb(color);
200 if (!rgb) {
201 // Can't parse — darken the fallback instead
202 const fallback = parseColorToRgb(FALLBACK_ACCENT)!;
203 const factor = 1 - percent / 100;
204 return rgbToHex(fallback.r * factor, fallback.g * factor, fallback.b * factor);
205 }
206 const factor = 1 - percent / 100;
207 return rgbToHex(rgb.r * factor, rgb.g * factor, rgb.b * factor);
208}
209
210/**
211 * Get the accent color from Obsidian CSS variables, with a sensible fallback.
212 * Always returns a value that can be used in SVG fill/stroke attributes.
213 */
214function getAccentColor(): string {
215 try {
216 const style = getComputedStyle(document.body);
217 const accent = style.getPropertyValue("--interactive-accent").trim();
218 if (!accent) return FALLBACK_ACCENT;
219 // Validate that we can parse it — if not, return fallback hex
220 const rgb = parseColorToRgb(accent);
221 if (!rgb) return FALLBACK_ACCENT;
222 return rgbToHex(rgb.r, rgb.g, rgb.b);
223 } catch {
224 return FALLBACK_ACCENT;
225 }
226}
227
228// ─── SVG Pin Icon ─────────────────────────────────────────────────────
229
230/** Validate that a string is a safe hex color for SVG attributes. */
231function isHexColor(s: string): boolean {
232 return /^#[0-9a-f]{6}$/i.test(s);
233}
234
235/**
236 * Create an SVG teardrop pin icon for map markers.
237 */
238function createPinIcon(fillColor: string, strokeColor: string): L.DivIcon {
239 // Ensure colors are safe hex values to prevent SVG injection
240 const safeFill = isHexColor(fillColor) ? fillColor : FALLBACK_ACCENT;
241 const safeStroke = isHexColor(strokeColor) ? strokeColor : FALLBACK_ACCENT;
242
243 const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="36" viewBox="0 0 24 36" class="teardrop-pin">
244 <path d="M12 0C5.4 0 0 5.4 0 12c0 9 12 24 12 24s12-15 12-24C24 5.4 18.6 0 12 0z"
245 fill="${safeFill}" stroke="${safeStroke}" stroke-width="1.5"/>
246 <circle cx="12" cy="12" r="4" fill="white" opacity="0.9"/>
247 </svg>`;
248
249 return L.divIcon({
250 html: svg,
251 className: "map-viewer-pin",
252 iconSize: [24, 36] as L.PointExpression,
253 iconAnchor: [12, 36] as L.PointExpression,
254 popupAnchor: [0, -36] as L.PointExpression,
255 });
256}
257
258// ─── Marker Grouping ──────────────────────────────────────────────────
259
260interface MarkerGroup {
261 key: string;
262 lat: number;
263 lng: number;
264 places: Place[];
265}
266
267/**
268 * Group places by identical coordinates using toFixed(6) for comparison.
269 * Only includes places with both lat and lng defined.
270 */
271function groupPlacesByLocation(places: Place[]): MarkerGroup[] {
272 const groups = new Map<string, MarkerGroup>();
273
274 for (const place of places) {
275 if (place.lat == null || place.lng == null) continue;
276
277 const key = `${place.lat.toFixed(6)},${place.lng.toFixed(6)}`;
278
279 if (groups.has(key)) {
280 groups.get(key)!.places.push(place);
281 } else {
282 groups.set(key, {
283 key,
284 lat: place.lat,
285 lng: place.lng,
286 places: [place],
287 });
288 }
289 }
290
291 return Array.from(groups.values());
292}
293
294// ─── Popup Content ────────────────────────────────────────────────────
295
296/**
297 * Build popup HTML for a marker group.
298 * Each place is shown as a linked name (if url exists) or plain text.
299 */
300function buildPopupContent(places: Place[]): string {
301 const items = places.map((p) => {
302 if (p.url && isSafeUrl(p.url)) {
303 return `<a href="${escapeAttr(p.url)}" target="_blank" rel="noopener">${escapeHtml(p.name)}</a>`;
304 }
305 return escapeHtml(p.name);
306 });
307
308 return items.join("<br>");
309}
310
311/** Check if a URL is safe for use in href (reject javascript:, data:, vbscript:, etc.) */
312function isSafeUrl(url: string): boolean {
313 const trimmed = url.trim().toLowerCase();
314 // Only allow http and https
315 if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
316 return true;
317 }
318 // Reject protocol-relative URLs (//evil.com) and any explicit protocol
319 if (trimmed.startsWith("//") || /^[a-z][a-z0-9+.-]*:/i.test(trimmed)) {
320 return false;
321 }
322 // Allow relative URLs (no protocol)
323 return true;
324}
325
326function escapeHtml(text: string): string {
327 return text
328 .replace(/&/g, "&")
329 .replace(/</g, "<")
330 .replace(/>/g, ">")
331 .replace(/"/g, """)
332 .replace(/'/g, "'");
333}
334
335/**
336 * Escape a string for safe use in a double-quoted HTML attribute value.
337 * Only safe for `attr="..."` contexts — not single-quoted or unquoted attributes.
338 */
339function escapeAttr(text: string): string {
340 return escapeHtml(text);
341}
342
343// ─── createMap ────────────────────────────────────────────────────────
344
345export function createMap(
346 container: HTMLElement,
347 places: Place[],
348 callbacks: MapCallbacks
349): MapController {
350 // Contract 1: Inject Leaflet CSS (idempotent)
351 injectLeafletCSS();
352
353 // Contract 2: Create map div inside container
354 const mapDiv = document.createElement("div");
355 mapDiv.className = "map-viewer-map";
356 container.appendChild(mapDiv);
357
358 // Initialize Leaflet map
359 const map = L.map(mapDiv, {
360 zoomControl: true,
361 attributionControl: true,
362 });
363
364 // Contract 3: Add tile layers
365 const TILE_ATTRIBUTION =
366 'Map tiles by <a href="https://stamen.com/">Stamen Design</a>, ' +
367 'hosted by <a href="https://collection.cooperhewitt.org/">Cooper Hewitt</a>. ' +
368 'Labels by <a href="https://carto.com/">CARTO</a>. ' +
369 'Data © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>';
370
371 L.tileLayer(WATERCOLOR_URL, {
372 maxZoom: 18,
373 attribution: TILE_ATTRIBUTION,
374 }).addTo(map);
375
376 L.tileLayer(LABELS_URL, {
377 maxZoom: 18,
378 attribution: TILE_ATTRIBUTION,
379 subdomains: "abcd",
380 }).addTo(map);
381
382 // Internal state
383 let currentMarkers: L.Marker[] = [];
384 let currentGroups: MarkerGroup[] = [];
385 let highlightRing: L.CircleMarker | null = null;
386 let destroyed = false;
387
388 // Map from startLine to MarkerGroup for selectPlace lookup
389 let startLineToGroup = new Map<number, MarkerGroup>();
390 // Map from group key to L.Marker
391 let groupKeyToMarker = new Map<string, L.Marker>();
392
393 // ─── Marker Management ──────────────────────────────────────────
394
395 function clearMarkers(): void {
396 // Remove highlight ring
397 removeHighlight();
398
399 // Remove all markers
400 for (const marker of currentMarkers) {
401 marker.remove();
402 }
403 currentMarkers = [];
404 currentGroups = [];
405 startLineToGroup = new Map();
406 groupKeyToMarker = new Map();
407 }
408
409 function createMarkers(newPlaces: Place[]): void {
410 const accentColor = getAccentColor();
411 const strokeColor = darkenColor(accentColor, 25);
412 const icon = createPinIcon(accentColor, strokeColor);
413
414 const groups = groupPlacesByLocation(newPlaces);
415 currentGroups = groups;
416
417 // Build lookup maps
418 for (const group of groups) {
419 for (const place of group.places) {
420 startLineToGroup.set(place.startLine, group);
421 }
422 }
423
424 // Create markers
425 for (const group of groups) {
426 const marker = L.marker([group.lat, group.lng], { icon }).addTo(map);
427
428 // Contract 6: Popup
429 const popupContent = buildPopupContent(group.places);
430 marker.bindPopup(popupContent);
431
432 // Contract 6: Click handler
433 marker.on("click", (e: unknown) => {
434 L.DomEvent.stopPropagation(e as L.LeafletEvent);
435 callbacks.onPlaceSelect?.(group.places);
436 });
437
438 currentMarkers.push(marker);
439 groupKeyToMarker.set(group.key, marker);
440 }
441 }
442
443 function removeHighlight(): void {
444 if (highlightRing) {
445 highlightRing.remove();
446 highlightRing = null;
447 }
448 }
449
450 function applyFitBounds(): void {
451 if (currentGroups.length === 0) {
452 // Zero markers: show default world view
453 return; // View already set or preserve current
454 }
455
456 if (currentGroups.length === 1) {
457 // Single marker: center at zoom 13
458 const group = currentGroups[0];
459 map.setView({ lat: group.lat, lng: group.lng }, SINGLE_MARKER_ZOOM);
460 return;
461 }
462
463 // Multiple markers: fit bounds with padding
464 const bounds = L.latLngBounds(
465 currentGroups.map((g) => [g.lat, g.lng] as L.LatLngExpression)
466 );
467 map.fitBounds(bounds, { padding: [50, 50] });
468 }
469
470 // ─── Initial Setup ──────────────────────────────────────────────
471
472 // Create initial markers from provided places
473 createMarkers(places);
474
475 // Set initial view based on marker count
476 if (currentGroups.length === 0) {
477 map.setView(DEFAULT_CENTER, DEFAULT_ZOOM);
478 } else {
479 applyFitBounds();
480 }
481
482 // Contract 13: ResizeObserver
483 const resizeObserver = new ResizeObserver(() => {
484 if (!destroyed) {
485 map.invalidateSize();
486 }
487 });
488 resizeObserver.observe(container);
489
490 // ─── Controller ─────────────────────────────────────────────────
491
492 const controller: MapController = {
493 updateMarkers(newPlaces: Place[], fitBoundsArg?: boolean): void {
494 if (destroyed) return;
495
496 const shouldFit = fitBoundsArg !== false; // default true
497
498 clearMarkers();
499 createMarkers(newPlaces);
500
501 if (shouldFit) {
502 applyFitBounds();
503 }
504 },
505
506 selectPlace(place: Place | null): void {
507 if (destroyed) return;
508
509 // Remove existing highlight
510 removeHighlight();
511
512 if (place === null) return;
513
514 // Find the group for this place's startLine
515 const group = startLineToGroup.get(place.startLine);
516 if (!group) return;
517
518 const marker = groupKeyToMarker.get(group.key);
519 if (!marker) return;
520
521 // Pan/zoom to marker — never zoom out, only zoom in if needed
522 const targetZoom = Math.max(map.getZoom(), SINGLE_MARKER_ZOOM);
523 map.setView({ lat: group.lat, lng: group.lng }, targetZoom);
524
525 // Show highlight ring
526 highlightRing = L.circleMarker([group.lat, group.lng], {
527 radius: 20,
528 color: getAccentColor(),
529 weight: 3,
530 fillOpacity: 0,
531 className: "map-marker-highlight",
532 }).addTo(map);
533
534 // Open popup
535 marker.openPopup();
536 },
537
538 fitBounds(): void {
539 if (destroyed) return;
540 applyFitBounds();
541 },
542
543 invalidateSize(): void {
544 if (destroyed) return;
545 map.invalidateSize();
546 },
547
548 destroy(): void {
549 if (destroyed) return;
550 destroyed = true;
551
552 // Disconnect ResizeObserver
553 resizeObserver.disconnect();
554
555 // Clear all markers, highlight, and lookup maps
556 clearMarkers();
557
558 // Remove map
559 map.remove();
560
561 // Remove map div from container
562 if (mapDiv.parentNode) {
563 mapDiv.parentNode.removeChild(mapDiv);
564 }
565 },
566 };
567
568 return controller;
569}