Nuxt.js: Auto-complete search with Google Maps API (TypeScript)

I had an opportunity to develop a travel reservation feature using Goole Maps auto-complete suggestion (GCP MAPs API) with Nuxt.js & TypeScript.
This post might help someone do the same development 🙂

Demo.

Here is the step to make it.

  1. Activate GCP Maps API
  2. Add the api key to nuxt-config.js
  3. Install load-google-maps-api
  4. Implement the Map component

Activate GCP Maps API

You need to activate the GCP to get the api key.

  1. Maps JavaScript API
  2. Places API

You can get the api key by activating those in Google Cloud Platform.

Please be careful about the access control on the key. (IP, host restriction)

Add the api key to nuxt-config.js

Please add the api key to nuxt-config.js as an environment variable.

module.exports = {
  ...
  env: {
    gmapKey: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
  },
  ...
}

Install load-google-maps-api

$ yarn add load-google-maps-api

or

$ npm i -S load-google-maps-api

If you are with TypeScript, please install @types/load-google-maps-api.

$ yarn add @types/load-google-maps-api

or

$ npm i -S @types/load-google-maps-api

Implement Map component

├── components
│   └── Map.vue
└── index.vue

index.vue

Simply index.vue just imports Map component.
The @onChangeLocation receives the location info from the Map component.

<template>
  <div class="container">
    <Map @onChangeLocation="updateLocation" />
  </div>
</template>

<script lang="ts">
import { Component, Vue } from 'nuxt-property-decorator'
import Map from '~/components/Map.vue'

type location = {
  name: string
  address: string
  coordinate: string
}

@Component({
  name: 'Index',
  components: {
    Map
  }
})
export default class Index extends Vue {
  private updateLocation(props: location) {
    console.log(props)
  }
}
</script>

<style>
/* Sample `apply` at-rules with Tailwind CSS
.container {
  @apply min-h-screen flex justify-center items-center text-center mx-auto;
}
*/
.container {
  margin: 0 auto;
  min-height: 100vh;
  display: flex;
  justify-content: center;
  align-items: center;
}
</style>

Map.vue

It is optional to setting the default location information.

<template>
  <section class="map w-full max-w-xl">
    <div class="flex flex-wrap">
      <div class="w-full">
        <label
          class="block uppercase tracking-wide text-gray-700 text-xs font-bold mb-2"
          for="grid-password"
        >
          Map search (Auto-complete)
        </label>
        <input
          ref="input"
          v-model="locationName"
          class="appearance-none block w-full bg-gray-200 text-gray-700 border border-gray-200 rounded py-3 px-4 mb-3 leading-tight focus:outline-none focus:bg-white focus:border-gray-500"
          type="text"
          placeholder="Serch word..."
        />
      </div>
    </div>
    <div ref="map" class="map-main" />
    <div ref="infoContent" class="map-info">
      <img :src="placeIcon" class="map-info__icon" />
      <span class="map-info__title">{{ placeName }}</span>
      <br />
      <span>{{ placeAddress }}</span>
    </div>
  </section>
</template>

<script lang="ts">
import { Component, Vue } from 'nuxt-property-decorator'
import loadGoogleMapsApi from 'load-google-maps-api'

type coord = {
  lat: number
  lng: number
}

@Component({
  name: 'Map'
})
export default class Map extends Vue {
  // Component variables
  private locationName: string = ''
  private locationAddress: string = ''
  private locationCoordinate!: coord
  private placeIcon: string = ''
  private placeName: string = ''
  private placeAddress: string = ''
  // Goole map variables
  private gmap: any = {}
  private map: any
  private mapService: any
  private mapAutoComplete: any
  private mapInfoWindow: any
  private mapMarker: any
  private defaultZoom: number = 14.0
  private coord: coord = {
    lat: 32.80093,
    lng: 130.70064
  }

  mounted() {
    this.initMap()
  }

