Skip to content

Map

Overview

The GoogleMapComponent is a Vue.js component designed for rendering a Google Map using the vue3-google-map library. It supports features like customizable markers, zoom controls, auto-centering, and geo-location. The component is highly flexible, allowing developers to set map options dynamically, update markers, and listen to user interactions on the map, such as clicks and zoom changes.

Key Features:

  • Displays a Google Map with customizable center and zoom.
  • Supports auto-centering based on user location.
  • Allows adding and removing markers, each with custom colors and info windows.
  • Emits events for interactions like zoom changes, map clicks, and marker updates.
  • Provides utility methods for marker management.

Usage Examples

Basic Usage Example

vue
<template>
  <GoogleMapComponent
    :center="{ lat: 37.7749, lng: -122.4194 }"
    :zoom="12"
    :markers="markers"
    @zoomed="handleZoomChange"
    @clicked="handleMapClick"
    @located="handleLocation"
    @marked="handleMarkerChange"
  />
</template>

<script setup>
import { ref } from 'vue'

const markers = ref([
  { lat: 37.7749, lng: -122.4194, title: 'San Francisco', color: 'red', info: 'Welcome to SF' },
])

function handleZoomChange(zoom) {
  console.log('Zoom level changed to:', zoom)
}

function handleMapClick(location) {
  console.log('Map clicked at:', location)
}

function handleLocation(location) {
  console.log('User located at:', location)
}

function handleMarkerChange(updatedMarkers) {
  console.log('Markers updated:', updatedMarkers)
}
</script>

Adding and Removing Markers

vue
<template>
  <GoogleMapComponent
    :center="{ lat: 40.7128, lng: -74.006 }"
    :zoom="10"
    :markers="markerList"
    @marked="handleMarkerUpdate"
  />
  <button @click="addMarker">Add Marker</button>
  <button @click="removeMarker">Remove Marker</button>
</template>

<script setup>
import { ref } from 'vue'

const markerList = ref([{ lat: 40.7128, lng: -74.006, title: 'New York', color: 'blue' }])

function addMarker() {
  markerList.value.push({ lat: 40.73061, lng: -73.935242, title: 'New Marker', color: 'green' })
}

function removeMarker() {
  markerList.value.pop()
}

function handleMarkerUpdate(updatedMarkers) {
  console.log('Markers updated:', updatedMarkers)
}
</script>

API Reference

Props

PropTypeDefaultDescription
center{ lat: number, lng: number }{ lat: 0, lng: 0 }Coordinates of the map center. Required.
zoomnumber10Initial zoom level for the map. Required.
markersArray<TBsbMapMarker>[]Array of markers with latitude, longitude, title, color, and info window.
autoCenterbooleantrueAutomatically centers the map based on user's geolocation.
autoLocationbooleantrueAttempts to get the user’s location upon mounting the component.

Emits

Event NamePayloadDescription
centered{ lat: number, lng: number }Emitted when the map center is changed.
zoomednumberEmitted when the zoom level changes.
located{ lat: number, lng: number }Emitted when the user's location is determined.
markedArray<TBsbMapMarker>Emitted when the markers are updated.
clicked{ lat: number, lng: number }Emitted when the map is clicked, with the location of the click.
loadingbooleanEmitted when the component is in a loading state (e.g., fetching geolocation).

Methods (Exposed via defineExpose)

Method NameDescription
getMarkersReturns the current array of markers.
setMarkersUpdates the marker list by adding new markers.
delMarkersRemoves specific markers from the marker list based on their coordinates.

Slots

None.

Types

ts
export type TBsbMapMarker = {
  lat: number
  lng: number
  title?: string
  color?: string
  info?: string
}

Source

  1. Install Dependencies
ps
npm install @types/google.maps
npm install vue3-google-map
  1. Add Google API Key to /.env.production and /.env.development.local
ini
VITE_GOOGLE_MAP_API_KEY = 'YOUR_GOOGLE_MAP_API_KEY'
  1. Create component and unit test.
