この投稿は、
DMM WEBCAMP Advent Calendar 2023
シリーズ2 投稿8日目のエントリー(大遅刻穴埋め勢)です。
7日目も私ですが、本記事の元ネタとなります。
はじめに
DMM WEBCAMP でメンターをやらせていただいております。 @tomoaki-kimura です。
Rails7はフロント周りがとても楽しくなりました。 Stimulus
もその一つです。丁度よい機能と学習コストです。
JQueryも状況によっては全然アリなのですが、どうやら世の中脱JQueryの流れにはなっているようですし、今後を踏まえてキャッチアップしておくのも手ではないでしょうか。
今回は、既出の記事
こちらの、Rails7版をやっていきます。
環境
- Ruby 3.2.2
- Rails 7.1.2
- yarn 1.22.19
Importmap に NPM js-api-loader を読ませて GoogleMapApi を使ってみます。
作るもの
- Pointモデルを作り、 名前・緯度経度・住所 を登録できる。
- GooGleMapApiでPointを地図表示。
- 登録したポイントを一覧表示する地図とポイントの登録用の地図は1ページで。
- 別途詳細ページにも地図を置く。
- 地図の登録は、キーワード検索と地図クリックで補正が出来る。
- 住所は緯度経度から逆ジオコーディングで入力補助。自由入力も可能。
- 一覧表示の地図のピンをクリックしたら吹き出しが出て詳細へのリンクを設置。
プロジェクトの作成
まずはプロジェクトを作成します。 DBもSQLiteを使います。importmapを使いますので、何も指定せず開始です。
(7.0.x系でcssオプションをBootstrapにした場合、esbuildになるので注意して下さい。7.1.x系は問題ないです)
下記コマンドを実行して下さい。
$ gem install rails -v 7.1.2
$ rails _7.1.2_ new google_map
長いので略
create app/javascript/controllers/application.js
create app/javascript/controllers/hello_controller.js
Import Stimulus controllers
append app/javascript/application.js
Pin Stimulus
Appending: pin "@hotwired/stimulus", to: "stimulus.min.js", preload: true"
append config/importmap.rb
Appending: pin "@hotwired/stimulus-loading", to: "stimulus-loading.js", preload: true
append config/importmap.rb
Pin all controllers
Appending: pin_all_from "app/javascript/controllers", under: "controllers"
append config/importmap.rb
run bundle install
Bundle complete! 14 Gemfile dependencies, 82 gems now installed.
Use `bundle info [gemname]` to see where a bundled gem is installed.
run bundle lock --add-platform=x86_64-linux
Writing lockfile to /Users/kimuratomoaki/Desktop/google_map/Gemfile.lock
run bundle lock --add-platform=aarch64-linux
Writing lockfile to /Users/kimuratomoaki/Desktop/google_map/Gemfile.lock
プロジェクトが作成後
$ cd google_map
でディレクトリを移動しておきましょう。
モデルの作成
今回は緯度経度と住所を登録する Point
モデルを作成します。
attr | class | |
---|---|---|
名前 | name | String |
緯度 | latitude | Float |
緯度 | longitude | Float |
住所 | address | String |
では、早速今回必要なモデルを作ってしまいましょう。下記コマンドで作成します。
$ rails g model point name latitude:float longitude:float address
モデルが作成出来たら、
$ rails db:migrate
をしておきましょう。
point.rb
各カラムに presence: true
と keyword
を attr_accessor
に書いておきます。
class Point < ApplicationRecord
attr_accessor :keyword
validates :name, presence: true
validates :latitude, presence: true
validates :longitude, presence: true
validates :address, presence: true
end
ダミーデーター
seeds.rb
これは必須ではないのですが、最初の動作を確認しやすいので、ダミーデーターを入れておきます。
seeds.rbを以下のように書き換えましょう。
data = [
{ name: "都庁",
latitude: 35.6895014,
longitude: 139.6917337,
address: "日本、〒163-8001 東京都新宿区西新宿2丁目8−1" },
{ name: "後楽園",
latitude: 35.7054551,
longitude: 139.7535553,
address: "日本、〒112-0004 東京都文京区後楽1丁目3" },
{ name: "江ノ電自転車ニキ",
latitude: 35.3076198,
longitude: 139.4937805,
address: "日本、〒248-0033 神奈川県鎌倉市腰越2丁目10−25" }
]
data.each do |point_attribute|
Point.create!(point_attribute)
end
データーを流します。
$ rails db:seed
Running via Spring preloader in process 98208
エラーが出なければOKです。
コントローラー
contorolle と view をコマンドで作ります。
テンプレートは index
と show
を作ります。
$ rails g controller points index show
create app/controllers/points_controller.rb
route get 'points/index'
get 'points/show'
invoke erb
create app/views/points
create app/views/points/index.html.erb
create app/views/points/show.html.erb
invoke test_unit
create test/controllers/points_controller_test.rb
invoke helper
create app/helpers/points_helper.rb
invoke test_unit
points_controller.rb
コントローラーは以下のように書いておきましょう。
class PointsController < ApplicationController
def index
@points = Point.all
@points_json = @points.map { |o| point_to_hash(o) }.to_json
@point = Point.new
end
def show
@point = Point.find(params[:id])
@point_json = point_to_hash(@point).to_json
end
def create
@point = Point.new(point_params)
if @point.save
flash[:notice] = 'ポイントを登録しました'
redirect_to root_url
else
@points = Point.all
@points_json = @points.map { |o| point_to_hash(o) }.to_json
flash.now[:alert] = 'ポイントを登録できませんでした'
render :index, status: :unprocessable_entity
end
end
private
def point_params
params.require(:point).permit(:name, :latitude, :longitude, :address, :keyword)
end
def point_to_hash(point)
{ id: point.id,
name: point.name,
lat: point.latitude,
lng: point.longitude }
end
end
JS側に渡すデーターは、モデルオブジェクトから、Jsonへと変換しますが、下記のように
def point_to_hash(point)
{ id: point.id,
name: point.name,
lat: point.latitude,
lng: point.longitude }
end
一旦Hashに変換するメソッドを作っておき、以下のように使います。
すると、それぞれのアクションに必要なJsonを作成可能となります。
@points_json = @points.map { |o| point_to_hash(o) }.to_json
@point_json = point_to_hash(@point).to_json
ルーティング
routes.rb
ルーティングですが、自動生成されたものもありますが、全て下記のように書き換えてしまいましょう。
Rails.application.routes.draw do
root "points#index"
post "/", to: "points#create"
resources :points, only: :show
get "up" => "rails/health#show", as: :rails_health_check
end
データーの確認
points/index.html.erb
中身はさておき、トップページを確認しておきたいので、 index.html.erb をちょっと触ってみます。
下記のような感じでざっくり・・。
<h1>Points#index</h1>
<%= @points.inspect %>
<br><br>
<%= @points_json %>
inspect
は モデルオブジェクトの集合体を可視化するために付け加えてます。
これで、一旦起動しましょう。
$ rails s
いい感じです。
正常に表示されたら中身を確認します。
[{"id":1,"name":"都庁","lat":35.6895014,"lng":139.6917337},{"id":2,"name":"後楽園","lat":35.7054551,"lng":139.7535553},{"id":3,"name":"江ノ電自転車ニキ","lat":35.3076198,"lng":139.4937805}]
使うのはこちらの Json
で、データーはhtmlの datasetプロパティ
を使って埋め込み、そこから lat
lng
を取り出してマーカーを立てていきます。
プレビューが確認できたら、一旦サーバーを落としましょう。
Stimulesにコントローラーを追加する
ここからが本題です。
StimulusはRails7から正式採用されていますので、すぐに使い始められます。
Stimulus自体は、 webpack や esbuild 環境下なら動作しますので、他のFWに導入する事も意外と簡単です。
冒頭で紹介したrails6のものや
↓こちらではElixirのFW、Phoenixでほぼ同一のコードのまま移植しています。ご興味のある方は是非ちらっと覗いてみて下さい。
ファイルの確認
Stimulusのコントローラーの場所を見ておきましょう。
(Hello_controllerはサンプルなので消しても良いですが、無視します。)
controllers/application.js
そのファイル中で一つ、javascript/controllers/application.js
を開いて下さい。
必須ではないですが、デバッグの表示を切り替えておきます。
application.debug
を true
にしましょう。
( ※このモードは切り替えなくても普通にconsole.log等は使えますので絶対必要ではありません。)
import { Application } from "@hotwired/stimulus"
const application = Application.start()
// Configure Stimulus development experience
application.debug = true //ここfalseからtrueに
window.Stimulus = application
export { application }
他も確認のみしておきましょう。
controllers/index.js
// Import and register all your controllers from the importmap under controllers/*
import { application } from "controllers/application"
// Eager load all controllers defined in the import map under controllers/**/*_controller
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
eagerLoadControllersFrom("controllers", application)
// Lazy load controllers as they appear in the DOM (remember not to preload controllers in import map!)
// import { lazyLoadControllersFrom } from "@hotwired/stimulus-loading"
// lazyLoadControllersFrom("controllers", application)
javascript/application.js
// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
import "@hotwired/turbo-rails"
import "controllers"
一旦起動しましょう。
$ rails s
デベロッパーツール上にこのような表示が出たら、Stimulusのdebugが有効になっていますので、まず読み込みは大丈夫という事になります。
確認出来たらサーバーは落としておきましょう。
GoogleMapApiの準備
この辺で地図の方も準備しておきます。
Apiキーはフロント側で作業する場合、結局は見えるので間違いなくApiキー制限を行って下さい。
キーが出来たらGoogleMapApi自体を使えるように、
@googlemaps/js-api-loader
をインポートマップに登録しますので、
$ ./bin/importmap pin @googlemaps/js-api-loader
を実行しておきましょう。
Pinning "@googlemaps/js-api-loader" to https://ga.jspm.io/npm:@googlemaps/js-api-loader@1.16.2/dist/index.esm.js
このように返ってきます。importmap は
などからCDNを読みに行くようにうまい具合に設定してくれます。
これで準備完了です。
StimulusControllers
今回は、Stimulusの継承を使って、3種類のマップを個別のコントローラーで制御します。
イメージは、こんな感じです。
では、まず最初は application.js
から
google_map/application_controller.js
コマンドでコントローラーが作成可能です。
$ rails g stimulus google_map/application
と打つと、
create app/javascript/controllers/google_map/application_controller.js
このように返ってきます。
作成された、 google_map/application.js
は以下のように書き換えておきましょう。
import { Controller } from "@hotwired/stimulus"
import { Loader } from "@googlemaps/js-api-loader"
export default class extends Controller {
// 地図の設定。APIキーはHTML上で見えるので、Google側で要制限
setLoader() {
return new Loader({
apiKey: "xxxxxxxここに取得したAPIキーxxxxxxxxx",
version: "weekly",
});
}
}
このファイルではライブラリのインポート
import { Controller } from "@hotwired/stimulus"
import { Loader } from "@googlemaps/js-api-loader"
に加えて、
APIキーの設定を行っています。
return new Loader({
apiKey: "xxxxxxxここに取得したAPIキーxxxxxxxxx",
version: "weekly",
});
APIキーですが、気になる方は、RailsCredentialやDotrenv等を利用しても良いと思います。
が、JSなので基本モロバレです。しつこいようですが、Googleの利用制限は確実に行っておきましょう。
参考までに、私が今回 RailsCredential
を使っている方法を紹介しますと、
$ EDITOR=vi rails credentials:edit
でvimを立ち上げ、
# aws:
# access_key_id: 123
# secret_access_key: 345
google:
api_key: xxxxxxxxxxxxxxYOUR_API_KEYxxxxxxxxxxxxxxxxx
# Used as the base secret for all MessageVerifiers in Rails, including the one protecting cookies.
secret_key_base: xxxxxxxxここはそれぞれの環境で違う文字の羅列(触らない)xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
このように、ymlで google.api_key という階層を作り、値に取得したキーを入れます。(""はあってもなくても良いです)
google:
api_key: xxxxxxxxxxxxxxYOUR_API_KEYxxxxxxxxxxxxxxxxx
これを、JS側で読めるように、このようなタグを作成し、メタタグとして埋めます。
<%= tag :meta, name: :google_api_key, content: Rails.application.credentials.google[:api_key] %>
<!DOCTYPE html>
<html>
<head>
<title>GoogleMap</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= tag :meta, name: :google_api_key, content: Rails.application.credentials.google[:api_key] %>
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
<%= javascript_importmap_tags %>
</head>
<body>
<%= notice %>
<%= alert %>
<%= yield %>
</body>
</html>
最後に、 google_map/application_controller.js
の apiKey
を
import { Controller } from "@hotwired/stimulus"
import { Loader } from "@googlemaps/js-api-loader"
export default class extends Controller {
// 地図の設定。APIキーはHTML上で見えるので、Google側で要制限
setLoader() {
return new Loader({
apiKey: document.head.querySelector("meta[name=google_api_key]").content,
version: "weekly",
});
}
}
という具合に、 document.head.querySelector("meta[name=google_api_key]")
でDOMを取り出し、 content
でKeyを取り出す記述に変更しておきます。
こうすれば、少なくともGithub等で見られる事はなくなり、デプロイ環境でも master.key
さえあれば良い事になります。
(ホントしつこいようですが、これ普通に見えますのでちゃんと設定しましょう)
これで application_controllerの設定は終わりですので、各ページの実装をしていきましょう。
/points/:id で個別表示
まずは簡単なやつからいきます。1件だけのPointデーターとピン付きの地図を表示します。
points/show.html.erb
showアクションのテンプレートを以下のように書いて下さい。
<h1>Points#show</h1>
<div data-controller="google-map--show">
<div data-google-map--show-target="map" data-json="<%= @point_json %>" style="height:30vh;max-width:400px;"></div>
</div>
<br>
<table border="1">
<tbody>
<tr>
<th>ポイント</th>
<td><%= @point.name %></td>
</tr>
<tr>
<th>住所</th>
<td><%= @point.address %></td>
</tr>
<tr>
<th>緯度</th>
<td><%= @point.latitude %></td>
</tr>
<tr>
<th>軽度</th>
<td><%= @point.longitude %></td>
</tr>
</tbody>
</table>
<%= link_to "back", root_url, data: { turbolinks: false } %>
この中で、地図の表示は、
<div data-controller="google-map--show">
<div data-google-map--show-target="map" data-json="<%= @point_json %>" style="height:30vh;max-width:400px;"></div>
</div>
ここになります。また、
back
ボタンは data: { turbo: false }
で turbo
の介入を防いでおくと不要なエラーを防ぐ事が出来ます。
data: { turbo: false }
は 'data-turbo': false
と書き換える事もできます。
フォームヘルパー内でハイフン-
の付くプロパティはクォーテーション""
で囲むと正常に動作します。
基本的にJSの読み込み順で動作が怪しくなるページへのリンクは全て、個別に turbolinks
を切る設定をしておくと良いでしょう。
data-controller
data-controller="google-map--show"
が これから作成するコントローラー名の指定部分となります。
data-controller
で囲まれた部分がその該当コントローラーと接続される事になりますので、効果範囲を自由に明確に設定する事ができます。
google-map--show
の部分は javascript/controllers/google_map/show_controller.js
というファイルパスを指している事になります。
data-google-map--show-target
data-google-map--show-target="map"
の部分は、DOMをStimulus側に登録するためのキーです。
data-コントローラー名-target
または、
data-ディレクトリ名--ファイル名-target
のような命名規則となります。
値の map
は登録する変数名の元となります。
Stimulus側で、 値Target
として登録できますので、今回だと mapTarget
として登録でき、
this.mapTarget
と書くとDOMを呼び出す事が可能となります。
data-json="<%= @point_json %>"
の部分は1件分の座標データーをStimulusが受け取るようにRailsから変数を渡します。
google_map/show_controller.js
では対応するコントローラーを作りましょう。
$ rails g stimulus google_map/show
Running via Spring preloader in process 10989
create app/javascript/controllers/google_map/show_controller.js
続いてマニフェストの更新を行います。
$ rails stimulus:manifest:update
何も表示がなければ成功です。
google_map/show_controller.js
は以下のように編集しておきます。
//親コントローラーをimportする事で共通の設定や関数をまとめている
import ApplicationController from "./application_controller";
// ↓これは元々記述されているコメントで、View側でタグにこの記述を埋めるとこのコントローラーとつながるよという内容
// Connects to data-controller="google-map--show"
export default class extends ApplicationController {
// View側の、data-google-map--show-target="map" のついたDOM を mapTarget として登録
static targets = [ 'map' ]
// Viewが繋がったときに実行される関数
connect() {
// newMap()の呼び出し
this.newMap()
}
// 地図を表示させる関数
newMap() {
// 親コントローラーにある関数、 setLoader()の呼び出し。
const loader = this.setLoader()
// async wait を使ってGoogleMapのライブラリが読み込まれるまで待つ。
loader.load().then(async () => {
// 読み込みを待つとMapコンストラクターが使えるように。
const { Map } = await google.maps.importLibrary("maps");
// this.mapTarget.dataset.json で埋め込んだデーターを取り出し、Jsonに変換。
this._location = JSON.parse(this.mapTarget.dataset.json)
// new Map で新しく地図を作成 座標と縮尺を設定。
this._map = new Map(this.mapTarget, {
center: { lat: this._location.lat, lng: this._location.lng },
zoom: 15,
})
// マーカー設置。先程作った this._map に 座標 this._location を指定する事でピンを刺す 。
new google.maps.Marker({
map: this._map,
position: this._location
})
})
}
}
動作はコメントに書いていますので、流れは追えるかな・・と思います。
ここで、 this._xxxx
という変数が気になると思いますが、 stimulus番ローカル変数だという認識良いでしょう。
const xxxx
と書く事と役割は変わりないのですが、Stimulusでローカル変数を書く場合、 this._
を付けて扱っているようです。
また、 this._xxx
は const
と違って再代入は可能です。
なので、コードの意図によっては使い分けて良いのかな・・・と思います。
これで一旦確認してみましょう。
$ rails s
で起動して、 points/1
にアクセスしてみましょう。
表示されていたらOKです。
確認出来たらサーバーを落としても大丈夫です。
/points での全件表示+ふきだし
次に複数件表示させる方法です。
また、ここでは、各ピンにクリックイベントで吹き出しの出現と、さらに showアクションに飛ぶリンクを設置します。
points/index.html.erb
一覧ページは次のように書き換えます。
<h1>Points#index</h1>
<div data-controller="google-map--index">
<div data-google-map--index-target="map" data-json="<%= @points_json %>" style="height:50vh;max-width:800px;"></div>
</div>
<br>
先程と同じパターンなので、ここは特に気にする部分もないでしょう。
google_map/index_controller.js
こちらも対応するコントローラーを作りましょう。
$ rails g stimulus google_map/index
Running via Spring preloader in process 15006
create app/javascript/controllers/google_map/index_controller.js
続いて下記を実行
$ rails stimulus:manifest:update
中身は以下のように編集します。
import ApplicationController from "./application_controller";
// ファイル内でグローバルに使いたい変数。
let map
let markers = []
let infoWindows = []
// Connects to data-controller="google-map--index"
export default class extends ApplicationController {
// ステートとして管理したい変数を設定。デフォルト値も先に入れておく。
// locationのように階層化されたデーターはgetは出来るが下層データーを個別にsetできない。
// pointsのような配列データーも1件ずつの登録変更削除はできず、配列そのものを再代入しかできない。
static values = { location: {
lat: 38.041184121,
lng: 137.1063077823
},
zoom: 5,
points: [] }
// 使うDomを変数に登録。 this.mapTarget で呼び出し。
static targets = [ 'map' ]
// 読み込み時に実行する関数。
connect() {
this.setPoints()
this.newMap()
}
newMap() {
const loader = this.setLoader()
loader.load().then(async () => {
const { Map } = await google.maps.importLibrary("maps");
// 地図を statis values の情報から作成
map = new Map(this.mapTarget, {
center: this.locationValue,
zoom: this.zoomValue,
})
// 地図に全マーカーをセット
this.addMarkersToMap()
})
}
// ###地図にピンを追加する
addMarkersToMap() {
// 全ポイントをループで1件ずつ処理
this.pointsValue.forEach((o, i) => {
// markers に 座標データー1件を追加
this.addMarkerToMarkers(o)
// infoWindows に 吹き出しデーターを1件追加
this.addInfoWindowToInfoWindows(o)
// マーカーに該当する吹き出しを開くクリックイベントを追加
this.addEventToMarker(i)
})
}
addMarkerToMarkers(o) {
// 引数の値からマーカーを1件作成
this._marker = new google.maps.Marker({
position: { lat: o.lat, lng: o.lng },
map,
name: o.name
})
// markersに1件マーカーを追加
markers.push(this._marker)
}
addInfoWindowToInfoWindows(o) {
// 引数の値から吹き出しを1件作成
// 吹き出しの中にはpoints/:idへのリンク
// リンクには data-turbolinks="false" でリロードさせるとJS側のワーニングが出ない。
this._infoWindow = new google.maps.InfoWindow({
content: `
<a href="/points/${o.id}" data-turbo="false">
${o.name}
</a>
`
})
// infoWindowに1件吹き出しを追加
infoWindows.push(this._infoWindow)
}
addEventToMarker(i) {
// i番目のマーカーにクリックイベントを追加
markers[i].addListener('click', () => {
// 同じインデックス番号iを吹き出しを開く
infoWindows[i].open(map, markers[i]);
});
}
// ###Valuesの値操作
// DBに登録されているPoint全件をValuesに追加し、デフォルトの座標も更新
setPoints() {
this.pointsValue = JSON.parse(this.mapTarget.dataset.json)
this.getLastPointLocation()
}
// 最後に登録されたデーターの緯度経度をデフォルトの値としてリセット
getLastPointLocation() {
// pointsValue に登録されているポイントの要素がある場合のみ
if (this.pointsValue.length > 0) {
// 要素をid順に並び替えたときの最後の要素を取得し
this._lastPoint = this.pointsValue.sort((a, b) => { a.id - b.id }).reverse()[0]
// locationValueにポイントを登録
this.locationValue = this._lastPoint
// ズームは適当だが、データーが無い時より拡大気味に
this.zoomValue = 12
}
}
}
再び確認します。
$ rails s
こちらもOKのようです。
/points での入力補助
points/_form.html.erb
3つ目の入力フォーム。こちらはパーシャルを新規作成します。
パーシャルに分離しておけば、他のページ(例えば new
や edit
) を作っても 苦労せず動作を再現できます。
<div data-controller="google-map--form">
<%= form_with(model: point, url: root_path, local: true ) do |f| %>
<ul>
<% f.object.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
キーワードで探す:
<%= f.search_field :keyword, data: { google_map__form_target: 'keyword' } %><br>
<%= f.number_field :latitude, data: { google_map__form_target: 'latitude' }, tabindex: -1, readonly: true, class: "looks_disable" %>
<%= f.number_field :longitude, data: { google_map__form_target: 'longitude' }, tabindex: -1, readonly: true, class: "looks_disable"%><br>
<div data-google-map--form-target="map" style="height:20vh;max-width:300px;"></div>
name:
<%= f.text_field :name, data: { google_map__form_target: 'name' } %><br>
address:
<%= f.text_field :address, list: 'address_list', data: { google_map__form_target: 'address' } %><br>
<datalist id="address_list" data-google-map--form-target="addressList" ></datalist>
<%= f.submit "場所を登録する" %>
<% end %>
</div>
<style>
.looks_disable {
background-color: #eee;
pointer-events: none;
}
</style>
この中で目新しいものと言えば、フォームヘルパー内で書かれている、
data: { google_map__form_target: 'keyword' }
この書き方ですが、これはhtml上では、
data-google-map--form-target="keyword"
このように変換されます。
もうひとつ、今回は datalist
タグを使用しています。
以下のように input
の list
と datalist
の id
を紐付ける事で、自由入力と候補選択の2択の入力を行う事ができるフォームとなります。
<input list="同じ名称" name="">
<datalist id="同じ名称">
<option value="入力候補1"></option>
<option value="入力候補2"></option>
</datalist>
address
の入力に使用していますが、
address:
<%= f.text_field :address, list: 'address_list', data: { google_map__form_target: 'address' } %><br>
<datalist id="address_list" data-google-map--form-target="addressList" ></datalist>
option
タグが空の状態で作っておいて、 キーワード検索や地図のクリック時にリバースジオコーディングを行い、住所情報の候補を取ってきちゃおうという企みです。
これなら入力時に住所の確認をしてからDBをに登録する事ができますので、↓のgifのようなものが実現できます。
後は、緯度経度のフォームを書き込み禁止かつ、データーの送信を可能にするため、
tabindex
readonly
といったプロパティと、
.looks_disable {
background-color: #eee;
pointer-events: none;
}
このようなCSSを組み合わせて、
disabled
に近い見た目を作っています。
ここではCSSは直接htmlに書いてますが、本来は書くべき場所に記述していただけると良いですね。
posts/index.html.erb
今作ったパーシャルは index
から呼び出しますので、
<%= render "points/form", point: @point %>
この呼び出しを追記し、以下のようにしておきます。
<h1>Points#index</h1>
<div data-controller="google-map--index">
<div data-google-map--index-target="map" data-json="<%= @points_json %>" style="height:50vh;max-width:800px;"></div>
</div>
<br>
<%= render "points/form", point: @point %>
google_map/form_controller.js
最後のコントローラーを作りましょう。
$ rails g stimulus google_map/form
Running via Spring preloader in process 15268
create app/javascript/controllers/google_map/form_controller.js
続いて以下を実行。(一気にコントローラーだけ作って1回で済ませても良いです)
$ rails stimulus:manifest:update
google_map/show_controller.js
は以下のように編集しておきます。
import ApplicationController from "./application_controller";
// ファイル内でグローバルに使いたい変数
let map
let marker
// Connects to data-controller="google-map--form"
export default class extends ApplicationController {
// addressListValueに今回の入力補助の住所データーを格納する
static values = { location: {
lat: 35.6895014,
lng: 139.6917337
},
zoom: 15,
addressList: [] }
static targets = [ 'map', 'keyword', `address`,
'latitude', 'longitude', 'addressList' ]
connect() {
this.newMap()
}
newMap() {
const loader = this.setLoader()
loader.load().then(async () => {
const { Map } = await google.maps.importLibrary("maps");
map = new Map(this.mapTarget, {
center: this.Value,
zoom: this.zoomValue,
})
// フォームに緯度経度の情報があれば、locationValue の値と、地図の座標を更新
this.initMarker()
// 更新された情報で地図の中心を更新(setCenterはgooglemapの組み込み関数)
map.setCenter(this.locationValue)
// Stimulusのイベントを使うと `loader` のスコープ外となるので、async内に普通にJSのイベントを書く
this.keywordTarget.addEventListener('change', () => {
// 検索キーワードが変更されたらchangeKeywordAction()を実行
this.changeKeywordAction()
})
google.maps.event.addListener(map, 'click', (e) => {
// 地図がクリックされたらclickMapAction(イベントの戻り値)を実行
this.clickMapAction(e)
});
})
}
changeKeywordAction() {
// this.keywordTarget.value でkeywordフォームの値を取得
// 取得したキーワードからジオコーディングする
this.geoCoding(this.keywordTarget.value)
}
clickMapAction(e) {
// 引数から取得できる緯度経度の情報を取り出す
// lat() lng() と関数になっているので()が要るため注意
this._location = { lat: e.latLng.lat(), lng: e.latLng.lng() }
// 古いマーカーを削除
this.clearMarker()
// 緯度経度の情報を更新
this.setLocation(this._location)
// マーカーのセット
this.newMarker()
// キーワードフォームをクリア
this.keywordTarget.value = ""
// 緯度経度から住所を取り出す
this.reverseGeoGoding()
}
geoCoding(keyword) {
// ジオコーダーのコンストラクターを呼び出す
this._geocoder = new google.maps.Geocoder()
// キーワードで緯度経度を検索
this._geocoder.geocode({ address: keyword }, (results, status) => {
// 住所が見つかった場合の処理
if (status == 'OK') {
// 結果の緯度経度情報部分を取得
this._result = results[0].geometry.location
// 緯度経度情報をmap側で使えるように変換
this._location = { lat: this._result.lat(), lng: this._result.lng() }
// 古いマーカーを削除
this.clearMarker()
// 緯度経度の情報を更新
this.setLocation(this._location)
// マーカーのセット
this.newMarker()
// 更新された情報で地図の中心を更新
map.setCenter(this._location)
// 逆ジオコーディングで住所データーを取得
this.reverseGeoGoding()
} else {
// 駄目だったら緯度経度フォームをクリア
this.clearLocationForm()
}
})
}
reverseGeoGoding() {
// ジオコーダーのコンストラクターを呼び出す
this._geocoder = new google.maps.Geocoder()
// 緯度経度から住所を検索
this._geocoder.geocode({ location: this.locationValue }, (results, status) => {
if (status == 'OK') {
// 該当があれば datalist に住所情報を用意する
this.setAddresList(results)
} else {
// なければ datalist はクリアする
this.clearAddressList()
}
})
// アドレスフォームはクリアする
this.addressTarget.value = ""
}
clearAddressList() {
// addressListValue を空配列に戻し
this.addressListValue = []
// datalist 内も空にする
this.addressListTarget.innerHTML = ""
}
setAddresList(result) {
// 古い住所情報を削除
this.clearAddressList()
// 新しい住所情報を addressListValue にセット
this.setAddressListValue(result)
// セットした情報を `option` タグに変換し `datalist` に加える
this.addressListValue.forEach(address => {
this._option = document.createElement('option')
this._option.value = address
this.addressListTarget.append(this._option)
})
}
setAddressListValue(result) {
// ローカル変数に空配列を用意しておき
this._addressList = []
// 住所候補を1件ずつ追加
result.forEach(o => {
this._address = o.formatted_address
this._addressList.push(this._address)
});
// addressListValue を更新(static values は要素個別の追加ができないため一気に)
this.addressListValue = this._addressList
}
clearMarker() {
// マーカーがnullだとコンストラクターがないため、setMap()が使えない為の条件式
if (marker != null) {
// マーカーを空にする
marker.setMap(null)
}
}
newMarker() {
// マーカーをセット
marker = new google.maps.Marker({
map: map,
position: this.locationValue
})
}
writeToLocationForm(location) {
// フォームに緯度経度を書き込む
this.latitudeTarget.value = location.lat
this.longitudeTarget.value = location.lng
}
clearLocationForm() {
// 緯度経度のフォームをクリア
this.latitudeTarget.value = ""
this.longitudeTarget.value = ""
}
initMarker() {
// フォームから緯度経度の値を取得
this._latitude = this.latitudeTarget.value
this._longitude = this.longitudeTarget.value
// フォームの値が緯度経度共に空でない場合
if (this._latitude != "" && this._longitude != "") {
// 取得した情報は文字の為parseFloatした上で locationValue にセット
this.locationValue = { lat: parseFloat(this._latitude), lng: parseFloat(this._longitude) }
// セットした locationValue からマーカーをセット
this.newMarker(this.locationValue)
}
}
setLocation(location) {
// lovcationValue に 引数の値をセット
this.locationValue = location
// 緯度経度のフォームに引数の値をセット
this.writeToLocationForm(location)
}
}
コメントを入れているのでコードが長くなっていますが、 VSCodeを使用している場合、 ctrl(cmd)
+ click
で対応する関数にジャンプ出来ますので、流れを追いかけてみて下さい。
では、最後の確認です。
完成物
$ rails s
でプレビューを確認します。
完成です。
ここまでお疲れさまでした。
おわりに
Rails7のimportmapでGooglaMapApiを使ってみました。いかがでしたか?
Stimulusは HotWireの中でも割と入りやすく、機能がシンプルなので応用がききやすいと感じています。
簡単なハンズオンなので、是非年末年始の暇つぶしに作っていただけると幸いです。
では、引き続き DMM WEBCAMP Advent Calendar 2023 をよろしくお願いいたします。
次回 9日目の投稿は、@masaa0802 さんです。
学習中の方には転職のリアルなお話は助かりますね。
スレッドの購読やいいねも是非お願いします。
ここまで読んでいただきありがとうございます。