この投稿は、
DMM WEBCAMP Advent Calendar 2023
シリーズ2 投稿7日目のエントリー(大遅刻穴埋め勢)です。
6日目は、@Hiron0120 さんで、
でした。
はじめに
DMM WEBCAMP でメンターをやらせていただいております。 @tomoaki-kimura です。
Rails7はフロント周りがとても楽しくなりました。 Stimulus
もその一つです。丁度よい機能と学習コストです。
Rails6環境でも Stimulus
を使ったJSの管理はかなり有用です。
JQueryも状況によっては全然アリなのですが、どうやら世の中脱JQueryの流れにはなっているようですし、今後を踏まえてキャッチアップしておくのも手ではないでしょうか。
という事で今回は、GoogleMapApiを Stimulusで書いていこうと思います。
環境
- Ruby 3.1.2
- Rails 6.1.7
- yarn 1.22.19
作るもの
- Pointモデルを作り、 名前・緯度経度・住所 を登録できる。
- GooGleMapApiでPointを地図表示。
- 登録したポイントを一覧表示する地図とポイントの登録用の地図は1ページで。
- 別途詳細ページにも地図を置く。
- 地図の登録は、キーワード検索と地図クリックで補正が出来る。
- 住所は緯度経度から逆ジオコーディングで入力補助。自由入力も可能。
- 一覧表示の地図のピンをクリックしたら吹き出しが出て詳細へのリンクを設置。
プロジェクトの作成
まずはプロジェクトを作成します。 DBもSQLiteを使います。
(6.1.7 でバージョン指定していますが、6系であれば表記や動作の違い等の問題は起こらないと思われます。)
下記コマンドを実行して下さい。
$ gem install rails -v 6.1.7
$ rails _6.1.7_ new google_map
長いので略
├─ spdy-transport@3.0.0
├─ spdy@4.0.2
├─ strip-eof@1.0.0
├─ thunky@1.1.0
├─ toidentifier@1.0.1
├─ url-parse@1.5.10
├─ utils-merge@1.0.1
├─ uuid@3.4.0
├─ wbuf@1.7.3
├─ webpack-dev-middleware@3.7.3
├─ webpack-dev-server@3.11.3
├─ websocket-driver@0.7.4
├─ websocket-extensions@0.1.4
└─ ws@6.2.2
✨ Done in 7.25s.
Webpacker successfully installed 🎉 🍰
プロジェクトが作成後
$ 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
Running via Spring preloader in process 98359
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
invoke assets
invoke scss
create app/assets/stylesheets/points.scss
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
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
end
データーの確認
points/index.html.erb
中身はさておき、トップページを確認しておきたいので、 index.html.erb をちょっと触ってみます。
下記のような感じでざっくり・・。
<h1>Points#index</h1>
<%= @points.inspect %>
<br><br>
<%= @points_json %>
inspect
は モデルオブジェクトの集合体を可視化するために付け加えてます。
これで、一旦起動しましょう。
$ rails s
いい感じです。
ここでもし、このようなエラーになった場合、
下記コマンドを実行して下さい。
$ yarn add @babel/plugin-proposal-private-methods @babel/plugin-proposal-private-property-in-object
正常に表示されたら中身を確認します。
[{"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から正式採用されていますが、gemで導入すればRails6でも簡単にも使えます。
Stimulus自体は、 webpack や esbuild 環境下なら動作しますので、他のFWに導入する事も意外と簡単です。
↓ではElixirのFW、Phoenixでほぼ同一のコードのまま移植しています。ご興味のある方は是非ちらっと覗いてみて下さい。
Gemfile
Gemfileに以下のgemを追加して下さい。
gem 'stimulus-rails'
続いて、
$ bundle install
を実行しましょう。
(略)
Using tilt 2.3.0
Using sassc-rails 2.1.2
Using sass-rails 6.0.0
Using websocket 1.2.10
Using selenium-webdriver 4.10.0
Using semantic_range 3.0.0
Using spring 4.1.3
Using sqlite3 1.6.9 (arm64-darwin)
Using stimulus-rails 1.3.0
Using turbolinks-source 5.2.0
Using turbolinks 5.2.1
Using web-console 4.2.1
Using webdrivers 5.3.1
Using webpacker 5.4.4
Bundle complete! 18 Gemfile dependencies, 82 gems now installed.
Use `bundle info [gemname]` to see where a bundled gem is installed.
続いて必要ファイルを作成するコマンド、
$ rails stimulus:install
を実行して下さい。
Create controllers directory
create app/javascript/controllers
create app/javascript/controllers/index.js
create app/javascript/controllers/application.js
create app/javascript/controllers/hello_controller.js
Couldn't find "app/javascript/application.js".
You must import "./controllers" in your JavaScript entrypoint file
Install Stimulus
run yarn add @hotwired/stimulus from "."
yarn add v1.22.18
warning ../package.json: No license field
[1/4] 🔍 Resolving packages...
⠂ @hotwired/stimulus(node:43260) [DEP0040] DeprecationWarning: The `punycode` module is deprecated. Please use a userland alternative instead.
(Use `node --trace-deprecation ...` to show where the warning was created)
[2/4] 🚚 Fetching packages...
[3/4] 🔗 Linking dependencies...
warning " > @babel/plugin-proposal-private-methods@7.18.6" has unmet peer dependency "@babel/core@^7.0.0-0".
warning "@babel/plugin-proposal-private-methods > @babel/helper-create-class-features-plugin@7.22.15" has unmet peer dependency "@babel/core@^7.0.0".
warning "@babel/plugin-proposal-private-methods > @babel/helper-create-class-features-plugin > @babel/helper-replace-supers@7.22.9" has unmet peer dependency "@babel/core@^7.0.0".
warning " > @babel/plugin-proposal-private-property-in-object@7.21.11" has unmet peer dependency "@babel/core@^7.0.0-0".
warning "@babel/plugin-proposal-private-property-in-object > @babel/plugin-syntax-private-property-in-object@7.14.5" has unmet peer dependency "@babel/core@^7.0.0-0".
[4/4] 🔨 Building fresh packages...
success Saved lockfile.
success Saved 1 new dependency.
info Direct dependencies
└─ @hotwired/stimulus@3.2.2
info All dependencies
└─ @hotwired/stimulus@3.2.2
✨ Done in 2.90s.
インストール出来ました。
何やら見つかれない旨を言われていますが、
Couldn't find "app/javascript/application.js".
You must import "./controllers" in your JavaScript entrypoint file
この一文は今から対応しますので、無視して大丈夫です。
ファイルの確認
この3つのファイルと、外包する controllers/
が作成されている事を確認しましょう。
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 }
packs/application.js
次に、webpackerにインポートします。
app/javascript/packs/application.js
を以下のように編集します。
// This file is automatically compiled by Webpack, along with any other files
// present in this directory. You're encouraged to place your actual application logic in
// a relevant structure within app/javascript and only use these pack files to reference
// that code so it'll be compiled.
import Rails from "@rails/ujs"
import Turbolinks from "turbolinks"
import * as ActiveStorage from "@rails/activestorage"
import "channels"
import "controllers" //追記
Rails.start()
Turbolinks.start()
ActiveStorage.start()
これで新規追加された
app/jabascript/controllers/
を見に行きます。
一旦起動しましょう。
$ rails s
デベロッパーツール上にこのような表示が出たら、Stimulusのdebugが有効になっていますので、まず読み込みは大丈夫という事になります。
確認出来たらサーバーは落としておきましょう。
GoogleMapApiの準備
この辺で地図の方も準備しておきます。
Apiキーはフロント側で作業する場合、結局は見えるので間違いなくApiキー制限を行って下さい。
キーが出来たらGoogleMapApi自体を使えるように、
@googlemaps/js-api-loader
をインストールしますので、
$ yarn add @googlemaps/js-api-loader
を実行しておきましょう。
yarn add v1.22.19
[1/4] 🔍 Resolving packages...
⠂ @googlemaps/js-api-loader(node:13624) [DEP0040] DeprecationWarning: The `punycode` module is deprecated. Please use a userland alternative instead.
(Use `node --trace-deprecation ...` to show where the warning was created)
[2/4] 🚚 Fetching packages...
[3/4] 🔗 Linking dependencies...
warning " > @babel/plugin-proposal-private-methods@7.18.6" has unmet peer dependency "@babel/core@^7.0.0-0".
warning "@babel/plugin-proposal-private-methods > @babel/helper-create-class-features-plugin@7.23.6" has unmet peer dependency "@babel/core@^7.0.0".
warning "@babel/plugin-proposal-private-methods > @babel/helper-create-class-features-plugin > @babel/helper-replace-supers@7.22.20" has unmet peer dependency "@babel/core@^7.0.0".
warning " > @babel/plugin-proposal-private-property-in-object@7.21.11" has unmet peer dependency "@babel/core@^7.0.0-0".
warning "@babel/plugin-proposal-private-property-in-object > @babel/plugin-syntax-private-property-in-object@7.14.5" has unmet peer dependency "@babel/core@^7.0.0-0".
[4/4] 🔨 Building fresh packages...
success Saved lockfile.
success Saved 1 new dependency.
info Direct dependencies
└─ @googlemaps/js-api-loader@1.16.2
info All dependencies
└─ @googlemaps/js-api-loader@1.16.2
✨ Done in 3.33s.
準備完了です。
StimulusControllers
次にStimulusです。
今回は、Stimulusの継承を使って、3種類のマップを個別のコントローラーで制御します。
イメージは、こんな感じです。
では、まず最初は application.js
から
google_map/application_controller.js
gem
でインストールしているので、コマンドでコントローラーが作成可能です。
$ rails g stimulus google_map/application
と打つと、
Running via Spring preloader in process 2658
create app/javascript/controllers/google_map/application_controller.js
rails stimulus:manifest:update
このように返ってきます。
app/javascript/controllers/google_map/application_controller.js
が作成され、
app/javascript/controllers/index.js
が、
// This file is auto-generated by ./bin/rails stimulus:manifest:update
// Run that command whenever you add a new controller or create them with
// ./bin/rails generate stimulus controllerName
import { application } from "./application"
import GoogleMap__ApplicationController from "./google_map/application_controller"
application.register("google-map--application", GoogleMap__ApplicationController)
import HelloController from "./hello_controller"
application.register("hello", HelloController)
このようになっています。先程のコマンドにより、
import GoogleMap__ApplicationController from "./google_map/application_controller"
application.register("google-map--application", GoogleMap__ApplicationController)
この2行が自動追加されます。
これは、 index.js
から見て相対パス、
./google_map/application_controller.js
を
"google-map--application"
として、Stimulusに登録する設定となります。
アンダースコア _
はハイフン -
に、ディレクトリを挟んだ場合、 ハイフン×2 --
として命名されるようです。
では、 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側で読めるように、下記ファイルを作成します。
Webpacker::Compiler.env["GOOGLE_API_KEY"] = Rails.application.credentials.dig(:google, :api_key)
これでWebpacker側の環境変数に登録します。
(※webpack-dev-serverのオートリロードだと読めないので注意)
最後に、 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: process.env.GOOGLE_API_KEY,
version: "weekly",
});
}
}
と、 process.env.GOOGLE_API_KEY
に書き換えておきます。
こうすれば、少なくともGithub等で見られる事はなくなり、デプロイ環境でも master.key
さえあれば良い事になります。
(ホントしつこいようですが、これでもDevTool等で普通に見えますのでちゃんと設定しましょう)
これで application_controllerの設定は終わりですので、各ページの実装をしていきましょう。
/points/:id で個別表示
まずは簡単なやつからいきます。1件だけのPointデーターとピン付きの地図を表示します。
points/index.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: { turbolinks: false }
で turbolinks
の介入を防いでおくと不要なエラーを防ぐ事が出来ます。
data: { turbolinks: false }
は 'data-turbolinks': 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");
const { AdvancedMarkerElement } = await google.maps.importLibrary("marker"); // 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,
mapId: 'DEMO_MAP_ID'
})
// マーカー設置。先程作った this._map に 座標 this._location を指定する事でピンを刺す 。
new google.maps.marker.AdvancedMarkerElement({
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");
const { AdvancedMarkerElement } = await google.maps.importLibrary("marker");
// 地図を statis values の情報から作成
map = new Map(this.mapTarget, {
center: this.locationValue,
zoom: this.zoomValue,
mapId: 'DEMO_MAP_ID'
})
// 地図に全マーカーをセット
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.AdvancedMarkerElement({
position: { lat: o.lat, lng: o.lng },
map: map
})
// 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-turbolinks="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
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");
const { AdvancedMarkerElement } = await google.maps.importLibrary("marker");
map = new Map(this.mapTarget, {
center: this.Value,
zoom: this.zoomValue,
mapId: 'DEMO_MAP_ID'
})
// フォームに緯度経度の情報があれば、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.AdvancedMarkerElement({
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
でプレビューを確認します。
完成です。
ここまでお疲れさまでした。
おわりに
今回はRails6のwebpackerでGooglaMapApiを使ってみました。いかがでしたか?
個人的にVueやReactの強力なテンプレートエンジンも魅力ですが、StimulusのようにView側でidやclassに依存せず、ロジックもコントローラー側に集中しているので、最終的に鬼の汎用性を持つコントローラーを作れるようになっており、バックエンドのFWに組み込む場合、かなり相性が良い気がしています。DHHはやはり神です。
簡単なハンズオンなので、是非年末年始の暇つぶしに作っていただけると幸いです。
そして、最後にちょっと記事紹介。
では、引き続き DMM WEBCAMP Advent Calendar 2023 をよろしくお願いいたします。
次回 8日目の投稿も私で、本記事の Rails7版を書いています。
スレッドの購読やいいねも是非お願いします。
ここまで読んでいただきありがとうございます。