この記事を書いた背景
DIC(プログラミングスクール)の卒業課題として作成をしたオリジナルアプリで、日記投稿機能にGoogle Mapsを実装しました。その際にGoogle Mapsの実装に苦労をしたので「自分の為に、また同様な事に悩んでいる人の為に」改めてまとめてみました。
この記事の概要
実際に作成をしたオリジナルアプリ(下記URL)では、「ユーザー」「グループ」「日記」「地図」の機能がありますが、今回は記事としての汎用性が高くなるように、「日記」「地図」の機能のみを改めて実装した内容をまとめました。日記を投稿する前に確認ができる「投稿確認画面」の機能付きです。それでは記事をご覧ください!
※「日記1つの投稿に対して、地図情報は1つのみ」の実装です。
※API導入に関してはこちらでは取り上げていません、参考文献など他の記事をご覧ください。また、APIキーの取り扱いにはお気をつけください!
※実務未経験者です、間違い等ありましたらご指摘をいただけると助かります。
実際に作成をしたオリジナルアプリはこちら
開発環境
- macOS Monterey 12.1
- Ruby 3.0.1
- Rails 6.1.6
- psql (PostgreSQL) 14.3
目次
1. 使用したgem
# --- 省略 ---
# 追加
gem 'geocoder'
gem 'gon'
group :development, :test do
# 追加
gem 'dotenv-rails'
end
gem geocoder
「ジオコーディング」の為に使用します。
ジオコーディングとは、住所や地名、駅名などの地理的情報を、緯度・経度の座標値に変換する技術のことである。日常生活で使用している、場所を示すキーワードを用いて地図上の場所を特定することができるため、地図データの利用効率を向上させることができる。(IT用語辞典バイナリより 参考文献*1にURLを掲載)
gem gon
今回「controller」で取得した値を「viewsで使用されているJavaScript」に渡す為に利用をしています。
gem dotenv-rails
環境変数の管理に使用します。
こちらの記事では詳しく取り上げていませんが、APIキーの取り扱いにはお気をつけください!
※「gem dotenv-rails」についての参考になるよう、参考文献*2にURLを掲載しています。
2. diariesテーブル
今回作成をする「diariesテーブル」はこちらです。
カラム論理名 | カラム物理名 | データ型 | オプション |
---|---|---|---|
タイトル | title | string | null: false |
内容 | content | text | |
住所 | address | string | |
緯度 | latitude | float | |
経度 | longitude | float |
「addressカラム」に実際に入る内容は「〒105-0011 東京都港区芝公園4丁目2−8といった『住所』」や「東京タワーなどの『シンボル名』」が入る事になります。
3. ルーティング
Rails.application.routes.draw do
# 追加
resources :diaries do
post 'confirm', on: :collection
end
end
今回は「投稿確認画面」実装の為にpost 'confirm', on: :collection
を記述しています。
4. モデル
class Diary < ApplicationRecord
# 日記タイトルが空の場合は、バリデーションを発生させる
validates :title, presence: true
# モデルの中で、オブジェクトの住所がどこにあるかをgeocoderに伝える
geocoded_by :address
# addressカラムに入った情報を元に「latitude」「longitude」に緯度・経度の情報が入る
after_validation :geocode
end
5. コントローラー
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ファイルとなります。
(1) 「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. ヘルパー」で定義をしていますので、そちらをご覧ください。
<%= 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」を編集します。
<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」を編集します。
<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. ヘルパー
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の「公式サイトのREADME.md」やgem geocoder - lib/generators/geocoder/config/templates/initializer.rb、または他の記事をご覧ください。
※API導入に関してはこちらでは取り上げていません、参考文献など他の記事をご覧ください。また、APIキーの取り扱いにはお気をつけください!
Geocoder.configure(
lookup: :google,
# 「GOOGLE_MAP_KEY」には、自分で定義をした環境変数を入れる
api_key: ENV['GOOGLE_MAP_KEY'],
)
9. 参考文献
IT用語辞典 - バイナリ-ジオコーディング *1
【Rails】 初心者向け!gem 'dotenv-rails'の使い方 *2
HTML5入門 - async属性 *3
HTML5入門 - defer属性 *3
【rails】google maps api 地図情報含んだ投稿をして表示させる方法
Google Mapを「投稿画面」と「詳細画面」の2か所に実装してみた。
【Rails6 / Google Map API】初学者向け!Ruby on Railsで簡単にGoogle Map APIの導入する
Google Maps API を使ってみた
Rails5でGoogleMapを表示してみるまで
RailsのGeocoderとあそぼ