【Rails6.1.6】「日記投稿機能」にGoogle Mapsを実装する（投稿確認画面 付）

## この記事を書いた背景
DIC（プログラミングスクール）の卒業課題として作成をしたオリジナルアプリで、日記投稿機能にGoogle Mapsを実装しました。その際にGoogle Mapsの実装に苦労をしたので「自分の為に、また同様な事に悩んでいる人の為に」改めてまとめてみました。

## この記事の概要
実際に作成をしたオリジナルアプリ（下記URL）では、「ユーザー」「グループ」「日記」「地図」の機能がありますが、<font color="PaleVioletRed">今回は記事としての汎用性が高くなるように、「日記」「地図」の機能のみを改めて実装した内容をまとめました。日記を投稿する前に確認ができる「投稿確認画面」の機能付きです。</font>それでは記事をご覧ください！  
※「日記１つの投稿に対して、地図情報は1つのみ」の実装です。  
※API導入に関してはこちらでは取り上げていません、[参考文献](#9-参考文献)など他の記事をご覧ください。また、<font color="PaleVioletRed">APIキーの取り扱いにはお気をつけください！</font>
※実務未経験者です、間違い等ありましたらご指摘をいただけると助かります。

**実際に作成をしたオリジナルアプリはこちら**
- [Re: Timeline](http://35.78.10.205/)
- [GitHub - Re: Timeline](https://github.com/DaisukeKurita/re_timeline)

## 開発環境
- macOS Monterey 12.1
- Ruby 3.0.1
- Rails 6.1.6
- psql (PostgreSQL) 14.3

## 目次
- [1. 使用したgem](#1-使用したgem)
- [2. diariesテーブル](#2-diariesテーブル)
- [3. ルーティング](#3-ルーティング)
- [4. モデル](#4-モデル)
- [5. コントローラー](#5-コントローラー)
- [6. ビュー](#6-ビュー)
- [7. ヘルパー](#7-ヘルパー)
- [8. 「geocoder.rb」の設定](#8-geocoderrbの設定)
- [9. 参考文献](#9-参考文献)

## 1. 使用したgem
```:Gemfile
# --- 省略 ---
# 追加
gem 'geocoder'
gem 'gon'

group :development, :test do
  # 追加
  gem 'dotenv-rails'
end
```
**[gem geocoder](https://github.com/alexreisner/geocoder)**
「ジオコーディング」の為に使用します。
>ジオコーディングとは、住所や地名、駅名などの地理的情報を、緯度・経度の座標値に変換する技術のことである。日常生活で使用している、場所を示すキーワードを用いて地図上の場所を特定することができるため、地図データの利用効率を向上させることができる。（IT用語辞典バイナリより [参考文献*1](#9-参考文献)にURLを掲載）

**[gem gon](https://github.com/gazay/gon)**
今回「controller」で取得した値を「viewsで使用されているJavaScript」に渡す為に利用をしています。

**[gem dotenv-rails](https://github.com/bkeepers/dotenv)**
環境変数の管理に使用します。
<font color="PaleVioletRed">こちらの記事では詳しく取り上げていませんが、APIキーの取り扱いにはお気をつけください！</font>
※「gem dotenv-rails」についての参考になるよう、[参考文献*2](#9-参考文献)にURLを掲載しています。

## 2. diariesテーブル
今回作成をする「diariesテーブル」はこちらです。
|カラム論理名|カラム物理名|データ型|オプション|
|----|----|----|----|
|タイトル|title|string|null: false|
|内容|content|text|
|住所|address|string|
|緯度|latitude|float|
|経度|longitude|float|

「addressカラム」に実際に入る内容は「〒105-0011 東京都港区芝公園４丁目２−８といった『住所』」や「東京タワーなどの『シンボル名』」が入る事になります。

## 3. ルーティング
```ruby:routes.rb
Rails.application.routes.draw do
  # 追加
  resources :diaries do
    post 'confirm', on: :collection
  end
end
```

今回は「投稿確認画面」実装の為に`post 'confirm', on: :collection`を記述しています。

## 4. モデル
```ruby:diary.rb
class Diary < ApplicationRecord
  # 日記タイトルが空の場合は、バリデーションを発生させる
  validates :title, presence: true
  # モデルの中で、オブジェクトの住所がどこにあるかをgeocoderに伝える
  geocoded_by :address
  # addressカラムに入った情報を元に「latitude」「longitude」に緯度・経度の情報が入る
  after_validation :geocode
end
```
## 5. コントローラー
```ruby:diaries_controller.rb
class DiariesController < ApplicationController
  before_action :set_diary, only: %i[show edit update destroy]
  before_action :assignment_to_diary_instance_and_gon_lat_lng, only: %i[confirm create]

  # --- 省略 ---
  def new
    @diary = Diary.new
  end

  def edit
    # 「@diary.addressに値が存在する場合」は...
    return unless @diary.address.present?

    # 「gon.lat_lng」に [@diary.latitude, @diary.longitude]を代入する
    gon.lat_lng = [@diary.latitude, @diary.longitude]
  end

  def confirm
    render :new if @diary.invalid?
  end

  def create
    return render :new if params[:back]

    if @diary.save
      redirect_to diaries_path
    else
      render :new
    end
  end

  def update
    if @diary.update(diary_params)
      redirect_to diaries_path
    else
      render :edit
    end
  end

  private

  def set_diary
    @diary = Diary.find(params[:id])
  end

  def diary_params
    params.require(:diary).permit(:title, :content, :address)
  end

  def assignment_to_diary_instance_and_gon_lat_lng
    @diary = Diary.new(diary_params)
    # 「gon.lat_lng」に緯度・経度を代入
    # 仮に「@diary.address = '東京タワー'」だった場合
    # 「gon.lat_lng = [35.6585805, 139.7454329]」と代入される
    # 「@diary.address = ""」だった場合は「nil」が代入される
    gon.lat_lng = Geocoder.coordinates(@diary.address)
  end
end
```

## 6. ビュー
今回紹介する箇所は下記の4ファイルとなります。
- [application.html.erb](#１-viewslayoutsapplicationhtmlerbを編集します)
- [_form.html.erb](#2-viewsdiaries_formhtmlerbを編集します)
- [confirm.html.erb](#3-viewsdiariesconfirmhtmlerbを編集します)
- [index.html.erb](#4-viewsdiariesindexhtmlerbを編集します)


##### （1） 「views/layouts/application.html.erb」を編集します。
```eruby:views/layouts/application.html.erb
<!DOCTYPE html>
<html>
  <head>
    <title>GoogleMaps</title>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <%# 「include_gon(init: true)」を追加 %>
    <%# 【(init: true)】を記述する事により、「リクエストごとにwindow.gon = {};を初期化」 %>
    <%# ↓【(init: true)】の記述が無いと「未定義（今回は値が無い）」時に【JavaScript】のエラーが発生する %>
    <%# 【エラー内容】Uncaught (in promise) ReferenceError: gon is not defined %>
    <%= include_gon(init: true) %>
    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>
    <%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %>
    <%= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload' %>
  </head>

  <body>
    <%= yield %>
  </body>
</html>
```
##### （2） 「views/diaries/_form.html.erb」を編集します。
「new.html.erb」「edit.html.erb」を「_form.html.erb」（パーシャル）を利用して、共通化しています。「choose_new_or_edit」のコードについては「[7. ヘルパー](#7-ヘルパー)」で定義をしていますので、そちらをご覧ください。
```eruby:views/diaries/_form.html.erb
<%= form_with(model: @diary, local: true, url: choose_new_or_edit) do |form| %>
  <% if @diary.errors.any? %>
    <div>
      <%= @diary.errors.count %>のエラーが発生<br>
      <% @diary.errors.full_messages.each do |msg| %>
        <%= msg %><br>
      <% end %>
    </div>
  <% end %>

  <div>
    <%= form.label :title %>
    <%= form.text_field :title %>
  </div>

  <div>
    <%= form.label :content %>
    <%= form.text_area :content %>
  </div>

  <%# script内の「hidden_address.setAttribute("value", inputAddress)」を
  介して、【住所入力用のテキストボックス】の値を受け取る %>
  <%= form.hidden_field :address, id: "hidden_address" %>

  <div>
    イベント先
    <%# 住所入力用のテキストボックスを作成 %>
    <input id="address" type="textbox" value="">
    <%# 地図検索ボタンを作成 %>
    <input type="button" value="地図検索" onclick="codeAddress()">
  </div>

  <%# 地図検索ボタンを押すと、ここに【経度・緯度】が表示される %>
  <div id="display"></div>

  <%# 地図が表示される %>
  <div id='map' style='height: 600px; width: 600px;' ></div>

  <script>
    const display = document.getElementById('display')
    let map
    let marker

    // 地図の初期設定
    function initMap() {
      // 地図の作成
      map = new google.maps.Map(document.getElementById('map'), {
        center: {
          lat: 35.6458437,
          lng: 139.7046171
        },
        zoom: 9,
      });

      // 緯度・経度が存在する場合はif文の中身を実行
      // 緯度・経度が存在する状況は【日記新規作成確認画面から「戻る」場合】と【「日記編集」をする場合】がある
      // gonを使用してcontrollerから緯度・経度を取得
      if (!!gon.lat_lng) {
        const lat_lng = {lat: gon.lat_lng[0], lng: gon.lat_lng[1]};
         // 指定された座標でマップを中央に表示
        map.setCenter(lat_lng);
        // 指定された座標でマーカーを立てる
        marker = new google.maps.Marker({
          map: map,
          position: lat_lng
        });
      }
    }
    
    // 地図検索ボタンを押すと実行される関数
    function codeAddress() {
      // 【住所入力用のテキストボックス】の値を取得
      const inputAddress = document.getElementById('address').value;

      const geocoder = new google.maps.Geocoder()
      geocoder.geocode({
        'address': inputAddress
      // resultsに「ジオコーディングの結果」、statusに『ステータスコード』を渡す
      }, function (results, status) {
        // statusが「OK」の場合は...
        if (status == 'OK') {
          // 指定された座標でマップを中央に表示
          map.setCenter(results[0].geometry.location); 
          // 「既にmarkerに値がある」場合は...
          if (typeof marker != 'undefined') {
            // markerをnullにする（前のマーカーが削除される）
            marker.setMap(null);
          }
          // 指定された座標でマーカーを立てる
          marker = new google.maps.Marker({
          map: map,
          position: results[0].geometry.location
        });
        // 「hidden_address」に【住所入力用のテキストボックス】の値を送る
        const hidden_address = document.getElementById('hidden_address');
        hidden_address.setAttribute("value", inputAddress);

        // 検索結果が存在する場合は「display」に緯度・経度を表示させる
        display.textContent = "<%= '検索結果' %>" + results[0].geometry.location
        } else {
          // 検索結果が無い場合はその旨を表示させる 
          alert("<%= '検索結果がありません' %>" + status);
        }
      });
    }
  </script>

  <%# APIキーの読み込み %>
  <%# callback=initMap … APIが読み込まれたらinitMap()という関数を実行 %>
  <%# ※下記の説明は引用です ------- %>
    <%# async … async属性は、外部のスクリプト・ファイルのスクリプトが使用可能になったら実行する属性 %>
    <%# defer … defer属性は、ページ読み込み時に、外部のスクリプト・ファイルのスクリプトを実行する属性 %>
    <%# 「async属性」と「defer属性」を両方指定した場合、 「async属性」に対応しているブラウザは「async属性」に従う。「async属性」に対応していないブラウザは「defer属性」に従う %>
    <%# 参考文献*3にURLを掲載 %>
  <%# -------------------------- %>

  <%# 「GOOGLE_MAP_KEY」には、自分で定義をした環境変数を入れる %>
  <script src="https://maps.googleapis.com/maps/api/js?key=<%= ENV['GOOGLE_MAP_KEY'] %>&callback=initMap" async defer></script>

  <%= form.submit %>
<% end %>
```
##### （3） 「views/diaries/confirm.html.erb」を編集します。
```eruby:views/diaries/confirm.html.erb
<h2>日記新規投稿確認画面</h2>

<%= form_with(model: @diary, local: true) do |form| %>
  <div>
    <%= form.label :title %>
    <%= form.text_field :title, disabled: true %>
  </div>

  <div>
    <%= form.label :content %>
    <%= form.text_area :content, disabled: true %>
  </div>

  <div>
    <%= form.label :address %>
    <%= form.text_field :address, disabled: true %>
  </div>

  <%= form.hidden_field :title %>
  <%= form.hidden_field :content %>
  <%= form.hidden_field :address %>

  <%# @diary.addressが存在する場合のみ地図が表示される %>
  <% if @diary.address.present? %>
    <div id='map' style='height: 600px; width: 600px;' ></div>

    <script>
      const lat_lng = {lat: gon.lat_lng[0], lng: gon.lat_lng[1]};
      function initMap() {
        const map = new google.maps.Map(document.getElementById('map'), {
          center: lat_lng,
          zoom: 9,
        });
        var marker = new google.maps.Marker({
          position: lat_lng,
          map: map
        });
      }
    </script>

    <script src="https://maps.googleapis.com/maps/api/js?key=<%= ENV['GOOGLE_MAP_KEY'] %>&callback=initMap" async defer></script>
  <% end %>

  <%= form.submit %>
  <%= form.submit '戻る', name: 'back' %>
<% end %>
```
##### （4） 「views/diaries/index.html.erb」を編集します。
```eruby:views/diaries/index.html.erb
<h2>日記一覧</h2>

<%# 省略 %>

<%# 【data: {turbolinks: false}】の記述によりAPIの再読み込むを防ぐ %>
<%# 記述が無いと【JavaScript】のエラーが発生する↓ %>
<%# 【エラー内容】<%# You have included the Google Maps JavaScript API multiple times on this page. This may cause unexpected errors. %>
<%= link_to '日記新規投稿へ', new_diary_path, data: {turbolinks: false} %>
```
今回に限らず「turbolinks」を無効にしておかないと「2回目にAPIキーを使用している画面」に遷移した際に、再度APIキーを読み込んでしまい【JavaScript】でエラーが発生します。

## 7. ヘルパー
```ruby:diaries_helper.rb
module DiariesHelper
  def choose_new_or_edit
    case action_name
    # 【確認画面から『戻る』】→ もう一度【確認画面】
    # 【日記新規投稿】 → 【バリデーション発生】 → もう一度【確認画面】
    # 以上の2点にも対応ができるように『'create'』『'confirm'』の両方を記述している
    when 'new', 'create', 'confirm'
      confirm_diaries_path
    when 'edit'
      diary_path
    end
  end
end
```

## 8. 「geocoder.rb」の設定
検索精度を向上させる為「geocoder.rb」の設定を行います。今回は下記のように設定をしました。「geocoder.rb」の他の詳しい設定に関しては、[gem geocoder](https://github.com/alexreisner/geocoder)の「公式サイトのREADME.md」や[gem geocoder - lib/generators/geocoder/config/templates/initializer.rb](https://github.com/alexreisner/geocoder/blob/master/lib/generators/geocoder/config/templates/initializer.rb)、または他の記事をご覧ください。
※API導入に関してはこちらでは取り上げていません、[参考文献](#9-参考文献)など他の記事をご覧ください。また、<font color="PaleVioletRed">APIキーの取り扱いにはお気をつけください！</font>
```ruby:config/initializers/geocoder.rb
Geocoder.configure(
  lookup: :google,
  # 「GOOGLE_MAP_KEY」には、自分で定義をした環境変数を入れる
  api_key: ENV['GOOGLE_MAP_KEY'],
)
```

## 9. 参考文献
[IT用語辞典 - バイナリ-ジオコーディング *1](https://www.sophia-it.com/content/%E3%82%B8%E3%82%AA%E3%82%B3%E3%83%BC%E3%83%87%E3%82%A3%E3%83%B3%E3%82%B0)
[【Rails】 初心者向け！gem 'dotenv-rails'の使い方 *2](https://qiita.com/__Wata16__/items/d2d3aa21623f8c026d5d)
[HTML5入門 - async属性 *3](http://html5.cyberlab.info/elements/scripting/script-async.html)
[HTML5入門 - defer属性 *3](http://html5.cyberlab.info/elements/scripting/script-defer.html)
[【rails】google maps api　地図情報含んだ投稿をして表示させる方法](https://qiita.com/sho-17/items/0854d59d52d9c3679370)
[Google Mapを「投稿画面」と「詳細画面」の2か所に実装してみた。](https://qiita.com/kanato4/items/f2f3f7accd880224616a)
[【Rails6 / Google Map API】初学者向け！Ruby on Railsで簡単にGoogle Map APIの導入する](https://qiita.com/nagase_toya/items/e49977efb686ed05eadb)
[Google Maps API を使ってみた](https://qiita.com/Haruka-Ogawa/items/997401a2edcd20e61037)
[Rails5でGoogleMapを表示してみるまで](https://qiita.com/tiara/items/4a1c98418917a0e74cbb)
[RailsのGeocoderとあそぼ](https://qiita.com/tiara/items/573fe5f1a84ca57dabcd)