Phoenix1.7とElixirDesktopでスマホアプリ開発 GPSと地図アプリ編

# はじめに
本記事はPhoenix1.7をベースとして、Phoenixプロジェクトをネイティブアプリ化するライブラリElixirDesktopを使用し、
スマホアプリを作成する手順を紹介する記事になります

この記事は３までで作成したCRUDに追加でGPSログとMapLibereを連動させて地図アプリを作ってみます

ElixirDesktopでスマホアプリ作成シリーズ
1. [Phoenix1.7とElixirDesktopでスマホアプリ開発 セットアップ編](https://qiita.com/the_haigo/items/fa3906e45cf805ca38fa)
2. [Phoenix1.7とElixirDesktopでスマホアプリ開発 認証機能編](https://qiita.com/items/c831ff92b06b207d0dbf)
3. [Phoenix1.7とElixirDesktopでスマホアプリ開発 CRUD編](https://qiita.com/the_haigo/items/7472274168939bccbe91)
4. [Phoenix1.7とElixirDesktopでスマホアプリ開発 GPSと地図アプリ編](https://qiita.com/the_haigo/items/7d0e3e24ef215fba13c1)

# GSPログを保存するPostionモデルの作成
すでにあるコンテキストにモデルを追加するときはphx.gen.schemaを使いschemaファイルとmigrationファイルを作成します

```
mix phx.gen.schema Loggers.Position positions lat:float, lng:float route_id:references:routes
```

refrencesのオプションでrouteが削除された時に関連するpositionも消すようにします

```diff_elixir
defmodule Spotties.Repo.Migrations.CreatePositions do
  use Ecto.Migration

  def change do
    create table(:positions) do
      add(:lat, :float)
      add(:lng, :float)
-      add :route_id, references(:routes, on_delete: :nothing)
+      add(:route_id, references(:routes, on_delete: :delete_all))

      timestamps()
    end

    create(index(:positions, [:route_id]))
  end
end

```

マイグレーションを実行します

```
mix ecto.migrate
```

schemaファイルをrouteに紐付いて保存できるように修正します

```diff_elixir:phoenix/lib/spotties/loggers/position.ex
defmodule Spotties.Loggers.Position do
  use Ecto.Schema
  import Ecto.Changeset

  schema "positions" do
    field(:lat, :float)
    field(:lng, :float)
-    field(:route_id, :integer)
+    belongs_to(:route, Spotties.Loggers.Route) 

    timestamps()
  end

  @doc false
  def changeset(position, attrs) do
    position
-    |> cast(attrs, [:lat, :lng])
-    |> validate_required([:lat, :lng])
+    |> cast(attrs, [:lat, :lng, :route_id])
+    |> validate_required([:lat, :lng, :route_id])
  end
end
```

routeにもリレーションの設定をしておきます

```diff_elixir:phoenix/lib/spotties/loggers/route.ex
defmodule Spotties.Loggers.Route do
  use Ecto.Schema
  import Ecto.Changeset

  schema "routes" do
    field(:name, :string)
    belongs_to(:user, Spotties.Accounts.User)
+   has_many(:positions, Spotties.Loggers.Position)

    timestamps()
  end
  ...
end
```

loggersコンテキストにlist_positionsとcreate_positionを追加します

```phoenix/lib/spotties/loggers.ex
defmodule Spotties.Loggers do
  alias Spotties.Loggers.Position

  def list_positions(route_id) do
    from(
      pos in Position,
      where: pos.route_id == ^route_id
    )
    |> Repo.all()
  end

  def create_position(attrs \\ %{}) do
    %Position{}
    |> Position.changeset(attrs)
    |> Repo.insert()
  end
end
```

これでモデルの実装が完了しました、次はLiveViewを実装していきます

# GPSログの取得
今回はJSのGeolocation APIを使います

https://developer.mozilla.org/ja/docs/Web/API/Geolocation_API

## 位置情報取得の許可
iOSアプリで位置情報を使う場合は許可が必要なので　info.plistに以下を追加します

```ios/todoapp/Info.plist
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>NSLocationWhenInUseUsageDescription</key>
	<string>use GPS logging</string>
    ...
</dict>
```

Androidはこちらを参考にしてください

https://qiita.com/the_haigo/items/fd0282821c4a942cafda#android%E3%81%AEwebview%E3%81%A7%E4%BD%8D%E7%BD%AE%E6%83%85%E5%A0%B1%E3%82%92%E5%8F%96%E5%BE%97%E3%81%A7%E3%81%8D%E3%82%8B%E3%82%88%E3%81%86%E3%81%AB%E3%81%99%E3%82%8B

## JS Hookの作成
JSをLiveViewから便利に使う　 JS Hookを作ります

```phoenix/assets/js/hooks.js
let Hooks = {};
Hooks.Gps = {
  mounted() {
    this.handleEvent("start_logging", () => {
      const watchID = navigator.geolocation.watchPosition((position) => {
        const coords = position.coords;
        this.pushEvent("update", {
          heading: coords.heading,
          lat: coords.latitude,
          lng: coords.longitude,
          speed: coords.speed,
          timestamp: position.timestamp,
        });
      });
      window.watchID = watchID;
    });

    this.handleEvent("stop_logging", () => {
      navigator.geolocation.clearWatch(window.watchID);
    });
  },
};

export default Hooks;
```

`start_logging`はLiveViewから`push_event`で呼ばれた時に現在位置を取得して結果を`this.pushEvent`でliveView側に送信して`update`イベントを発火します

`stop_logging`はLiveViewから`push_event`で呼ばれた時に現在位置を取得し続けているのを解除します

Hooksができたらapp.jsで追加します
```diff_javascript:phoenix/assets/js/app.js
import "phoenix_html";
// Establish Phoenix Socket and LiveView configuration.
import { Socket } from "phoenix";
import { LiveSocket } from "phoenix_live_view";
import topbar from "../vendor/topbar";
+ import Hooks from "./hooks";

let csrfToken = document
  .querySelector("meta[name='csrf-token']")
  .getAttribute("content");
let liveSocket = new LiveSocket("/live", Socket, {
+   hooks: Hooks,
  params: { _csrf_token: csrfToken },
});
```

次はLiveViewの方を作成していきます

## GPSログデータの表示
**GPSログデータの表示のためにやるだけですので飛ばしても構いません**

`gps.ex`を作ってそこで表示してみましょう

getlocationでlat,lngとheading(向き),speed, timestampが取得できるのでそれを表示します
socketに各値をassignします


```phoenix/lib/spotties_web/live/route_live/gps.ex
defmodule SpottiesWeb.RouteLive.Gps do
  use SpottiesWeb, :live_view

  @impl true
  def mount(_params, _session, socket) do
    socket
    |> assign(:title, "GPS")
    |> assign(:lat, 0)
    |> assign(:lng, 0)
    |> assign(:heading, 0)
    |> assign(:speed, 0)
    |> assign(:timestamp, 0)
    |> assign(:status, "")
    |> push_event("init", %{})
    |> then(&{:ok, &1})
  end

  @impl true
  def handle_event("start", _, socket) do
    {
      :noreply,
      push_event(socket, "start_logging", %{})
    }
  end

  @impl true
  def handle_event("stop", _, socket) do
    {
      :noreply,
      push_event(socket, "stop_logging", %{})
    }
  end

  @impl true
  def handle_event("update", params, socket) do
    {
      :noreply,
      socket
      |> assign(
        params
        |> Enum.reduce(
          %{},
          fn {k, v}, acc -> Map.put(acc, String.to_atom(k), v) end
        )
      )
    }
  end
end
```

start,stop,updateのイベントをそれぞれ実装します
イベントが実装できたらHeex部分を実装します
雑にulタグでもいいんですが、味気ないのでいい感じにします 

JS Hooksを使うときの注意ですが idをつけたタグにphx-hookで使用するhooksを指定します

```elixir:phoenix/lib/spotties_web/live/route_live/show.html.heex
<.gheader title="GPS">
</.gheader>
<div id="gps" class="hero bg-base-200 mt-4" phx-hook="Gps">
  <div class="hero-content text-center">
    <div class="max-w-md">
        <button phx-click="start" class="btn btn-primary">Start Logging</button>
        <button phx-click="stop" class="btn btn-secondary">Stop Logging</button>
    </div>
  </div>
</div>
<div class="stats stats-vertical shadow mt-4 w-full">  
  <div class="stat">
    <div class="stat-title">Latitude</div>
    <div class="stat-value text-primary"><%= @lat %></div>
  </div>
  
  <div class="stat">
    <div class="stat-title">Longtitude</div>
    <div class="stat-value text-secondary"><%= @lng %></div>
  </div>  
    <div class="stat">
    <div class="stat-title">saved at</div>
    <div class="stat-value text-secondary text-sm"><%= DateTime.from_unix!(@timestamp, :millisecond) %></div>
  </div>  

</div>

<div class="stats shadow mt-4 w-full">  
  <div class="stat">
    <div class="stat-title">Heading</div>
    <div class="stat-value text-primary"><%= @heading %></div>
  </div>
  
  <div class="stat">
    <div class="stat-title">Speed</div>
    <div class="stat-value text-secondary"><%= @speed %></div>
  </div>  
</div>
<.bottom_tab title="GPS" />
```
bottom navに追加します

```diff_elixir:phoenix/lib/spotties_web/components/navigation.ex
  defp links() do
    [
      {"Routes", "/routes"},
+      {"GPS", "/gps"},
      {"Setting", "/users/settings"}
    ]
  end
```

routerのrequire_authenticated_userのセッション配下に追加して完了です

```phoenix/lib/spotties_web/router.ex
  scope "/", SpottiesWeb do
    pipe_through([:browser, :require_authenticated_user])

    live_session :require_authenticated_user,
      on_mount: [{SpottiesWeb.UserAuth, :ensure_authenticated}] do
      ...      
      live("/gps", RouteLive.Gps)
    end
  end
```

![2ec4a75536c495b14c171630b8efc8a8.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64198/16356b5b-077b-f0ec-5f11-6614ce93e8e4.gif)


# 地図アプリ化
GPSログは取れてるのは確認できたのでGPSログから地図上に現在位置を表示しながらDBに保存していきます

地図ライブラリは無料のMapLibereを使います

https://maplibre.org/

https://maplibre.org/maplibre-gl-js-docs/api/

## ライブラリの読み込み
npmのがうまく動かなかったのでCDNで追加します

```diff_elixir:phoenix/lib/spotties_web/components/layouts/root.html.heex
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="csrf-token" content={get_csrf_token()} />
    <.live_title suffix=" · Phoenix Framework">
      <%= assigns[:page_title] || "Spotties" %>
    </.live_title>
    <link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} />
+    <link href='https://unpkg.com/maplibre-gl@2.4.0/dist/maplibre-gl.css' rel='stylesheet' />
+    <script src='https://unpkg.com/maplibre-gl@2.4.0/dist/maplibre-gl.js'></script>
    <script defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}>
    </script>
  </head>
  <body class="bg-slate-50 antialiased h-screen">
    <%= @inner_content %>
  </body>
</html>
```
## hooksの実装
各イベントはそれぞれ以下のようなことをやっています

### init
- 記録済みのログがあったらGEO JSON形式に変換
- 現在地を取得
- - MapLibereを生成
- - 現在位置を初期値にセット
- mapの読み込み完了後の処理を追加
- - navigationを表示するようにする
- - ログ保持用のソースを作成、 記録済みのログを初期値にセット
- - ソースをデータを同表示するかを設定
- 現在位置にマーカーを作成してwindowに追加
- windowに MapLibereを追加
- 現在位置を取得し続けるように設定
- - 移動を検知したら pushEvent "update"でLiveViewに通知

### update_log
pushEvent "update"でLiveViewに通知後、100m以上移動していたら LiveViewから push_eventで呼ばれる

- 移動ログをGeoJSONに変換
- 地図のセンターを現在地にセット
- 移動ログのGeoJSONでマップ上で表示しているログを差し替え
- pushEvent "save_position" でLiveViewに通知

```phoenix/assets/js/hooks.js
Hooks.Map = {
  mounted() {
    this.handleEvent("init", ({ log }) => {
      const logs = log.map((latlng) => {
        return {
          type: "Feature",
          properties: {},
          geometry: {
            type: "Point",
            coordinates: latlng,
          },
        };
      });

      navigator.geolocation.getCurrentPosition((pos) => {
        const init = [pos.coords.longitude, pos.coords.latitude];
        const map = new maplibregl.Map({
          container: "map",
          style:
            "https://api.maptiler.com/maps/streets/style.json?key=get_your_own_OpIi9ZULNHzrESv6T2vL",
          center: init,
          zoom: 12,
        });

        map.on("load", () => {
          map.addControl(new maplibregl.NavigationControl());
          map.addSource("map_log", {
            type: "geojson",
            data: {
              type: "FeatureCollection",
              features: logs,
            },
          });
          map.addLayer({
            id: "points",
            type: "circle",
            source: "map_log",
            layout: {},
            paint: {
              "circle-color": "#0000FF",
              "circle-radius": 4,
            },
          });
        });

        const user = new maplibregl.Marker().setLngLat(init).addTo(map);
        window.map = map;
        window.user = user;
      });

      const watchID = navigator.geolocation.watchPosition((position) => {
        const coords = position.coords;
        window.user.setLngLat([coords.longitude, coords.latitude]);
        this.pushEvent("update", {
          lat: coords.latitude,
          lng: coords.longitude,
        });
      });
      window.watchID = watchID;
    });

    this.handleEvent("update_log", ({ lat, lng, log }) => {
      const newLog = log.map((latlng) => {
        return {
          type: "Feature",
          properties: {},
          geometry: {
            type: "Point",
            coordinates: latlng,
          },
        };
      });
      window.map.setCenter([lng, lat]);
      window.map.getSource("map_log").setData({
        type: "FeatureCollection",
        features: newLog,
      });
      this.pushEvent("save_position", { lat: lat, lng: lng });
    });
  },
};
```

## LiveViewの実装

基本は`upate`イベントで RECフラグがtrueで移動距離が１つ前チェックポイント(`pos`)から100m離れていると保存するという形にしています
２つ目の `update`イベントはGPSログがうまく取れなかった時に何もしないようにしてエラーになるのを防いでいます

100m移動したかをどう計算するかですがGeoclacというライブラリを使ってlat,lngから２点間の距離を計算してくれます

https://hexdocs.pm/geocalc/readme.html

`start`, `stop`イベントでRECフラグを切り替えます

`save_position`イベントが発火したときはtryで囲んでDBに保存して失敗してもエラーにならないようにしています

最後に`handle_params`でrouteとrouteに関連するpositionを取得してアサインして、push_event "init"を実行しています

```phoenix/lib/spotties_web/live/route_live/show.ex
defmodule SpottiesWeb.RouteLive.Show do
  use SpottiesWeb, :live_view

  alias Spotties.Loggers

  @impl true
  def mount(_params, _session, socket) do
    socket
    |> assign(:lat, 0)
    |> assign(:lng, 0)
    |> assign(:pos, [0, 0])
    |> assign(:rec, false)
    |> then(&{:ok, &1})
  end

  @impl true
  def handle_event("start", _, socket) do
    socket
    |> assign(:rec, true)
    |> then(&{:noreply, &1})
  end

  @impl true
  def handle_event("stop", _, socket) do
    socket
    |> assign(:rec, false)
    |> then(&{:noreply, &1})
  end

  @impl true
  def handle_event(
        "update",
        %{"lat" => lat, "lng" => lng},
        %{assigns: %{pos: pos, rec: rec, log: log, route: route}} = socket
      ) do
    dis = Geocalc.distance_between(pos, [lat, lng])

    socket
    |> assign(:lat, lat)
    |> assign(:lng, lng)
    |> then(
      &if dis > 100 && rec,
        do:
          &1
          |> assign(:pos, [lat, lng])
          |> assign(:log, [[lng, lat] | log])
          |> push_event("update_log", %{lat: lat, lng: lng, log: log}),
        else: &1
    )
    |> then(&{:noreply, &1})
  end

  def handle_event("update", _, socket) do
    {:noreply, socket}
  end

  def handle_event(
        "save_position",
        %{"lat" => lat, "lng" => lng} = params,
        %{assigns: %{route: route}} = socket
      ) do
    try do
      Loggers.create_position(%{lat: lat, lng: lng, route_id: route.id})
    after
      {:noreply, socket}
    end
  end

  @impl true
  def handle_params(%{"id" => id}, _, socket) do
    route = Loggers.get_route!(id)
    log = Loggers.list_positions(id) |> Enum.map(fn pos -> [pos.lng, pos.lat] end)

    {:noreply,
     socket
     |> assign(:page_title, page_title(socket.assigns.live_action))
     |> assign(:route, route)
     |> assign(:log, log)
     |> push_event("init", %{log: log})}
  end

  defp page_title(:show), do: "Show Route"
end
```

## 画面の実装

idをつけたタグにphx-hookを設定するのは一緒ですが、canvasをゴリゴリ描画する画面の場合は`phx-update="ignore"`をつけないと無駄に画面が再描画されるので注意が必要です

```elixir:phoenix/lib/spotties_web/live/route_live/show.html.heex
<.gheader title={@route.name}>
  <:back>
    <.link navigate={~p"/routes"}>Back</.link>
  </:back>
  <:actions>
    <%= if @rec do %>
      <button phx-click="stop" class="btn btn-accent">Stop REC</button>      
    <% else %>
      <button phx-click="start" class="btn btn-secondary">Start REC</button>
    <% end %>
  </:actions>
</.gheader>
<div id={"route#{@route.id}"} class="h-full" phx-hook="Map" phx-update="ignore">
  <div id="map" class="w-screen h-[600px] ml-[-20px] mt-[-20px]"></div>
</div>
<.bottom_tab title="Routes" />
```

# demo

![3c6ce748617c9156ac411f496310b3aa.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64198/63662a04-6ede-1727-0163-f9cd823acbe8.gif)

# 最後に

MapLibereの細かい設定が面倒ですが、割と簡単（？）に地図アプリができました！
MapLibereはElixirラッパーはあるのですが、Kino用なのでそのままLiveViewで使うというのはできなさそうでした

Kinoは便利なwidgetが多く開発されているので気軽にLiveViewで使えるようになりたいですね

本記事は以上になりますありがとうございました

# 参考ページ
https://b1san-blog.com/post/js/js-location/
https://trueman-developer.blogspot.com/2017/01/ioswebviewgps.html
https://qiita.com/eito_2/items/7e7d0b658f2bcda3897e
https://maplibre.org/
https://maplibre.org/maplibre-gl-js-docs/api/
https://day-journal.com/memo/maplibregljs-017/
http://taustation.com/maplibre-capture-features-in-source/
https://github.com/yltsrc/geocalc
https://hexdocs.pm/geocalc/readme.html