LeafletJS Integration with Vue 3, TypeScript, and Vite

Introduction

Interactive maps have become essential components in many web applications today – from location-based services and real estate sites to weather applications and travel platforms. If you’re looking to add sophisticated mapping capabilities to your Vue application, LeafletJS offers one of the best solutions available.

In this comprehensive guide, I’ll walk you through integrating LeafletJS with a modern Vue 3 stack using TypeScript and Vite. This combination creates a powerful, type-safe, and developer-friendly foundation for building mapping applications.

Why This Stack?

This technology combination offers several distinct advantages:

  • LeafletJS provides a lightweight yet feature-rich mapping library with excellent documentation and a mature ecosystem. Its extensive range of plugins and customization options make it suitable for projects of all sizes.
  • Vue 3’s Composition API offers a flexible approach to component logic that makes complex map interactions more maintainable. The reactivity system pairs beautifully with dynamic maps that need to respond to user interactions and data changes.
  • TypeScript ensures type safety throughout your application, which is particularly valuable when working with complex geographic data and the extensive LeafletJS API.
  • Vite delivers an incredibly fast development experience with near-instantaneous hot module replacement, making the development workflow smoother when working with map components.

What We’ll Build

By the end of this guide, you’ll have a robust mapping application that:

  • Initializes and displays interactive maps with custom configurations
  • Manages markers with custom icons and popups
  • Handles map events appropriately
  • Integrates with Vue’s reactivity system
  • Maintains type safety throughout the application
  • Uses best practices for performance optimization

Whether you’re building a location finder, a data visualization tool, or just adding a simple map to your application, this guide will provide you with the foundation you need to create professional mapping experiences in your Vue applications.

Let’s begin by setting up our project environment and installing the necessary dependencies.

Initial Setup and Configuration

Setting up a Leaflet map in a Vue 3 project involves several key steps. Let’s walk through the process of creating a solid foundation for our mapping application.

Project Creation

First, we’ll create a new Vue 3 project using Vite’s TypeScript template:

npm create vite@latest my-map-app -- --template vue-ts
cd my-map-app
npm install

Next, we need to install Leaflet and its TypeScript definitions:

npm install leaflet @types/leaflet

Our project structure will look like this:

my-map-app/
├── src/
│   ├── components/
│   │   └── MapComponent.vue
│   ├── composables/
│   │   └── useMap.ts
│   ├── types/
│   │   └── map.ts
│   ├── App.vue
│   └── main.ts
└── package.json

Basic Configuration

Vite Configuration

Let’s set up our Vite configuration with path aliases for cleaner imports vite.config.js:

// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'

export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: {
      '@': resolve(__dirname, 'src')
    }
  }
})

This configuration enables us to use @/ as a shorthand for the src directory, making imports more concise and maintainable.

Environment Variables

Setting up environment variables helps manage configuration across different environments:

VITE_MAP_TILE_URL=https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png
VITE_MAP_ATTRIBUTION="© OpenStreetMap contributors"

These variables will be used for the map tile URL and attribution text, allowing us to change them easily without modifying code.

LeafletJS Integration

Installing TypeScript Definitions

The @types/leaflet package provides comprehensive TypeScript definitions for LeafletJS, enabling autocompletion and type checking when working with Leaflet objects and methods.

Configuring Marker Icons

One common issue with Leaflet in module environments is that the default marker icons don’t load correctly. We need to fix this by configuring the marker icons manually in our src/main.ts file:

// src/main.ts
import { createApp } from 'vue'
import App from './App.vue'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png'
import markerIcon from 'leaflet/dist/images/marker-icon.png'
import markerShadow from 'leaflet/dist/images/marker-shadow.png'

// Fix the missing icon issue
delete (L.Icon.Default.prototype as any)._getIconUrl
L.Icon.Default.mergeOptions({
  iconUrl: markerIcon,
  iconRetinaUrl: markerIcon2x,
  shadowUrl: markerShadow
})

createApp(App).mount('#app')

This code imports the necessary marker icon assets and configures Leaflet to use them for the default markers. We delete the original _getIconUrl method from the Icon.Default.prototype before setting the icon URLs to ensure our configurations are used.

Asset Handling in Vite

To ensure Leaflet’s CSS is properly loaded, we import it in our main.ts file:

import 'leaflet/dist/leaflet.css'

This stylesheet contains essential styling for Leaflet maps, controls, and markers. Importing it in main.ts ensures it’s available throughout the application.

With these configurations in place, we’ve laid the groundwork for integrating Leaflet with Vue 3 and TypeScript. Our environment is now set up to handle maps, markers, and other geographic features with proper typing and asset management.

Core Implementation

The heart of our LeafletJS integration is building reusable map components that leverage Vue 3’s Composition API and TypeScript for type safety. Let’s walk through implementing the essential components of our mapping application.

Basic Map Component

Example Leaflet.JS Map
Example Leaflet.JS Map

Let’s create a foundational map component in src/components/MapComponent.vue:

<template>
  <div ref="mapContainer" class="map-container"></div>