  private async initMap() {
    this.gmap = await loadGoogleMapsApi({
      key: process.env.gmapKey,
      libraries: ['places'],
      language: 'en'
    })
    // Initialize
    this.mapAutoComplete = new this.gmap.places.Autocomplete(this.$refs.input)
    this.map = new this.gmap.Map(this.$refs.map, {
      center: new this.gmap.LatLng(this.coord.lat, this.coord.lng),
      zoom: this.defaultZoom,
      mapTypeControl: false,
      streetViewControl: false
    })

    this.mapInfoWindow = new this.gmap.InfoWindow()

    this.mapMarker = new this.gmap.Marker({
      map: this.map
    })

    // For auto-complete
    this.initAutoComplete()

    // For getting map info in clicking map
    this.mapService = new this.gmap.places.PlacesService(this.map)
    this.map.addListener('click', this.handleClickPOI.bind(this))
  }

  private initAutoComplete() {
    this.mapAutoComplete.bindTo('bounds', this.map)
    this.mapAutoComplete.setFields([
      'geometry',
      'icon',
      'name',
      'formatted_address'
    ])

    this.mapInfoWindow.setContent(this.$refs.infoContent)

    this.mapAutoComplete.addListener('place_changed', () => {
      this.onClickLocation()
    })
  }

  private onClickLocation() {
    this.mapInfoWindow.close()
    this.mapMarker.setVisible(false)
    const place = this.mapAutoComplete.getPlace()

    if (place.geometry.viewport) {
      this.map.fitBounds(place.geometry.viewport)
    } else {
      this.map.setCenter(place.geometry.location)
    }
    this.mapMarker.setPosition(place.geometry.location)
    this.mapMarker.setVisible(true)

    // Display info window
    this.placeIcon = place.icon
    this.placeName = place.name
    this.placeAddress = place.formatted_address
    this.mapInfoWindow.open(this.map, this.mapMarker)

    this.locationName = place.name
    this.locationAddress = place.formatted_address
    this.locationCoordinate = {
      lat: place.geometry.location.lat(),
      lng: place.geometry.location.lng()
    }

    this.updateLocation()
  }

  private handleClickPOI(e: any) {
    const self = this

    if (e.placeId) {
      this.mapService.getDetails({ placeId: e.placeId }, (
        place: any,
        status: string
      ) => {
        if (status === 'OK') {
          self.locationName = place.name
          self.locationAddress = place.formatted_address
          self.locationCoordinate = {
            lat: place.geometry.location.lat(),
            lng: place.geometry.location.lng()
          }

          self.updateLocation()
        }
      })
    }
  }

  private updateLocation() {
    this.$emit('onChangeLocation', {
      name: this.locationName,
      address: this.locationAddress,
      coordinate: this.locationCoordinate
    })
  }
}
</script>

<style lang="scss" scoped>
.map {
  width: 100%;

  &-main {
    width: 100%;
    height: 400px;
  }

  &-info {
    &__icon {
      display: inline-block;
      width: 16px;
      height: 16px;
    }

    &__title {
      font-weight: bold;
    }
  }
}
</style>

load-google-map-api のimport

import loadGoogleMapsApi from 'load-google-maps-api'

Create Google Map instance

We need the Google Map instance to create instances mentioned in the next section.

The Google Map instance is initialized in mounted().
It cannot be initialized in created() because the created() is fired in SSR which caused an error. It is because the Google Map api refers to the window object in the browser.
Please not the created() is called in both in the server and the client, while the mounted() is called only in CSR.

this.gmap = await loadGoogleMapsApi({
  key: process.env.gmapKey,
  libraries: ['places'],
  language: 'en'
})

The language property is useful for i18n integration.
If the this.gmap instance is inilialized in the Nux plugin feature, the property cannot be changed dynamically.

Create instances of Google Map

