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' }],
    ])
  })
})