42
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

DMM WEBCAMPAdvent Calendar 2023

Day 7

【Rails6】StimulusでGoogleMapApiを扱ったら良い感じだった話

Last updated at Posted at 2023-12-16

この投稿は、

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系であれば表記や動作の違い等の問題は起こらないと思われます。)

下記コマンドを実行して下さい。

shell
$ 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 🎉 🍰

プロジェクトが作成後

shell
$ cd google_map

でディレクトリを移動しておきましょう。

モデルの作成

今回は緯度経度と住所を登録する Point モデルを作成します。

attr class
名前 name String
緯度 latitude Float
緯度 longitude Float
住所 address String

では、早速今回必要なモデルを作ってしまいましょう。下記コマンドで作成します。

shell
$ rails g model point name latitude:float longitude:float address

モデルが作成出来たら、

shell
$ rails db:migrate

をしておきましょう。

point.rb

各カラムに presence: true と、 keyword(検索用) を attr_accessor に書いておきます。

app/models/point.rb
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を以下のように書き換えましょう。

db/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

データーを流します。

shell
$ rails db:seed
Running via Spring preloader in process 98208

エラーが出なければOKです。

コントローラー

contorolle と view をコマンドで作ります。
テンプレートは indexshow を作ります。

shell
$ 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

コントローラーは以下のように書いておきましょう。

app/controllers/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を作成可能となります。

index(複数要素)
@points_json = @points.map { |o| point_to_hash(o) }.to_json
show(単体)
@point_json = point_to_hash(@point).to_json

ルーティング

routes.rb

ルーティングですが、自動生成されたものもありますが、全て下記のように書き換えてしまいましょう。

config/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 をちょっと触ってみます。
下記のような感じでざっくり・・。

app/views/points/index.html.erb
<h1>Points#index</h1>

<%= @points.inspect %>
<br><br>
<%= @points_json %>

inspect は モデルオブジェクトの集合体を可視化するために付け加えてます。
これで、一旦起動しましょう。

shell
$ rails s

Image from Gyazo

いい感じです。

ここでもし、このようなエラーになった場合、

Image from Gyazo

下記コマンドを実行して下さい。

shell
$ 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を追加して下さい。

Gemfile
gem 'stimulus-rails'

続いて、

shell
$ 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.

続いて必要ファイルを作成するコマンド、

shell
$ 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

この一文は今から対応しますので、無視して大丈夫です。

ファイルの確認

Image from Gyazo

この3つのファイルと、外包する controllers/ が作成されている事を確認しましょう。

controllers/application.js

そのファイル中で一つ、javascript/controllers/application.js を開いて下さい。

必須ではないですが、デバッグの表示を切り替えておきます。

application.debugtrue にしましょう。
( ※このモードは切り替えなくても普通にconsole.log等は使えますので絶対必要ではありません。)

app/javascript/controllers/application.js
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 を以下のように編集します。

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/ を見に行きます。

一旦起動しましょう。

shell
$ rails s

デベロッパーツール上にこのような表示が出たら、Stimulusのdebugが有効になっていますので、まず読み込みは大丈夫という事になります。

Image from Gyazo

確認出来たらサーバーは落としておきましょう。

GoogleMapApiの準備

この辺で地図の方も準備しておきます。

Apiキーはフロント側で作業する場合、結局は見えるので間違いなくApiキー制限を行って下さい。

キーが出来たらGoogleMapApi自体を使えるように、

@googlemaps/js-api-loader をインストールしますので、

shell
$ 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種類のマップを個別のコントローラーで制御します。

イメージは、こんな感じです。

Image from Gyazo

では、まず最初は application.js から

google_map/application_controller.js

gemでインストールしているので、コマンドでコントローラーが作成可能です。

shell
$ 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

が、

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 の中身を触ります。

以下のように書き換えておきましょう。

app/javascript/controllers/google_map/application_controller.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 を使っている方法を紹介しますと、

shell
$ EDITOR=vi rails credentials:edit

でvimを立ち上げ、

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側で読めるように、下記ファイルを作成します。

config/initializers/google_map.rb(新規作成)
Webpacker::Compiler.env["GOOGLE_API_KEY"] = Rails.application.credentials.dig(:google, :api_key)

これでWebpacker側の環境変数に登録します。
(※webpack-dev-serverのオートリロードだと読めないので注意)

最後に、 google_map/application_controller.jsapiKey

app/javascript/controllers/google_map/application_controller.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: 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アクションのテンプレートを以下のように書いて下さい。

app/views/points/show.html.erb
<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

では対応するコントローラーを作りましょう。

shell
$ 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 は以下のように編集しておきます。

app/javascript/controllers/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._xxxconst と違って再代入は可能です。

なので、コードの意図によっては使い分けて良いのかな・・・と思います。

これで一旦確認してみましょう。

shell
$ rails s

で起動して、 points/1 にアクセスしてみましょう。

Image from Gyazo

表示されていたらOKです。
確認出来たらサーバーを落としても大丈夫です。

/points での全件表示+ふきだし

次に複数件表示させる方法です。
また、ここでは、各ピンにクリックイベントで吹き出しの出現と、さらに showアクションに飛ぶリンクを設置します。

points/index.html.erb

一覧ページは次のように書き換えます。

app/views/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

こちらも対応するコントローラーを作りましょう。

shell
$ 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

中身は以下のように編集します。

app/javascript/controllers/google_map/index_controller.js
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
    } 
  }
}

再び確認します。

shell
$ rails s

Image from Gyazo

こちらもOKのようです。

/points での入力補助

points/_form.html.erb

3つ目の入力フォーム。こちらはパーシャルを新規作成します。

パーシャルに分離しておけば、他のページ(例えば newedit) を作っても 苦労せず動作を再現できます。

app/views/points/_form.html.erb(新規作成)
<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 タグを使用しています。

以下のように inputlistdatalistid を紐付ける事で、自由入力と候補選択の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 %>

この呼び出しを追記し、以下のようにしておきます。

app/views/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>

<%= render "points/form", point: @point %>

google_map/form_controller.js

最後のコントローラーを作りましょう。

shell
$ 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 は以下のように編集しておきます。

app/javascript/controllers/google_map/form_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 で対応する関数にジャンプ出来ますので、流れを追いかけてみて下さい。

では、最後の確認です。

完成物

shell
$ rails s

でプレビューを確認します。

完成です。

ここまでお疲れさまでした。

おわりに

今回はRails6のwebpackerでGooglaMapApiを使ってみました。いかがでしたか?

個人的にVueやReactの強力なテンプレートエンジンも魅力ですが、StimulusのようにView側でidやclassに依存せず、ロジックもコントローラー側に集中しているので、最終的に鬼の汎用性を持つコントローラーを作れるようになっており、バックエンドのFWに組み込む場合、かなり相性が良い気がしています。DHHはやはり神です。

簡単なハンズオンなので、是非年末年始の暇つぶしに作っていただけると幸いです。

そして、最後にちょっと記事紹介。

では、引き続き DMM WEBCAMP Advent Calendar 2023 をよろしくお願いいたします。

次回 8日目の投稿も私で、本記事の Rails7版を書いています。

スレッドの購読やいいねも是非お願いします。

ここまで読んでいただきありがとうございます。

42
8
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
42
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?