import * as L from "leaflet"; import type { CalendarEvent } from "./parser"; /** * Read the Obsidian accent color from CSS custom properties. * Falls back to the default Obsidian purple if unavailable. */ function getAccentColor(): string { return getComputedStyle(document.body).getPropertyValue("--interactive-accent").trim() || "#7f6df2"; } /** * Darken a hex color by a given factor (0–1). Used for the pin stroke. */ function darkenHex(hex: string, factor: number): string { const h = hex.replace("#", ""); const r = Math.max(0, Math.round(parseInt(h.slice(0, 2), 16) * (1 - factor))); const g = Math.max(0, Math.round(parseInt(h.slice(2, 4), 16) * (1 - factor))); const b = Math.max(0, Math.round(parseInt(h.slice(4, 6), 16) * (1 - factor))); return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`; } /** * Create an SVG teardrop pin icon for map markers. * Uses the Obsidian accent color, or a muted grey for sold-out events. */ function createPinIcon(soldOut: boolean): L.DivIcon { const accent = getAccentColor(); const fill = soldOut ? "#999" : accent; const stroke = soldOut ? "#777" : darkenHex(accent, 0.25); const svg = `` + `` + `` + ``; return L.divIcon({ html: svg, className: "cal-pin-icon", iconSize: [28, 40], iconAnchor: [14, 40], popupAnchor: [0, -36], }); } export interface MapCallbacks { onMarkerClick: (event: CalendarEvent) => void; } export interface MapController { /** Update markers to reflect current events. Pass fitBounds=true to auto-zoom. */ updateMarkers(events: CalendarEvent[], fitBounds?: boolean): void; /** Select an event: pan to its marker, open popup, highlight it. */ selectEvent(event: CalendarEvent | null): void; /** Auto-fit map bounds to all current markers. */ fitBounds(): void; /** Call when container is resized. */ invalidateSize(): void; /** Clean up the Leaflet instance. */ destroy(): void; } // 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 TILE_ATTRIBUTION = 'Map tiles by Stamen Design, hosted by Cooper Hewitt. ' + 'Labels by CARTO. ' + 'Data © OpenStreetMap'; /** * Format a popup's HTML content for a list of events at the same location. */ function popupContent(events: CalendarEvent[]): string { return events .map((ev) => { const title = ev.url ? `${escapeHtml(ev.title)}` : escapeHtml(ev.title); const soldOut = ev.soldOut ? ' (Sold out)' : ""; const date = ev.date.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric", }); const time = ev.rawTime ? `, ${escapeHtml(ev.rawTime)}` : ""; const venue = ev.venue ? `
${escapeHtml(ev.venue)}` : ""; return `
${title}${soldOut}
${date}${time}${venue}
`; }) .join(""); } function escapeHtml(s: string): string { return s .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } /** * Group events by their lat/lng coordinates (rounded to avoid floating point issues). */ function groupByLocation(events: CalendarEvent[]): Map { const groups = new Map(); for (const ev of events) { if (ev.lat === undefined || ev.lng === undefined) continue; // Round to 6 decimal places for grouping const key = `${ev.lat.toFixed(6)},${ev.lng.toFixed(6)}`; if (!groups.has(key)) groups.set(key, []); groups.get(key)!.push(ev); } return groups; } /** * Create a Leaflet map in the given container and return a MapController. */ export function createMap(container: HTMLElement, events: CalendarEvent[], callbacks: MapCallbacks): MapController { // Inject Leaflet CSS if not already present if (!document.getElementById("leaflet-css")) { const link = document.createElement("style"); link.id = "leaflet-css"; link.textContent = leafletCss(); document.head.appendChild(link); } const mapDiv = container.createDiv({ cls: "cal-map" }); const map = L.map(mapDiv, { zoomControl: true, attributionControl: true, }); // Watercolor base layer L.tileLayer(WATERCOLOR_URL, { attribution: TILE_ATTRIBUTION, maxZoom: 19, }).addTo(map); // Labels overlay on top of watercolor L.tileLayer(LABELS_URL, { maxZoom: 19, subdomains: "abcd", pane: "overlayPane", }).addTo(map); // Track markers and their associated events const markerLayer = L.layerGroup().addTo(map); let markerMap = new Map(); let selectedMarker: L.Marker | null = null; let highlightCircle: L.CircleMarker | null = null; function clearHighlight(): void { if (highlightCircle) { highlightCircle.remove(); highlightCircle = null; } selectedMarker = null; } function highlightMarker(marker: L.Marker): void { clearHighlight(); selectedMarker = marker; const latlng = marker.getLatLng(); highlightCircle = L.circleMarker(latlng, { radius: 18, color: "var(--interactive-accent, #7b6cd9)", fillColor: "var(--interactive-accent, #7b6cd9)", fillOpacity: 0.2, weight: 2, className: "cal-marker-highlight", }).addTo(map); } function buildMarkers(evts: CalendarEvent[]): void { markerLayer.clearLayers(); markerMap.clear(); clearHighlight(); const groups = groupByLocation(evts); for (const [key, groupEvents] of groups) { const [lat, lng] = key.split(",").map(Number); const allSoldOut = groupEvents.every((e) => e.soldOut); const marker = L.marker([lat, lng], { icon: createPinIcon(allSoldOut), }); marker.bindPopup(popupContent(groupEvents), { maxWidth: 240, className: "cal-map-popup", }); marker.on("click", () => { highlightMarker(marker); // Fire callback for the first event in the group callbacks.onMarkerClick(groupEvents[0]); }); marker.addTo(markerLayer); markerMap.set(key, { marker, events: groupEvents }); } } buildMarkers(events); function doFitBounds(): void { const allMarkers = Array.from(markerMap.values()).map((m) => m.marker); if (allMarkers.length === 0) { // Default to a world view map.setView([20, 0], 2); return; } if (allMarkers.length === 1) { map.setView(allMarkers[0].getLatLng(), 13); return; } const group = L.featureGroup(allMarkers); map.fitBounds(group.getBounds().pad(0.15)); } // Initial fit after a tick (let the container settle) setTimeout(() => { map.invalidateSize(); doFitBounds(); }, 50); const controller: MapController = { updateMarkers(evts: CalendarEvent[], fit = true): void { buildMarkers(evts); if (fit) { doFitBounds(); } }, selectEvent(event: CalendarEvent | null): void { if (!event) { clearHighlight(); map.closePopup(); return; } if (event.lat === undefined || event.lng === undefined) return; const key = `${event.lat.toFixed(6)},${event.lng.toFixed(6)}`; const entry = markerMap.get(key); if (entry) { highlightMarker(entry.marker); entry.marker.openPopup(); map.panTo(entry.marker.getLatLng(), { animate: true }); } }, fitBounds(): void { doFitBounds(); }, invalidateSize(): void { map.invalidateSize(); }, destroy(): void { map.remove(); }, }; return controller; } /** * Inline Leaflet CSS — we inject this rather than importing a CSS file * so esbuild doesn't need a CSS loader and the plugin stays self-contained. * * This is the minified core Leaflet 1.9.4 CSS. */ function leafletCss(): string { // We'll load from the installed leaflet package at runtime via a simpler approach: // Import the CSS content as a string. Since esbuild can't handle CSS imports // from node_modules without a loader, we inline the essential Leaflet styles. return ` /* Leaflet 1.9.4 — essential styles */ .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; } .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-safari .leaflet-tile-container { width: 1600px; height: 1600px; -webkit-transform-origin: 0 0; } .leaflet-marker-icon, .leaflet-marker-shadow { display: block; } .leaflet-container .leaflet-overlay-pane svg { max-width: none !important; max-height: none !important; } .leaflet-container .leaflet-marker-pane img, .leaflet-container .leaflet-shadow-pane img, .leaflet-container .leaflet-tile-pane img, .leaflet-container img.leaflet-image-layer, .leaflet-container .leaflet-tile { max-width: none !important; max-height: none !important; width: auto; padding: 0; } .leaflet-container.leaflet-touch-zoom { touch-action: pan-x pan-y; } .leaflet-container.leaflet-touch-drag { touch-action: none; touch-action: pinch-zoom; } .leaflet-container.leaflet-touch-drag.leaflet-touch-zoom { touch-action: none; } .leaflet-container { -webkit-tap-highlight-color: transparent; } .leaflet-container a { -webkit-tap-highlight-color: rgba(51,181,229,0.4); } .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-vml-shape { width: 1px; height: 1px; } .lvml { behavior: url(#default#VML); display: inline-block; position: absolute; } .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-control { float: left; clear: both; } .leaflet-right .leaflet-control { float: right; } .leaflet-top .leaflet-control { margin-top: 10px; } .leaflet-bottom .leaflet-control { margin-bottom: 10px; } .leaflet-left .leaflet-control { margin-left: 10px; } .leaflet-right .leaflet-control { margin-right: 10px; } .leaflet-fade-anim .leaflet-popup { opacity: 1; transition: opacity 0.2s linear; } .leaflet-fade-anim .leaflet-map-pane .leaflet-popup { opacity: 1; } .leaflet-zoom-animated { transform-origin: 0 0; } .leaflet-zoom-anim .leaflet-zoom-animated { will-change: transform; transition: transform 0.25s cubic-bezier(0,0,0.25,1); } .leaflet-zoom-anim .leaflet-tile, .leaflet-pan-anim .leaflet-tile { transition: none; } .leaflet-zoom-anim .leaflet-zoom-hide { visibility: hidden; } .leaflet-interactive { cursor: pointer; } .leaflet-grab { cursor: grab; } .leaflet-crosshair, .leaflet-crosshair .leaflet-interactive { cursor: crosshair; } .leaflet-popup-pane, .leaflet-control { cursor: auto; } .leaflet-dragging .leaflet-grab, .leaflet-dragging .leaflet-grab .leaflet-interactive, .leaflet-dragging .leaflet-marker-draggable { cursor: move; cursor: grabbing; } .leaflet-marker-icon, .leaflet-marker-shadow, .leaflet-image-layer, .leaflet-pane > svg path, .leaflet-tile-container { pointer-events: none; } .leaflet-marker-icon.leaflet-interactive, .leaflet-image-layer.leaflet-interactive, .leaflet-pane > svg path.leaflet-interactive, svg.leaflet-image-layer.leaflet-interactive path { pointer-events: visiblePainted; pointer-events: auto; } .leaflet-container a.leaflet-active { outline: 2px solid orange; } .leaflet-zoom-box { border: 2px dotted #38f; background: rgba(255,255,255,0.5); } .leaflet-bar { box-shadow: 0 1px 5px rgba(0,0,0,0.65); border-radius: 4px; } .leaflet-bar a, .leaflet-bar a:hover { background-color: #fff; border-bottom: 1px solid #ccc; width: 26px; height: 26px; line-height: 26px; display: block; text-align: center; text-decoration: none; color: black; } .leaflet-bar a, .leaflet-control-layers-toggle { background-position: 50% 50%; background-repeat: no-repeat; display: block; } .leaflet-bar a:hover, .leaflet-bar a:focus { background-color: #f4f4f4; } .leaflet-bar a:first-child { border-top-left-radius: 4px; border-top-right-radius: 4px; } .leaflet-bar a:last-child { border-bottom-left-radius: 4px; border-bottom-right-radius: 4px; border-bottom: none; } .leaflet-bar a.leaflet-disabled { cursor: default; background-color: #f4f4f4; color: #bbb; } .leaflet-touch .leaflet-bar a { width: 30px; height: 30px; line-height: 30px; } .leaflet-touch .leaflet-bar a:first-child { border-top-left-radius: 2px; border-top-right-radius: 2px; } .leaflet-touch .leaflet-bar a:last-child { border-bottom-left-radius: 2px; border-bottom-right-radius: 2px; } .leaflet-control-zoom-in, .leaflet-control-zoom-out { font: bold 18px 'Lucida Console', Monaco, monospace; text-indent: 1px; } .leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out { font-size: 22px; } .leaflet-control-layers { box-shadow: 0 1px 5px rgba(0,0,0,0.4); background: #fff; border-radius: 5px; } .leaflet-control-layers-toggle { background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABoAAAAaCAQAAAADQ4RFAAACf0lEQVR4AY1TA5AVURDsmc22bdu2bdu2bdu2bdu2bZ3v3P3f1t2q7mL6AQBSNG03EMHQF8MwEiNxBmfxMz7FJ/iROI0zOINfCF2RC9lRG8MxFRdwSw0EcBu3cBEXcRt31ECq4AFMI3RFLVRHZ3RBf0zAHJzBDdzEHdzFfTzAQzzCYzzBUzzDc7zASwjBV3iDb/AO3+MHkiSf8dPf4mt8ha/xJb7AF/gcn+FTfIJP8DE+wof4AO/jPbyLd/A23sKbeAOv43W8hlfxCl7Gy3gJL+IFPIdncQ+P8RRP8ARP8BSvwGOAkBm50AsrsR+38BCv4BLO4DTu4h6e4SVe4CXe4CU+wPt4D+/iHbyNt/Am3sDreA2v4hW8jJfwIl7Ac3gWz+AuHuMpnuAJnuI5eMQjP4cLauA+buE6LuMCzuE0TuE8ruEObuMhHuEWHuABnuExnuIRnuAJnuEZ+FfFa3gVr+BlvISX8CJewPN4Ds/iGdzFYzzFEzzBUzzHSwjBV/gG3+I7fI8fCKLkM37+W3yNr/A1vsSX+AKf4zN8ik/wMT7Ch/gA7+M9vIt38DbewsN4C2/iDbyO1/EaXsUreBkv4SW8iBfwPJ7DU7iHx3iKx3iCJ3iGF+CRH+Ey7uEOLuMMzuMiruEGbuI2buMO7uE+HuIhHuMpHuMJnuI5XoJHvIJX8Rpexat4Ba/gZbyEl/ACnsdzeBbP4C4e4yme4Ame4hlewZ8+DJmRC30xAbtxDQ/xGu7gNE7hMu7gLu7jAR7iMZ7gKR7jCZ7hOV6AAG/hTbyBt/AWHsJbeAsP4x28i3fwNt7CG3gdr+E1vIpX8DJewot4Ac/hWTyNu3iEx3j67w8P/z9FWIJUIAAAAABJRU5ErkJggg==); width: 36px; height: 36px; } .leaflet-retina .leaflet-control-layers-toggle { background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADQAAAA0CAQAAABvcdNgAAAEsklEQVR4AWL4TydIhpZK1kAAAABJRU5ErkJggg==); background-size: 26px 26px; } .leaflet-touch .leaflet-control-layers-toggle { width: 44px; height: 44px; } .leaflet-control-layers .leaflet-control-layers-list, .leaflet-control-layers-expanded .leaflet-control-layers-toggle { display: none; } .leaflet-control-layers-expanded .leaflet-control-layers-list { display: block; position: relative; } .leaflet-control-layers-expanded { padding: 6px 10px 6px 6px; color: #333; background: #fff; } .leaflet-control-layers-scrollbar { overflow-y: scroll; overflow-x: hidden; padding-right: 5px; } .leaflet-control-layers-selector { margin-top: 2px; position: relative; top: 1px; } .leaflet-control-layers label { display: block; font-size: 13px; font-size: 1.08333em; } .leaflet-control-layers-separator { height: 0; border-top: 1px solid #ddd; margin: 5px -10px 5px -6px; } .leaflet-default-icon-path { background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABkAAAApCAYAAADAk4LOAAAFgUlEQVR4Aa1XA5BjWRTN2oW17d3YassW17LFNesR17LWtAnOez/J5tsupervised/cLOGeli2lGZjUtMPail370a57y6u6deuj75NAfO/6fOM0M0SUR4JYAAAAABJRU5ErkJggg==); } .leaflet-container .leaflet-control-attribution { background: #fff; background: rgba(255,255,255,0.8); margin: 0; } .leaflet-control-attribution, .leaflet-control-scale-line { padding: 0 5px; color: #333; line-height: 1.4; } .leaflet-control-attribution a { text-decoration: none; } .leaflet-control-attribution a:hover, .leaflet-control-attribution a:focus { text-decoration: underline; } .leaflet-attribution-flag { display: none !important; } .leaflet-left .leaflet-control-scale { margin-left: 5px; } .leaflet-bottom .leaflet-control-scale { margin-bottom: 5px; } .leaflet-control-scale-line { border: 2px solid #777; border-top: none; line-height: 1.1; padding: 2px 5px 1px; white-space: nowrap; overflow: hidden; box-sizing: border-box; background: rgba(255,255,255,0.5); } .leaflet-control-scale-line:not(:first-child) { border-top: 2px solid #777; border-bottom: none; margin-top: -2px; } .leaflet-control-scale-line:not(:first-child):not(:last-child) { border-bottom: 2px solid #777; } .leaflet-touch .leaflet-control-attribution, .leaflet-touch .leaflet-control-layers, .leaflet-touch .leaflet-bar { box-shadow: none; } .leaflet-touch .leaflet-control-layers, .leaflet-touch .leaflet-bar { border: 2px solid rgba(0,0,0,0.2); background-clip: padding-box; } .leaflet-popup { position: absolute; text-align: center; margin-bottom: 20px; } .leaflet-popup-content-wrapper { padding: 1px; text-align: left; border-radius: 12px; } .leaflet-popup-content { margin: 13px 24px 13px 20px; line-height: 1.3; font-size: 13px; font-size: 1.08333em; min-height: 1px; } .leaflet-popup-content p { margin: 17px 0; margin: 1.3em 0; } .leaflet-popup-tip-container { width: 40px; height: 20px; position: absolute; left: 50%; margin-top: -1px; margin-left: -20px; overflow: hidden; pointer-events: none; } .leaflet-popup-tip { width: 17px; height: 17px; padding: 1px; margin: -10px auto 0; pointer-events: auto; transform: rotate(45deg); } .leaflet-popup-content-wrapper, .leaflet-popup-tip { background: white; color: #333; box-shadow: 0 3px 14px rgba(0,0,0,0.4); } .leaflet-container a.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; color: #757575; text-decoration: none; background: transparent; } .leaflet-container a.leaflet-popup-close-button:hover, .leaflet-container a.leaflet-popup-close-button:focus { color: #585858; } .leaflet-popup-scrolled { overflow: auto; } .leaflet-oldie .leaflet-popup-content-wrapper { -ms-zoom: 1; } .leaflet-oldie .leaflet-popup-tip { width: 24px; margin: 0 auto; -ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)"; filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678); } .leaflet-oldie .leaflet-control-zoom, .leaflet-oldie .leaflet-control-layers, .leaflet-oldie .leaflet-popup-content-wrapper, .leaflet-oldie .leaflet-popup-tip { border: 1px solid #999; } .leaflet-div-icon { background: #fff; border: 1px solid #666; } .leaflet-tooltip { position: absolute; padding: 6px; background-color: #fff; border: 1px solid #fff; border-radius: 3px; color: #222; white-space: nowrap; user-select: none; pointer-events: none; box-shadow: 0 1px 3px rgba(0,0,0,0.4); } .leaflet-tooltip.leaflet-interactive { cursor: pointer; pointer-events: auto; } .leaflet-tooltip-top:before, .leaflet-tooltip-bottom:before, .leaflet-tooltip-left:before, .leaflet-tooltip-right:before { position: absolute; pointer-events: none; border: 6px solid transparent; background: transparent; content: ""; } .leaflet-tooltip-bottom { margin-top: 6px; } .leaflet-tooltip-top { margin-top: -6px; } .leaflet-tooltip-bottom:before, .leaflet-tooltip-top:before { left: 50%; margin-left: -6px; } .leaflet-tooltip-top:before { bottom: 0; margin-bottom: -12px; border-top-color: #fff; } .leaflet-tooltip-bottom:before { top: 0; margin-top: -12px; margin-left: -6px; border-bottom-color: #fff; } .leaflet-tooltip-left { margin-left: -6px; } .leaflet-tooltip-right { margin-left: 6px; } .leaflet-tooltip-left:before, .leaflet-tooltip-right:before { top: 50%; margin-top: -6px; } .leaflet-tooltip-left:before { right: 0; margin-right: -12px; border-left-color: #fff; } .leaflet-tooltip-right:before { left: 0; margin-left: -12px; border-right-color: #fff; } @media print { .leaflet-control { -webkit-print-color-adjust: exact; print-color-adjust: exact; } } `; }