</template>

<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import L from 'leaflet'

const mapContainer = ref<HTMLElement | null>(null)
const map = ref<L.Map | null>(null)

onMounted(() => {
  if (mapContainer.value) {
    map.value = L.map(mapContainer.value).setView([51.505, -0.09], 13)
    
    L.tileLayer(import.meta.env.VITE_MAP_TILE_URL || 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
      attribution: import.meta.env.VITE_MAP_ATTRIBUTION || '© OpenStreetMap contributors'
    }).addTo(map.value)
  }
})

onUnmounted(() => {
  // Clean up the map instance when the component is destroyed
  map.value?.remove()
})
</script>

<style scoped>
.map-container {
  height: 400px;
  width: 100%;
}
</style>

This component:

  • Creates a container div for the map with appropriate styling
  • Uses Vue’s ref to store references to the DOM element and map instance
  • Initializes the map on component mount with a default center and zoom level
  • Adds a tile layer using our environment variables
  • Properly cleans up resources when the component is unmounted

TypeScript Integration

To enhance type safety and developer experience, let’s create type definitions for our map components:

// src/types/map.ts
import type { LatLngExpression, MapOptions } from 'leaflet'

export interface MapConfig {
  center: LatLngExpression;
  zoom: number;
  maxZoom?: number;
  minZoom?: number;
}

export interface MapMarker {
  id: number | string;
  position: LatLngExpression;
  popup?: string;
  icon?: L.Icon;
  draggable?: boolean;
}

export interface MapEvents {
  click: (e: L.LeafletMouseEvent) => void;
  moveend: () => void;
  zoomend: () => void;
}

Now, let’s update our map component to leverage these types src/components/MapComponent.vue:

<template>
  <div ref="mapContainer" class="map-container"></div>
</template>

<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue'
import L from 'leaflet'
import type { MapConfig, MapMarker } from '@/types/map'

const props = defineProps<{
  initialConfig: MapConfig;
  markers?: MapMarker[];
}>()

const emit = defineEmits<{
  (e: 'click', event: L.LeafletMouseEvent): void
  (e: 'update:center', center: L.LatLng): void
  (e: 'update:zoom', zoom: number): void
  (e: 'markerClick', marker: MapMarker): void
}>()

const mapContainer = ref<HTMLElement | null>(null)
const map = ref<L.Map | null>(null)

onMounted(() => {
  initializeMap()
})

onUnmounted(() => {
  map.value?.remove()
})

function initializeMap() {
  if (!mapContainer.value) return
  
  map.value = L.map(mapContainer.value).setView(
    props.initialConfig.center, 
    props.initialConfig.zoom
  )
  
  L.tileLayer(import.meta.env.VITE_MAP_TILE_URL || 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
    attribution: import.meta.env.VITE_MAP_ATTRIBUTION || '© OpenStreetMap contributors',
    maxZoom: props.initialConfig.maxZoom || 18,
    minZoom: props.initialConfig.minZoom || 1
  }).addTo(map.value)
  
  setupEventHandlers()
  updateMarkers()
}

function setupEventHandlers() {
  if (!map.value) return
  
  map.value.on('click', (e: L.LeafletMouseEvent) => {
    emit('click', e)
  })
  
  map.value.on('moveend', () => {
    if (map.value) {
      emit('update:center', map.value.getCenter())
    }
  })
  
  map.value.on('zoomend', () => {
    if (map.value) {
      emit('update:zoom', map.value.getZoom())
    }
  })
}

function updateMarkers() {
  if (!map.value || !props.markers) return
  
  props.markers.forEach(marker => {
    const leafletMarker = L.marker(marker.position)
      .addTo(map.value!)
    
    if (marker.popup) {
      leafletMarker.bindPopup(marker.popup)
    }
    
    leafletMarker.on('click', () => {
      emit('markerClick', marker)
    })
  })
}

// Watch for changes to markers and update the map
watch(() => props.markers, updateMarkers, { deep: true })
</script>

<style scoped>
.map-container {
  height: 400px;
  width: 100%;
}
</style>

Composition API Implementation

To make our map logic more reusable across components, let’s extract it into a composable function:

// src/composables/useMap.ts
import { ref, onUnmounted, type Ref } from 'vue'
import L from 'leaflet'
import type { MapConfig, MapMarker } from '@/types/map'

