Something went wrong. Try again.
A wayfinder inspired map plugin for obisidian
Something went wrong. Try again.
20 kB · 569 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570/** * mapRenderer.ts — Leaflet Map (Effectful/DOM) * * Create and manage a Leaflet map with place markers, selection highlighting, * and popups. Uses Stadia Maps Watercolor + CartoDB Light Labels tiles. */
import * as L from "leaflet";import type { Place } from "./parser";
// ─── Types ────────────────────────────────────────────────────────────
export interface MapCallbacks { onPlaceSelect?: (places: Place[]) => void;}
export interface MapController { updateMarkers(places: Place[], fitBounds?: boolean): void; selectPlace(place: Place | null): void; fitBounds(): void; invalidateSize(): void; destroy(): void;}
// ─── Constants ────────────────────────────────────────────────────────
// Stamen Watercolor tiles hosted by the Smithsonian / Cooper Hewitt (free, no API key)const WATERCOLOR_URL = "https://watercolormaps.collection.cooperhewitt.org/tile/watercolor/{z}/{x}/{y}.jpg";// CartoDB light labels overlay (free, no API key)const LABELS_URL = "https://{s}.basemaps.cartocdn.com/light_only_labels/{z}/{x}/{y}{r}.png";
const DEFAULT_CENTER: L.LatLngExpression = [20, 0];const DEFAULT_ZOOM = 2;const SINGLE_MARKER_ZOOM = 13;
// ─── Leaflet CSS Injection ────────────────────────────────────────────
/** Leaflet CSS as a minimal inline string — injected into document.head once. */const LEAFLET_CSS_ID = "leaflet-css";
function injectLeafletCSS(): void { if (document.getElementById(LEAFLET_CSS_ID)) return;
const style = document.createElement("style"); style.id = LEAFLET_CSS_ID; // Structural Leaflet CSS only — positioning, z-index, overflow, cursors. // All cosmetic/visual styles (colors, backgrounds, shadows, animations) // are owned by styles.css for Obsidian theme compatibility. style.textContent = ` .leaflet-pane, .leaflet-tile, .leaflet-marker-icon, .leaflet-marker-shadow, .leaflet-tile-container, .leaflet-pane > svg, .leaflet-pane > canvas, .leaflet-zoom-box, .leaflet-image-layer, .leaflet-layer { position: absolute; left: 0; top: 0; } .leaflet-container { overflow: hidden; -webkit-tap-highlight-color: transparent; } .leaflet-tile, .leaflet-marker-icon, .leaflet-marker-shadow { user-select: none; -webkit-user-select: none; } .leaflet-tile::selection { background: transparent; } .leaflet-safari .leaflet-tile { image-rendering: -webkit-optimize-contrast; } .leaflet-tile { filter: inherit; visibility: hidden; } .leaflet-tile-loaded { visibility: inherit; } .leaflet-zoom-box { width: 0; height: 0; box-sizing: border-box; z-index: 800; } .leaflet-overlay-pane svg { -moz-user-select: none; } .leaflet-pane { z-index: 400; } .leaflet-tile-pane { z-index: 200; } .leaflet-overlay-pane { z-index: 400; } .leaflet-shadow-pane { z-index: 500; } .leaflet-marker-pane { z-index: 600; } .leaflet-tooltip-pane { z-index: 650; } .leaflet-popup-pane { z-index: 700; } .leaflet-map-pane canvas { z-index: 100; } .leaflet-map-pane svg { z-index: 200; } .leaflet-control { position: relative; z-index: 800; pointer-events: visiblePainted; pointer-events: auto; } .leaflet-top, .leaflet-bottom { position: absolute; z-index: 1000; pointer-events: none; } .leaflet-top { top: 0; } .leaflet-right { right: 0; } .leaflet-bottom { bottom: 0; } .leaflet-left { left: 0; } .leaflet-popup-content-wrapper { padding: 1px; text-align: left; } .leaflet-popup-tip-container { width: 40px; height: 20px; position: absolute; left: 50%; margin-left: -20px; overflow: hidden; pointer-events: none; } .leaflet-popup-tip { width: 17px; height: 17px; padding: 1px; margin: -10px auto 0; transform: rotate(45deg); } .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; } .leaflet-popup { position: absolute; text-align: center; margin-bottom: 20px; } .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; } .leaflet-control-zoom-in { border-top-left-radius: 2px; border-top-right-radius: 2px; } .leaflet-control-zoom-out { border-bottom-left-radius: 2px; border-bottom-right-radius: 2px; } .leaflet-control-attribution { padding: 0 5px; } .leaflet-control-attribution a { text-decoration: none; } .leaflet-grab { cursor: grab; } .leaflet-dragging .leaflet-grab { cursor: grabbing; } .leaflet-fade-anim .leaflet-popup { opacity: 1; transition: opacity 0.2s linear; } .leaflet-zoom-anim .leaflet-zoom-animated { transition: transform 0.25s cubic-bezier(0,0,0.25,1); } `; document.head.appendChild(style);}
// ─── Color Utilities ──────────────────────────────────────────────────
/** * Parse any CSS color string to RGB components using a canvas 2d context. * Handles hex, rgb(), rgba(), hsl(), hsla(), oklch(), named colors, etc. * Falls back to regex parsing if canvas is unavailable (e.g., in tests). * Returns null if the color cannot be parsed. */function parseColorToRgb(color: string): { r: number; g: number; b: number } | null { const trimmed = color.trim(); if (!trimmed) return null;
// Try canvas-based parsing first (handles all CSS color formats) try { const ctx = document.createElement("canvas").getContext("2d"); if (ctx) { ctx.fillStyle = "#000000"; // reset to known value ctx.fillStyle = trimmed; // If the browser didn't recognize the color, fillStyle stays "#000000" const result = ctx.fillStyle; if (result === "#000000" && trimmed.toLowerCase() !== "#000000" && trimmed.toLowerCase() !== "black") { // Color was not recognized — fall through to regex } else { // Parse the result (always #rrggbb or an rgb()/rgba() string) const hexMatch = result.match(/^#([0-9a-f]{6})$/i); if (hexMatch) { return { r: parseInt(hexMatch[1].substring(0, 2), 16), g: parseInt(hexMatch[1].substring(2, 4), 16), b: parseInt(hexMatch[1].substring(4, 6), 16), }; } const rgbMatch = result.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)/); if (rgbMatch) { return { r: parseInt(rgbMatch[1], 10), g: parseInt(rgbMatch[2], 10), b: parseInt(rgbMatch[3], 10), }; } } } } catch { // Canvas unavailable — fall through to regex }
// Regex fallback for environments without canvas (tests, SSR) const hex6Match = trimmed.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i); if (hex6Match) { return { r: parseInt(hex6Match[1], 16), g: parseInt(hex6Match[2], 16), b: parseInt(hex6Match[3], 16), }; }
const hex3Match = trimmed.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/i); if (hex3Match) { return { r: parseInt(hex3Match[1] + hex3Match[1], 16), g: parseInt(hex3Match[2] + hex3Match[2], 16), b: parseInt(hex3Match[3] + hex3Match[3], 16), }; }
const rgbMatch = trimmed.match(/^rgb\(\s*(\d{1,3})\s*[,\s]\s*(\d{1,3})\s*[,\s]\s*(\d{1,3})\s*\)$/i); if (rgbMatch) { const r = parseInt(rgbMatch[1], 10); const g = parseInt(rgbMatch[2], 10); const b = parseInt(rgbMatch[3], 10); if (r <= 255 && g <= 255 && b <= 255) { return { r, g, b }; } }
return null;}
/** * Convert RGB to hex string. */function rgbToHex(r: number, g: number, b: number): string { const toHex = (n: number) => Math.max(0, Math.min(255, Math.round(n))) .toString(16) .padStart(2, "0"); return `#${toHex(r)}${toHex(g)}${toHex(b)}`;}
const FALLBACK_ACCENT = "#7b6cd9";
/** * Darken a CSS color by a percentage (0-100). * Falls back to darkening the default accent if the color can't be parsed. */function darkenColor(color: string, percent: number): string { const rgb = parseColorToRgb(color); if (!rgb) { // Can't parse — darken the fallback instead const fallback = parseColorToRgb(FALLBACK_ACCENT)!; const factor = 1 - percent / 100; return rgbToHex(fallback.r * factor, fallback.g * factor, fallback.b * factor); } const factor = 1 - percent / 100; return rgbToHex(rgb.r * factor, rgb.g * factor, rgb.b * factor);}
/** * Get the accent color from Obsidian CSS variables, with a sensible fallback. * Always returns a value that can be used in SVG fill/stroke attributes. */function getAccentColor(): string { try { const style = getComputedStyle(document.body); const accent = style.getPropertyValue("--interactive-accent").trim(); if (!accent) return FALLBACK_ACCENT; // Validate that we can parse it — if not, return fallback hex const rgb = parseColorToRgb(accent); if (!rgb) return FALLBACK_ACCENT; return rgbToHex(rgb.r, rgb.g, rgb.b); } catch { return FALLBACK_ACCENT; }}
// ─── SVG Pin Icon ─────────────────────────────────────────────────────
/** Validate that a string is a safe hex color for SVG attributes. */function isHexColor(s: string): boolean { return /^#[0-9a-f]{6}$/i.test(s);}
/** * Create an SVG teardrop pin icon for map markers. */function createPinIcon(fillColor: string, strokeColor: string): L.DivIcon { // Ensure colors are safe hex values to prevent SVG injection const safeFill = isHexColor(fillColor) ? fillColor : FALLBACK_ACCENT; const safeStroke = isHexColor(strokeColor) ? strokeColor : FALLBACK_ACCENT;
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="36" viewBox="0 0 24 36" class="teardrop-pin"> <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" fill="${safeFill}" stroke="${safeStroke}" stroke-width="1.5"/> <circle cx="12" cy="12" r="4" fill="white" opacity="0.9"/> </svg>`;
return L.divIcon({ html: svg, className: "map-viewer-pin", iconSize: [24, 36] as L.PointExpression, iconAnchor: [12, 36] as L.PointExpression, popupAnchor: [0, -36] as L.PointExpression, });}
// ─── Marker Grouping ──────────────────────────────────────────────────
interface MarkerGroup { key: string; lat: number; lng: number; places: Place[];}
/** * Group places by identical coordinates using toFixed(6) for comparison. * Only includes places with both lat and lng defined. */function groupPlacesByLocation(places: Place[]): MarkerGroup[] { const groups = new Map<string, MarkerGroup>();
for (const place of places) { if (place.lat == null || place.lng == null) continue;
const key = `${place.lat.toFixed(6)},${place.lng.toFixed(6)}`;
if (groups.has(key)) { groups.get(key)!.places.push(place); } else { groups.set(key, { key, lat: place.lat, lng: place.lng, places: [place], }); } }
return Array.from(groups.values());}
// ─── Popup Content ────────────────────────────────────────────────────
/** * Build popup HTML for a marker group. * Each place is shown as a linked name (if url exists) or plain text. */function buildPopupContent(places: Place[]): string { const items = places.map((p) => { if (p.url && isSafeUrl(p.url)) { return `<a href="${escapeAttr(p.url)}" target="_blank" rel="noopener">${escapeHtml(p.name)}</a>`; } return escapeHtml(p.name); });
return items.join("<br>");}
/** Check if a URL is safe for use in href (reject javascript:, data:, vbscript:, etc.) */function isSafeUrl(url: string): boolean { const trimmed = url.trim().toLowerCase(); // Only allow http and https if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) { return true; } // Reject protocol-relative URLs (//evil.com) and any explicit protocol if (trimmed.startsWith("//") || /^[a-z][a-z0-9+.-]*:/i.test(trimmed)) { return false; } // Allow relative URLs (no protocol) return true;}
function escapeHtml(text: string): string { return text .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, """) .replace(/'/g, "'");}
/** * Escape a string for safe use in a double-quoted HTML attribute value. * Only safe for `attr="..."` contexts — not single-quoted or unquoted attributes. */function escapeAttr(text: string): string { return escapeHtml(text);}
// ─── createMap ────────────────────────────────────────────────────────
export function createMap( container: HTMLElement, places: Place[], callbacks: MapCallbacks): MapController { // Contract 1: Inject Leaflet CSS (idempotent) injectLeafletCSS();
// Contract 2: Create map div inside container const mapDiv = document.createElement("div"); mapDiv.className = "map-viewer-map"; container.appendChild(mapDiv);
// Initialize Leaflet map const map = L.map(mapDiv, { zoomControl: true, attributionControl: true, });
// Contract 3: Add tile layers const TILE_ATTRIBUTION = 'Map tiles by <a href="https://stamen.com/">Stamen Design</a>, ' + 'hosted by <a href="https://collection.cooperhewitt.org/">Cooper Hewitt</a>. ' + 'Labels by <a href="https://carto.com/">CARTO</a>. ' + 'Data © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>';
L.tileLayer(WATERCOLOR_URL, { maxZoom: 18, attribution: TILE_ATTRIBUTION, }).addTo(map);
L.tileLayer(LABELS_URL, { maxZoom: 18, attribution: TILE_ATTRIBUTION, subdomains: "abcd", }).addTo(map);
// Internal state let currentMarkers: L.Marker[] = []; let currentGroups: MarkerGroup[] = []; let highlightRing: L.CircleMarker | null = null; let destroyed = false;
// Map from startLine to MarkerGroup for selectPlace lookup let startLineToGroup = new Map<number, MarkerGroup>(); // Map from group key to L.Marker let groupKeyToMarker = new Map<string, L.Marker>();
// ─── Marker Management ──────────────────────────────────────────
function clearMarkers(): void { // Remove highlight ring removeHighlight();
// Remove all markers for (const marker of currentMarkers) { marker.remove(); } currentMarkers = []; currentGroups = []; startLineToGroup = new Map(); groupKeyToMarker = new Map(); }
function createMarkers(newPlaces: Place[]): void { const accentColor = getAccentColor(); const strokeColor = darkenColor(accentColor, 25); const icon = createPinIcon(accentColor, strokeColor);
const groups = groupPlacesByLocation(newPlaces); currentGroups = groups;
// Build lookup maps for (const group of groups) { for (const place of group.places) { startLineToGroup.set(place.startLine, group); } }
// Create markers for (const group of groups) { const marker = L.marker([group.lat, group.lng], { icon }).addTo(map);
// Contract 6: Popup const popupContent = buildPopupContent(group.places); marker.bindPopup(popupContent);
// Contract 6: Click handler marker.on("click", (e: unknown) => { L.DomEvent.stopPropagation(e as L.LeafletEvent); callbacks.onPlaceSelect?.(group.places); });
currentMarkers.push(marker); groupKeyToMarker.set(group.key, marker); } }
function removeHighlight(): void { if (highlightRing) { highlightRing.remove(); highlightRing = null; } }
function applyFitBounds(): void { if (currentGroups.length === 0) { // Zero markers: show default world view return; // View already set or preserve current }
if (currentGroups.length === 1) { // Single marker: center at zoom 13 const group = currentGroups[0]; map.setView({ lat: group.lat, lng: group.lng }, SINGLE_MARKER_ZOOM); return; }
// Multiple markers: fit bounds with padding const bounds = L.latLngBounds( currentGroups.map((g) => [g.lat, g.lng] as L.LatLngExpression) ); map.fitBounds(bounds, { padding: [50, 50] }); }
// ─── Initial Setup ──────────────────────────────────────────────
// Create initial markers from provided places createMarkers(places);
// Set initial view based on marker count if (currentGroups.length === 0) { map.setView(DEFAULT_CENTER, DEFAULT_ZOOM); } else { applyFitBounds(); }
// Contract 13: ResizeObserver const resizeObserver = new ResizeObserver(() => { if (!destroyed) { map.invalidateSize(); } }); resizeObserver.observe(container);
// ─── Controller ─────────────────────────────────────────────────
const controller: MapController = { updateMarkers(newPlaces: Place[], fitBoundsArg?: boolean): void { if (destroyed) return;
const shouldFit = fitBoundsArg !== false; // default true
clearMarkers(); createMarkers(newPlaces);
if (shouldFit) { applyFitBounds(); } },
selectPlace(place: Place | null): void { if (destroyed) return;
// Remove existing highlight removeHighlight();
if (place === null) return;
// Find the group for this place's startLine const group = startLineToGroup.get(place.startLine); if (!group) return;
const marker = groupKeyToMarker.get(group.key); if (!marker) return;
// Pan/zoom to marker — never zoom out, only zoom in if needed const targetZoom = Math.max(map.getZoom(), SINGLE_MARKER_ZOOM); map.setView({ lat: group.lat, lng: group.lng }, targetZoom);
// Show highlight ring highlightRing = L.circleMarker([group.lat, group.lng], { radius: 20, color: getAccentColor(), weight: 3, fillOpacity: 0, className: "map-marker-highlight", }).addTo(map);
// Open popup marker.openPopup(); },
fitBounds(): void { if (destroyed) return; applyFitBounds(); },
invalidateSize(): void { if (destroyed) return; map.invalidateSize(); },
destroy(): void { if (destroyed) return; destroyed = true;
// Disconnect ResizeObserver resizeObserver.disconnect();
// Clear all markers, highlight, and lookup maps clearMarkers();
// Remove map map.remove();
// Remove map div from container if (mapDiv.parentNode) { mapDiv.parentNode.removeChild(mapDiv); } }, };
return controller;}