Ceate instances in initMap().

  • AutoComplete (auto-complete form)
  • Map (displaying the map)
  • InfoWindow (display facility information in tooltip)
  • Marker (markers in the map)
  • PlaceService (searching facilities)

AutoComplete(auto-complete form)

this.mapAutoComplete = new this.gmap.places.Autocomplete(this.$refs.input)

Map (displaying the map)

this.map = new this.gmap.Map(this.$refs.map, {
  center: new this.gmap.LatLng(this.coord.lat, this.coord.lng),
  zoom: this.defaultZoom,
  mapTypeControl: false,
  streetViewControl: false
})

center: specify the center coordination in the map
zoom: zoom level
mapTypeControl: whether showing the map controller or not
streetViewControl: whether showing the street view controller or not


InfoWindow (display facility information in tooltip)

This is an instance showing a tooltip with the selected facility information. It is dynamically updated when a searched item is selected.

this.mapInfoWindow = new this.gmap.InfoWindow()

Marker (map marker)

Iterally it is a instance shoing a marker on the map.

this.mapMarker = new this.gmap.Marker({
  map: this.map
})

PlaceService (fetching facility info)

The PlaceServce instance is used when a facility on the map is clicked for fetching the information (getDetails()).
The getDetails() is called in an event listener.

this.mapService = new this.gmap.places.PlacesService(this.map)
this.map.addListener('click', this.handleClickPOI.bind(this))

this.map.addListener('click', this.handleClickPOI.bind(this))

private handleClickPOI(e: any) {
  const self = this

  if (e.placeId) {
    this.mapService.getDetails({ placeId: e.placeId }, (
      place: any,
      status: string
    ) => {
      if (status === 'OK') {
        self.locationName = place.name
        self.locationAddress = place.formatted_address
        self.locationCoordinate = {
          lat: place.geometry.location.lat(),
          lng: place.geometry.location.lng()
        }

        self.updateLocation()
      }
    })
  }
}

this.mapService.getDetails() returns data like this.

e.g.

{
  address: "1802-2 Oyatsu, 益城町 上益城郡 Kumamoto 861-2204, Japan"
  coordinate: {lat: 32.8369716, lng: 130.8628225}
  name: "Kumamoto Airport"
 }

Initialize auto-complete

private initAutoComplete() {
  this.mapAutoComplete.bindTo('bounds', this.map)
  this.mapAutoComplete.setFields([
    'geometry',
    'icon',
    'name',
    'formatted_address'
  ])

  this.mapAutoComplete.addListener('place_changed', () => {
    this.onClickLocation()
  })

  this.mapInfoWindow.setContent(this.$refs.infoContent)
}

this.mapAutoComplete.bindTo('bounds', this.map)

This is used if you want to narrow down the searching area.


this.mapAutoComplete.setFields()

Specify the items you want the API to return.
This time has these three: geometory, icon, name, and formatted_address.


this.mapAutoComplete.addListener()

Register a callback function to the event listener. The function is called when an item in the suggested list is selected.
When the lister detectsplace_changed event, the location information is shown in the map using InfoWindow and Marker instances.

private onClickLocation() {
  this.mapInfoWindow.close()
  this.mapMarker.setVisible(false)
  const place = this.mapAutoComplete.getPlace()

  if (place.geometry.viewport) {
    this.map.fitBounds(place.geometry.viewport)
  } else {
    this.map.setCenter(place.geometry.location)
  }
  this.mapMarker.setPosition(place.geometry.location)
  this.mapMarker.setVisible(true)

  // Display info window
  this.placeIcon = place.icon
  this.placeName = place.name
  this.placeAddress = place.formatted_address
  this.mapInfoWindow.open(this.map, this.mapMarker)

  this.locationName = place.name
  this.locationAddress = place.formatted_address
  this.locationCoordinate = {
    lat: place.geometry.location.lat(),
    lng: place.geometry.location.lng()
  }

  this.updateLocation()
}