10
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?

More than 1 year has passed since last update.

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

Last updated at Posted at 2023-03-08

はじめに

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

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

ElixirDesktopでスマホアプリ作成シリーズ

  1. Phoenix1.7とElixirDesktopでスマホアプリ開発 セットアップ編
  2. Phoenix1.7とElixirDesktopでスマホアプリ開発 認証機能編
  3. Phoenix1.7とElixirDesktopでスマホアプリ開発 CRUD編
  4. Phoenix1.7とElixirDesktopでスマホアプリ開発 GPSと地図アプリ編

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も消すようにします

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に紐付いて保存できるように修正します

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にもリレーションの設定をしておきます

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を使います

位置情報取得の許可

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はこちらを参考にしてください

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で追加します

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を指定します

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に追加します

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

地図アプリ化

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

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

ライブラリの読み込み

npmのがうまく動かなかったのでCDNで追加します

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で移動距離が1つ前チェックポイント(pos)から100m離れていると保存するという形にしています
2つ目の updateイベントはGPSログがうまく取れなかった時に何もしないようにしてエラーになるのを防いでいます

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

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"をつけないと無駄に画面が再描画されるので注意が必要です

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

最後に

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

10
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
10
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?