Component @/components/VBsbMap.vue
vue
<template>
  <GoogleMap
    id="google-map"
    ref="googleMap"
    map-id="google-map"
    :api-key
    :center="localCenter"
    :zoom="localZoom"
    @zoom_changed="zoomChanged"
    @click="clickChanged"
    :auto-center
    :auto-location
  >
    <AdvancedMarker
      v-for="(marker, index) in localMarkers"
      :key="marker.title || index"
      :options="{ position: { lat: marker.lat, lng: marker.lng }, title: marker.title }"
      :pin-options="{ background: marker.color }"
    >
      <InfoWindow v-if="marker.info"><div v-html="marker.info"></div></InfoWindow>
    </AdvancedMarker>
    <v-overlay contained persistent v-model="loading" />
  </GoogleMap>
</template>

<script setup lang="ts">
import { GoogleMap, AdvancedMarker, InfoWindow } from 'vue3-google-map'

const apiKey = import.meta.env.VITE_GOOGLE_MAP_API_KEY

const googleMap: Ref<typeof GoogleMap | null> = ref(null)

const props = defineProps({
  center: {
    type: Object as PropType<{ lat: number; lng: number }>,
    default: () => ({ lat: 0, lng: 0 }),
    required: true,
  },
  zoom: {
    type: Number,
    default: 10,
    required: true,
  },
  markers: {
    type: Array as PropType<TBsbMapMarker[]>,
    default: () => [],
  },
  autoCenter: {
    type: Boolean,
    default: true,
  },
  autoLocation: {
    type: Boolean,
    default: true,
  },
})

const emits = defineEmits(['centered', 'zoomed', 'located', 'marked', 'clicked', 'loading'])

const localCenter = ref(props.center)
const setCenter = (newCenter: { lat: number; lng: number }) => {
  localCenter.value = newCenter
  emits('centered', localCenter.value)
}
watch(
  () => props.center,
  (newCenter: { lat: number; lng: number }) => {
    setCenter(newCenter)
  },
)

const localZoom = ref(props.zoom)
function zoomChanged() {
  const newZoom = googleMap.value?.map.getZoom() || 0
  const emit = localZoom.value !== newZoom
  localZoom.value = newZoom
  if (emit) emits('zoomed', localZoom.value)
}
watch(
  () => props.zoom,
  (newZoom: number) => {
    const emit = localZoom.value !== newZoom
    localZoom.value = newZoom
    if (emit) emits('zoomed', localZoom.value)
  },
)

const location = ref({ lat: 0, lng: 0 })

export type TBsbMapMarker = {
  lat: number
  lng: number
  title?: string
  color?: string
  info?: string
}

const localMarkers = ref<TBsbMapMarker[]>(props.markers)
const getMarkers = () => localMarkers.value
const setMarkers = (newMarkers: TBsbMapMarker[]) => {
  localMarkers.value = [
    ...new Map(
      [...localMarkers.value, ...newMarkers].map((marker) => [JSON.stringify(marker), marker]),
    ).values(),
  ]
  emits('marked', localMarkers.value)
}
const delMarkers = (newMarkers: TBsbMapMarker[]) => {
  localMarkers.value = localMarkers.value.filter(
    (marker) =>
      !newMarkers.some((newMarker) => marker.lat === newMarker.lat && marker.lng === newMarker.lng),
  )
  emits('marked', localMarkers.value)
}
watch(
  () => props.markers,
  (newMarkers: TBsbMapMarker[]) => {
    setMarkers(newMarkers)
  },
)

const clickChanged = (event: google.maps.MapMouseEvent) => {
  if (event.latLng) emits('clicked', { lat: event.latLng.lat(), lng: event.latLng.lng() })
}

const loading = ref(false)

defineExpose({
  loading,
  location,
  getMarkers,
  setMarkers,
  delMarkers,
})

onMounted(() => {
  if (!props.autoLocation) return
  loading.value = true
  emits('loading', loading.value)
  navigator.geolocation.getCurrentPosition(
    (position) => {
      location.value = {
        lat: position.coords.latitude,
        lng: position.coords.longitude,
      }
      emits('located', location.value)
      if (props.autoCenter) setCenter(location.value)
      loading.value = false
      emits('loading', loading.value)
    },
    (error) => {
      console.error(error)
      loading.value = false
      emits('loading', loading.value)
    },
    { timeout: 10000 },
  )
})
</script>