export function useMap(container: Ref<HTMLElement | null>) {
  const map = ref<L.Map | null>(null)
  const markers = ref<L.Marker[]>([])
  
  function initializeMap(config: MapConfig) {
    if (container.value && !map.value) {
      // Ensure config is defined and has required properties
      if (!config) {
        console.error('Map configuration is required')
        return
      }
      
      // Use default values if properties are missing
      const center = config.center || [0, 0]
      const zoom = config.zoom || 13
      const options = {
        maxZoom: config.maxZoom,
        minZoom: config.minZoom
      }
      
      map.value = L.map(container.value).setView(center, zoom, options)
      
      addTileLayer()
    }
  }
  
  function addTileLayer() {
    if (!map.value) return
    
    L.tileLayer(import.meta.env.VITE_MAP_TILE_URL || 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
      attribution: import.meta.env.VITE_MAP_ATTRIBUTION || '© OpenStreetMap contributors'
    }).addTo(map.value)
  }
  
  function addMarkers(items: MapMarker[]) {
    if (!map.value) return
    
    // Clear existing markers
    clearMarkers()
    
    // Add new markers
    items.forEach(item => {
      const marker = L.marker(item.position)
      
      if (item.popup) {
        marker.bindPopup(item.popup)
      }
      
      if (item.icon) {
        marker.setIcon(item.icon)
      }
      
      marker.addTo(map.value!)
      markers.value.push(marker)
    })
  }
  
  function clearMarkers() {
    markers.value.forEach(marker => marker.remove())
    markers.value = []
  }
  
  function setView(center: L.LatLngExpression, zoom: number) {
    map.value?.setView(center, zoom)
  }
  
  function getCurrentState() {
    if (!map.value) return null
    
    return {
      center: map.value.getCenter(),
      zoom: map.value.getZoom(),
      bounds: map.value.getBounds()
    }
  }
  
  const cleanupResources = () => {
    // Remove all event listeners
    if (map.value) {
      map.value.off()
      map.value.eachLayer(layer => {
        if ('off' in layer) {
          layer.off()
        }
      })
    }
    
    // Clear all layers
    clearMarkers()
    
    // Remove map
    if (map.value) {
      map.value.remove()
      map.value = null
    }
  }
  
  // Clean up on unmount
  onUnmounted(() => {
    cleanupResources()
  })
  
  return {
    map,
    markers,
    initializeMap,
    addMarkers,
    clearMarkers,
    setView,
    getCurrentState,
    cleanupResources
  }
}

Now, let’s update our src/components/MapComponent.vue to use this composable:

<template>
  <div ref="mapContainer" class="map-container"></div>
</template>

<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import type { MapConfig, MapMarker } from '@/types/map'
import { useMap } from '@/composables/useMap'
import L from 'leaflet'

const props = defineProps<{
  initialConfig: MapConfig;
  markers?: MapMarker[];
}>()

const emit = defineEmits<{
  (e: 'click', event: L.LeafletMouseEvent): void
  (e: 'update:center', center: L.LatLng): void
  (e: 'update:zoom', zoom: number): void
  (e: 'markerClick', marker: MapMarker): void
}>()

const mapContainer = ref<HTMLElement | null>(null)
const { map, initializeMap, addMarkers } = useMap(mapContainer)

onMounted(() => {
  initializeMap(props.initialConfig)
  setupEventHandlers()
  if (props.markers) {
    addMarkers(props.markers)
  }
})

function setupEventHandlers() {
  if (!map.value) return
  
  map.value.on('click', (e: L.LeafletMouseEvent) => {
    emit('click', e)
  })
  
  map.value.on('moveend', () => {
    if (map.value) {
      emit('update:center', map.value.getCenter())
    }
  })
  
  map.value.on('zoomend', () => {
    if (map.value) {
      emit('update:zoom', map.value.getZoom())
    }
  })
}

// Watch for changes to markers and update the map
watch(() => props.markers, (newMarkers) => {
  if (newMarkers) {
    addMarkers(newMarkers)
  }
}, { deep: true })
</script>

<style scoped>
.map-container {
  height: 400px;
  width: 100%;
}
</style>

With this implementation, we have:

  1. A reusable useMap composable that encapsulates map functionality
  2. Strong TypeScript typing for all map properties and events
  3. Reactive handling of props changes
  4. Proper cleanup of resources to prevent memory leaks
  5. Event handling that communicates map changes back to parent components

This core implementation provides a solid foundation for building more complex mapping features. The composable pattern makes it easy to reuse map logic across multiple components, and TypeScript ensures that we maintain type safety throughout our application.

Using the Map Component in Your Application

Now that we’ve built our MapComponent with TypeScript support and the useMap composable, let’s integrate it into our main application to display a working map.

<template>
  <div class="app-container">
    <h1>LeafletJS with Vue 3 and TypeScript</h1>
    <MapComponent 
      :initialConfig="mapConfig"
      :markers="mapMarkers"
      @click="handleMapClick"
      @markerClick="handleMarkerClick"
    />
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import MapComponent from './components/MapComponent.vue'
import type { MapConfig, MapMarker } from './types/map'
import L from 'leaflet'

// Map configuration
const mapConfig: MapConfig = {
  center: [51.505, -0.09], // London
  zoom: 13,
  maxZoom: 18,
  minZoom: 3
}

// Sample markers
const mapMarkers = ref<MapMarker[]>([
  {
    id: 1,
    position: [51.5, -0.09],
    popup: 'Hello, I am a marker!'
  },
  {
    id: 2,
    position: [51.51, -0.1],
    popup: 'Another interesting location'
  }
])

