| 3 min read

How to Build a Leaflet Map with Dynamic Event Data

Leaflet JavaScript mapping web development geospatial events

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: true in 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.