【Vue.js + Googleマップ】売上データをGoogleマップ上に可視化する

# はじめに
つい先日仲の良いクライアントさんから、とある相談を受けたときの一幕。

🙍‍♂️「全国にある小売店の**製品別売上データ**があるんだけど、これを**GoogleMap上で可視化**できないかな？ なんか棒グラフが積み上がっててどこの小売店で何がよく売れてるか一目で見れるようなやつ。元データがCSVなんだけど。」

僕「調べてみますね。あー、簡単なグラフでよければできそう。CSVもらえます？」

🙎‍♂️「あ、本当に？そしたらお願いします。これCSVね。」

僕「了解🙆‍♂️」

※ フェイクが入っていますが、要するに売上データをGoogleMap上に可視化するというのが今回のミッションです。

# 成果物
こんな感じでどの地域でどんなものが売れているのか、積み上げ棒グラフで可視化しました。赤から上に伸びていきます。棒グラフをクリックすると小売店名と各製品の売上高が表示されます。スクショあげたかったんですがGoogleMapのスクショが著作権的にNGなようなのでイメージ図です。白地図は[白地図専門店](https://www.freemap.jp/)さんからいただきました。ありがとうございます🙇‍♂️ 棒グラフの緯度経度、長さは適当です。意味はありません。
今回のプロジェクトは[Github](https://github.com/nasteng/visualization)にあげたのでご参考ください。

![samlpe.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/115590/b44f8195-2a27-f60f-6f0d-64fa369e1397.png)


# 今回使った技術
- [Google Map API（Maps Javascript API）](https://developers.google.com/maps/documentation/javascript/overview)
- [google-maps-api-loader](https://www.npmjs.com/package/google-maps-api-loader)
    - Maps Javascript APIのラッパーライブラリ
- [Vue.js](https://jp.vuejs.org/index.html)（ver 2.6.11）
    - 今回はVueを使いましたがフロントはHTML+Javascriptでもreactでも何でもいいです

# 主なVueコンポーネント
- GoogleMapLoader.vue
    - Googleマップを読み込むローダー
- GoogleMap.vue
    - GoogleMapLoaderのコンテナー
- GoogleMapMarker.vue
    - 売上データを表示するマーカー　クリック時に情報ウィンドウを開く

# 事前準備
Googleマップを利用するにはAPIキーの取得が必要になります。下記の記事などを参考にAPIキーを取得しておいてください。APIキー取得の流れをめちゃくちゃ丁寧に説明してくれています。

参考）
Google Maps API を使ってみた
[https://qiita.com/Haruka-Ogawa/items/997401a2edcd20e61037](https://qiita.com/Haruka-Ogawa/items/997401a2edcd20e61037)

# 実装
Googleマップとマーカーを表示するのはVue公式のCookbookが役立ちます。これに従えばGoogleマップの表示が可能なのでマップを表示するコードの解説は割愛します。

参考）
Practical use of scoped slots with GoogleMaps
[https://jp.vuejs.org/v2/cookbook/practical-use-of-scoped-slots.html](https://jp.vuejs.org/v2/cookbook/practical-use-of-scoped-slots.html)

```GoogleMapLoader.vue
<template>
  <div>
    <div class="google-map" ref="googleMap"></div>
    <template v-if="Boolean(this.google) && Boolean(this.map)">
      <slot :google="google" :map="map" />
    </template>
  </div>
</template>

<script>
import GoogleMapsApiLoader from "google-maps-api-loader"
export default {
  props: {
    mapConfig: Object,
    apiKey: String,
  },

  data() {
    return {
      google: null,
      map: null,
    }
  },

  async mounted() {
    const googleMapApi = await GoogleMapsApiLoader({
      apiKey: this.apiKey,
    })

    this.google = googleMapApi
    this.initializeMap()
  },

  methods: {
    initializeMap() {
      const mapContainer = this.$refs.googleMap
      this.map = new this.google.maps.Map(mapContainer, this.mapConfig)
    },
  },
}
</script>
<style>
.google-map {
  height: 100vh;
}
</style>
```

```GoogleMap.vue
<template>
  <google-map-loader :mapConfig="mapConfig" :apiKey="apiKey">
    <template slot-scope="{ google, map }">
      <google-map-marker
        v-for="store in stores"
        :key="store.id"
        :map="map"
        :google="google"
        :store="store"
      />
    </template>
  </google-map-loader>
</template>

<script>
import GoogleMapLoader from "./GoogleMapLoader.vue"
import GoogleMapMarker from "./GoogleMapMarker.vue"

export default {
  components: { GoogleMapLoader, GoogleMapMarker },
  data() {
    return {
      apiKey: process.env.VUE_APP_GOOGLE_MAP_API_KEY,
      stores: [
        {
          id: 1,
          name: "ほげほげ店",
          lat: 35.69,
          lng: 139.765,
          products: [
            {
              name: "A商品",
              salesAmount: 100000,
              rgb: "#f00",
              salesRatio: 1.0,
            },
            {
              name: "B商品",
              salesAmount: 150000,
              rgb: "#0f0",
              salesRatio: 1.0,
            },
            {
              name: "C商品",
              salesAmount: 200000,
              rgb: "#00f",
              salesRatio: 1.0,
            },
          ],
        },
        {
          id: 2,
          name: "ふがふが店",
          lat: 35.690015,
          lng: 139.761897,
          products: [
            {
              name: "A商品",
              salesAmount: 50000,
              rgb: "#f00",
              salesRatio: 0.5,
            },
            {
              name: "B商品",
              salesAmount: 75000,
              rgb: "#0f0",
              salesRatio: 0.5,
            },
            {
              name: "C商品",
              salesAmount: 100000,
              rgb: "#00f",
              salesRatio: 0.5,
            },
          ],
        },
        {
          id: 3,
          name: "ぴよぴよ店",
          lat: 35.6946,
          lng: 139.76112,
          products: [
            {
              name: "A商品",
              salesAmount: 10000,
              rgb: "#f00",
              salesRatio: 0.1,
            },
            {
              name: "B商品",
              salesAmount: 15000,
              rgb: "#0f0",
              salesRatio: 0.1,
            },
            {
              name: "C商品",
              salesAmount: 20000,
              rgb: "#00f",
              salesRatio: 0.1,
            },
          ],
        },
        {
          id: 4,
          name: "すごすご店",
          lat: 35.684599,
          lng: 139.76551,
          products: [
            {
              name: "A商品",
              salesAmount: 30000,
              rgb: "#f00",
              salesRatio: 0.333,
            },
            {
              name: "B商品",
              salesAmount: 50000,
              rgb: "#0f0",
              salesRatio: 0.333,
            },
            {
              name: "C商品",
              salesAmount: 66666,
              rgb: "#00f",
              salesRatio: 0.333,
            },
          ],
        },
        {
          id: 5,
          name: "やばやば店",
          lat: 35.681109,
          lng: 139.76801,
          products: [
            {
              name: "A商品",
              salesAmount: 20000,
              rgb: "#f00",
              salesRatio: 0.2,
            },
            {
              name: "B商品",
              salesAmount: 30000,
              rgb: "#0f0",
              salesRatio: 0.2,
            },
            {
              name: "C商品",
              salesAmount: 40000,
              rgb: "#00f",
              salesRatio: 0.2,
            },
          ],
        },
      ],
    }
  },
  computed: {
    mapConfig() {
      return {
        center: {
          // マップの中心は東京駅
          lat: 35.6800156,
          lng: 139.7642828,
        },
        zoom: 14,
      }
    },
  },
}
</script>

```

```GoogleMapMarker.vue
<template>
  <div></div>
</template>
<script>
const MAX_SALES_BAR_HEIGHT_PER_PRODUCT = 50

export default {
  props: {
    google: {
      required: true,
      type: Object,
    },
    map: {
      required: true,
      type: Object,
    },
    store: {
      required: true,
      type: Object,
    },
  },

  data() {
    return {
      currentBarHeight: 0,
    }
  },

  methods: {
    makeInfoWindow() {
      let content = `<h1>${this.store.name}</h1>`

      this.store.products.forEach(function(item) {
        content += `<p>${item.name}：¥${item.salesAmount.toLocaleString()}</p>`
      })

      return new this.google.maps.InfoWindow({
        content: content,
      })
    },

    totalSalesBarHeight() {
      const height = this.store.products.reduce((prev, product) => {
        return prev + MAX_SALES_BAR_HEIGHT_PER_PRODUCT * product.salesRatio
      }, 0)

      return height
    },

    makeSalesBarGraph(width, height) {
      let svg = `<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="10" height="${this.totalSalesBarHeight()}">`

      this.store.products.forEach((product) => {
        const productHeight = MAX_SALES_BAR_HEIGHT_PER_PRODUCT * product.salesRatio
        svg += `<rect x="0" y="${this.currentBarHeight}" width="10" height="${productHeight}" fill="${product.rgb}" />`
        this.currentBarHeight += height
      })

      svg += "</svg>"

      return {
        url: `data:image/svg+xml,${encodeURIComponent(svg)}`,
        scaledSize: this.google.maps.Size(width, height),
      }
    },
  },

  mounted() {
    const store = new this.google.maps.Marker({
      position: {
        lat: this.store.lat,
        lng: this.store.lng,
      },
      marker: this.store,
      map: this.map,
      icon: this.makeSalesBarGraph(10, this.totalSalesBarHeight()),
    })

    const infoWindow = this.makeInfoWindow()

    store.addListener("click", () => {
      infoWindow.open(this.map, store)
    })
  },
}
</script>
```

# 解説
GoogleMap.vueにstoresというプロパティがありますが、これがCSVの売上データを整形した各小売店の1ヶ月の製品別売上データだと思ってください。小売店IDと名前、緯度経度、製品の配列（製品の名前、製品の売上（salesAmount）、売上の比率（salesRatio）、棒グラフ上での色を表すRGB）を持ったオブジェクトが配列で存在するイメージです（最終的にこれがバックエンドから返される値になります）。salesRatioについては後述します。例に漏れず緯度経度は適当な数値です。意味はありません。

GoogleMap.vueはこのstoresをGoogleMapLoaderのslot-scopeを通してGoogleMapMarker.vueに渡します。

さて、このGoogleMapMarker.vueが今回のキモになります。
マーカー初期化時にiconプロパティに色々（ざっくり）指定するとデフォルトマーカーの替わりに画像を表示できたりします。ドキュメントを下記にあげておきます。

参考）
Customizing a Google Map: Custom Markers
[https://developers.google.com/maps/documentation/javascript/custom-markers](https://developers.google.com/maps/documentation/javascript/custom-markers)

ただ、棒グラフを自作するにあたってPNGやJPGでは扱いにくかったのでSVGをコードで書くことにしました。

## 棒グラフを描画するにあたって考えたこと
まず、売上高（salesAmount）に応じた高さの矩形を積み上げていく必要があります。とりあえず売上高の1000分の1くらいのピクセル数で高さを描画してみましたがうまくいきませんでした。売上高の数値が伸びれば伸びるほど棒グラフが長くなってしまうからです。そりゃそうだ。

今回可視化する上で考慮しなければならなかったのが
**棒グラフ同士で長さが比較できること**
**売上高に関わらず棒グラフの長さが一定の長さ以上にならないこと**
の2点。

そのためにsalesRatioという値をもたせることにしました。
salesRatioというのは「集計期間中に一番売り上げた製品の売上に対する各店舗の製品別売上の比率」です。（にほんごむずかしい）

例えば今回の集計期間ではIDが1のほげほげ店が全店舗の中でもA商品（¥100,000）、B商品（¥150,000）、C商品（¥200,000）全てで最大の売上となっています。
この数値を基準に各店舗の製品別の比率をバックエンド側で計算して返却するイメージです。

つまりIDが2のぴよぴよ店ではA商品は¥50,000なので¥50,000 / ¥100,000 = 0.5 = 50%という感じです。

各製品ごとの比率を使うことで一定の高さ以上にならない、かつ各店舗で長さが比較できる棒グラフができるはずです。ということでコードの解説に戻ります。

棒グラフを表すSVGを生成しているのがmakeSalesBarGraph関数です。
引数に10とtotalSalesBarHeight()が渡っていますが、これが棒グラフの幅と高さになります。
棒グラフの幅は10pxで固定しました。

totalSalesBarHeight関数は名のごとく、棒グラフの最大の長さを計算する関数です。
salesRatioをMAX_SALES_BAR_HEIGHT_PER_PRODUCT(=50px)という定数にかけてreduce関数を使って足し合わせていきます。
こうすることでほげほげ店の棒グラフの高さは比率が全て1なので最大の150px。50%のぴよぴよ店は半分の75px、という結果が得られます。

```GoogleMapMarker.vue
    totalSalesBarHeight() {
      const height = this.store.products.reduce((prev, product) => {
        return prev + MAX_SALES_BAR_HEIGHT_PER_PRODUCT * product.salesRatio
      }, 0)

      return height
    }
```

各店舗の棒グラフの高さが計算できるようになったのでSVGで矩形を描画していきます。

```GoogleMapMarker.vue
    makeSalesBarGraph(width, height) {
      ① let svg = `<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="10" height="${this.totalSalesBarHeight()}">`

      ② this.store.products.forEach((product) => {
        const productHeight =
          MAX_SALES_BAR_HEIGHT_PER_PRODUCT * product.salesRatio
        svg += `<rect x="0" y="${this.currentBarHeight}" width="${width}" height="${productHeight}" fill="${product.rgb}" />`
        this.currentBarHeight += productHeight
      })

      ③ svg += "</svg>"

      ④ return {
        url: `data:image/svg+xml,${encodeURIComponent(svg)}`,
        scaledSize: this.google.maps.Size(width, height),
      }
    }
```

① 棒グラフの高さを取得してSVGの開始タグを生成
② 製品ごとの棒グラフの高さを計算　計算結果を元にrectタグで矩形を描画
③ 全ての製品をループし終えたらSVGの終了タグを追加
④ 生成したSVGをエンコードして返却

こんな流れでSVGを生成しています。SVGをエンコードすることについては下記の記事が大変参考になりました。ありがとうございます🙇‍♂️

参考）
GoogleMapsAPI v3でマーカーにSVGのパスを指定するとクソ重い件
[https://qiita.com/aoi_/items/7c70d725b4fa9321a740](https://qiita.com/aoi_/items/7c70d725b4fa9321a740)

# ついでに
棒グラフをクリックしたときに情報ウィンドウを出すことにします。
これに関してはすごく簡単で、makeInfoWindow関数でHTMLタグを生成してInfoWindowオブジェクトをインスタンス化して、マーカーオブジェクトにaddListenerでクリックイベントを登録しているだけです。

```GoogleMapMarker.vue
    makeInfoWindow() {
      let content = `<h1>${this.store.name}</h1>`

      this.store.products.forEach(function(item) {
        content += `<p>${item.name}：¥${item.salesAmount.toLocaleString()}</p>`
      })

      return new this.google.maps.InfoWindow({
        content: content,
      })
    },
  },
  mounted() {
    const store = new this.google.maps.Marker({
      position: {
        lat: this.store.lat,
        lng: this.store.lng,
      },
      marker: this.store,
      map: this.map,
      icon: this.makeSalesBarGraph(10, this.totalSalesBarHeight()),
    })

    const infoWindow = this.makeInfoWindow()

    store.addListener("click", () => {
      infoWindow.open(this.map, store)
    })
  }
```

# 最後に
SVGをコードで描画する機会が今まで無かったのでSVGの動的生成がぶっちゃけ一番苦労しました。できたコード見るとなんてことないんですけどね。
SVGもっとよく知ればもっとリッチなグラフが描けるんだろうなと思いつつ、クライアントさんが割と急いでいたのでとりあえずもらったCSVを参考に成果物を見せたら、🙎‍♂️「おー！めっちゃ良いじゃん！完璧です！」のお言葉をもらったのでヨシ！

# その後

僕「満足してもらえたようでよかったー。」

🙎‍♂️「これ毎月CSV渡して作ってもらうのめんどくさいからWebで見られるようになったりしない？ 前月との比較とか見られると嬉しいなぁ。」

僕「できますよ🙆‍♂️」

ということで最終的にフロントをVue, バックエンドをLaravelで構築したWebアプリを納品しました。

以上、HappyなGoogleMap, Vue, Laravel, SVGライフを🎉🎉