// Event handlers
const handleMapClick = (event: L.LeafletMouseEvent) => {
  console.log('Map clicked at:', event.latlng)
  
  // Add a new marker on click (optional)
  const newMarker: MapMarker = {
    id: Date.now(),
    position: [event.latlng.lat, event.latlng.lng],
    popup: `New marker at ${event.latlng.lat.toFixed(4)}, ${event.latlng.lng.toFixed(4)}`
  }
  
  mapMarkers.value.push(newMarker)
}

const handleMarkerClick = (marker: MapMarker) => {
  console.log('Marker clicked:', marker)
}
</script>

<style>
.app-container {
  max-width: 1200px;
  margin: 0 auto;
  padding: 20px;
}

h1 {
  text-align: center;
  margin-bottom: 20px;
}
</style>

With this implementation:

  1. We’ve imported our MapComponent and necessary types
  2. Set up a basic map configuration centered on London
  3. Created sample markers with popup information
  4. Added event handlers for map clicks and marker clicks
  5. Demonstrated how to dynamically add markers when clicking on the map

When you run the application with npm run dev, you’ll see a fully functional map displayed in your browser with the markers we defined. The map is interactive – you can zoom, pan, click on markers to see popups, and click anywhere on the map to add new markers.

Advanced Features

Now that we have a functional map component integrated into our application, let’s explore some advanced features that will enhance your mapping capabilities. This section focuses on practical enhancements you’ll likely need in real-world applications.

Understanding Vue 3 Composables

Before diving into advanced features, let’s briefly discuss the composable pattern we’re using. Vue 3’s Composition API allows us to extract and reuse stateful logic across components through functions called “composables.” These functions follow a convention of starting with “use” (e.g., useMapuseMarkers).

Composables allow us to:

  • Organize code by logical concerns rather than lifecycle hooks
  • Share complex functionality between components
  • Maintain reactivity with Vue’s reactivity system
  • Create cleaner, more maintainable code

Each composable typically contains:

  • Reactive state (using ref or reactive)
  • Functions that modify that state
  • Lifecycle hooks if needed (like onMounted or onUnmounted)
  • Return values exposing state and functions

With this understanding, let’s explore advanced mapping features through composables.

Interactive Features

Custom Controls

Adding custom controls to your map allows you to extend Leaflet’s functionality with your own UI elements src/composables/useMapControls.ts:

// src/composables/useMapControls.ts
import L from 'leaflet'
import { type Ref } from 'vue'

export function useMapControls(map: Ref<L.Map | null>) {
  const addCustomControl = (position: L.ControlPosition, content: string, className: string) => {
    if (!map.value) return null
    
    const CustomControl = L.Control.extend({
      onAdd: () => {
        const container = L.DomUtil.create('div', className)
        container.innerHTML = content
        
        // Prevent clicks from propagating to the map
        L.DomEvent.disableClickPropagation(container)
        
        return container
      }
    })
    
    const control = new CustomControl({ position })
    map.value.addControl(control)
    return control
  }

  return { addCustomControl }
}

Usage in your component:

<template>
  <div ref="mapContainer" class="map-container"></div>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useMap } from '@/composables/useMap'
import { useMapControls } from '@/composables/useMapControls'
import type { MapConfig } from '@/types/map'

const props = defineProps<{
  initialConfig: MapConfig;
}>()

const mapContainer = ref<HTMLElement | null>(null)
const { map, initializeMap } = useMap(mapContainer)
const { addCustomControl } = useMapControls(map)

onMounted(() => {
  initializeMap(props.initialConfig)
  
  addCustomControl('topright', '<button class="reset-btn">Center Map</button>', 'custom-control')
  
  // You can access and add event listeners to the button after it's created
  setTimeout(() => {
    const button = document.querySelector('.reset-btn')
    if (button) {
      button.addEventListener('click', () => {
        map.value?.setView(props.initialConfig.center, props.initialConfig.zoom)
      })
    }
  }, 0)
})
</script>

<style scoped>
.map-container {
  height: 400px;
  width: 100%;
}

:deep(.custom-control) {
  background-color: white;
  padding: 5px;
  border-radius: 4px;
  box-shadow: 0 1px 5px rgba(0,0,0,0.4);
}

:deep(.custom-control button) {
  padding: 6px 10px;
  border: none;
  background: #4285f4;
  color: white;
  cursor: pointer;
  border-radius: 3px;
}

:deep(.custom-control button:hover) {
  background: #3367d6;
}
</style>

Advanced Event Handling

Optimizing event handling with debouncing to improve performance when handling frequent map events:

// src/utils/functions.ts
export function debounce<T extends (...args: any[]) => any>(
  func: T, 
  wait: number
): (...args: Parameters<T>) => void {
  let timeout: number | null = null
  
  return function(...args: Parameters<T>) {
    if (timeout !== null) {
      clearTimeout(timeout)
    }
    
    timeout = window.setTimeout(() => {
      func(...args)
    }, wait)
  }
}
// src/composables/useMapEvents.ts
import L from 'leaflet'
import { type Ref } from 'vue'
import { debounce } from '@/utils/functions'

