35
3

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 8

【Rails7】ImportmapでGoogleMapApiのloaderを使ってみる

Last updated at Posted at 2023-12-18

この投稿は、

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

ImportmapNPM js-api-loader を読ませて GoogleMapApi を使ってみます。

作るもの

  • Pointモデルを作り、 名前・緯度経度・住所 を登録できる。
  • GooGleMapApiでPointを地図表示。
  • 登録したポイントを一覧表示する地図とポイントの登録用の地図は1ページで。
  • 別途詳細ページにも地図を置く。
  • 地図の登録は、キーワード検索と地図クリックで補正が出来る。
  • 住所は緯度経度から逆ジオコーディングで入力補助。自由入力も可能。
  • 一覧表示の地図のピンをクリックしたら吹き出しが出て詳細へのリンクを設置。

プロジェクトの作成

まずはプロジェクトを作成します。 DBもSQLiteを使います。importmapを使いますので、何も指定せず開始です。
(7.0.x系でcssオプションをBootstrapにした場合、esbuildになるので注意して下さい。7.1.x系は問題ないです)

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

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

プロジェクトが作成後

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: truekeywordattr_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
      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

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

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

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

  get "up" => "rails/health#show", as: :rails_health_check
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

いい感じです。
正常に表示されたら中身を確認します。

[{"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はサンプルなので消しても良いですが、無視します。)

Image from Gyazo

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 }

他も確認のみしておきましょう。

controllers/index.js

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

app/javascript/packs/application.js(確認)
// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
import "@hotwired/turbo-rails"
import "controllers"

一旦起動しましょう。

shell
$ rails s

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

Image from Gyazo

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

GoogleMapApiの準備

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

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

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

@googlemaps/js-api-loader をインポートマップに登録しますので、

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

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

Image from Gyazo

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

google_map/application_controller.js

コマンドでコントローラーが作成可能です。

shell
$ rails g stimulus google_map/application

と打つと、

      create  app/javascript/controllers/google_map/application_controller.js

このように返ってきます。

作成された、 google_map/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側で読めるように、このようなタグを作成し、メタタグとして埋めます。

<%= tag :meta, name: :google_api_key, content: Rails.application.credentials.google[:api_key] %>
app/views/layouts/application.html.erb
<!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.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: 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 さえあれば良い事になります。

(ホントしつこいようですが、これ普通に見えますのでちゃんと設定しましょう)

Image from Gyazo

これで application_controllerの設定は終わりですので、各ページの実装をしていきましょう。

/points/:id で個別表示

まずは簡単なやつからいきます。1件だけのPointデーターとピン付きの地図を表示します。

points/show.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: { 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

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

shell
$ rails g stimulus google_map/show
Running via Spring preloader in process 10989
      create  app/javascript/controllers/google_map/show_controller.js

続いてマニフェストの更新を行います。

shell
$ 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");
      // 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._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");
      // 地図を 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
    } 
  }
}

再び確認します。

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

続いて以下を実行。(一気にコントローラーだけ作って1回で済ませても良いです)

shell
$ 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");
      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 で対応する関数にジャンプ出来ますので、流れを追いかけてみて下さい。

では、最後の確認です。

完成物

shell
$ rails s

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

完成です。

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

おわりに

Rails7のimportmapでGooglaMapApiを使ってみました。いかがでしたか?

Stimulusは HotWireの中でも割と入りやすく、機能がシンプルなので応用がききやすいと感じています。

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

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

次回 9日目の投稿は、@masaa0802 さんです。

学習中の方には転職のリアルなお話は助かりますね。

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

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

35
3
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
35
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?