A wayfinder inspired map plugin for obisidian
at main 569 lines 20 kB view raw
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, "&amp;") 329 .replace(/</g, "&lt;") 330 .replace(/>/g, "&gt;") 331 .replace(/"/g, "&quot;") 332 .replace(/'/g, "&#39;"); 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 &copy; <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}