申し込みフォームなどで、Auto-complete(サジェスト)マップ検索を利用する機会があり、今回はNuxt.js + Google Maps APIを使った実装します。
Demoは こちら
今回の流れは以下の通りです。
- GCP Maps APIの有効化
nuxt-config.js
へのAPIキーの登録load-google-maps-api
パッケージのインストール- Map component実装
GCP Maps APIの有効化
この機能を実現するためには以下のGCP APIを有効化する必要があります。
- Maps JavaScript API
- 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()
}