How to Build a Leaflet Map with Dynamic Event Data
Interactive Maps for Event Data
I needed to build an interactive map that displays events from a REST API, with filtering by category, date range, and location. Leaflet is my go-to mapping library because it is lightweight, open-source, and works beautifully on mobile devices. Here is the complete implementation.
Basic Map Setup
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9/dist/leaflet.css" />
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster/dist/MarkerCluster.css" />
<style>
#map { height: 600px; width: 100%; }
.event-popup { max-width: 250px; }
.event-popup h3 { margin: 0 0 8px 0; font-size: 16px; }
.event-popup .date { color: #666; font-size: 13px; }
.event-popup .category {
display: inline-block; padding: 2px 8px;
border-radius: 4px; font-size: 12px;
background: #e3f2fd;
}
</style>
</head>
<body>
<div id="controls">
<select id="category-filter">
<option value="all">All Categories</option>
</select>
<input type="date" id="date-from" />
<input type="date" id="date-to" />
<button onclick="applyFilters()">Filter</button>
</div>
<div id="map"></div>
<script src="https://unpkg.com/leaflet@1.9/dist/leaflet.js"></script>
<script src="https://unpkg.com/leaflet.markercluster/dist/leaflet.markercluster.js"></script>
</body>
</html>
Initializing the Map
const map = L.map('map').setView([51.505, -0.09], 10);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors',
maxZoom: 19
}).addTo(map);
const markerCluster = L.markerClusterGroup({
maxClusterRadius: 50,
spiderfyOnMaxZoom: true,
showCoverageOnHover: false,
zoomToBoundsOnClick: true
});
map.addLayer(markerCluster);
Fetching and Displaying Event Data
let allEvents = [];
let activeMarkers = [];
async function loadEvents() {
try {
const response = await fetch('/api/events');
allEvents = await response.json();
populateCategoryFilter(allEvents);
displayEvents(allEvents);
} catch (error) {
console.error('Failed to load events:', error);
}
}
function displayEvents(events) {
markerCluster.clearLayers();
activeMarkers = [];
events.forEach(event => {
if (!event.latitude || !event.longitude) return;
const marker = L.marker([event.latitude, event.longitude], {
icon: getCategoryIcon(event.category)
});
const popupContent = `
<div class="event-popup">
<h3>${event.title}</h3>
<p class="date">${formatDate(event.date)}</p>
<span class="category">${event.category}</span>
<p>${event.description || ''}</p>
${event.url ? `<a href="${event.url}" target="_blank">More info</a>` : ''}
</div>
`;
marker.bindPopup(popupContent);
marker.eventData = event;
activeMarkers.push(marker);
markerCluster.addLayer(marker);
});
if (activeMarkers.length > 0) {
const group = L.featureGroup(activeMarkers);
map.fitBounds(group.getBounds().pad(0.1));
}
}
Custom Category Icons
const CATEGORY_COLORS = {
'music': '#e91e63',
'tech': '#2196f3',
'food': '#ff9800',
'sports': '#4caf50',
'art': '#9c27b0',
'default': '#607d8b'
};
function getCategoryIcon(category) {
const color = CATEGORY_COLORS[category.toLowerCase()] || CATEGORY_COLORS.default;
return L.divIcon({
className: 'custom-marker',
html: `<div style="
background: ${color};
width: 24px; height: 24px;
border-radius: 50%;
border: 3px solid white;
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
"></div>`,
iconSize: [24, 24],
iconAnchor: [12, 12],
popupAnchor: [0, -14]
});
}
Filtering
function populateCategoryFilter(events) {
const categories = [...new Set(events.map(e => e.category))];
const select = document.getElementById('category-filter');
categories.sort().forEach(cat => {
const option = document.createElement('option');
option.value = cat;
option.textContent = cat;
select.appendChild(option);
});
}
function applyFilters() {
const category = document.getElementById('category-filter').value;
const dateFrom = document.getElementById('date-from').value;
const dateTo = document.getElementById('date-to').value;
let filtered = allEvents;
if (category !== 'all') {
filtered = filtered.filter(e => e.category === category);
}
if (dateFrom) {
filtered = filtered.filter(e => e.date >= dateFrom);
}
if (dateTo) {
filtered = filtered.filter(e => e.date <= dateTo);
}
displayEvents(filtered);
updateResultCount(filtered.length);
}
function updateResultCount(count) {
document.getElementById('result-count').textContent =
`${count} event${count !== 1 ? 's' : ''} found`;
}
Real-Time Updates with Polling
class EventPoller {
constructor(interval = 30000) {
this.interval = interval;
this.lastUpdate = null;
this.polling = false;
}
start() {
this.polling = true;
this.poll();
}
stop() {
this.polling = false;
}
async poll() {
if (!this.polling) return;
try {
const url = this.lastUpdate
? `/api/events?updated_since=${this.lastUpdate}`
: '/api/events';
const response = await fetch(url);
const newEvents = await response.json();
if (newEvents.length > 0) {
this.mergeEvents(newEvents);
this.lastUpdate = new Date().toISOString();
}
} catch (error) {
console.error('Polling error:', error);
}
setTimeout(() => this.poll(), this.interval);
}
mergeEvents(newEvents) {
const existingIds = new Set(allEvents.map(e => e.id));
newEvents.forEach(event => {
if (existingIds.has(event.id)) {
const idx = allEvents.findIndex(e => e.id === event.id);
allEvents[idx] = event;
} else {
allEvents.push(event);
}
});
applyFilters();
}
}
const poller = new EventPoller(30000);
poller.start();
Mobile Optimization
Leaflet maps on mobile need special attention:
- Use
tap: truein map options for better touch handling - Increase marker sizes for touch targets (minimum 44x44 pixels)
- Implement a locate button so users can find events near them
- Reduce cluster radius on mobile to prevent overlapping tap targets
L.control.locate({
position: 'topright',
strings: { title: 'Find events near me' },
flyTo: true
}).addTo(map);
Performance for Large Datasets
When displaying thousands of events, performance optimization becomes critical:
- Marker clustering is essential for anything over 100 markers
- Load data progressively based on the current map viewport
- Debounce filter operations to prevent excessive re-rendering
- Use web workers for heavy data processing off the main thread
Leaflet with marker clustering handles 10,000+ events smoothly in my testing. The combination of server-side pagination, viewport-aware loading, and client-side clustering creates a map that feels responsive regardless of dataset size.