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 ? `
` : "";
return `
`;
})
.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; } }
`;
}