Skip to content

EpMap

WARNING

In order to use EpMap in a Vite app, you must add this to vite.config.js:

js
optimizeDeps: {
  include: ['mapbox-gl'],
},

This is because Vite does not pre-bundle mapbox-gl by default, which can cause import issues*.

*Headaches galore

Props

NameDescriptionTypeDefault
accessToken-string-
mapCenter-array[-122.3321, 47.6062]
mapZoom-number12
mapStyle-string'mapbox://styles/mapbox/streets-v11'
mapSource-objectnull
mapLayer-objectnull
pinLocations-array[]
scrollZoom-booleantrue
navigationControl-booleantrue
fitToBounds-booleanfalse

Events

NameDescriptionPayload
centerChange--
dropPin--
zoomChange--

INFO

This component does not use slots.

Component Code

vue
<template>
  <div
    ref="epMapContainer"
    class="ep-map-container"
  >
    <div id="ep-map" />
  </div>
</template>

<script setup>
  import 'mapbox-gl/dist/mapbox-gl.css'

  import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'

  defineOptions({
    name: 'EpMap',
  })

  const props = defineProps({
    accessToken: {
      type: String,
      required: true
    },
    mapCenter: {
      type: Array,
      default: () => [-122.3321, 47.6062]
    },
    mapZoom: {
      type: Number,
      default: 12
    },
    mapStyle: {
      type: String,
      default: 'mapbox://styles/mapbox/streets-v11'
    },
    mapSource: {
      type: Object,
      default: null
    },
    mapLayer: {
      type: Object,
      default: null
    },
    pinLocations: {
      type: Array,
      default: () => []
    },
    scrollZoom: {
      type: Boolean,
      default: true
    },
    navigationControl: {
      type: Boolean,
      default: true
    },
    fitToBounds: {
      type: Boolean,
      default: false
    }
  })

  const emit = defineEmits(['centerChange', 'dropPin', 'zoomChange'])

  const init = ref(true)
  const map = ref(null)
  const markers = ref([])
  let mapboxgl = null

  watch(() => props.mapCenter, (newCenter) => {
    emit('centerChange', newCenter)
    flyTo(newCenter, props.mapZoom)
  })

  watch(() => props.mapZoom, (newZoom) => {
    emit('zoomChange', newZoom)
    flyTo(props.mapCenter, newZoom)
  })

  watch(() => props.mapStyle, (newStyle) => {
    map.value.setStyle(newStyle)
  })

  watch(() => props.pinLocations, () => {
    removeMarkers()
    addMarkers()
  })

  watch(() => props.scrollZoom, (newScrollZoom) => {
    if (newScrollZoom) {
      map.value.scrollZoom.enable()
    } else {
      map.value.scrollZoom.disable()
    }
  })

  // get a reference to the parent container
  const epMapContainer = ref(null)

  // create a new ResizeObserver instance
  const observer = new ResizeObserver(() => {
    if (map.value) {
      // Ensure map.value is initialized before calling resize
      nextTick(() => {
        map.value.resize()
      })
    }
  })

  onMounted(() => {
    loadMap().then(() => {
      // map layer
      if (props.mapSource) addSource(props.mapSource, props.mapLayer)
      // fit to bounds
      if (props.fitToBounds) {
        fitBounds(getBounds(props.mapSource.source.data.geometry.coordinates))
      }
      // if pin locations exist, add them
      if (props.pinLocations.length) addMarkers()
      init.value = false
    })

    // attach the observer to the container
    observer.observe(epMapContainer.value)
  })

  onBeforeUnmount(() => {
    observer.disconnect()

    if (map.value) {
      removeMarkers()

      if (map.value.getLayer('test')) map.value.removeLayer('test')
      if (map.value.getSource('test')) map.value.removeSource('test')
      map.value.remove()
    }
  })

  const loadMap = () => {
    return new Promise((resolve) => {
      // Perform the dynamic import and other async operations
      import('mapbox-gl').then((module) => {
        mapboxgl = module.default
        map.value = new mapboxgl.Map({
          accessToken: props.accessToken,
          container: 'ep-map',
          center: props.mapCenter,
          zoom: props.mapZoom,
          style: props.mapStyle,
        })

        // Various options
        if (!props.scrollZoom) map.value.scrollZoom.disable()
        if (props.navigationControl) map.value.addControl(new mapboxgl.NavigationControl())

        map.value.on('load', () => resolve())
        map.value.on('dragend', onDragEnd)
      })
    })
  }

  const addMarkers = () => {
    props.pinLocations.forEach((location) => {
      const marker = new mapboxgl.Marker().setLngLat(location).addTo(map.value)
      markers.value.push(marker)
    })
  }

  const removeMarkers = () => {
    markers.value.forEach((marker) => marker.remove())
    markers.value = []
  }

  const flyTo = (center = props.mapCenter, zoom = props.mapZoom) => {
    map.value.flyTo({
      center,
      zoom
    })
  }

  const onDragEnd = () => {
    const center = map.value.getCenter()
    emit('centerChange', [center.lng, center.lat])
  }

  const getBounds = (coordinates) => {
    return coordinates.reduce(
      (bounds, coord) => bounds.extend(coord),
      new mapboxgl.LngLatBounds(coordinates[0], coordinates[0])
    )
  }

  const fitBounds = (bounds) => {
    map.value.fitBounds(bounds, {
      linear: false,
      duration: 1000,
      padding: 60
    })
  }

  const addSource = (source, layer) => {
    map.value.addSource(source.id, source.source)
    map.value.addLayer(layer)
  }
</script>

Styles (SCSS)

scss
.ep-map-container {
  position: relative;
  width: 100%;
  height: 100%;
}

#ep-map {
  position: absolute;
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;
}