export function useMapEvents(map: Ref<L.Map | null>) {
  const setupDebouncedEvents = (events: Record<string, (e: L.LeafletEvent) => void>, delay = 300) => {
    if (!map.value) return
    
    Object.entries(events).forEach(([event, handler]) => {
      const debouncedHandler = debounce(handler, delay)
      map.value!.on(event, debouncedHandler)
    })
  }
  
  return { setupDebouncedEvents }
}

Now, here’s how to integrate this into your MapComponent:

<template>
  <div ref="mapContainer" class="map-container"></div>
</template>

<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import type { MapConfig, MapMarker } from '@/types/map'
import { useMap } from '@/composables/useMap'
import { useMapEvents } from '@/composables/useMapEvents'
import L from 'leaflet'

const props = defineProps<{
  initialConfig: MapConfig;
  markers?: MapMarker[];
}>()

const emit = defineEmits<{
  (e: 'click', event: L.LeafletMouseEvent): void
  (e: 'update:center', center: L.LatLng): void
  (e: 'update:zoom', zoom: number): void
  (e: 'markerClick', marker: MapMarker): void
  (e: 'moveend', bounds: L.LatLngBounds): void
}>()

const mapContainer = ref<HTMLElement | null>(null)
const { map, initializeMap, addMarkers } = useMap(mapContainer)
const { setupDebouncedEvents } = useMapEvents(map)

onMounted(() => {
  initializeMap(props.initialConfig)
  setupEventHandlers()
  if (props.markers) {
    addMarkers(props.markers)
  }
})

function setupEventHandlers() {
  if (!map.value) return
  
  // Regular event handling for click events (no debounce)
  map.value.on('click', (e: L.LeafletMouseEvent) => {
    emit('click', e)
  })
  
  // Setup debounced events for performance-sensitive operations
  setupDebouncedEvents({
    'moveend': () => {
      if (!map.value) return
      emit('update:center', map.value.getCenter())
      emit('moveend', map.value.getBounds())
    },
    'zoomend': () => {
      if (!map.value) return
      emit('update:zoom', map.value.getZoom())
    },
    'resize': () => {
      console.log('Map resized, updating layout...')
      map.value?.invalidateSize()
    }
  }, 250) // 250ms debounce delay
}

// Watch for changes to markers and update the map
watch(() => props.markers, (newMarkers) => {
  if (newMarkers) {
    addMarkers(newMarkers)
  }
}, { deep: true })
</script>

<style scoped>
.map-container {
  height: 400px;
  width: 100%;
}
</style>

Key Benefits of Debounced Event Handling

  1. Performance Improvement: By debouncing events like moveend and zoomend, you prevent excessive handler calls during user interactions like panning or zooming the map.
  2. Reduced Network Traffic: When map events trigger API calls or data fetching, debouncing reduces the number of requests sent during continuous operations like dragging the map.
  3. Smoother User Experience: Debouncing ensures UI updates happen at a reasonable rate, preventing choppy interactions or excessive DOM updates.
  4. Battery Life Preservation: On mobile devices, reducing the frequency of event handling can help preserve battery life during map interactions.

This approach keeps the MapComponent’s code clean while leveraging the power of composables to add sophisticated event handling. The debouncing implementation is particularly useful when your map interactions trigger expensive operations like API calls, state updates, or complex UI changes.

Data Management

GeoJSON Integration

Working with GeoJSON data is a common requirement for many mapping applications:

// src/composables/useGeoJson.ts
import L from 'leaflet'
import { ref, type Ref } from 'vue'

export function useGeoJson(map: Ref<L.Map | null>) {
  const geoJsonLayers = ref<L.GeoJSON[]>([])
  
  const addGeoJson = (data: any, options?: L.GeoJSONOptions) => {
    if (!map.value) return null
    
    const layer = L.geoJSON(data, options).addTo(map.value)
    geoJsonLayers.value.push(layer)
    return layer
  }
  
  const loadGeoJson = async (url: string, options?: L.GeoJSONOptions) => {
    try {
      const response = await fetch(url)
      if (!response.ok) throw new Error('Failed to load GeoJSON')
      
      const data = await response.json()
      return addGeoJson(data, options)
    } catch (error) {
      console.error('Error loading GeoJSON:', error)
      return null
    }
  }
  
  const clearGeoJson = () => {
    geoJsonLayers.value.forEach(layer => {
      if (map.value) {
        map.value.removeLayer(layer)
      }
    })
    geoJsonLayers.value = []
  }
  
  return { addGeoJson, loadGeoJson, clearGeoJson, geoJsonLayers }
}

Custom Marker Management

Creating and managing markers with custom icons and interactions:

// src/composables/useMarkers.ts
import L from 'leaflet'
import { ref, type Ref } from 'vue'
import type { MapMarker } from '@/types/map'

