はじめに
本記事はPhoenix1.7をベースとして、Phoenixプロジェクトをネイティブアプリ化するライブラリElixirDesktopを使用し、
スマホアプリを作成する手順を紹介する記事になります
この記事は3までで作成したCRUDに追加でGPSログとMapLibereを連動させて地図アプリを作ってみます
ElixirDesktopでスマホアプリ作成シリーズ
- Phoenix1.7とElixirDesktopでスマホアプリ開発 セットアップ編
- Phoenix1.7とElixirDesktopでスマホアプリ開発 認証機能編
- Phoenix1.7とElixirDesktopでスマホアプリ開発 CRUD編
- 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に紐付いて保存できるように修正します
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にもリレーションの設定をしておきます
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を追加します
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に以下を追加します
<?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を作ります
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で追加します
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します
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を指定します
<.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に追加します
defp links() do
[
{"Routes", "/routes"},
+ {"GPS", "/gps"},
{"Setting", "/users/settings"}
]
end
routerのrequire_authenticated_userのセッション配下に追加して完了です
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
地図アプリ化
GPSログは取れてるのは確認できたのでGPSログから地図上に現在位置を表示しながらDBに保存していきます
地図ライブラリは無料のMapLibereを使います
ライブラリの読み込み
npmのがうまく動かなかったのでCDNで追加します
<!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に通知
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"を実行しています
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"
をつけないと無駄に画面が再描画されるので注意が必要です
<.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
最後に
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