【GoogleMapsAPI】Rails6アプリにMapsJavaScript APIとGeocoding APIを導入してみた

## 背景

ポートフォリオを作成するにあたり、Google Maps Platform(旧GoogleMapsAPI)を使うことは決めていました。
実装したことを理解して自分のスキルとするために記事にまとめることにしました。


### 開発環境

Ruby 2.7.2
Rails 6.1
MySQL 5.7
Google Maps API(Maps JavaScript API, Geocoding API)

## なぜGoogleMapsなのか

自分の場合は自分の課題を解決できるのはGoogleMapsだろうなと当たりをつけていたから。
でも調べてみると、初学者が地図を実装するとなるとGoogleMapsを使うのが一番良いと思いました。

理由は以下の通り。
- Googleが持っている情報を使える（最新の地図、シェア率など）
- 通信量だけ気をつければ無料で使える(９０日間300ドル相当、その後200ドル/月相当)
- API機能が豊富

## 実装内容

- Google Mapを表示する
- クリックした位置にピンを立てる
- クリックした位置の座標を取得する

## 実装方法

コーディングに入る前に[GoogleMapsPlatform](https://cloud.google.com/maps-platform/)にて設定を行いましょう。
参考：[APIキーの取得方法](https://www.zenrin-datacom.net/business/gmapsapi/api_key/)

## 新規投稿

```erb:new.html.erb
<div id="map"></div>

<script>
let mapInstance = {};
let marker;
let geocoder;
const tokyoStationLat = 35.6809591;
const tokyoStationLng = 139.7673068;

function initMap() {
  geocoder = new google.maps.Geocoder();
  // 初期マップ
  mapInstance = new google.maps.Map(document.getElementById('map'), {
    center: {lat: tokyoStationLat, lng: tokyoStationLng},
    zoom:12,
    mapTypeControl: false
  });

  // クリックしたときに関数を実行
  mapInstance.addListener('click', function(e) {
    getClickLatLng(e.latLng, mapInstance);
    });
  }


// 住所、座標を取得してマーカー設置
function getClickLatLng(latlng, mapInstance) {
  document.getElementById('road_latitude').value = latlng.lat();
  document.getElementById('road_longitude').value = latlng.lng();

  // 既存のマーカーの削除
  deleteMarker();

  // マーカー設置
  marker = new google.maps.Marker({
    position: latlng,
    map: mapInstance
  });

  mapInstance.panTo(latlng);
}

// 既にあるマーカーの削除
function deleteMarker() {
  if(marker != null){
    marker.setMap(null);
  }
  marker = null;
}

function searchArea() {
  // 検索フォームの入力内容を取得
  let inputAddress = document.getElementById('address').value;

  geocoder.geocode( { 'address': inputAddress }, function(results, status) {
    // 該当する検索結果がヒットした時に、地図の中心を検索結果の緯度経度に更新
    if (status == 'OK') {
      mapInstance.setCenter(results[0].geometry.location);
    } else {
      // 検索結果がなかった場合に表示
      alert('該当する結果がありませんでした:' + status);
    }
  }); 
}
</script>

<style>
#map {
  height: 600px;
  width: 600px;
}
</style>
```

この記述によって初期マップをHTML上に表示する。

- `div id="map"`：　マップを表示するタグ
- `initMap`：　初期マップの情報を設定
- `getClickLatLng`: クリックした箇所の緯度経度を取得、更にマーカーを設置(もし、すでにマーカーがある場合はdeleteMarker()によって削除される)
`latlng.lat`で緯度、 `latlng.lng`で経度を取得できる。
- `new google.maps.Marker({})`: これはマーカーを設置する。postionで緯度経度を指定し、mapは表示する地図を指定。
参考： [Let'sプログラミング マーカーの作成](https://www.javadrive.jp/google-maps-javascript/gmarker/index1.html)
- `geocoder = new google.maps.Geocoder();`: ジオコードリクエストをGoogleサーバーに送信するGeocoderの新しいインスタンスを作成。
- `searchArea`: マップ内の検索機能です。上記のジオコードインスタンスを作成したことを前提に、フォームに入力した住所のワードがヒットした場合、緯度経度に変換され、その緯度経度を元にmapインスタンスを更新し、結果としてマップを検索ワードに該当するマップを表示する。

また、今回はマーカーは一つに設定したため、2つ目以降のマーカーを設置したときに、直前のマーカーを削除するように`function deleteMarker()`で定義しています。


### 注意点
APIキーを読み込ませてあげないと、GoogleMapsは動きません。以下を書きましょう。今回はAPIキーの管理がしやすいように.env等で環境変数に格納しておきましょう。
デベロッパーツール使うとわかるのですが、APIキーは見れてしまいます。
悪用されると料金がえらいことになってしまう可能性があるので、GoogleMapsPlatformの方でリクエスト数の制限を行っておきましょう！
設定方法は以下のサイトが参考になりました。
参考: [Google Maps PlatformのAPI 使用の上限設定](https://ring-and-link.co.jp/dream2000/user/notice/web/2975)

```erb
<script src="https://maps.googleapis.com/maps/api/js?key=自分のAPIキー&callback=initMap" async defer></script>
```
これは、何をしているのかというと、
外部スクリプト(GoogleMapsAPIを実行しながら、コールバック関数としてinitMapを呼び出している
→結果、マップを表示している。

<b>コールバック関数:</b> 関数Aの引数として関数Bを渡すこと。

実装イメージは以下の感じです。

- 検索フォームでマップを移動
- マップをクリックすることでマーカーの設置、緯度/経度の取得

![スクリーンショット 2021-08-31 23.31.53.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1161057/dc699e85-f064-042c-cf61-c6740fa60665.png)



## 更新機能

```erb:edit.html.erb
<script>
let mapInstance
let geocoder
let marker

const savedLat = <%= @post.latitude %>
const savedLng = <%= @post.longitude %>

function initMap() {
  geocoder = new google.maps.Geocoder();
  // 保存された座標データが中心に来るように設定
  mapInstance = new google.maps.Map(document.getElementById('map'), {
    center: {lat: savedLat, lng: savedLng},
    zoom:13,
    mapTypeControl: false
  });

  // 保存された座標を使ってピンをさす
  pos = new google.maps.LatLng(
    savedLat,
    savedLng
  );
  default_marker = new google.maps.Marker({
    map: mapInstance,
    position: pos,
    icon: {
      url: ' https://maps.google.com/mapfiles/ms/icons/green-dot.png',
      scaledSize: new google.maps.Size(40, 40)
    }
  });

    // クリックしたときに関数を実行
  mapInstance.addListener('click', function(e) {
    deleteMarker();
    getClickLatLng(e.latLng, mapInstance);
    });
  }


// 住所、座標を取得
function getClickLatLng(latlng, mapInstance) {
  document.getElementById('road_latitude').value = latlng.lat();
  document.getElementById('road_longitude').value = latlng.lng();

  marker = new google.maps.Marker({
    position: latlng,
    map: mapInstance
  });

  mapInstance.panTo(latlng);
}

// 既にあるマーカーの削除
function deleteMarker() {
  if(marker != null){
    marker.setMap(null);
  }
  marker = null;
}


function searchArea() {
  // 検索フォームの入力内容を取得
  let inputAddress = document.getElementById('address').value;

  geocoder.geocode( { 'address': inputAddress }, function(results, status) {
    // 該当する検索結果がヒットした時に、地図の中心を検索結果の緯度経度に更新
    if (status == 'OK') {
      mapInstance.setCenter(results[0].geometry.location);
    } else {
      // 検索結果がなかった場合に表示
      alert('該当する結果がありませんでした:' + status);
    }
  }); 
}

</script>
```

更新機能もほとんど変わりません。
投稿データテーブルのlatitudeカラムやlongitudeカラムに保存された内容を呼び出して、initMapのpositionを投稿データにしているだけです。


## 詳細機能

```erb:show.html.erb
<div id="map"></div>

<script>

let mapInstance
let geocoder
let marker

const savedLat = <%= @post.latitude %>
const savedLng = <%= @post.longitude %>

function initMap() {
  // 保存された座標データが中心に来るように設定
  mapInstance = new google.maps.Map(document.getElementById('map'), {
    center: {lat: savedLat, lng: savedLng},
    zoom:13,
    mapTypeControl: false
  });

  // 保存された座標を使ってピンをさす
  pos = new google.maps.LatLng(
    savedLat,
    savedLng
  );
  default_marker = new google.maps.Marker({
    map: mapInstance,
    position: pos,
    icon: {
      url: ' https://maps.google.com/mapfiles/ms/icons/green-dot.png',
      scaledSize: new google.maps.Size(40, 40)
    }
  });
}


function codeAddress() {
  let inputAddress = document.getElementById('address').value;

  geocoder.geocode({
    'address': inputAddress
  }, function (results, status) {
    if (status == 'OK') {
      mapInstance.setCenter(results[0].geometry.location);

      if(marker != null){
        marker.setMap(null);
      }
      marker = null;

      marker = new google.maps.Marker({
        map: mapInstance,
        position: results[0].geometry.location
      });

      let title = document.getElementById('map_title');
      title.setAttribute("value", inputAddress);
    } else {
      alert('Geocode was not successful for the following reason: ' + status)
    }
  });
}
</script>
```
詳細もほとんど同じですね。
保存されていた緯度と経度を呼び出して、initMapに反映させているだけです。

## リロードしないと正しくマップが表示されない場合

実は私の環境ですとこのままだとマップが正しく動作しません。
動作しないと言うか、それぞれ投稿も詳細表示も編集もできるのですが、各ページにアクセスしたときのマップ表示に不具合がありました。
具体的には、ページAにアクセスした後、ページBにアクセスすると、ページAの地図が表示されてしまうというものです。
これはturbolinksという高速化の機能によるものです。
対応としては、各リンクに`data: {"turbolinks"=>false}`を記述してturbolinksを無効化しましょう。

```erb:例
// リンク
<%= link_to "#{post.title}", post_path(road), {class: "show-link", data: {"turbolinks"=>false}} %>

// 画像リンク
<%= link_to image_tag(post.post_images.first, class: "show-img-link", data: {"turbolinks"=>false}), post_path(post) %>
```

## まとめ

GoogleMapsAPIは本当にいろんなことができます。
敷居もそこまで高くないですし、APIを試すのであればオススメです。

- 使うにはAPIキーが必要で、`<script src="https://maps.googleapis.com/maps/api/js?key=自分のAPIキー&callback=initMap" async defer></script>`でAPIキーを使用する

- GoogleMapsPlatform内で使いたいAPIを設定する

- `new google.maps.Map`でマップインスタンス、`new google.maps.Geocoder`でgeocodingインスタンスを生成する

今回ポートフォリオで使ってみて、もっと色んな機能を試してみたくなりました。
ただ、今回のコードもリファクタリングなどまだまだ課題あるので、時間見つけてやろうと思います。


## 参考記事

- [Google Maps Platform
ドキュメント](https://yizm.work/sample_code/javascript/google_map_click_address/)
- [Let'sプログラミング マーカーの作成](https://www.javadrive.jp/google-maps-javascript/gmarker/index1.html)
- [Google Maps PlatformのAPI 使用の上限設定](https://ring-and-link.co.jp/dream2000/user/notice/web/2975)