export function useMarkers(map: Ref<L.Map | null>) {
  const markers = ref<Record<string | number, L.Marker>>({})
  
  const createIcon = (options: L.IconOptions) => {
    return L.icon(options)
  }
  
  const addMarker = (marker: MapMarker) => {
    if (!map.value) return null
    
    // Create marker options object, only including icon if it exists
    const markerOptions: L.MarkerOptions = {}
    
    if (marker.draggable) {
      markerOptions.draggable = marker.draggable
    }
    
    // Only add icon to options if it's defined
    if (marker.icon) {
      markerOptions.icon = marker.icon
    }
    
    const leafletMarker = L.marker(marker.position, markerOptions).addTo(map.value)
    
    if (marker.popup) {
      leafletMarker.bindPopup(marker.popup)
    }
    
    markers.value[marker.id] = leafletMarker
    return leafletMarker
  }
  
  const removeMarker = (id: string | number) => {
    const marker = markers.value[id]
    if (marker && map.value) {
      map.value.removeLayer(marker)
      delete markers.value[id]
    }
  }
  
  const updateMarkerPosition = (id: string | number, position: L.LatLngExpression) => {
    const marker = markers.value[id]
    if (marker) {
      marker.setLatLng(position)
    }
  }
  
  return {
    markers,
    createIcon,
    addMarker,
    removeMarker,
    updateMarkerPosition
  }
}

Performance Optimization

Layer Groups and Clustering

Leaflet.JS Clustering
Leaflet.JS Clustering

Optimize performance when dealing with large numbers of markers using clustering:

Note: This requires installing the additional package: npm install leaflet.markercluster @types/leaflet.markercluster

// src/composables/useCluster.ts
import L from 'leaflet'
import 'leaflet.markercluster'
import { ref, type Ref } from 'vue'
import type { MapMarker } from '@/types/map'

export function useCluster(map: Ref<L.Map | null>) {
  const clusterGroup = ref<L.MarkerClusterGroup | null>(null)
  
  const initializeCluster = (options?: L.MarkerClusterGroupOptions) => {
    if (!map.value) return null
    
    clusterGroup.value = L.markerClusterGroup(options || {
      chunkedLoading: true,
      spiderfyOnMaxZoom: true,
      showCoverageOnHover: true,
      zoomToBoundsOnClick: true
    }).addTo(map.value)
    
    return clusterGroup.value
  }
  
  const addMarkersToCluster = (markers: MapMarker[]) => {
    if (!clusterGroup.value) {
      initializeCluster()
    }
    
    markers.forEach(marker => {
      // Create marker options object, only including icon if it exists
      const markerOptions: L.MarkerOptions = {}
      
      if (marker.draggable) {
        markerOptions.draggable = marker.draggable
      }
      
      // Only add icon to options if it's defined
      if (marker.icon) {
        markerOptions.icon = marker.icon
      }
      
      const leafletMarker = L.marker(marker.position, markerOptions)
      
      if (marker.popup) {
        leafletMarker.bindPopup(marker.popup)
      }
      
      clusterGroup.value?.addLayer(leafletMarker)
    })
  }
  
  const clearCluster = () => {
    clusterGroup.value?.clearLayers()
  }
  
  return {
    clusterGroup,
    initializeCluster,
    addMarkersToCluster,
    clearCluster
  }
}

Now create the component to display the new cluster map src/components/ClusterMapComponent.vue:

<template>
  <div>
    <div class="controls">
      <button @click="loadRandomMarkers(100)">Load 100 Markers</button>
      <button @click="loadRandomMarkers(1000)">Load 1000 Markers</button>
      <button @click="clearAllMarkers">Clear All</button>
    </div>
    <div ref="mapContainer" class="cluster-map"></div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useMap } from '@/composables/useMap'
import { useCluster } from '@/composables/useCluster'
import type { MapConfig, MapMarker } from '@/types/map'
import L from 'leaflet'

const mapContainer = ref<HTMLElement | null>(null)
const { map, initializeMap } = useMap(mapContainer)
const { initializeCluster, addMarkersToCluster, clearCluster } = useCluster(map)

const mapConfig: MapConfig = {
  center: [0, 0],
  zoom: 2
}

onMounted(() => {
  initializeMap(mapConfig)
  initializeCluster({
    maxClusterRadius: 50,
    disableClusteringAtZoom: 16
  })
})

// Generate random markers for demonstration
function loadRandomMarkers(count: number) {
  const markers: MapMarker[] = []
  
  for (let i = 0; i < count; i++) {
    // Generate random coordinates within a reasonable range
    const lat = (Math.random() * 180) - 90
    const lng = (Math.random() * 360) - 180
    
    markers.push({
      id: i,
      position: [lat, lng],
      popup: `Marker ${i} at ${lat.toFixed(4)}, ${lng.toFixed(4)}`
    })
  }
  
  addMarkersToCluster(markers)
}

function clearAllMarkers() {
  clearCluster()
}
</script>

<style>
.cluster-map {
  height: 600px;
  width: 100%;
}

.controls {
  margin-bottom: 10px;
  display: flex;
  gap: 10px;
}

