/**
* 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;
}