/** * 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 = ` `; 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(); 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 `${escapeHtml(p.name)}`; } return escapeHtml(p.name); }); return items.join("
"); } /** 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, "'"); } /** * 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 Stamen Design, ' + 'hosted by Cooper Hewitt. ' + 'Labels by CARTO. ' + 'Data © OpenStreetMap'; 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(); // Map from group key to L.Marker let groupKeyToMarker = new Map(); // ─── 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; }