GoogleMapを表示させるコンポーネントを作ってみました

とある開発している中で、Vue.jsでGoogleMapを表示させる必要があったので
その時作ったコンポーネントとその使い方の一部をご紹介したいと思います。

使用技術

  • Vue.js(2.6.10)
  • TypeScript (3.8.3)
  • Vue Property Decorator (8.1.0)

自作した理由

vue-google-mapsなどの既存ライブラリを使う選択肢もありましたが、
今後色々とカスタマイズ加えていきたいと考えた時に少々柔軟性にかけると感じ、
自由にカスタマイズしていきたいと思ったので今回は自作することにしました。

コンポーネントを作ってみる

必要最低限Mapを表示できる状態のコンポーネントにいくつかカスタマイズを抜粋して追加してみた例を紹介します。

Props

props type 用途
zoom number mapのズームレベルを指定
center { lat: number, lng: number } map中心の緯度経度を指定
id string googlemapを表示する親NodeのID
clickableIcons boolean 地図上に存在するPOIアイコンをクリックできるか否かを指定するオプション
gestureHandling 'auto' | 'greedy' | 'cooperative' | 'none' スクロール時の挙動を決める

Events

events 用途
change:bounds 地図移動した時に、表示範囲の緯度経度情報を返却する

GoogleMap表示コンポーネント全体

<template>
  <div>
    <div :id="id" :class="staticClass" :style="staticStyle" />
    <template v-if="google && map">
      <slot :google="google" :map="map" />
    </template>
    <div v-show="loadError">
      地図読み込みに失敗しました
    </div>
  </div>
</template>

<script lang="ts">
import { Component, Vue, Prop, Watch } from 'vue-property-decorator'
import qs from 'qs'

const params = {
  key: 'YourGoogleMapAPIKey',
  libraries: 'geometry,drawing,places',
  callback: 'handleLoadGoogleMapsScript'
}

interface GoogleMapWindow extends Window {
  handleLoadGoogleMapsScript: Function
  google: any
}

declare const window: GoogleMapWindow

const MAP_SCRIPT_ID = 'google-map-script'
const MAX_RETRY_LOAD_SCRIPT = 10
const LOAD_SCRIPT_INTERVAL = 500

@Component
export default class GoogleMap extends Vue {
  @Prop({ default: 17 }) private zoom!: number
  @Prop() private center!: { lat: number; lng: number }
  @Prop({ default: 'googleMap' }) private id!: string
  @Prop({ default: false }) private clickableIcons!: boolean
  @Prop({ default: 'auto' }) private gestureHandling!:
    | 'auto'
    | 'cooperative'
    | 'none'
    | 'greedy'
  google: any = null
  map: any = null
  loadError: boolean = false
  
  get staticClass() {
    return this.$vnode.data ? this.$vnode.data.staticClass : ''
  }
  
  get staticStyle() {
    return this.$vnode.data ? this.$vnode.data.staticStyle : ''
  }
  
  @Watch('center.lat')
  @Watch('center.lng')
  updatedCenter() {
    this.map && this.map.panTo(this.center)
  }
  
  mounted() {
    this.loadGoogleMapsScript()
      .then(google => {
        this.google = google
        this.initializeMap()
      })
      .catch(_e => {
        if (this.map) return
        this.loadError = true
      })
  }
  
  loadGoogleMapsScript() {
    return new Promise(async (resolve, reject) => {
      if (window.google) {
        return resolve(window.google)
      }
      const script = document.createElement('script')
      script.id = MAP_SCRIPT_ID
      script.src = `https://maps.googleapis.com/maps/api/js?${qs.stringify(
        params
      )}`
      const head = document.querySelector('head')
      if (!head) return reject(new Error('head node is undefined'))
      if (!document.getElementById(MAP_SCRIPT_ID)) {
        head.appendChild(script)
        window.handleLoadGoogleMapsScript = () => {
          resolve(window.google)
        }
      }
      for (const retryCount of range(1, MAX_RETRY_LOAD_SCRIPT)) {
        if (window.google) {
          resolve(window.google)
          break
        }
        await sleep(LOAD_SCRIPT_INTERVAL)
        if (retryCount === MAX_RETRY_LOAD_SCRIPT) {
          reject(new Error('failed load google api'))
        }
      }
    })
  }
  
  initializeMap() {
    const mapContainer = this.$el.querySelector(`#${this.id}`)
    const { Map, MapTypeId } = this.google.maps
    this.map = new Map(mapContainer, {
      zoom: this.zoom,
      center: this.center,
      mapTypeId: MapTypeId.ROADMAP,
      clickableIcons: this.clickableIcons,
      gestureHandling: this.gestureHandling
    })
    this.map.addListener('bounds_changed', () => {
      this.$emit('change:bounds', this.buildBounds())
    })
  }
  
  buildBounds() {
    const bounds = this.map.getBounds()
    const swLatlng = bounds.getSouthWest()
    const swLat = swLatlng.lat()
    const swLng = swLatlng.lng()
    const neLatlng = bounds.getNorthEast()
    const neLat = neLatlng.lat()
    const neLng = neLatlng.lng()
    return { swLat, swLng, neLat, neLng }
  }
}
</script>

GoogleMapコンポーネント利用例

<template>
  <GoogleMap
    :zoom="16"
    :center="{ lat, lng }"
    gestureHandling="greedy"
    @change:bounds="handleChangeBounds"
    style="width: 600px; height: 600px;"
  />
</template>

<script lang="ts">
import { Component, Vue } from 'vue-property-decorator'
import GoogleMap from 'path/to/GoogleMap.vue'

@Component({ components: { GoogleMap } })
export default class Example extends Vue {
  lat: number = 35.6695939
  lng: number = 139.7617964
  
  handleChangeBounds(bounds: { swLat: number, swLng: numebr, neLat: number, neLng: number }) {
    console.log(bounds)
  }
}
</script>

感想

自作で色んな用途に特化したコンポーネントを作成して、いかに使いやすく便利にできるかを考察するのは楽しいですし、自由にカスタマイズもできるのでメリットも大きいと感じました。
機会があればその他GoogleMapの機能についてもご紹介できればなと思います。