Nuxt.js: Google Maps APIでauto complete検索を実装する (TypeScript)

申し込みフォームなどで、Auto-complete(サジェスト)マップ検索を利用する機会があり、今回はNuxt.js + Google Maps APIを使った実装します。

Demoは こちら

今回の流れは以下の通りです。

  1. GCP Maps APIの有効化
  2. nuxt-config.js へのAPIキーの登録
  3. load-google-maps-api パッケージのインストール
  4. Map component実装

GCP Maps APIの有効化

この機能を実現するためには以下のGCP APIを有効化する必要があります。

  1. Maps JavaScript API
  2. Places API

これらを Google Cloud Platform にて有効化して、APIキーを取得します。

nuxt-config.jsへのAPIキーの登録

上記で取得したAPIキーを環境変数でnuxt-config.jsに持たせます。APIキーは特定のIPアドレス、hostからのアクセスしか認めないように設定(GCP)しておきましょう。

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

load-google-maps-api のインストール

通常、Google Maps APIを利用する場合は、HTMLにscriptタグで外部JavaScriptを読み込ませて利用しますが、今回は load-google-maps-api パッケージを利用します。

$ yarn add load-google-maps-api

or

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

TypeScript環境の場合は、以下もインストールします。

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

or

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

Map componentの実装

今回は以下のような構成で処理を行います。

index.vue

こちらは単純にMapコンポーネントをembedするだけです。
@onChangeLocation のbindはMapコンポーネントから位置情報を受け取るために準備しました。

<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

初期表示のための座標を設定していますが、こちらは任意になります。

<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検索 (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="検索文字を入力してください"
        />
      </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 }, function(
        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

packageをimportします。

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

Google Mapインスタンスの生成

機能実現に必要な各種インスタンスを生成するための親分インスタンスを生成します。
mounted() でGoogle mapインスタンスの初期化を行います。
created() でこの初期化を行うとSSRでの初期化が走ってしまうのでエラーとなります。Google map apiでブラウザーのwindowオブジェクトを参照しているためです。
created() はserver, clientの両方で発火されますが、 mounted() はCSRとなります。

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

languageプロパティはi18nを利用した国際化対応をしている場合には変数化すると実現できます。plugin化してしまうとページロード時に呼び出され、このプロパティを動的に変更できなくなるので注意が必要です。
上記例では、英語をセットしていますが、日本語の場合は、'ja' となります。

各種インスタンスの生成

必要なGoogle Mapインスタンスを生成します。(initMap()内の処理)

  • AutoCompleteインスタンス生成(Auto-completeフォーム)
  • Mapインスタンス生成(地図表示)
  • InfoWindowインスタンス生成(施設情報表示tooltip)
  • Markerインスタンス生成(地図上マーカー)
  • PlaceServiceインスタンス生成(施設情報検索)

AutoCompleteインスタンス生成(Auto-completeフォーム)

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

Mapインスタンス生成(地図表示)

refでmapを表示するelementを指定します。これで地図が表示されます。

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: 表示時の中心ポイントを座標指定
zoom: 表示拡大率
mapTypeControl: コントローラー表示制御
streetViewControl: ストリートビュー表示コントローラー表示制御


InfoWindowインスタンス生成(施設情報表示tooltip)

検索結果から選択した地点(施設)の情報を地図上に吹き出し表示するためのインスタンスです。表示される検索結果リスト内のアイテムが選択された際にこのインスタンス変数を操作することで表示内容を動的に変えることができます。

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

Markerインスタンス生成(地図上マーカー)

検索結果から選択した地点(施設)をマーカー表示するためのインスタンスです。
mapプロパティにMapインスタンスを設定します。

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

PlaceServiceインスタンス生成(施設情報検索)

Auto-completeで表示されるリストではなく、直接地図上の施設をクリック(タップ)した場合にその施設情報をAPI取得するためのインスタンスです。
このインスタンスメソッドである getDetails() を利用しますが、Mapインスタンスへのイベントリスナーを登録し、コールバック関数内で呼び出します。

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 }, function(
      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() で以下のような内容が取得できます。
取得したものをコンポーネントdataに反映します。

e.g.

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

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.mapAutoComplete.setFields()

API返り値に含む項目を指定します。今回は geometory (座標情報)、 icon (アイコン画像)、 name (地点・施設名)、 formatted_address (フォマット化された住所) を利用します。


this.mapAutoComplete.addListener()

サジェストされた検索リスト内のアイテムを選択した場合に呼び出すコールバックをリスナー登録します。
place_changed が検知されるとInfoWindowやMarkerインスタンスを利用して地図上に情報を反映させます。

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()
}