.controls button {
  padding: 8px 16px;
  background: #4285f4;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

.controls button:hover {
  background: #3367d6;
}
</style>

Add the component to our application in src/App.vue:

<template>
  <div class="app-container">
    <h1>Marker Clustering Example</h1>
    <ClusterMapComponent />
  </div>
</template>

<script setup lang="ts">
import ClusterMapComponent from './components/ClusterMapComponent.vue'
</script>

The component demonstrates how to handle large numbers of markers efficiently using clustering. When you have many markers, they will be grouped into clusters based on the zoom level, significantly improving performance compared to rendering individual markers.

When a user zooms in, the clusters break apart into smaller clusters or individual markers, providing a smooth and responsive user experience even with thousands of points on the map.

This approach is particularly useful for applications that need to display large datasets like:

  • Store locations
  • User-generated content
  • Sensor data
  • Traffic incidents
  • Wildlife tracking

By using marker clustering, you can maintain good performance while displaying large volumes of geographical data on your map.

Practical Implementation Examples

Interactive Map with Search Functionality

Leaflet.JS map with search
LeafletJS Map with Search

Combine the previous features to create a map with search capabilities src/components/SearchMapComponent.vue:

<template>
  <div class="map-container">
    <div class="search-bar">
      <input 
        v-model="searchQuery" 
        type="text" 
        placeholder="Search locations..." 
        @keyup.enter="searchLocation"
      />
      <button @click="searchLocation">Search</button>
    </div>
    <div ref="mapContainer" class="map"></div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useMap } from '@/composables/useMap'
import { useMarkers } from '@/composables/useMarkers'
import type { MapConfig, MapMarker } from '@/types/map'
import L from 'leaflet'

const mapContainer = ref<HTMLElement | null>(null)
const { map, initializeMap } = useMap(mapContainer)
const { addMarker, removeMarker } = useMarkers(map)

const searchQuery = ref('')
const searchResult = ref<MapMarker | null>(null)

const mapConfig: MapConfig = {
  center: [51.505, -0.09],
  zoom: 13
}

onMounted(() => {
  initializeMap(mapConfig)
})

const searchLocation = async () => {
  if (!searchQuery.value.trim() || !map.value) return
  
  try {
    // Example using Nominatim search API (consider using a commercial API for production)
    const response = await fetch(
      `https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(searchQuery.value)}`
    )
    
    const data = await response.json()
    if (data && data.length > 0) {
      const result = data[0]
      
      // Remove previous search result marker
      if (searchResult.value) {
        removeMarker(searchResult.value.id)
      }
      
      // Create new marker for search result
      searchResult.value = {
        id: 'search-result',
        position: [parseFloat(result.lat), parseFloat(result.lon)],
        popup: result.display_name
      }
      
      // Add marker and center map
      const marker = addMarker(searchResult.value)
      marker?.openPopup()
      
      map.value.setView(searchResult.value.position as L.LatLngExpression, 16)
    } else {
      alert('No results found')
    }
  } catch (error) {
    console.error('Search error:', error)
    alert('Error searching for location')
  }
}
</script>

<style scoped>
.map-container {
  position: relative;
  height: 100%;
}

.search-bar {
  position: absolute;
  top: 20px;
  left: 50%;
  transform: translateX(-50%);
  z-index: 1000;
  display: flex;
  gap: 8px;
}

.search-bar input {
  padding: 8px 12px;
  min-width: 300px;
  border: 1px solid #ccc;
  border-radius: 4px;
}

