今回の記事では前記事でDBに蓄えたgeo情報を使って、移動した軌跡を地図上に再現する方法を示します。同じプロジェクトを拡張して、検索ページを追加して、検索のためのRest APIを追加します。
Elmとleafletでつくるgeolocationの発信機・受信機 - Qiita
今回の技術的なポイントは以下の通りです。
1.PhoenixでRest APIを実装する
2.Ecto.Queryを使ってデータを取得する
3.Timexで時間文字列をunixtimeに変換する
4.Leaflet.jsで、polylineでラインを描き、複数のマーカをgroup化する
5.Leaflet.jsでlatlng.distanceTo()で距離を計算する
6.ElmからRest API CALLを行う
7.Elmで簡単なデータ解析を行う
#1.再現・データ解析ページ
まず再現・データ解析ページ(Page2)を追加します。このページは開始時刻と終了時刻を入力すると、APIでその時間帯に歩いたデータを取得し、地図上にラインを描くというものです。抜粋したポイントの時刻もマーカで表示します。ついでにトータル距離や時間、速度、最高速度などのデータ解析を行い表示してくれます。ダイエットや健康促進にご利用いただけます?
最高速度は10秒間毎の速度の最大値を取っていますが、バラツキが激しいですね。10秒間でなく、せいぜい3分毎程度のものを測らないと意味がないかもしれません。
上の地図のラインは、以下のようなDBテーブルをもとに描いています。
設定ですが手動で行います。routerとcontroller、viewと設定しますが全て基本的なものです。
##1-1.router
scope "/", LeafletChannelWeb do
pipe_through :browser # Use the default browser stack
get "/", PageController, :index
get "/page2", Page2Controller, :redraw
end
##1-2.controller
defmodule LeafletChannelWeb.Page2Controller do
use LeafletChannelWeb, :controller
def redraw(conn, _params) do
render conn, "page2.html"
end
end
##1-3.view
defmodule LeafletChannelWeb.Page2View do
use LeafletChannelWeb, :view
end
##1-4.template (leaflet.jsコード)
テンプレートのためのディレクトリを作ります。
mkdir lib/leaflet_channel_web/templates/page2
テンプレートですが、leaflet.jsを使って地図を表示するためのJavaScriptコードを含みます。
<html>
<head>
<title>Leaflet AND Elm</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.3.1/dist/leaflet.css">
<script src="https://unpkg.com/leaflet@1.3.1/dist/leaflet.js"></script>
<style type="text/css">
<!--
#mapid { height: 400px; width: 600px}
-->
</style>
</head>
<body>
<a href="/">トップ</a>
<br /><br />
<div id="elm-area"></div>
<br /><br />
<div id="mapid"></div>
<script src="/js/vendor/page2.js"></script>
<script>
const app = Elm.Page2.embed(document.getElementById("elm-area"));
const point0 = [35.7102, 139.8132];
let polyline = null;
let grp = null;
let zoom = 15;
let dist = -1;
let latlng1, latlng2;
const mymap = L.map('mapid')
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 18,
attribution: 'Map data © <a href="http://openstreetmap.org">OpenStreetMap</a> contributors, '
}).addTo(mymap);
mymap.setView(point0, zoom);
app.ports.portReqGeo.subscribe( (geogeos) => {
var obj = [];
geogeos.forEach(function(geogeo) {
latlng1 = L.latLng(geogeo[0].lat, geogeo[0].lng);
latlng2 = L.latLng(geogeo[1].lat, geogeo[1].lng);
dist = latlng1.distanceTo(latlng2);
obj.push ({geo1: geogeo[0], geo2: geogeo[1], dist: dist});
})
app.ports.portResNeo.send(obj);
})
app.ports.portLocations.subscribe( (latlngs) => {
if( polyline ) {
mymap.removeLayer(polyline);
}
if( latlngs.length > 0 ) {
let p1 = latlngs[0];
mymap.setView(p1, zoom);
polyline = L.polyline(latlngs, {color: 'red'}).addTo(mymap);
} else {
mymap.setView(point0, zoom);
}
})
app.ports.portMarkers.subscribe( (geos) => {
if( grp ) {
grp.clearLayers()
}
grp = L.layerGroup([]);
geos.forEach(function(geo) {
var time = unixTime2ymd(geo.time);
//var marker = L.marker([geo.lat,geo.lng]).addTo(mymap).bindPopup(time).openPopup();
var marker = L.marker([geo.lat,geo.lng]).bindPopup(time).openPopup();
grp.addLayer(marker);
});
grp.addTo(mymap);
});
function unixTime2ymd(intTime){
// var d = new Date( intTime );
var d = new Date( intTime * 1000 );
var year = d.getFullYear();
var month = d.getMonth() + 1;
var day = d.getDate();
var hour = ( '0' + d.getHours() ).slice(-2);
var min = ( '0' + d.getMinutes() ).slice(-2);
var sec = ( '0' + d.getSeconds() ).slice(-2);
return( year + '-' + month + '-' + day + ' ' + hour + ':' + min + ':' + sec );
}
</script>
</body>
</html>
このテンプレートはleaflet.jsのコードが地図を表示する部分です。Elmコードからportを通してコントロールされています。Elmのport定義の抜粋を以下に示します。
-- OUTGOING PORT
port portLocations : List (List Float) -> Cmd msg
port portMarkers : List Geo -> Cmd msg
port portReqGeo : List (Geo, Geo) -> Cmd msg
-- INCOMING PORT
port portResNeo : (List Neo -> msg) -> Sub msg
それぞれのportの説明です。
-- OUTGOING PORT
port portLocations : 歩いた軌跡のラインを描きます
port portMarkers : ポイントを抜粋して時刻を表示するマーカを描きます
port portReqGeo : 2地点の位置情報から距離を計算するためのリクエストです。
-- INCOMING PORT
port portResNeo : 2地点の距離の計算結果を返します。
3つのOUTGOING PORTsは同じタイミングで発行されますので、1つにまとめることが可能ですが、機能が別なので分けています。それぞれのportで必要とする引数はElm側で加工してから渡しています。JavaScriptのコードは最小限に抑えて、データの加工などはElmに任せた方が全体的にスッキリと書ける気がします。
最後に、時刻表示のマーカですが、複数表示するのでgroup化します。描き直すときにクリアーしますが、group毎一括して行えますので。
app.ports.portMarkers.subscribe( (geos) => {
if( grp ) {
grp.clearLayers()
}
grp = L.layerGroup([]);
geos.forEach(function(geo) {
#
grp.addLayer(marker);
});
grp.addTo(mymap);
});
#2.Rest APIの追加
検索ページからリクエストを投げるAPIです。これもrouterとcontoroller、viewを定義していきます。手動で設定します。
##2-1.router
#
scope "/api", LeafletChannelWeb do
pipe_through :api
get "/points", PointController, :index
end
#
##2-2.contoroller
ここではEcto.Queryを使ってDBからデータを取得しています。問い合わせの定義は、通常のSQLをラッピングしてElixir構造体で表現しています。Ectoの設定については前回の記事を参考にしてください。
Elmとleafletでつくるgeolocationの発信機・受信機 - Qiita
defmodule LeafletChannelWeb.PointController do
use LeafletChannelWeb, :controller
alias LeafletChannel.Point
# Imports only from/2 of Ecto.Query
import Ecto.Query, only: [from: 2]
def index(conn, %{"start" => start, "stop" => stop} ) do
start = Timex.parse!(start<>" Asia/Tokyo", "%Y-%m-%d %H:%M:%S %Z" , :strftime) |> Timex.to_unix
stop = Timex.parse!(stop<>" Asia/Tokyo", "%Y-%m-%d %H:%M:%S %Z" , :strftime) |> Timex.to_unix
points = LeafletChannel.Repo.all(from p in Point, where: p.time > ^start and p.time < ^stop, order_by: [desc: p.time])
render(conn, "index.json", points: points)
end
end
Timex.parse!でTime Zoneを指定しないと9時間ずれてしまうので注意してください。%Z で指定します。
iex(23)> t=Timex.parse!("2013-03-05 12:30:45 Asia/Tokyo", "%Y-%m-%d %H:%M:%S %Z", :strftime)
#DateTime<2013-03-05 12:30:45+09:00 JST Asia/Tokyo>
iex(24)> t |> Timex.to_unix
1362454245
timexは以下のようにしてインストールします。
#
defp deps do
[
#
{:timex, "~> 3.1"}
]
end
#
mix deps.get
##2-3.view
Elmクライアントに返すJsonを定義します。
defmodule LeafletChannelWeb.PointView do
use LeafletChannelWeb, :view
alias LeafletChannelWeb.PointView
def render("index.json", %{points: points}) do
%{data: render_many(points, PointView, "point.json")}
end
def render("point.json", %{point: point}) do
%{id: point.id,
lat: point.lat,
lng: point.lng,
time: point.time}
end
end
##2-4.Ectoあれこれ
本筋とはあまり関係ありませんが。
以下のコマンドでPoint テーブルを表示できます。
$ iex -S mix
iex(3)> LeafletChannel.Point |> LeafletChannel.Repo.all
もうすこし複雑な問い合わせです。
alias LeafletChannel.Point
import Ecto.Query, only: [from: 2]
LeafletChannel.Repo.all(from p in Point, where: p.time > 1525055400 and p.time < 1588224600, order_by: [desc: p.time])
PostgreSQLのコマンドでも直接確認できます。
sudo -u postgres psql leaflet_channel_dev -c 'select * from point'
開発中はDBを何度もリセットしますね。
mix ecto.drop
mix ecto.create
mix ecto.migrate
#3.Elmの環境設定
brunch-config.jsにPage2.elmを追加します。
#
elmBrunch: {
elmFolder: "elm",
mainModules: ["App.elm","Page2.elm"], // Elmプログラム
outputFolder: "../static/vendor/js" // Elmの実行形をstaticに吐き出す
}
#
(※重要)outputFolderにはvendorサブディレクトリが指定されていることに注意してください。brunchにおいてはvendorにあるfileが優先的にロードされるようです。つまりElmのjsファイルが安全にロードされます。単にoutputFolder を "../static/js"と vendor抜きで指定すると、かなりの確率でElmのjsファイルのロードに失敗してしまいます。
以下のようなパッケージをインストールします。
{
"version": "1.0.0",
"summary": "helpful summary of your project, less than 80 characters",
"repository": "https://github.com/user/project.git",
"license": "BSD3",
"source-directories": [
"."
],
"exposed-modules": [],
"dependencies": {
"elm-lang/core": "5.1.1 <= v < 6.0.0",
"elm-lang/html": "2.0.0 <= v < 3.0.0",
"elm-lang/http": "1.0.0 <= v < 2.0.0",
"elm-lang/http": "1.0.0 <= v < 2.0.0",
"saschatimme/elm-phoenix": "0.3.0 <= v < 1.0.0"
},
"elm-version": "0.18.0 <= v < 0.19.0"
}
elm-github-install
#4.Elmプログラム
##4-1.プログラム概略
今回のElmプログラムは、前回の記事のものよりPhoenix channelを使わない分すっきりしています。その代わりにHttpでAPIを叩いていますが。
Elmとleafletでつくるgeolocationの発信機・受信機 - Qiita
クライアントプログラムでは、以下のような仕事をします。
(1) DBに指定した時刻幅のgeoデータをリクエスト(API)する
(2) 歩いた地図を描き、軌跡を赤ラインで描画する
(3) 抜粋したポイントの時刻をマーカで表示する
(4)トータルの距離や時間、速度、最高速度などのデータ解析を行う
(1)はPhoenixにhttpsリクエスト(API)を発行しています。
(2) - (4) は(1)で得られた情報を加工してJavaScriptのleaflet.jsに指示を出しています。以下のportに対応しています。
-- OUTGOING PORT
(2) port portLocations : 歩いた軌跡のラインを描きます
(3) port portMarkers : ポイントを抜粋して時刻を表示するマーカを描きます
(4) port portReqGeo : 2地点の位置情報から距離を計算するためのリクエストです。
-- INCOMING PORT
(4) port portResNeo : 2地点の距離の計算結果を返します。
特に(4)については leaflet.jsの関数 latlng1.distanceTo(latlng2) を利用して、2ポイント間の距離を計算し、データ解析を行います。
##4-2.Elmの全コード
それでは以下にElmの全コードを示します。
port module Page2 exposing (..)
import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (..)
import Task exposing (Task)
import Http
import Json.Encode as E exposing (Value)
import Json.Decode as Decode
import Time exposing (Time)
minSpeed : Float
minSpeed = 50.0 -- 分速のMin。これ以下は異常値。
maxSpeed : Float
maxSpeed = 300.0 -- 分速のMax。これ以上は異常値。
-- OUTGOING PORT
port portLocations : List (List Float) -> Cmd msg
port portMarkers : List Geo -> Cmd msg
port portReqGeo : List (Geo, Geo) -> Cmd msg
-- INCOMING PORT
port portResNeo : (List Neo -> msg) -> Sub msg
geosUrl =
"/api/points"
main =
Html.program
{ init = init
, update = update
, view = view
, subscriptions = subscriptions
}
-- MODEL
type alias Geo =
{ id : Int
, lat : Float
, lng : Float
, time : Time
}
type alias Neo =
{ geo1 : Geo
, geo2 : Geo
, dist : Float
}
type alias Model =
{ geos : List Geo -- viewで使わないならModelに置く必要はない?
, neos : List Neo
, start : String
, stop : String -- Do not use end! keyword in elixir
}
start : String
start = "2018-05-01 06:30:00"
stop : String
stop = "2020-05-01 06:30:00"
init : ( Model, Cmd Msg )
init =
Model [] [] start stop ! []
-- UPDATE
type Msg
= GetGeos
| MsgNewGeos (Result Http.Error (List Geo))
| ResNeo (List Neo)
| ChangeStart String
| ChangeStop String
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
GetGeos ->
(model, (getGeosList model) )
MsgNewGeos (Ok newgeos) ->
let
len = List.length newgeos
_ = Debug.log "list len=" len
in
{ model | geos=newgeos } !
[ portLocations <| List.map latlng <| newgeos
, portMarkers <| List.filterMap isGeoSelect <| List.indexedMap (,) <| newgeos
, portReqGeo <| List.map2 (,) newgeos (List.drop 1 newgeos) -- [(g1,g2),(g2,g3),(g3,g4)...]
]
MsgNewGeos (Err _) ->
(model, Cmd.none)
ResNeo neos ->
let
len = List.length neos
_ = Debug.log "neos len=" len
in
{model | neos=neos} ! []
ChangeStart start ->
{ model | start=start } ! []
ChangeStop stop ->
{ model | stop=stop } ! []
isGeoSelect (n, geo) =
case n%18==0 of
True -> Just geo
False -> Nothing
latlng : Geo -> List Float
latlng geo =
{--let
_ = Debug.log "geo time=" geo.time
in--}
[ geo.lat, geo.lng ]
getGeosList : Model -> Cmd Msg
getGeosList model =
Http.send MsgNewGeos (requestGeos model)
requestGeos : Model -> Http.Request (List Geo)
requestGeos model =
let
pointsUrl = geosUrl ++ "?start=" ++ model.start ++ "&stop=" ++ model.stop
in
Http.get pointsUrl ( Decode.field "data" ( Decode.list geo ) )
geo : Decode.Decoder Geo
geo =
Decode.map4 toGeo
(Decode.field "id" Decode.int)
(Decode.field "lat" Decode.float)
(Decode.field "lng" Decode.float)
(Decode.field "time" Decode.float)
toGeo : Int -> Float -> Float -> Time -> Geo
toGeo id lat lng time =
Geo id lat lng time
-- VIEW
view : Model -> Html Msg
view model =
div []
[ input [ value model.start, onInput ChangeStart ] []
, input [ value model.stop, onInput ChangeStop ] []
, button [ onClick GetGeos ] [ text "リスト取得" ]
, div [] ( viewNeos model )
]
viewNeos model =
case (List.length model.geos)<2 || (List.length model.neos)<2 of
True -> []
False -> viewNeos2 model
viewNeos2 model =
let
neos = List.filter (\neo -> neo.dist>0) model.neos
dist = List.foldr (\neo acc -> neo.dist+acc) 0 neos
diff = getTimeDiff(model.geos)
speed = if diff==0 then -1 else (dist/diff)*60 -- 分速
speeds = groupAve 18 <| List.filter (\s -> s>minSpeed && s<maxSpeed) <| List.map getSpeed neos
ave = case List.isEmpty(speeds) of
True -> -1
False -> (List.sum speeds) / toFloat(List.length(speeds))
max = case List.maximum speeds of
Just m -> m
Nothing -> -1
min = case List.minimum speeds of
Just m -> m
Nothing -> -1
-- _ = Debug.log "speeds=" speeds
in
[ h3 [] [text "解析結果"]
, p [] [text <| "距離(m) = "++(toString dist)]
, p [] [text <| "時間(分) = "++(toString (diff/60))]
, p [] [text <| "速度(分速) = "++(toString speed)]
, p [] [text <| "速度(有効平均) = "++(toString ave)]
, p [] [text <| "最高速度 = "++(toString max)]
, p [] [text <| "最低速度 = "++(toString min)]
-- , ul [] (List.map viewNeo model.neos)
]
getTimeDiff geos =
let
t1 = headTime geos
t9 = headTime <| List.reverse geos
in
t1 - t9
headTime geos =
case List.head geos of
Just geo -> geo.time
Nothing -> 0
getSpeed neo =
case neo.geo1.time > neo.geo2.time of
True -> (neo.dist / (neo.geo1.time - neo.geo2.time)) * 60
False -> -1
-- ある程度まとまった時間の分速を求める=> 10秒x18=3分毎の速度
groupAve : Int -> List Float -> List Float
groupAve n speeds =
case List.isEmpty speeds of
True -> []
False -> let
head = List.take n speeds
ave = (List.sum head) / toFloat(List.length(head))
tail = List.drop n speeds
aves = groupAve n tail
in
ave::aves
viewNeo neo =
li []
[ text <| toString <| neo.geo1.time
, text " - "
, text <| toString <| neo.geo2.time
, text " - "
, text <| toString <| neo.dist
]
-- SUBSCRIPTIONS
subscriptions : Model -> Sub Msg
subscriptions model =
Sub.batch [ portResNeo ResNeo ]
##4-3.Port
以下のコードが3つのOUTGOING port呼び出しと、それぞれの引数のためのデータ加工ですが、大変スッキリ書けていると自画自賛したくなります。Elmの記述性・効率性は素晴らしい!
#
MsgNewGeos (Ok newgeos) ->
let
len = List.length newgeos
_ = Debug.log "list len=" len
in
{ model | geos=newgeos } !
[ portLocations <| List.map latlng <| newgeos
, portMarkers <| List.filterMap isGeoSelect <| List.indexedMap (,) <| newgeos
, portReqGeo <| List.map2 (,) newgeos (List.drop 1 newgeos) -- [(g1,g2),(g2,g3),(g3,g4)...]
]
#
以下がINCOMING portをリッスンしているsubscriptionsの定義です。
subscriptions : Model -> Sub Msg
subscriptions model =
Sub.batch [ portResNeo ResNeo ]
##4-4.基本データ
以下が基本的なデータ定義です。GeoはDBから取得したpoint情報を入れるためのものです。Neoは、Geoをもとにleaflet.jsに計算させた2point間の距離を入れるためのものです。
type alias Geo =
{ id : Int
, lat : Float
, lng : Float
, time : Time
}
type alias Neo =
{ geo1 : Geo
, geo2 : Geo
, dist : Float
}
##4-5.Rest API CALL
以下の部分が、PhoenixのRest APIにリクエストを投げて、DBの検索結果を受け取る部分です。インターフェースをできるだけシンプルに設計すれば、この程度ならDecoderもシンプルに書けます。
#
getGeosList : Model -> Cmd Msg
getGeosList model =
Http.send MsgNewGeos (requestGeos model)
requestGeos : Model -> Http.Request (List Geo)
requestGeos model =
let
pointsUrl = geosUrl ++ "?start=" ++ model.start ++ "&stop=" ++ model.stop
in
Http.get pointsUrl ( Decode.field "data" ( Decode.list geo ) )
geo : Decode.Decoder Geo
geo =
Decode.map4 toGeo
(Decode.field "id" Decode.int)
(Decode.field "lat" Decode.float)
(Decode.field "lng" Decode.float)
(Decode.field "time" Decode.int)
toGeo : Int -> Float -> Float -> Int -> Geo
toGeo id lat lng time =
Geo id lat lng time
#
##4-6.簡単なデータ解析
以下がGeoリストとNeoリストを元に簡単なデータ解析を行い、表示している部分です。VIEWにおいて行っています。geo情報は、歩いたり止まったりしていて、必ずしもきれいな情報になっていない部分もあります。またブラウザの制限かなとも思いますが、環境によっては、そもそも常に正常なgeolocationが取得できるとも限らないようです。実際には異常値を取り除いてより正確な「速度」を割り出す工夫も必要なのでしょう。面白い課題だと思います。
-- VIEW
view : Model -> Html Msg
view model =
div []
[ input [ value model.start, onInput ChangeStart ] []
, input [ value model.stop, onInput ChangeStop ] []
, button [ onClick GetGeos ] [ text "リスト取得" ]
, div [] ( viewNeos model )
]
viewNeos model =
case (List.length model.geos)<2 || (List.length model.neos)<2 of
True -> []
False -> viewNeos2 model
viewNeos2 model =
let
neos = List.filter (\neo -> neo.dist>0) model.neos
dist = List.foldr (\neo acc -> neo.dist+acc) 0 neos
diff = getTimeDiff(model.geos)
speed = if diff==0 then -1 else (dist/diff)*60 -- 分速
speeds = groupAve 18 <| List.filter (\s -> s>minSpeed && s<maxSpeed) <| List.map getSpeed neos
ave = case List.isEmpty(speeds) of
True -> -1
False -> (List.sum speeds) / toFloat(List.length(speeds))
max = case List.maximum speeds of
Just m -> m
Nothing -> -1
min = case List.minimum speeds of
Just m -> m
Nothing -> -1
-- _ = Debug.log "speeds=" speeds
in
[ h3 [] [text "解析結果"]
, p [] [text <| "距離(m) = "++(toString dist)]
, p [] [text <| "時間(分) = "++(toString (diff/60))]
, p [] [text <| "速度(分速) = "++(toString speed)]
, p [] [text <| "速度(有効平均) = "++(toString ave)]
, p [] [text <| "最高速度 = "++(toString max)]
, p [] [text <| "最低速度 = "++(toString min)]
-- , ul [] (List.map viewNeo model.neos)
]
getTimeDiff geos =
let
t1 = headTime geos
t9 = headTime <| List.reverse geos
in
t1 - t9
headTime geos =
case List.head geos of
Just geo -> geo.time
Nothing -> 0
getSpeed neo =
case neo.geo1.time > neo.geo2.time of
True -> (neo.dist / (neo.geo1.time - neo.geo2.time)) * 60
False -> -1
-- ある程度まとまった時間の分速を求める=> 10秒x18=3分毎の速度
groupAve : Int -> List Float -> List Float
groupAve n speeds =
case List.isEmpty speeds of
True -> []
False -> let
head = List.take n speeds
ave = (List.sum head) / toFloat(List.length(head))
tail = List.drop n speeds
aves = groupAve n tail
in
ave::aves
今回は以上で終了です。