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

## この投稿は、

DMM WEBCAMP Advent Calendar 2023
シリーズ2　投稿７日目のエントリー（大遅刻穴埋め勢）です。

6日目は、@Hiron0120 さんで、

https://qiita.com/Hiron0120/items/c1e1c5fedc3871c92855

でした。

## はじめに
DMM WEBCAMP でメンターをやらせていただいております。 @tomoaki-kimura です。
Rails７はフロント周りがとても楽しくなりました。 `Stimulus`もその一つです。丁度よい機能と学習コストです。
Rails６環境でも `Stimulus` を使ったJSの管理はかなり有用です。

JQueryも状況によっては全然アリなのですが、どうやら世の中脱JQueryの流れにはなっているようですし、今後を踏まえてキャッチアップしておくのも手ではないでしょうか。

という事で今回は、GoogleMapApiを  [Stimulus](https://github.com/hotwired/stimulus-rails)で書いていこうと思います。

## 環境
- Ruby 3.1.2
- Rails 6.1.7
- yarn 1.22.19

## 作るもの

<img src="https://i.gyazo.com/be4237eaf6dbc34eedb3c258d4650c51.gif" width=400>

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

## プロジェクトの作成

まずはプロジェクトを作成します。 DBもSQLiteを使います。

(_6.1.7_ でバージョン指定していますが、6系であれば表記や動作の違い等の問題は起こらないと思われます。)

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

```bash:shell
$ gem install rails -v 6.1.7
$ rails _6.1.7_ new google_map
```
```bash
長いので略
├─ 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 🎉 🍰

```

プロジェクトが作成後

```bash:shell
$ cd google_map
```

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

## モデルの作成

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


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

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

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

モデルが作成出来たら、

```bash:shell
$ rails db:migrate
```

をしておきましょう。

### point.rb

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

https://docs.ruby-lang.org/ja/latest/method/Module/i/attr_accessor.html

```ruby: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を以下のように書き換えましょう。

```ruby:db/seeds.rb
data = [
  { name: "都庁",
    latitude: 35.6895014,
    longitude: 139.6917337,
    address: "日本、〒163-8001 東京都新宿区西新宿２丁目８−１" },
  { name: "後楽園",
    latitude: 35.7054551,
    longitude: 139.7535553,
    address: "日本、〒112-0004 東京都文京区後楽１丁目３" },
  { name: "江ノ電自転車ニキ",
    latitude: 35.3076198,
    longitude: 139.4937805,
    address: "日本、〒248-0033 神奈川県鎌倉市腰越２丁目１０−２５" }
]

data.each do |point_attribute|
  Point.create!(point_attribute)
end
```

データーを流します。

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

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

## コントローラー

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

```bash:shell
$ rails g controller points index show
```

```bash
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

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

```ruby: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へと変換しますが、下記のように

```ruby
  def point_to_hash(point)
    { id: point.id,
      name: point.name,
      lat: point.latitude,
      lng: point.longitude }
  end
```

一旦Hashに変換するメソッドを作っておき、以下のように使います。
すると、それぞれのアクションに必要なJsonを作成可能となります。

```ruby:index(複数要素)
@points_json = @points.map { |o| point_to_hash(o) }.to_json
```

```ruby:show（単体）
@point_json = point_to_hash(@point).to_json
```

## ルーティング

### routes.rb

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

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

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

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

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

```bash:shell
$ rails s
```

[![Image from Gyazo](https://i.gyazo.com/d03df14adbb9f9fc8a208ab513de16de.png)](https://gyazo.com/d03df14adbb9f9fc8a208ab513de16de)

いい感じです。

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

[![Image from Gyazo](https://i.gyazo.com/7750542c35706ecead0bbd7c070c704d.png)](https://gyazo.com/7750542c35706ecead0bbd7c070c704d)

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

```bash:shell
$ yarn add @babel/plugin-proposal-private-methods @babel/plugin-proposal-private-property-in-object
```

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

```ruby
[{"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` を取り出してマーカーを立てていきます。

https://developer.mozilla.org/ja/docs/Web/API/HTMLElement/dataset

プレビューが確認できたら、一旦サーバーを落としましょう。

## Stimulesの導入

ここからが本題です。

StimulusはRails7から正式採用されていますが、gemで導入すればRails6でも簡単にも使えます。

https://github.com/hotwired/stimulus-rails

Stimulus自体は、 webpack や esbuild 環境下なら動作しますので、他のFWに導入する事も意外と簡単です。 

↓ではElixirのFW、Phoenixでほぼ同一のコードのまま移植しています。ご興味のある方は是非ちらっと覗いてみて下さい。

https://qiita.com/tomoaki-kimura/items/72d2af1a4ee5b4f31530

### Gemfile

Gemfileに以下のgemを追加して下さい。

```ruby:Gemfile
gem 'stimulus-rails'
```

続いて、

```bash:shell
$ bundle install
```

を実行しましょう。

```bash
（略）
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.

```

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

```bash:shell
$ rails stimulus:install
```

を実行して下さい。

```bash
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.

```

インストール出来ました。
何やら見つかれない旨を言われていますが、

```bash
Couldn't find "app/javascript/application.js".
You must import "./controllers" in your JavaScript entrypoint file
```

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

### ファイルの確認

[![Image from Gyazo](https://i.gyazo.com/8e3411a3076a5393d37b9efc78bbf4f6.png)](https://gyazo.com/8e3411a3076a5393d37b9efc78bbf4f6)

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

### controllers/application.js

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

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

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

```javascript: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` を以下のように編集します。


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

一旦起動しましょう。

```bash:shell
$ rails s
```

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

[![Image from Gyazo](https://i.gyazo.com/2dfd7b2343787650b45a5896c0f42565.png)](https://gyazo.com/2dfd7b2343787650b45a5896c0f42565)

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

## GoogleMapApiの準備

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

https://developers.google.com/maps/documentation/javascript/get-api-key?hl=ja

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

https://cloud.google.com/docs/authentication/api-keys?hl=ja

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

https://developers.google.com/maps/documentation/javascript/load-maps-js-api?hl=ja

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

```bash:shell
$ yarn add @googlemaps/js-api-loader
```

を実行しておきましょう。

```bash
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の継承を使って、３種類のマップを個別のコントローラーで制御します。

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

[![Image from Gyazo](https://i.gyazo.com/2edcef7d8ff2fcd556e9d89538148562.png)](https://gyazo.com/2edcef7d8ff2fcd556e9d89538148562)

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

### google_map/application_controller.js

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

```bash:shell
$ rails g stimulus google_map/application
```

と打つと、

```bash
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`

が、

```javascript: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)
```

このようになっています。先程のコマンドにより、

```javascript
import GoogleMap__ApplicationController from "./google_map/application_controller"
application.register("google-map--application", GoogleMap__ApplicationController)
```

この２行が自動追加されます。
これは、 `index.js`から見て相対パス、

`./google_map/application_controller.js` を

`"google-map--application"` として、Stimulusに登録する設定となります。

アンダースコア `_` はハイフン `-` に、ディレクトリを挟んだ場合、 ハイフン×2 `--`として命名されるようです。

では、 `application.js` の中身を触ります。

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

```javascript: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",
    });
  }
}
```

このファイルではライブラリのインポート

```javascript
import { Controller } from "@hotwired/stimulus"
import { Loader } from "@googlemaps/js-api-loader"
```

に加えて、
APIキーの設定を行っています。

```javascript
    return new Loader({
      apiKey: "xxxxxxxここに取得したAPIキーxxxxxxxxx",
      version: "weekly",
    });
```

APIキーですが、気になる方は、RailsCredentialやDotrenv等を利用しても良いと思います。
が、JSなので基本モロバレです。しつこいようですが、Googleの利用制限は確実に行っておきましょう。

https://cloud.google.com/docs/authentication/api-keys?hl=ja

参考までに、私が今回 `RailsCredential` を使っている方法を紹介しますと、

```bash:shell
$ EDITOR=vi rails credentials:edit
```

でvimを立ち上げ、

```yml: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 という階層を作り、値に取得したキーを入れます。（""はあってもなくても良いです）

```yml
google:
  api_key: xxxxxxxxxxxxxxYOUR_API_KEYxxxxxxxxxxxxxxxxx
```

これを、JS側で読めるように、下記ファイルを作成します。

```ruby: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.js` の `apiKey` を
```javascript: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 で個別表示

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

### points/index.html.erb

showアクションのテンプレートを以下のように書いて下さい。

```erb: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 } %> 
```

この中で、地図の表示は、

```erb
<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 %>" ` の部分は１件分の座標データーをStimulusが受け取るようにRailsから変数を渡します。

### google_map/show_controller.js

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

```bash:shell
$ rails g stimulus google_map/show
```

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

```javascript: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._xxx` は `const` と違って再代入は可能です。

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

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

```bash:shell
$ rails s
```

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


[![Image from Gyazo](https://i.gyazo.com/6a7aa2f6902540b42dd42a31f726c8b4.png)](https://gyazo.com/6a7aa2f6902540b42dd42a31f726c8b4)

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

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

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

### points/index.html.erb

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

```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

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

```bash:shell
$ rails g stimulus google_map/index
```

```bash
Running via Spring preloader in process 15006
      create  app/javascript/controllers/google_map/index_controller.js
       rails  stimulus:manifest:update
```

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

```javascript: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のような配列データーも１件ずつの登録変更削除はできず、配列そのものを再代入しかできない。
  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() {
    // 全ポイントをループで１件ずつ処理
    this.pointsValue.forEach((o, i) => {
      // markers　に 座標データー１件を追加
      this.addMarkerToMarkers(o)
      // infoWindowｓ に 吹き出しデーターを１件追加 
      this.addInfoWindowToInfoWindows(o)
      // マーカーに該当する吹き出しを開くクリックイベントを追加
      this.addEventToMarker(i)
    })
  }

  addMarkerToMarkers(o) {
    // 引数の値からマーカーを１件作成
    this._marker = new google.maps.marker.AdvancedMarkerElement({
      position: { lat: o.lat, lng: o.lng },
      map: map
    })
    // markersに１件マーカーを追加
    markers.push(this._marker)
  }

  addInfoWindowToInfoWindows(o) {
    // 引数の値から吹き出しを１件作成
    // 吹き出しの中には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
    } 
  }
}
```

再び確認します。

```bash:shell
$ rails s
```

[![Image from Gyazo](https://i.gyazo.com/2da704b76b91afdfb616b04886206dd3.png)](https://gyazo.com/2da704b76b91afdfb616b04886206dd3)

こちらもOKのようです。

## /points　での入力補助

### points/_form.html.erb

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

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

```erb: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` タグを使用しています。

https://developer.mozilla.org/ja/docs/Web/HTML/Element/datalist

以下のように `input` の `list` と `datalist` の `id` を紐付ける事で、自由入力と候補選択の２択の入力を行う事ができるフォームとなります。

```html
<input list="同じ名称" name="">
<datalist id="同じ名称">
  <option value="入力候補１"></option>
  <option value="入力候補２"></option>
</datalist>
```

`address` の入力に使用していますが、

```erb
    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のようなものが実現できます。

<img src="https://i.gyazo.com/5a1c54c28a0a8d1fc9b87ecd0a4bd390.gif" width=300>

後は、緯度経度のフォームを書き込み禁止かつ、データーの送信を可能にするため、 

`tabindeｘ`

https://developer.mozilla.org/ja/docs/Web/HTML/Global_attributes/tabindex

`readonly`

https://developer.mozilla.org/ja/docs/Web/HTML/Attributes/readonly

といったプロパティと、

```css
  .looks_disable {
    background-color: #eee;
    pointer-events: none;
  }
```
このようなCSSを組み合わせて、 

`disabled` に近い見た目を作っています。

https://developer.mozilla.org/ja/docs/Web/HTML/Attributes/disabled

ここではCSSは直接htmlに書いてますが、本来は書くべき場所に記述していただけると良いですね。

### posts/index.html.erb

今作ったパーシャルは `index` から呼び出しますので、

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

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

```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>

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

### google_map/form_controller.js

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

```bash:shell
$ rails g stimulus google_map/form
```

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

```javascript: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 = []
    // 住所候補を１件ずつ追加
    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` で対応する関数にジャンプ出来ますので、流れを追いかけてみて下さい。

では、最後の確認です。

## 完成物

```bash:shell
$ rails s
```

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

<img src="https://i.gyazo.com/150f18188f0d89b28498ced998f8a774.gif" width=400>

完成です。

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

## おわりに

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

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

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

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

https://pr.forkwell.com/career_navi/dhh-rails-and-front-end/

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

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

https://qiita.com/tomoaki-kimura/items/efc080f915b2a739972a

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

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