.search-bar button {
  padding: 8px 16px;
  background: #4285f4;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

.map {
  height: 500px;
  width: 100%;
}
</style>

Update the src/App.vue to add the component to the application:

<template>
  <div class="app-container">
    <h1>Search Map Example</h1>
    <SearchMapComponent />
  </div>
</template>

<script setup lang="ts">
import SearchMapComponent from './components/SearchMapComponent.vue'
</script>

Dynamic Data Visualization

To do the dynamic data visualization we need to download some GeoJSON data:

mkdir -p public/data
curl -o public/data/us-states.json https://raw.githubusercontent.com/PublicaMundi/MappingAPI/refs/heads/master/data/geojson/us-states.json

Create a composable to help build our map that visualizes data with color-coded regions src/composables/useThematicMap.vue:

import L from 'leaflet'
import { type Ref } from 'vue'

export function useThematicMap(map: Ref<L.Map | null>) {
  const createColorScale = (min: number, max: number, colors: string[]) => {
    return (value: number) => {
      const range = max - min
      const step = range / (colors.length - 1)
      const index = Math.min(colors.length - 1, Math.floor((value - min) / step))
      return colors[index]
    }
  }
  
  const applyThematicStyle = (feature: any, getColor: (value: number) => string, valueProperty: string) => {
    const value = feature.properties[valueProperty]
    return {
      fillColor: getColor(value),
      weight: 1,
      opacity: 0.7,
      color: '#666',
      fillOpacity: 0.8
    }
  }
  
  const addThematicLayer = (geoJsonData: any, valueProperty: string, min: number, max: number, colors: string[]) => {
    if (!map.value) return null
    
    const getColor = createColorScale(min, max, colors)
    
    return L.geoJSON(geoJsonData, {
      style: (feature) => {
        return applyThematicStyle(feature, getColor, valueProperty)
      },
      onEachFeature: (feature, layer) => {
        layer.on({
          mouseover: (e) => {
            const layer = e.target
            layer.setStyle({
              weight: 3,
              opacity: 1
            })
            layer.bringToFront()
          },
          mouseout: (e) => {
            const feature = e.target.feature
            e.target.setStyle(applyThematicStyle(feature, getColor, valueProperty))
          }
        })
      }
    }).addTo(map.value)
  }
  
  const addLegend = (title: string, min: number, max: number, colors: string[], position = 'bottomright') => {
    if (!map.value) return null
    
    const legend = L.control({ position: position as L.ControlPosition })
    
    legend.onAdd = () => {
      const div = L.DomUtil.create('div', 'info legend')
      const grades = []
      const step = (max - min) / (colors.length - 1)
      
      for (let i = 0; i < colors.length; i++) {
        grades.push(Math.round(min + step * i))
      }
      
      div.innerHTML = `<h4>${title}</h4>`
      
      for (let i = 0; i < grades.length; i++) {
        div.innerHTML += 
          `<i style="background:${colors[i]}"></i> ${grades[i]}${
            i < grades.length - 1 ? ' &ndash; ' + grades[i + 1] + '<br>' : '+'
          }`
      }
      
      return div
    }
    
    legend.addTo(map.value)
    return legend
  }
  
  return { addThematicLayer, addLegend, createColorScale }
}

Now we need to create the mapping component src/components/ThematicMapComponent.vue:

<template>
  <div>
    <div ref="mapContainer" class="thematic-map"></div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useMap } from '@/composables/useMap'
import { useThematicMap } from '@/composables/useThematicMap'
import { useGeoJson } from '@/composables/useGeoJson'
import type { MapConfig } from '@/types/map'

const mapContainer = ref<HTMLElement | null>(null)
const { map, initializeMap } = useMap(mapContainer)
const { addThematicLayer, addLegend } = useThematicMap(map)
const { loadGeoJson } = useGeoJson(map)

const mapConfig: MapConfig = {
  center: [37.8, -96],
  zoom: 4,
  minZoom: 2,
  maxZoom: 10
}

onMounted(async () => {
  initializeMap(mapConfig)
  
  // Example: Load US States GeoJSON and create thematic visualization
  try {
    const response = await fetch('/data/us-states.json')
    const statesData = await response.json()
    
    // Population density visualization
    addThematicLayer(
      statesData, 
      'density',  // property in the GeoJSON to visualize
      1,          // min value
      1000,       // max value
      ['#FFEDA0', '#FED976', '#FEB24C', '#FD8D3C', '#FC4E2A', '#E31A1C', '#BD0026', '#800026'] // color scale
    )
    
    // Add a legend
    addLegend(
      'Population Density',
      1,
      1000,
      ['#FFEDA0', '#FED976', '#FEB24C', '#FD8D3C', '#FC4E2A', '#E31A1C', '#BD0026', '#800026'],
      'bottomright'
    )
  } catch (error) {
    console.error('Error loading GeoJSON:', error)
  }
})
</script>

<style>
.thematic-map {
  height: 600px;
  width: 100%;
}

.info.legend {
  background-color: white;
  padding: 8px;
  border-radius: 4px;
  box-shadow: 0 0 15px rgba(0,0,0,0.2);
  line-height: 18px;
  color: #555;
}

.legend h4 {
  margin: 0 0 5px;
  color: #777;
}

.legend i {
  width: 18px;
  height: 18px;
  float: left;
  margin-right: 8px;
  opacity: 0.7;
}
</style>

Now we need to add the comonent to our src/App.vue:

<template>
  <div class="app-container">
    <h1>Thematic Map Example</h1>
    <ThematicMapComponent />
  </div>
</template>

<script setup lang="ts">
import ThematicMapComponent from './components/ThematicMapComponent.vue'
</script>

Conclusion

In this comprehensive guide, we’ve explored how to integrate LeafletJS with Vue 3, TypeScript, and Vite to create powerful, type-safe mapping applications. We’ve covered:

  1. Project Setup: Creating a solid foundation with proper configuration and TypeScript support.
  2. Core Implementation: Building reusable map components using Vue 3’s Composition API with strong TypeScript typing.
  3. Advanced Features: Adding sophisticated capabilities like custom controls, event optimization, GeoJSON integration, and performance enhancements through clustering.
  4. Practical Examples: Demonstrating real-world use cases like search functionality and data visualization.

The composable pattern we’ve used throughout this guide provides a clean, maintainable approach to managing map functionality. By extracting reusable logic into composables like useMapuseMarkers, and useCluster, we’ve created a modular architecture that can scale with your application’s needs.

More From Author

You May Also Like