<style scoped>
#google-map {
  position: relative;
  width: 100%;
  height: 100%;
}
</style>
Test @/components/__tests__/VBsbMap.test.ts
ts
import { describe, it, expect, beforeEach } from 'vitest'
import { mount, VueWrapper } from '@vue/test-utils'
import VBsbMap from '../../components/VBsbMap.vue'
import { GoogleMap, AdvancedMarker, InfoWindow } from 'vue3-google-map'
import vuetify from '../../plugins/vuetify'
import type { TBsbMapMarker } from '../../components/VBsbMap.vue'

type VBsbMapComponent = InstanceType<typeof VBsbMap>

describe('VBsbMap.vue', () => {
  let wrapper: VueWrapper<VBsbMapComponent>

  beforeEach(() => {
    wrapper = mount(VBsbMap, {
      global: {
        components: {
          GoogleMap,
          AdvancedMarker,
          InfoWindow,
        },
        plugins: [vuetify],
      },
      props: {
        center: { lat: 37.7749, lng: -122.4194 },
        zoom: 12,
        markers: [
          { lat: 37.7749, lng: -122.4194, title: 'Marker 1', color: 'red', info: 'Marker 1 info' },
          { lat: 37.7849, lng: -122.4294, title: 'Marker 2', color: 'blue', info: 'Marker 2 info' },
        ],
        autoCenter: true,
        autoLocation: false,
      },
    })
  })

  it('renders GoogleMap with correct center and zoom', () => {
    const googleMap = wrapper.findComponent(GoogleMap)
    expect(googleMap.exists()).toBe(true)
    expect(googleMap.props('center')).toEqual({ lat: 37.7749, lng: -122.4194 })
    expect(googleMap.props('zoom')).toBe(12)
  })

  it('renders the correct number of markers', () => {
    const markers = wrapper.findAllComponents(AdvancedMarker)
    expect(markers.length).toBe(2)
    expect(markers[0].props('options')).toEqual({
      position: { lat: 37.7749, lng: -122.4194 },
      title: 'Marker 1',
    })
    expect(markers[1].props('options')).toEqual({
      position: { lat: 37.7849, lng: -122.4294 },
      title: 'Marker 2',
    })
  })

  it('emits "centered" when the map center changes', async () => {
    await wrapper.setProps({
      center: { lat: 40.7128, lng: -74.006 },
    })
    expect(wrapper.emitted('centered')).toBeTruthy()
    const emitted = wrapper.emitted('centered')
    expect(emitted?.[0]).toEqual([{ lat: 40.7128, lng: -74.006 }])
  })

  it('adds new markers correctly', async () => {
    const newMarkers: TBsbMapMarker[] = [
      { lat: 40.7128, lng: -74.006, title: 'New Marker', color: 'green' },
    ]
    wrapper.vm.setMarkers(newMarkers)
    await wrapper.vm.$nextTick()
    expect(wrapper.emitted('marked')).toBeTruthy()
    const emitted = wrapper.emitted('marked')
    expect(emitted?.[0]).toEqual([
      [
        { lat: 37.7749, lng: -122.4194, title: 'Marker 1', color: 'red', info: 'Marker 1 info' },
        { lat: 37.7849, lng: -122.4294, title: 'Marker 2', color: 'blue', info: 'Marker 2 info' },
        { lat: 40.7128, lng: -74.006, title: 'New Marker', color: 'green' },
      ],
    ])
  })

  it('deletes markers correctly', async () => {
    const markersToDelete: TBsbMapMarker[] = [{ lat: 37.7749, lng: -122.4194 }]
    wrapper.vm.delMarkers(markersToDelete)
    await wrapper.vm.$nextTick()
    expect(wrapper.emitted('marked')).toBeTruthy()
    const emitted = wrapper.emitted('marked')
    expect(emitted?.[0]).toEqual([
      [{ lat: 37.7849, lng: -122.4294, title: 'Marker 2', color: 'blue', info: 'Marker 2 info' }],
    ])
  })
})