12
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 5 years have passed since last update.

マルチユーザ対応geolocationアプリ - Elm + Phoenix

Last updated at Posted at 2018-05-20

 本記事はElmとPhoenixを用いて、如何にスマートにアプリ開発を行えるかを試みた記録です。断片的な技術の紹介ではなく、1個の完全なアプリ開発を目指したものです。ElmとPhoenixを勉強し始めて半年ぐらいです。特にElmに関してですが大変気に入っているのですが、果たして現実的なプログラムに耐えうるものなのか、少し疑問を抱いていました。今は結構使えると実感を持ち始めています。

 Elmの相方にはPhoenix(Elixir)を選びました。純粋でないにしても関数型言語ですし、スケーラビティに優れているし、扱いやすい。特にPhoenixのChannelは、かつてMeteorに求めていたリアルタイム性の代替になる、魅力的なものでした。

 Elm + Phoenixの組み合わせは大変魅力的です。今回のアプリは紹介するには少し大きすぎるのですが、そのボリューム感も含めてお伝えできればいいかなと思っています。

#1.マルチユーザ対応geolocationアプリ

 本アプリは、スマホを持ち歩いて位置情報を発信し、家のPCでそれを受信しマップ上に表示するものです。同時に位置情報はDBに保存され、後にデータ解析に使われます。歩いた距離や時間、平均速度や最高速度などを知ることができます。

 これらの位置データはユーザ単位で管理されますので、自分のデータは知ることができますが、他人のデータを知ることはできません。ですから本アプリを使うにはユーザ登録を行い、ログインする必要があります。

 一度ログインすれば、ログイン情報はブラウザのlocalstorageに保存されますので、次回からは自動的にログイン状態になります。localstorageのデータは、明示的に消去しない限り、永続的に使うことが可能です。但し実際にはログイントークンの有効期限が1か月なので、1か月に一回再ログインする必要があります。

##1-1.画像でアプリ紹介

###1.ログインフォーム
image.png

###2.ユーザ登録フォーム
image.png

###3.受信モード
ログインすると受信モードに入ります。
image.png

###4.発信モード
「発信開始」ボタンを押すと発信モードになります。
発信モードのスマホを持ち歩くと、現在位置を受信機に知らせ、受信機のマップと現在位置を同期します。
同時にサーバ上に現在位置の情報を蓄えていきます。
image.png

###5.再現モード
「検索ページ」をクリックすると再現ページに移動します。
時間を指定して「再現」ボタンを押すと、歩いた軌跡をマップ上に再現し、複数のマーカで時刻を表示します。
同時のデータ解析を行い、距離や時間、速度を表示してくれます。
image.png

##1-2.プログラム設計(HTTP, Channel)

 全体的な構成要素は大雑把に言って以下のように、JavaScriptとElm、Phoenixの3つになります。JavaScriptとElmはPortsで会話をします。ElmとPhoenixはHtmlとAPI、Channelの3種類を使い分けます。

JavaScript -- (Ports) --  Elm -- |--(HTTP Html)--| -- Phoenix 
                                 |--(HTTP API) --|
                                 |--(Channel)  --|

 Pageはトップページと再現ページの2画面あります。それぞれのパスは "/" と"/page2" で表示します。それぞれ専用のElmアプリを読み込み、以下のような役割を果たします。

Page(パス、ページ名、Elm、働き)
"/"       トップページ   App.elm    ユーザ登録・ログイン・geolocation発信機・受信機
"/page2"  再現ページ     Page2.elm  geolocation再現機・データ解析

 上のそれぞれのページから、以下のREST APIが呼ばれます。

API(パス、働き、コール元ページ)
"/users"    ユーザ登録      トップページ 
"/sign_in"  ログイン        トップページ 
"/points"   geolocation取得 再現ページ

 またトップページからはChannelを利用して、HTTPではできないリアルタイムの送受信を行っています。

Channel(トピック・サブトピック、イベント、働き、ページ)
"room"+user_name   new_msg     geolocationの発信  トップページ
"room"+user_name   new_point   geolocationの受信  トップページ

##1-3.プログラム設計(Ports - elmとJavaScriptの会話)

 クライアントコードのほとんどはElmで書くのですが、一部JavaScriptを利用する必要があります。ElmからはPortsを通してそれらの機能にアクセスします。

JavaScriptの役割
leaflet.js  -- 地図を表示し、現在位置にマーカを置き、歩いた軌跡を描きます
navigator.geolocation -- 現在位置を取得します
localstorage -- 認証トークン(JWT)を永続的に保存し、ページ間で共有します。

 トップページでは以下のようなPortsが定義されます。

Ports(トップページ)
-- OUTGOING PORT
portInitCurLocation :(発信・受信)ElmからJavaScriptにlocation初期値を渡します
portSetCurLocation  :(発信)ElmからJavaScriptに現在のlocationを取得し、地図上にマーカーを置き、geoデータをElmに返すように指示します。
                     (受信)chnnelで受信したgeoデータを渡し、地図上にマーカを置くよう指示します。
port portSetToken :  JWTをlocalstorageに保存する
port portGetToken :  localstorageのJWTを返すように依頼する

-- INCOMING PORT
port portGetCurLocation : 現在のlocationを返す
port portResToken : JWTを返す。NULLだったら未ログインと判断する。

 再現ページでは以下のようなPortsが定義されます。

Ports(再現ページ)
-- OUTGOING PORT
portLocations : 歩いた軌跡のラインを描きます
portMarkers : ポイントを抜粋して時刻を表示するマーカを描きます
portReqGeo : 2地点の位置情報から距離を計算するためのリクエストです。
portGetToken :  localstorageのJWTを返すように依頼する

-- INCOMING PORT
portResNeo :  2地点の距離の計算結果を返します。
portResToken :  JWTを返す。NULLだったら未ログインと判断する。

##1-4.プログラム設計(DB設計)

ログイン認証用のユーザテーブルです。passwordはハッシュ値を保存します。

    create table(:users) do
      add :email, :string
      add :name, :string
      add :password_hash, :string
      timestamps()
    end

 位置情報(geolocation)を保存しているテーブルです。user_idでタグ付けされています。

    create table(:point) do
      add :user_id, :integer
      add :lat, :float
      add :lng, :float
      add :time, :integer
    end

##1-5.プログラム設計(SSLと認証)

 Phoenixにおいては以下のようにSSLと認証が必要になります。Elm側でも対応が必要になりますが、後でElmの説明の時に説明します。

SSL - JavaScriptでnavigator.geolocationを使うために必要 
認証 - Guardian(JWT)とComeonin(ハッシュ)

#2.Phoenix環境構築

##2-1.SSLの設定

mix phx.new multi_leaflet
cd multi_leaflet

秘密キー(localhost.key)と証明書(localhost.cert)を作ります。

openssl genrsa -out localhost.key 2048
openssl req -new -x509 -key localhost.key -out localhost.cert -days 3650 -subj /CN=localhost

 秘密キー(localhost.key)と証明書(localhost.cert)を、適切な場所(どこでもよい)に移動します。

mkdir priv/keys
mv localhost.cert localhost.key priv/keys
config/dev.exs
#
config :multi_leaflet, MultiLeafletWeb.Endpoint,
  http: [port: 4009],
  https: [port: 4443,
          otp_app: :multi_leaflet,
          keyfile: "priv/keys/localhost.key",
          certfile: "priv/keys/localhost.cert"],
  debug_errors: true,
  code_reloader: true,
  check_origin: false,
  watchers: [node: ["node_modules/brunch/bin/brunch", "watch", "--stdin",
                    cd: Path.expand("../assets", __DIR__)]]
#

以上でSSLの設定は終わりです。後で、Ecto(PostgreSQL)とかの設定後に起動するとhttpsの表示が出ますので確認できます。ただしブラウザでアクセスしたときにオレオレ証明書なので注意されますが、例外とかに追加すれば表示できるようになります。あくまで実験ですので今回の目的には十分です。

##2-2.Channelの設定
 user_socket.exを修正します。以下のようにchannel行のコメントを外します。

lib/multi_leaflet_web/channels/user_socket.ex
defmodule MultiLeafletWeb.UserSocket do
  use Phoenix.Socket

  ## Channels
  channel "room:*", MultiLeafletWeb.RoomChannel
#

 room_channel.exを以下のように作成します。これは後のElmクライアントを実装に符合しています。

lib/multi_leaflet_web/channels/room_channel.ex
defmodule MultiLeafletWeb.RoomChannel do
  use MultiLeafletWeb, :channel

  def join("room:"<>subtopic, %{"user_name" => user_name}, socket) do
    IO.puts ("subtopic=#{subtopic}")
    socket = assign(socket, :user_name, user_name)
    {:ok, socket}
  end
  def join("room:" <> _private_room_id, _params, _socket) do
    {:error, %{reason: "unauthorized"}}
  end

  def handle_in("new_msg", %{"point" => point, "user_id" => user_id, "time" => time}, socket) do
    user_name = socket.assigns[:user_name]
    [lat,lng] = Poison.decode!(point) # string -> float
    itime = Poison.decode!(time) # string -> int
    id = Poison.decode!(user_id) # string -> int
    broadcast(socket, "new_point", %{lat: lat, lng: lng, time: itime, user_name: user_name})

    second = Kernel.trunc(itime/1000)  # DBへは秒で保存
    # p1 = %MultiLeaflet.Point{user_id: id, lat: lat, lng: lng, time: second}
    # MultiLeaflet.Repo.insert(p1)
    {:reply, :ok, socket}
  end

  def terminate(_reason, socket) do
    user_name = socket.assigns[:user_name]
    IO.puts("terminate user=#{user_name}")
    {:noreply, socket}
  end

end

 以下のように"room:"<>subtopicで受けています。つまりサブトピック名は変数となっており、任意の文字列がマッチします。これは後にElmクライアントから、ログインユーザ名が渡されます。つまりユーザ名毎にチャットルームができていくイメージですね。同じユーザ名でログインした受信機と発信機がgeolocation情報を共有し、マップの再現を行えます。

  def join("room:"<>subtopic, %{"user_name" => user_name}, socket) 

 terminate/2は、例えばブラウザを閉じた時にとかのsocket通信がクローズした時に呼ばれます。

room_channel.exでは外部データ(Json)をElixirに取り込む(Decode)ためにpoisonを使いますので、インストールします。

mix.exs
#
defp deps do
  [
#
    {:poison, "~> 3.1"}
  ]
end
#

 以下のコマンドでインストールします。

mix deps.get

 以上でChannelの設定が終了です。

##2-3.Ecto.Repo(PostgreSQL)の設定

 Elixirで設定する時は以下の過去記事にあるように多少ステップが複雑ですが、Phoenixでは初期設定でほとんど行われており、よりシンプルです。特にEcto.Repoの設定が楽になります。詳細を知りたいときは過去記事を参照してください。
Elixir Ecto のまとめ - Qiita

 Repo(Repositories)はdata storeのラッパーで、データストアに対するAPIを提供します。EctoではRepository を通して create, update, destroy や query を発行することができます。 ですからRepository は、データベースとコミュニケーションするための adapter や credentialsを知る必要があります。

 Phoenixの場合は自動で様々な設定が行われているので、Ecto.Repoに関しては以下の設定ファイルの登録だけでokです。

config/config.exs(追加)
#
config :multi_leaflet, MultiLeaflet.Repo,
  adapter: Ecto.Adapters.Postgres,
  database: "multi_leaflet_repo",
  username: "postgres",
  password: "postgres",
  hostname: "localhost"
#

 以上で準備は終わっているのでDBを作成します。以下のコマンドを打ちます。

$ mix ecto.create

##2-4. Point テーブル&Schemaの作成

(1) Point テーブルの作成

geolocationデータを蓄えておくテーブルの作成を行います。まず以下のmixコマンドを打ちます。

$  mix ecto.gen.migration create_point

* creating priv/repo/migrations
* creating priv/repo/migrations/20180506003818_create_point.exs

 ファイル priv/repo/migrations/20180506003818_create_point.exs を編集して以下のようにします。

priv/repo/migrations/20180422021155_create_point.exs
defmodule MultiLeaflet.Repo.Migrations.CreatePoint do
  use Ecto.Migration

  def change do
    create table(:point) do
      add :user_id, :integer
      add :lat, :float
      add :lng, :float
      add :time, :integer
    end
  end
end

 最後に上のmigrationファイルを走らせて、テーブルを作成します。

$ mix ecto.migrate

 もしこの時migrationに失敗したら、mix ecto.rollbackでこのchangeをundoできます。

(2) Point Schemaの作成

 Ecto.Schema はDB テーブルを Elixir struct に map するために使います。

 LeafletChannel.Point schemaは以下のような構文で作られ、Elixir structに落とし込まれます。schemaはデフォルトでidというintegrのfieldが自動追加されます。field macro はname と type で field を定義します。

lib/multi_leaflet/point.ex
defmodule MultiLeaflet.Point do
  use Ecto.Schema

  schema "point" do
    field :user_id, :integer
    field :lat, :float
    field :lng, :float
    field :time, :integer
  end
end

 room_channel.exの初期設定の段階では以下の2行はコメントアウトしていましたが、LeafletChannel.Point Schemaが定義されたのでコメントを外してください。

lib/multi_leaflet_web/channels/room_channel.ex
#
    p1 = %MultiLeaflet.Point{user_id: id, lat: lat, lng: lng, time: second}
    MultiLeaflet.Repo.insert(p1)
#

 schemaファイルを作ったらiex -S mixでElixir shellを立ち上げ、schemaを使ってみます。直接PostgreSQLのコマンドを叩くことはなく、Ectoレベルでのコマンドで十分確認できます。

iex -S mix
p1 = % MultiLeaflet.Point{user_id: 99, lat: 12.3, lng: 23.4, time: 12345}
MultiLeaflet.Repo.insert(p1)
import Ecto.Query, only: [from: 2]
MultiLeaflet.Repo.all(from p in MultiLeaflet.Point, where: p.user_id > 0)
MultiLeaflet.Repo.delete_all(MultiLeaflet.Point)

(3) Pointリソースの定義

 PointテーブルへアクセスするAPIを定義します。"/points"パスでアクセスします。

lib/multi_leaflet_web/router.ex
defmodule MultiLeafletWeb.Router do
# 
  scope "/api", MultiLeafletWeb do
    pipe_through :api
    get "/points", PointController, :index
  end
end

 contorollerではEcto.Queryを使ってDBからデータを取得しています。問い合わせの定義は、通常のSQLをラッピングしてElixir構造体で表現しています。

lib/multi_leaflet_web/controllers/point_controller.ex
defmodule MultiLeafletWeb.PointController do
  use MultiLeafletWeb, :controller

  alias MultiLeaflet.Point

  # Imports only from/2 of Ecto.Query
  import Ecto.Query, only: [from: 2]

  def index(conn, %{"start" => start, "stop" => stop} ) do
    resource = MultiLeaflet.Guardian.Plug.current_resource(conn)
    id = resource.id
#IO.inspect resource
    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 = MultiLeaflet.Repo.all(from p in Point, where: p.user_id == ^id and 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

 viewではElmクライアントに返すJsonを定義します。

lib/multi_leaflet_web/views/point_view.ex
defmodule MultiLeafletWeb.PointView do
  use MultiLeafletWeb, :view
  alias MultiLeafletWeb.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-5.Userリソースの作成

 マルチユーザ対応アプリなので、Userリソースを作成します。Pointテーブルでは手動でテーブルとSchemaを作成しました。ここではmix phx.gen.jsonというGeneraterを使い、JSON resource(User)のテーブルやSchemaだけでなく、controllerやviews, context も自動生成します。

mix phx.gen.json Users User users email:string name:string password_hash:string

 第1引数のUsersがcontextで、第2引数のUserがリソース名(単体)、第3引数のusersがリソース名(複数)です。

routerに以下の行を追加します。

lib/multi_leaflet_web/router.ex
defmodule MultiLeafletWeb.Router do
#
  # Other scopes may use custom stacks.
  scope "/api", MultiLeafletWeb do
    pipe_through :api
    resources "/users", UserController, except: [:new, :edit]
    get "/points", PointController, :index
  end
end

 このresourcesのルートで、以下のようなREST APIが確立します。但し:new と :editについては明示的に除外されています。
https://hexdocs.pm/phoenix/routing.html#resources

user_path  GET     /users           HelloWeb.UserController :index
user_path  GET     /users/:id/edit  HelloWeb.UserController :edit
user_path  GET     /users/new       HelloWeb.UserController :new
user_path  GET     /users/:id       HelloWeb.UserController :show
user_path  POST    /users           HelloWeb.UserController :create
user_path  PATCH   /users/:id       HelloWeb.UserController :update
           PUT     /users/:id       HelloWeb.UserController :update
user_path  DELETE  /users/:id       HelloWeb.UserController :delete

 mix phx.gen.jsonコマンドは以下のファイルを生成してくれます。

lib/multi_leaflet/users/user.ex --- schema ,with an users table
lib/multi_leaflet/users/users.ex --- context module ,for the users API
lib/multi_leaflet_web/controllers/user_controller.ex --- controller
lib/multi_leaflet_web/views/user_view.ex --- view

 またmix phx.gen.jsonコマンドは以下のようなmigrationファイルも生成してくれます。

priv/repo/migrations/20180506032759_create_users.exs

 以下のコマンドでmigrationファイルからテーブルを作成します。ここまでで"/api/users"というpathに対するContorollerやView、Schema、テーブルなどの基本的なフレームが出来上がりです。簡単でいいですね。

mix ecto.migrate

##2-6.再現ページのリソース

 再現ページのリソースを設定しておきます。

 router.exにパス"/page2"を追加します。

lib/multi_leaflet_web/router.ex
#
  scope "/", MultiLeafletWeb do
    pipe_through :browser # Use the default browser stack

    get "/", PageController, :index
    get "/page2", Page2Controller, :redraw
  end
#

 controllerです。

lib/multi_leaflet_web/controllers/page2_controller.ex
defmodule MultiLeafletWeb.Page2Controller do
  use MultiLeafletWeb, :controller

  def redraw(conn, _params) do
    render conn, "page2.html"
  end
end

 viewです。

lib/multi_leaflet_web/views/page2_view.ex
defmodule MultiLeafletWeb.Page2View do
  use MultiLeafletWeb, :view
end

 テンプレートについては、クライアントの再現ページのところで示します。とりあえずこのままで先に進みます。

3.ユーザ認証

 マルチユーザ対応なのでユーザ認証が必要になります

##3-1.Guardian1.0の設定

 PhoenixでJWTを扱うために、ueberauth/guardian ライブラリを使います。

 まずシークレットキーを作成しておきます。

mix phx.gen.secret
EM5heTVxaLpqUa4DgG9mU4S5RMQirYofwpaBYdxdTdhmyvrTetGUFSHEg1J65jQy

 次に以下のような、Implementation Moduleを実装します。これはserializationをハンドリングします。 ここがGuardian1.0で変わったところでもあります。

 subject_for_tokenはtokenに含めるsubjectの値を返す関数で、token作成時に呼ばれます。何の値でも構わないのですが、一般的にResourceにユニークな値が良いです。ここではuser id(user tableのid)を返します。resource_from_claimsは引数のclaimsの中のsubject(上の場合で言えばuser id)をkeyとして、resourceを取得して返します。ここではsub=idなので、userを取得して返しています。

lib/multi_leaflet/guardian.ex
defmodule MultiLeaflet.Guardian do
  use Guardian, otp_app: :multi_leaflet

  alias MultiLeaflet.Repo
  alias MultiLeaflet.Users.User

  def subject_for_token(resource, _claims) do
    {:ok, to_string(resource.id)}
    # tokenをdecodeすると、tokenの文字列の一部としてsub=xxxとidが表示される。
    # resource_from_claimsの中でclaims["sub"]としてidが取り出されている。
  end

  def resource_from_claims(claims) do
     id = claims["sub"]
     resource = Repo.get(User, id)
     {:ok,  resource}
  end
end

 次にconfigファイルにGuardianの設定を書きます。ここのシークレットキーに最初に生成したものを設定します。以下の行を末尾に追加します。

config/config.exs
#
config :multi_leaflet, MultiLeaflet.Guardian,
       issuer: "multi_leaflet",
       secret_key: "EM5heTVxaLpqUa4DgG9mU4S5RMQirYofwpaBYdxdTdhmyvrTetGUFSHEg1J65jQy"
#

 mix.exsの deps に guardian を追加します。面倒なので後で使うライブラリもここで登録しておきます。

mix.exs
  #
  defp deps do
    [
      {:phoenix, "~> 1.3.0"},
      {:phoenix_pubsub, "~> 1.0"},
      {:phoenix_ecto, "~> 3.2"},
      {:postgrex, ">= 0.0.0"},
      {:phoenix_html, "~> 2.10"},
      {:phoenix_live_reload, "~> 1.0", only: :dev},
      {:gettext, "~> 0.11"},
      {:cowboy, "~> 1.0"},
      {:poison, "~> 3.1"},   # これを追加
      {:timex, "~> 3.1"},    # これを追加
      {:guardian, "~> 1.0"}, # これを追加
      {:comeonin, "~> 3.0"}  # これを追加
    ]
  end
  #

 次のコマンドでguardianをインストールします。

mix do deps.get, compile

 以上でGuardianの設定は終了です。

##3-2.Comeoninの設定

 JWTはGuardianで扱いますが、パスワードのハッシュ化などはComeoninで扱います。

 まずphoenix.gen.jsonコマンドで作成したlib/multi_leaflet/users/user.exを調整します。テーブルのfieldとしてpassword_hashを作成しました。しかしユーザからの入力は平文のpasswordで受け取りますので、その受け皿として、Elixir structにpasswordの項目を追加します。この項目はテンポラリなものです。入力を受け取りハッシュを計算するまで必要ですが、最終的なテーブルには必要ないものなのでvirtualとします。以下のようになります。

 passwordはテーブルに保存する時に、Comeonin.Bcrypt.hashpwsaltでハッシュ化したものを保存します。

 また認証時にはクライアントから送られてきたpasswordをComeonin.Bcrypt.checkpwでハッシュ化し、保存してある予めハッシュ化されたpasswordと比較することで行います。

lib/multi_leaflet/users/user.ex
defmodule MultiLeaflet.Users.User do
  use Ecto.Schema
  import Ecto.Changeset


  schema "users" do
    field :email, :string
    field :name, :string
    field :password, :string, virtual: true # これを追加
    field :password_hash, :string

    timestamps()
  end

  @doc false
  def changeset(user, attrs) do
    user
    |> cast(attrs, [:email, :name, :password])
    |> validate_required([:email, :name, :password])
    |> validate_changeset
  end
  defp validate_changeset(user) do
    user
    |> validate_length(:email, min: 5, max: 255)
    |> validate_format(:email, ~r/@/)
    |> unique_constraint(:name)
    |> validate_length(:password, min: 8)
    |> validate_format(:password, ~r/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).*/, [message: "Must include at least one lowercase letter, one uppercase letter, and one digit"])
    |> generate_password_hash
  end


  defp generate_password_hash(changeset) do
    case changeset do
      %Ecto.Changeset{valid?: true, changes: %{password: password}} ->
        put_change(changeset, :password_hash, Comeonin.Bcrypt.hashpwsalt(password))
      _ ->
        changeset
    end
  end

  alias MultiLeaflet.Repo
  alias MultiLeaflet.Users.User

  def find_and_confirm_password(name, password) do
    case Repo.get_by(User, name: name) do
      nil ->
        {:error, :not_found}
      user ->
        if Comeonin.Bcrypt.checkpw(password, user.password_hash) do
          {:ok, user}
        else
          {:error, :unauthorized}
        end
    end
  end
end

 ここのchangesetはcreate_user/1から以下のようにして呼ばれ、最終結果がDBに挿入されます。このusers.exもphx.gen.jsonコマンドで自動生成されたものです。

lib/multi_leaflet/users/users.ex
#
  def create_user(attrs \\ %{}) do
    %User{}
    |> User.changeset(attrs)
    |> Repo.insert()
  end
#

 またfind_and_confirm_password/2という関数を作りましたが、これはnameとパpasswordでユーザ認証を行う関数です。Comeonin.Bcrypt.checkpw(password, user.password_hash)でチェックしてokかerrorかを決めます。このレベルではjwtは関係ありません。

 ここで、以前にmix phx.gen.jsonで自動生成した/migrationsファイルを確認しておきます。上のSchemaに対応するテーブルを作成するためのものです。

priv/repo/migrations/20180506032759_create_users.exs
defmodule MultiLeaflet.Repo.Migrations.CreateUsers do
  use Ecto.Migration

  def change do
    create table(:users) do
      add :email, :string
      add :name, :string
      add :password_hash, :string

      timestamps()
    end
    create unique_index(:users, [:name])

  end
end

 comeoninライブラリは既にインストール済みなので、以上でComeoninの設定を終わりとなります。

 ここでテストユーザを登録しておきます。別の端末からmix phx.serverでサーバを立ち上げ、以下のコマンドを打ちます。

curl -H 'Content-Type: application/json' -X POST -d '{"user": {"email": "hello@world.com","name": "John","password": "MyPass55"}}' http://localhost:4009/api/users

curl -X GET "http://localhost:4009/api/users"

##3-2.ログイン(sign_in -- Session)

 さてログインのために "/sign_in"というパスを振り分け、SessionControllerで対応することにします。router.exを修正します。

lib/multi_leaflet_web/router.ex
defmodule MultiLeafletWeb.Router do
#
  # Other scopes may use custom stacks.
  scope "/api", MultiLeafletWeb do
    pipe_through :api
    post "/sign_in", SessionController, :sign_in
    resources "/users", UserController, except: [:new, :edit]
    get "/points", PointController, :index
  end
end

 SessionControllerを実装し、sign_in/2を定義します。またコメントアウトしましたけど、decode_and_verify/1で得られたjwtの中身を確認できます。確かにidが含まれていることが確認できます。これは注意点かな、と思うのですが、Phx13Gdn10.Guardian.decode_and_verifyは、このようにフルで指定してください。Guardian.decode_and_verifなどと誤って省略してしまうと、コンパイルは通りますが実行エラーとなり嵌ります。ネットでも嵌っている方がいたようなので、要注意です。

lib/multi_leaflet_web/controllers/session_controller.ex
defmodule MultiLeafletWeb.SessionController do
  use MultiLeafletWeb, :controller

  alias MultiLeaflet.Users.User

  def sign_in(conn, %{"session" => %{"name" => name, "password" => password}}) do
    case User.find_and_confirm_password(name, password) do
      {:ok, user} ->
        {:ok, jwt, _full_claims} =  MultiLeaflet.Guardian.encode_and_sign(user)
        # {:ok, claims} = MultiLeaflet.Guardian.decode_and_verify(jwt)
        # IO.inspect(claims)

        conn
        |> render("sign_in.json", user: user, jwt: jwt)
      {:error, _reason} ->
        conn
        |> put_status(401)
        |> render("error.json", message: "Could not login")
    end
  end
end

 次にviewを作成します。

lib/multi_leaflet_web/views/session_view.ex
defmodule MultiLeafletWeb.SessionView do
  use MultiLeafletWeb, :view

  def render("sign_in.json", %{user: user, jwt: jwt}) do
    %{"token": jwt, "name": user.name}
  end

  def render("error.json", %{message: msg}) do
    %{"error": msg}
  end
end

 ログイン設定が完了したので、以下のcurlコマンドでログインを行います。tokenが長すぎて見づらいですが、望みの結果が表示されます。tokenを得たブラウザはログイン状態に入ります。次のリクエストからtokenをヘッダーにつけて渡すことになります。

curl -H 'Content-Type: application/json' -X POST -d '{"session": {"name": "John","password": "MyPass55"}}' http://localhost:4009/api/sign_in
{"token":"eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJtdWx0aV9sZWFmbGV0IiwiZXhwIjoxNTI5MjE5MDY4LCJpYXQiOjE1MjY3OTk4NjgsImlzcyI6Im11bHRpX2xlYWZsZXQiLCJqdGkiOiJhNmY3ZmJhMi02MDkyLTQ0ZjMtOGUyMi05MmNhOGI1NjIzYmQiLCJuYmYiOjE1MjY3OTk4NjcsInN1YiI6IjEiLCJ0eXAiOiJhY2Nlc3MifQ.VdhAnIDEIpKalU1HpCYl7I8c9c8LUet3BIWY-076dlG-m3d_5U6Zu6vLsLqNiFTHT2PmHXmAaZUD5emxw4av4A","name":"John"}[root@www13134uf multi_leaflet]#

 念のために、ログイン失敗のケースも見ておきましょう。ユーザ名とパスワードを少し変えてログインしてみます。返ってくるエラーメッセージも期待通りです。

curl -H 'Content-Type: application/json' -X POST -d '{"session": {"name": "Paul","password": "MyPass56"}}' http://localhost:4009/api/sign_in
{"error":"Could not login"}

##3-3.アクセス制限(Authorization)

 さて現状では、以下のユーザ一覧取得のコマンドは、非ログイン状態でも成功します。これをログインユーザのみに制限したいと思います。

curl -X GET "http://localhost:4009/api/users"
{"data":[{"password_hash":"$2b$12$S10Ls1YwQlrR/xLzsKT64OIYHaLxvopgHNC6ttfIhdv01V/SM3.pa","name":"John","id":1,"email":"hello@world.com"}]}

 ログインした時だけ "/api/users" のアクセスを許可(Autherization)するように変更します。

 Authentication schemeを構築するために、AuthPipeline moduleを作って、関連のあるplugを集めます。またAuthErrorHandler moduleを作って、エラーハンドラーを定義します。

lib/multi_leaflet/auth_pipeline.ex
defmodule MultiLeaflet.Guardian.AuthPipeline do
  @claims %{typ: "access"}

  use Guardian.Plug.Pipeline, otp_app: :MultiLeaflet,
                               module: MultiLeaflet.Guardian,
                               error_handler: MultiLeaflet.Guardian.AuthErrorHandler

  plug Guardian.Plug.VerifySession, claims: @claims
  plug Guardian.Plug.VerifyHeader, claims: @claims, realm: "Bearer"
  plug Guardian.Plug.EnsureAuthenticated
  plug Guardian.Plug.LoadResource, ensure: true
end

defmodule MultiLeaflet.Guardian.AuthErrorHandler do
  import Plug.Conn

  def auth_error(conn, {type, reason}, _opts) do
    body = Poison.encode!(%{message: to_string(type)})
    send_resp(conn, 401, body)
  end
end

 Guardian.Plug.VerifySessionはsessionの中のtokenを見つけ正しいかvalidateします。今回は特にsessionを利用していないので無視されます。Guardian.Plug.VerifyHeaderはAuthorization headerを見て、tokenが存在し改竄が無いことを確認します。一般的に、jwtはidを内包しているだけでなく、改竄チェックが行えるという優良な性質を持っています。またrealm: "Bearer"を明示的に指定する必要があります。これ無しだとElmのJWTパッケージを通してアクセスし他時に認証が通りませんでした。。このような操作を経て、Guardian.Plug.EnsureAuthenticatedは正しいtokenの存在を確認し、存在しなければauth_errorを呼んで:unauthenticatedを返します。Guardian.Plug.LoadResource は改竄チェックを終えたtokenからリソース(user)をロードします

 Authenticationの仕上げに、router.exに2か所追加をします。

lib/multi_leaflet_web/router.ex
defmodule MultiLeafletWeb.Router do
  use MultiLeafletWeb, :router

  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_flash
    plug :protect_from_forgery
    plug :put_secure_browser_headers
  end

  pipeline :api do
    plug :accepts, ["json"]
  end

  pipeline :authenticated do    # (1)3行追加
    plug MultiLeaflet.Guardian.AuthPipeline
  end

  scope "/", MultiLeafletWeb do
    pipe_through :browser # Use the default browser stack

    get "/", PageController, :index
    get "/page2", Page2Controller, :redraw
  end

  # Other scopes may use custom stacks.
  scope "/api", MultiLeafletWeb do
    pipe_through :api
    post "/sign_in", SessionController, :sign_in
    resources "/users", UserController, except: [:new, :edit]
    pipe_through :authenticated  # (2)pointsへのアクセス制限
    get "/points", PointController, :index
  end
end

 ここで特に注意しておきたいのは、/pointsは(1)と(2)で設定されたAuthenticationによって、ログインユーザのアクセスのみに制限されるようになることです。

#4.トップページ

##4-1.トップページ(JavaScriptプログラム)

 Phoenixのテンプレートをみます。まずレイアウトファイルですが、以下のようなシンプルなものに置き換えます。

lib/multi_leaflet_web/templates/layout/app.html.eex
<!DOCTYPE html>
<%= render @view_module, @view_template, assigns %>

 このHtmlではApp.elmとLeaflet.jsのコンテナを提供するとともに、前に説明したトップページのPortsの実装を行っています。

lib/multi_leaflet_web/templates/page/index.html.eex
<html>
  <head>
    <!-- Required meta tags -->
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">

    <!-- Bootstrap CSS -->
    <link rel="stylesheet" <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css" integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm" crossorigin="anonymous">

    <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>
    <br />
    <div id="elm-area"></div>
    <br /><br />
    <div align="center">
        <div id="mapid"></div>
    </div>

    <script src="/js/vendor/app.js"></script>

    <script>
      const app = Elm.App.embed(document.getElementById("elm-area"));
      var marker=null;
      var zoom = 15;
      var cgeo = null;

      app.ports.portSetToken.subscribe( (tn) => {
          if( tn && tn[0] && tn[1] ) {
              localStorage.setItem("token",tn[0]);
              localStorage.setItem("name",tn[1]);
          } else { // tn[0]=="" or tn[1]==""
              localStorage.removeItem("token");
              localStorage.removeItem("name");
          }
      } )

      app.ports.portGetToken.subscribe( (key) => {
        // key : not used
        const token = localStorage.getItem("token");
        const name = localStorage.getItem("name");
        if( token && name ) {
          app.ports.portResToken.send([token,name]);
        } else {
          app.ports.portResToken.send(["",""]);
        }
      })

      app.ports.portInitCurLocation.subscribe( (geo) => {
        console.log("app.ports.portSetCurLocation.subscribe n="+geo.point)
        zoom = geo.zoom;
        cgeo = geo;
        mymap.setView(geo.point, zoom);
      })

      app.ports.portSetCurLocation.subscribe( (geo) => {
        console.log("app.ports.portSetCurLocation.subscribe n="+geo.point)
        zoom = geo.zoom;
        cgeo = geo;
        if( !geo.player ) {
          updateCurLocation(geo)
        } else {
          setCurLocation()
        }
      })

      var mymap = L.map('mapid')

      L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
        maxZoom: 18,
        attribution: 'Map data &copy; <a href="http://openstreetmap.org">OpenStreetMap</a> contributors, '
      }).addTo(mymap);


      function updateCurLocation(geo) {
          mymap.setView(geo.point, geo.zoom);
          if(marker) {
              mymap.removeLayer(marker);
          }
          var lat = geo.point[0];
          var lng = geo.point[1];
          console.log("*** updateCurLocation lat="+lat+" lng="+lng);
          marker = L.marker([lat,lng]).addTo(mymap).bindPopup('現在地').openPopup();
      }


      function setCurLocation(){
        if (navigator.geolocation == false){
          alert('現在地を取得できませんでした。');
          return;
        }

        function success(e) {
          var lat  = e.coords.latitude;
          var lng = e.coords.longitude;
          mymap.setView([lat, lng], zoom);
          if(marker) {
              mymap.removeLayer(marker);
          }
          marker = L.marker([lat,lng]).addTo(mymap).bindPopup('現在地').openPopup();
          var obj = new Object();
          obj.player = cgeo.player;
          obj.zoom = zoom;
          obj.point = [lat, lng];
          var now = new Date();
          obj.time = now.getTime();
          app.ports.portGetCurLocation.send(obj);
        };

        function error() {
          alert('現在地を取得できませんでした。');
        };

        var options = {
          enableHighAccuracy: true, //GPS機能を利用
          timeout: 5000, //取得タイムアウトまでの時間(ミリ秒)
          maximumAge: 0 //常に新しい情報に更新
        };
        navigator.geolocation.getCurrentPosition(success, error, options);
      }
    </script>
  </body>
</html>

##4-2.Elm環境設定

 まずはstaticファイルの設定を行います。onlyをコメントアウトします。

lib/multi_leaflet_web/endpoint.ex
#
  plug Plug.Static,
    at: "/", from: :multi_leaflet, gzip: false
    # only: ~w(css fonts images js favicon.ico robots.txt)
#

 次にelm-brunchのインストールを行います。またelmディレクトリを作成し必要なパッケージをインストールしておきます。

cd assets/
npm install --save-dev elm-brunch
mkdir elm
cd elm
elm-package install elm-lang/html

 brunchの設定を変えて、Elmを扱えるようにします。

assets/brunch-config.js
#
  // Phoenix paths configuration
  paths: {
    // Dependencies and current project directories to watch
    // (1)"elm"を追加
    watched: ["static", "css", "js", "elm", "vendor"],
    // Where to compile files to
    public: "../priv/static"
  },

  // Configure your plugins
  plugins: {
    babel: {
      // Do not use ES6 compiler in vendor code
      ignore: [/vendor/]
    },
    // (2)elmBrunchを追加
    elmBrunch: {
      elmFolder: "elm",
      mainModules: ["App.elm", "Page2.elm"], // Elmプログラム
      outputFolder: "../static/js/vendor"  // Elmの実行形をstaticに吐き出す
    }
  },
#

(※重要)outputFolderにはvendorサブディレクトリが指定されていることに注意してください。brunchにおいてはvendorにあるfileが優先的にロードされるようです。つまりElmのjsファイルが安全にロードされます。単にoutputFolder を "../static/js"と vendor抜きで指定すると、かなりの確率でElmのjsファイルのロードに失敗してしまいます。

 必要なパッケージをelm-package.jsonに追加します。

assets/elm/elm-package.json
{
#
    "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",
        "justinmimbs/elm-date-extra": "3.0.0 <= v < 4.0.0",
        "rundis/elm-bootstrap": "4.0.0 <= v < 5.0.0",
        "saschatimme/elm-phoenix": "0.3.0 <= v < 1.0.0",
        "simonh1000/elm-jwt": "5.3.0 <= v < 6.0.0"
    },
#
}

 rundis/elm-bootstrapでBootstrapを使います。スマホ用の画面を作ります。Elm Bootstrapについて - Qiita

 saschatimme/elm-phoenixでPhoenixのChannelを使います。Phoenix Channelとelm-phoenixについて - Qiita

 simonh1000/elm-jwtでJWTを使えるようにします。Phoenix1.3+Guardian1.0でJWT - Qiita

 以下のコマンドで一括インストールします。

elm-github-install

 この時elm-phoenixのexampleもインストールされ、cssファイルなどが勝手にロードされてしまうので、削除します。

rm -rf ./assets/elm/elm-stuff/packages/saschatimme/elm-phoenix/0.3.2/example/

 以上でElmクライアントの環境設定を終わります。

##4-3.Elmの全コード

assets/elm/App.elm
port module App exposing (..)

import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (onClick,onSubmit,onInput)
--import Date exposing (Date, hour, minute, second)
import Date exposing (..)
import Date.Extra as Date
import Time exposing (Time, second)

import Phoenix
import Phoenix.Channel as Channel exposing (Channel)
import Phoenix.Socket as Socket exposing (Socket, AbnormalClose)
import Phoenix.Push as Push

import Http
import Json.Encode as JE
import Json.Decode as JD exposing (Decoder,field,string)
import Jwt exposing (..)

import Bootstrap.Alert as Alert
import Bootstrap.Form as Form
import Bootstrap.Form.Input as Input
import Bootstrap.Button as Button
import Bootstrap.Card as Card
import Bootstrap.Card.Block as Block
import Bootstrap.Grid as Grid
import Bootstrap.Grid.Col as Col
import Bootstrap.Grid.Row as Row
import Bootstrap.Text as Text
import Bootstrap.Utilities.Spacing as Spacing



-- OUTGOING PORT
port portInitCurLocation : Geo -> Cmd msg
port portSetCurLocation : Geo -> Cmd msg

port portSetToken :  (String, String) -> Cmd msg  --(token,name)
port portGetToken :  String -> Cmd msg


-- INCOMING PORT
port portGetCurLocation : (Geo -> msg) -> Sub msg

port portResToken : ((String,String) -> msg) -> Sub msg  --(token,name)

registerUrl =
    "/api/users"
authUrl =
    "/api/sign_in"


lobbySocket : String
lobbySocket =
    "wss://www.mypress.jp:4443/socket/websocket"




main : Program Never Model Msg
main =
    program
        { init = init
        , view = view
        , update = update
        , subscriptions = subscriptions
        }

-- MODEL
type alias Point =
    List Float -- [lat, lng]

type alias Geo = -- PortsでJavaScript(Leaflet.js)とやり取りするデータ
    { player : Bool --- 発信モード=>True、受信モード=>False
    , zoom   : Int
    , point  : Point
    , time  : Time
    }

type alias Cred = --ログインとユーザ登録で使用
    { name : String
    , email : String
    , password : String
    , password2 : String
    }

type alias NameEmail = -- only for Decoder at User Register
    { name : String
    , email : String
    }

type alias TokenName =
    { token : String
    , name : String
    }

type alias JwtToken =
    { aud : String
    , exp : Int
    , iat : Int
    , sub : String
    }

type LoginStatus
    = LoginOff
    | LoginFail
    | LoginSuccess
    | RegisterSuccess
    | RegisterFail
    | RegisterEmailFail

showLoginStatus : LoginStatus -> String
showLoginStatus status =
    case status of
        LoginOff -> "まだログインしていません"
        LoginFail -> "ログインに失敗しました"
        LoginSuccess -> "ログイン中です"
        RegisterSuccess -> "ユーザ登録に成功しました"
        RegisterFail -> "ユーザ登録に失敗しました"
        RegisterEmailFail -> "確認用パスワードが一致しません"

type alias Model =
    { geo : Geo
    , connectionStatus : ConnectionStatus -- Socket接続状況
    , currentTime : Time -- 現在時間
    , messageFailed : Bool -- Channel書込時のサーバエラー
    , messageCnt : Int -- Channel書込失敗カウンタ
    , inputCred : Cred
    , tokenName : Maybe TokenName -- Nothing=>ログイン画面
    , jwtSub : String -- user_id
    , jwtExp : Time -- JWTの有効期限
    , loginStatus : LoginStatus
    , userRegister : Bool -- True=>ユーザ登録画面
    }
point0 : Point
point0 = [35.7102, 139.8132] --lat, lng

initMessageCnt = 5

initCred =
    Cred "" "" "" ""

initSub : String
initSub = "0"
initExp : Time
initExp = 0

initModel : Model
initModel =
    Model (Geo  False 15 point0 0) Disconnected 0 False initMessageCnt initCred Nothing initSub initExp LoginOff False

init : ( Model, Cmd Msg )
init =
    initModel ! [ portInitCurLocation initModel.geo
                , portGetToken "token"
                ]

-- UPDATE
type ConnectionStatus
    = Connected
    | Disconnected
    | ScheduledReconnect { time : Time }

type Field
    = Fname
    | Femail
    | Fpass
    | Fpass2

type Msg = GetCurLocation Geo
    | Tick Time
    | Tick5 Time
    | Increment
    | Decrement
    | TogglePlayer
    | NewMsg JD.Value
    | SocketClosedAbnormally AbnormalClose
    | ConnectionStatusChanged ConnectionStatus
    | MessageFailed JD.Value
    | MessageArrived
    | Login
    | FormInput Field String
    | LoginResult (Result Http.Error TokenName)
    | ResToken (String, String)
    | RemoveToken
    | ToggleRegister
    | Register
    | RegisterResult (Result Http.Error NameEmail)


update :  Msg -> Model -> ( Model, Cmd Msg )
update msg model =
  case msg of
    RemoveToken -> -- Log out
          model ! [portSetToken ("", ""), portGetToken "token"]
    Tick time -> --現在時間 socket再接続のカウントダウンに使われる。tokenの有効期限の確認も行う。
      case model.currentTime > (model.jwtExp * 1000) of
          True ->
              { model | currentTime = time } ! [portSetToken ("", ""), portGetToken "token"]
          False ->
              { model | currentTime = time } ! []

    Tick5 _ ->   -- 【発信】5秒ごとにJavaScriptに現在値を取得する指令を出す
      (model, portSetCurLocation model.geo)

    GetCurLocation newgeo -> -- 【発信】JavaScriptからの現在位置をchannelに発信する
        case model.tokenName of
            Nothing ->
              model ! []
            Just tn ->
              let
                push =
                  Push.init ("room:"++tn.name) "new_msg"
                     |> Push.withPayload (JE.object [ ( "point", JE.string <| toString <| newgeo.point )
                                           , ( "user_id" , JE.string <| model.jwtSub )
                                           , ( "time" , JE.string <| toString <| newgeo.time ) ])
                     |> Push.onOk (\_ -> MessageArrived)
                     |> Push.onError MessageFailed
                _ =  Debug.log "NewMsg write : Encoded" newgeo.point
               in
               { model | geo=newgeo, messageCnt=model.messageCnt-1 } ! [ Phoenix.push lobbySocket push ]


    NewMsg payload -> -- 【受信】発信機の現在位置をchannelから受信する
      let
        dval = JD.decodeValue decodeNewMsg payload
        _ =  Debug.log "NewMsg recieved : Decoded" dval
      in
      case dval of
        Ok msg ->
            let
              oldgeo =  model.geo
              newgeo = { oldgeo | point = msg.point, time=msg.time }
            in
            ({ model | geo = newgeo}, portSetCurLocation newgeo)
        Err err ->
            model ! []

    Increment -> -- zoomを拡大する
      let
        oldgeo =  model.geo
        newgeo = { oldgeo | zoom = oldgeo.zoom + 1 }
      in
      ({ model| geo = newgeo }, Cmd.none)

    Decrement -> -- zoomを縮小する
      let
        oldgeo =  model.geo
        newgeo = { oldgeo | zoom = oldgeo.zoom - 1 }
      in
      ({ model| geo = newgeo }, Cmd.none)

    TogglePlayer -> -- モードを切り替える
      let
        oldgeo =  model.geo
        newgeo = { oldgeo | player = not oldgeo.player }
      in
      ({ model| geo = newgeo }, Cmd.none)

    ToggleRegister -> -- ユーザ登録モードを切り替える
      ({ model| userRegister = not model.userRegister }, Cmd.none)

    SocketClosedAbnormally abnormalClose -> -- socketの状態管理
        { model
            | connectionStatus =
                ScheduledReconnect
                    { time = roundDownToSecond (model.currentTime + abnormalClose.reconnectWait)
                    }
        }
            ! []

    ConnectionStatusChanged connectionStatus -> -- socketの状態管理
        let
            _ =  Debug.log "ConnectionStatusChanged : " connectionStatus
        in
        { model | connectionStatus = connectionStatus } ! []

    MessageFailed value -> -- channel発信時にサーバエラーを検知
        { model | messageFailed = True } ! []

    MessageArrived ->  -- channel発信がokだったので、エラーカウントをリセット
        { model | messageCnt = initMessageCnt } ! []

    FormInput inputId val ->
        case inputId of
            Fname ->
                let
                  oldCred = model.inputCred
                  newCred = { oldCred | name=val }
                in
                  { model | inputCred = newCred } ! []
            Fpass ->
                let
                  oldCred = model.inputCred
                  newCred = { oldCred | password=val }
                in
                  { model | inputCred = newCred } ! []
            Fpass2 ->
                let
                  oldCred = model.inputCred
                  newCred = { oldCred | password2=val }
                in
                  { model | inputCred = newCred } ! []
            Femail ->
                let
                  oldCred = model.inputCred
                  newCred = { oldCred | email=val }
                in
                  { model | inputCred = newCred } ! []

    Login ->
        model ! [ submitCredentials model ]

    LoginResult res ->
        case res of
            Ok tn ->
                let
                    newmodel =
                        case (decodeToken tokenDecoder tn.token) of
                            Ok jwt ->
                                {model | tokenName = Just tn, jwtSub = jwt.sub, jwtExp=(toFloat jwt.exp)}

                            _ -> model
                in
                { newmodel | loginStatus = LoginSuccess} ! [portSetToken (tn.token, tn.name)]

            Err err ->
                { model | loginStatus = LoginFail } ! []
    Register ->
        case model.inputCred.password == model.inputCred.password2 of
            True ->  model ! [ submitRegister model ]
            False -> { model | loginStatus = RegisterEmailFail } ! []

    RegisterResult res ->
        case res of
            Ok ne ->
                let
                    _ =  Debug.log "name : " ne.name
                    _ =  Debug.log "email : " ne.email
                in
                { model | userRegister=False, loginStatus = RegisterSuccess } ! []

            Err err ->
                { model | loginStatus = RegisterFail } ! []


    ResToken (token, name) ->
      case (String.length token > 0) && (String.length name > 0) of
          False -> { model | tokenName = Nothing, jwtSub=initSub, jwtExp=initExp, loginStatus=LoginOff } ! []
          True ->
            case (decodeToken tokenDecoder token) of
                Ok jwt ->
                    { model | tokenName = Just (TokenName token name), jwtSub=jwt.sub, jwtExp=(toFloat jwt.exp), loginStatus = LoginSuccess } ! []
                _ -> model ! []




submitRegister : Model -> Cmd Msg
submitRegister model =
    let
        body = Http.jsonBody <| JE.object [ ( "user",   userJson model ) ]
    in
        Http.send RegisterResult <| Http.post registerUrl body nameEmailDecoder

userJson model =
    JE.object
        [ ( "name",     JE.string model.inputCred.name )
        , ( "email",    JE.string model.inputCred.email )
        , ( "password", JE.string model.inputCred.password )
        ]

submitCredentials : Model -> Cmd Msg
submitCredentials model =
    JE.object
        [ ( "session",   crdentialJson model ) ]
        |> authenticate authUrl tokenNameDecoder
        |> Http.send LoginResult

crdentialJson model =
    JE.object
        [ ( "name",    JE.string model.inputCred.name )
        , ( "password", JE.string model.inputCred.password )
        ]


-- token decoder
nameEmailDecoder =
    JD.map2 NameEmail
        (JD.field "name" JD.string)
        (JD.field "email" JD.string)

tokenNameDecoder =
    JD.map2 TokenName
        (JD.field "token" JD.string)
        (JD.field "name" JD.string)

tokenDecoder =
    JD.map4 JwtToken
        (JD.field "aud" JD.string)
        (JD.field "exp" JD.int)
        (JD.field "iat" JD.int)
        (JD.field "sub" JD.string)



roundDownToSecond : Time -> Time
roundDownToSecond ms =
    (ms / 1000) |> truncate |> (*) 1000 |> toFloat

-- Decoder
decodeNewMsg : Decoder { userName : String, point : Point, time : Float }
decodeNewMsg =
    JD.map4 (\u lat lng t -> { userName=u, point=[lat, lng], time=t } )
        (JD.field "user_name" JD.string)
        (JD.field "lat" JD.float)
        (JD.field "lng" JD.float)
        (JD.field "time" JD.float)



-- VIEW
view : Model -> Html Msg
view model =
    case model.tokenName of              -- ログイン済み?
        Just tn -> viewMain model tn       -- ログイン済、メイン画面
        Nothing ->                         -- 未ログイン
            case model.userRegister of       -- ユーザ登録画面?
                False -> viewLogin model       -- ログイン画面
                True -> viewRegister model     -- ユーザ登録画面

viewLogin : Model -> Html Msg
viewLogin model =
  Grid.container []
  [ Grid.row [ Row.centerXs ]
  [ Grid.col [Col.xs12, Col.lg8]
  [ Card.config []
        |> Card.block []
            [ Block.titleH4 [] [ text "Login Form" ]
            , Block.custom <|
                Grid.container []
                  [ Grid.row []
                      [ Grid.col [Col.xs12, Col.lg4] [ Input.text [ Input.attrs [ placeholder "Name" ]
                                                 , Input.value model.inputCred.name
                                                 , Input.onInput (FormInput Fname)
                                                 ]
                                    ]
                      , Grid.col [Col.xs12, Col.lg4] [ Input.text [ Input.attrs [ placeholder "Password" ]
                                                 , Input.value model.inputCred.password
                                                 , Input.onInput (FormInput Fpass)
                                                 ]
                                    ]
                      , Grid.col [Col.xs6, Col.lg4] [ Button.button [ Button.primary, Button.onClick Login ] [ text "Login"]
                                    ]
                      ]
                  ]
            ]
        |> Card.block [ Block.align Text.alignXsRight ]
            [ Block.link [ href "#" , onClick ToggleRegister ] [ text "ユーザ登録" ]
            ]
        |> Card.block []
            [ Block.text []
                [ text <| showLoginStatus model.loginStatus ]
            ]
        |> Card.view

  ]]]

viewRegister : Model -> Html Msg
viewRegister model =
  let
    myStyle = style [("height", "5px")]
  in
  Grid.container []
  [ Grid.row [ Row.centerXs ]
  [ Grid.col [Col.xs12, Col.lg8]
  [ Card.config []
        |> Card.block []
            [ Block.titleH4 [] [ text "Register Form" ]
            , Block.custom <|
                Grid.container []
                  [ Grid.row []
                      [ Grid.col [Col.xs12, Col.lg12]
                                     [ Input.text [ Input.attrs [ placeholder "Name" ]
                                                  , Input.value model.inputCred.name
                                                  , Input.onInput (FormInput Fname)
                                                  ]
                                     ]
                      ,  Grid.colBreak []
                      ,  Grid.col [] [ div [ myStyle ] [] ]
                      ]
                  , Grid.row []
                      [ Grid.col [Col.xs12, Col.lg12]
                                      [ Input.text [ Input.attrs [ placeholder "Mail" ]
                                                   , Input.value model.inputCred.email
                                                   , Input.onInput (FormInput Femail)
                                                   ]
                                      ]
                      ,  Grid.colBreak []
                      ,  Grid.col [] [ div [ myStyle ] [] ]
                      ]
                  , Grid.row []
                      [ Grid.col [Col.xs12, Col.lg12]
                                      [ Input.text [ Input.attrs [ placeholder "Password" ]
                                                   , Input.value model.inputCred.password
                                                   , Input.onInput (FormInput Fpass)
                                                   ]
                                      ]
                      ,  Grid.colBreak []
                      ,  Grid.col [] [ div [ myStyle ] [] ]
                      ]
                  , Grid.row []
                      [ Grid.col [Col.xs12, Col.lg12]
                                      [ Input.text [ Input.attrs [ placeholder "確認のためPasswordを再入力" ]
                                                   , Input.value model.inputCred.password2
                                                   , Input.onInput (FormInput Fpass2)
                                                   ]
                                      ]
                      ,  Grid.colBreak []
                      ,  Grid.col [] [ div [ myStyle ] [] ]
                      ]
                  , Grid.row []
                      [ Grid.col [Col.xs6, Col.lg4]
                                      [ Button.button [ Button.primary, Button.onClick Register ] [ text "Regist"]
                                                 ]
                      ]
                  ]
            ]
        |> Card.block [ Block.align Text.alignXsRight ]
            [ Block.link [ href "#" , onClick ToggleRegister ] [ text "ログイン" ]
            ]
        |> Card.block []
            [ Block.text []
                [ text <| showLoginStatus model.loginStatus ]
            ]
        |> Card.view

  ]]]
viewMain : Model -> TokenName -> Html Msg
viewMain model tn =
  Grid.container []
  [ Grid.row [ Row.centerXs ]
  [ Grid.col [Col.xs12, Col.lg8]
  [ viewReconnectAlert model
  , Card.config
      [ Card.light
      , Card.textColor Text.primary
      , Card.attrs [ Spacing.mb3
                   --, style [ ( "width", "600px" ) ]
                   ]
      ]
        |> Card.headerH3 [] [ text "Map Controller"
                            -- , a [href "/page2"] [text "検索ページ"]
                            ]
        |> Card.block []
            [ Block.custom <|
                Grid.container []
                  [ Grid.row [ Row.betweenXs ]
                      [ Grid.col [Col.xs6, Col.lg4] [ Button.button [ Button.primary, Button.onClick TogglePlayer ] [ text  <| playerButton model.geo.player ]]
                      , Grid.col [Col.xs6, Col.lg4] [ Button.button [ Button.primary, Button.onClick RemoveToken ] [ text "Log out" ] ]
                      ]
                  ]
            ]
        |> Card.block []
            [ Block.custom <|
                Grid.container []
                  [ Grid.row [ Row.betweenXs ]
                      [ Grid.col [Col.xs6, Col.lg4]
                                   [ Button.button [ Button.primary, Button.onClick Increment ] [ text " + "]
                                    , span [] [ text <| " " ++ (toString model.geo.zoom) ++ " " ]
                                    , Button.button [ Button.primary, Button.onClick Decrement ] [ text " - "]
                                    ]
                      , Grid.col [Col.xs6, Col.lg5]
                                    [ text <| viewPointTime model
                                    ]
                      ]
                  ]
            ]
        |> Card.block []
            -- [ Block.text [] [ text <| (showLoginStatus model.loginStatus) ++ "( name = " ++ tn.name ++ ", user_id = " ++ (toString model.jwtSub) ++ " )"]
            [ Block.text [] [ text <| (showLoginStatus model.loginStatus) ++ " ( name = " ++ tn.name ++ " )"]
            -- , Block.custom <| statusMessage model
            -- , Block.text [] [ text <| " token有効期限=" ++ (toString model.jwtExp) ++ " 現在時間=" ++ (toString model.currentTime) ]
            -- , Block.text [] [ text <| " token = " ++  tn.token ]
            ]
        |> Card.block [ Block.align Text.alignXsRight ]
            [ Block.link [ href "/page2" ] [ text "検索ページ" ]
            ]
        |> Card.view

  , viewChannelAlert model
  ]]]

playerButton : Bool -> String
playerButton player =
    case player of
        True  -> "発信停止"
        False -> "発信開始"

viewPointTime : Model -> String
viewPointTime model =
    case model.geo.time > 0 of
        False -> "ポイント時間:--:--:--"
        True ->
            let
              date = Date.fromTime model.geo.time
            in
            "ポイント時間:" ++  (Date.toFormattedString "HH:mm:ss" date)

viewChannelAlert model =
    case model.geo.player of
        False -> Alert.simplePrimary [] [ text "--- 受信中 ---" ]
        True  ->
            case model.messageCnt > 0 of
                False -> Alert.simpleDanger [] [ text <| "xxx 発信不良 xxx" ++ (getMessageCnt model)]
                True  ->
                    case model.messageFailed of
                        True  -> Alert.simpleDanger [] [ text <| "xxx 発信良好 <> サーバエラー xxx" ++(getMessageCnt model) ]
                        False ->  Alert.simpleSuccess [] [ text <| "--- 発信良好 <> サーバ良好 ---" ++  (getMessageCnt model)]



getMessageCnt : Model -> String
getMessageCnt model = " cnt = " ++ toString model.messageCnt


viewReconnectAlert model =
    case model.connectionStatus of
        ScheduledReconnect { time } ->
            let
                remainingSeconds =
                    truncate <| (time - model.currentTime) / 1000

                reconnectStatus =
                    if remainingSeconds <= 0 then
                        "Reconnecting ..."
                    else
                        "Reconnecting in " ++ (toString remainingSeconds) ++ " seconds"
            in
                -- Html.div [ class "status-message" ] [ Html.text reconnectStatus ]
                Alert.simpleWarning [] [ text reconnectStatus ]

        _ ->
            Html.text ""



statusMessage : Model -> Html Msg
statusMessage model =
    case model.connectionStatus of
        ScheduledReconnect { time } ->
            let
                remainingSeconds =
                    truncate <| (time - model.currentTime) / 1000

                reconnectStatus =
                    if remainingSeconds <= 0 then
                        "Reconnecting ..."
                    else
                        "Reconnecting in " ++ (toString remainingSeconds) ++ " seconds"
            in
                Html.div [ class "status-message" ] [ Html.text reconnectStatus ]

        _ ->
            Html.text ""




-- SUBSCRIPTIONS
subscriptions : Model -> Sub Msg
subscriptions model =
    case model.geo.player of
        True ->
              Sub.batch
                  [ portGetCurLocation GetCurLocation
                  , Time.every Time.second Tick
                  , Time.every (5 * Time.second) Tick5
                  , phoenixSubscription model
                  , portResToken ResToken
                  ]
        False ->
              Sub.batch
                  [ phoenixSubscription model
                  , Time.every Time.second Tick
                  , portResToken ResToken
                  ]



{--- Initialize a socket with the default heartbeat intervall of 30 seconds ---}
socket : Socket Msg
socket =
    Socket.init lobbySocket
        |> Socket.onOpen (ConnectionStatusChanged Connected)
        |> Socket.onClose (\_ -> ConnectionStatusChanged Disconnected)
        |> Socket.onAbnormalClose SocketClosedAbnormally
        |> Socket.reconnectTimer (\backoffIteration -> (backoffIteration + 1) * 5000 |> toFloat)


lobby :  Model -> String -> Channel Msg
lobby model userName =
    case model.geo.player of
        True  ->
            Channel.init ("room:"++userName)
                |> Channel.withPayload (JE.object [ ( "user_name", JE.string userName ) ])
                |> Channel.withDebug
        False ->
            Channel.init ("room:"++userName)
                |> Channel.withPayload (JE.object [ ( "user_name", JE.string userName ) ])
                |> Channel.on "new_point" (\msg -> NewMsg msg)
                |> Channel.withDebug


phoenixSubscription model =
    case model.tokenName of
        Just tn -> Phoenix.connect socket [ lobby model tn.name ]
        _ -> Sub.none

 以下説明していきますが、JavaScriptとのインターフェースであるPortsについて再度説明しておきます。これを念頭に置いていただければと思います。

Ports(トップページ)
-- OUTGOING PORT
portInitCurLocation :(発信・受信)ElmからJavaScriptにlocation初期値を渡します
portSetCurLocation  :(発信)ElmからJavaScriptに現在のlocationを取得し、地図上にマーカーを置き、geoデータをElmに返すように指示します。
                     (受信)chnnelで受信したgeoデータを渡し、地図上にマーカを置くよう指示します。
port portSetToken :  JWTをlocalstorageに保存する
port portGetToken :  localstorageのJWTを返すように依頼する

-- INCOMING PORT
port portGetCurLocation : 現在のlocationを返す
port portResToken : JWTを返す。NULLだったら未ログインと判断する。

##4-4.viewのメインロジック

 Elmのプログラムを書いていて気持ちがいいのは、このようなメインロジックが宣言的にクリアーに書けることです。modelの値でどの画面を表示するかを切り替えています。

view
-- VIEW
view : Model -> Html Msg
view model =
    case model.tokenName of              -- ログイン済み?
        Just tn -> viewMain model tn       -- ログイン済、メイン画面
        Nothing ->                         -- 未ログイン
            case model.userRegister of       -- ユーザ登録画面?
                False -> viewLogin model       -- ログイン画面
                True -> viewRegister model     -- ユーザ登録画面

##4-5.subscriptionsのメインロジック

 subscriptionsはイベントリスナーを定義する場所ですが、これもmodelの値に応じて定義を動的に変更できます。model.geo.playerで発信モードと受信モードを切り替えていますが、それに応じてsubscriptionsの定義も動的に変えています。

subscriptions
-- SUBSCRIPTIONS
subscriptions : Model -> Sub Msg
subscriptions model =
    case model.geo.player of
        True ->  -- 発信モード
              Sub.batch
                  [ portGetCurLocation GetCurLocation
                  , Time.every Time.second Tick
                  , Time.every (5 * Time.second) Tick5
                  , phoenixSubscription model
                  , portResToken ResToken
                  ]
        False -> -- 受信モード
              Sub.batch
                  [ phoenixSubscription model
                  , Time.every Time.second Tick
                  , portResToken ResToken
                  ]

##4-6.ユーザ登録

 Phoenixのrouter.exでの定義により、Usersリソースのcreate(ユーザ登録)は以下のパスでアクセスできます。

registerUrl =
    "/api/users"

 以下のsubmitRegister関数でユーザ登録を行います。Httpパッケージを使います。

submitRegister
submitRegister : Model -> Cmd Msg
submitRegister model =
    let
        body = Http.jsonBody <| JE.object [ ( "user",   userJson model ) ]
    in
        Http.send RegisterResult <| Http.post registerUrl body nameEmailDecoder

userJson model =
    JE.object
        [ ( "name",     JE.string model.inputCred.name )
        , ( "email",    JE.string model.inputCred.email )
        , ( "password", JE.string model.inputCred.password )
        ]

 userJson modelでencodeされたUserオブジェクトは、以下のelixirのcreate関数のuser_paramsで受けるようになります。

lib/multi_leaflet_web/controllers/user_controller.ex
defmodule MultiLeafletWeb.UserController do
  use MultiLeafletWeb, :controller

  alias MultiLeaflet.Users
  alias MultiLeaflet.Users.User
#
  def create(conn, %{"user" => user_params}) do
    with {:ok, %User{} = user} <- Users.create_user(user_params) do
      conn
      |> put_status(:created)
      |> put_resp_header("location", user_path(conn, :show, user))
      |> render("show.json", user: user)
    end
  end
#

 ユーザ登録はcreate関数で行われますが、最後にrender("show.json", user: user)を呼んで、Elmに返答を返します。

lib/multi_leaflet_web/views/user_view.ex
defmodule MultiLeafletWeb.UserView do
  use MultiLeafletWeb, :view
  alias MultiLeafletWeb.UserView
#
  def render("show.json", %{user: user}) do
    %{"name": user.name, "email": user.email}
  end
#

 つまり%{"name": user.name, "email": user.email}がElmに返されるのですが、これをElmではnameEmailDecoderで受け取ります。

##4-7.ログイン

 ログインも大枠はユーザ登録と同じです。

submitCredentials
submitCredentials : Model -> Cmd Msg
submitCredentials model =
    JE.object
        [ ( "session",   crdentialJson model ) ]
        |> authenticate authUrl tokenNameDecoder
        |> Http.send LoginResult

crdentialJson model =
    JE.object
        [ ( "name",    JE.string model.inputCred.name )
        , ( "password", JE.string model.inputCred.password )
        ]

以下の行に注目します。

 |> authenticate authUrl tokenStringDecoder

 authenticate関数は、JWTパッケージで以下のように定義されていて、 login credentialsに基づいてHttp.Request を作ります。

Jwt.authenticate
authenticate : String -> Decoder a -> Value -> Request a

##4-8.geolocation発信機

 発信モードの時にsubscriptionsでは、5秒ごとにTick5 MSGを発行します。

Time.every (5 * Time.second) Tick5

 つまり5秒ごとにportSetCurLocationを呼んでJavaScriptに現在位置を取得して返してくれるように依頼します。返り値はGetCurLocation MSGで受け取ります。その位置情報をChannelに書き込みます。

update
update :  Msg -> Model -> ( Model, Cmd Msg )
update msg model =
  case msg of
#
    Tick5 _ ->   -- 【発信】5秒ごとにJavaScriptに現在値を取得する指令を出す
      (model, portSetCurLocation model.geo)

    GetCurLocation newgeo -> -- 【発信】JavaScriptからの現在位置をchannelに発信する
        case model.tokenName of
            Nothing ->
              model ! []
            Just tn ->
              let
                push =
                  Push.init ("room:"++tn.name) "new_msg"
                     |> Push.withPayload (JE.object [ ( "point", JE.string <| toString <| newgeo.point )
                                           , ( "user_id" , JE.string <| model.jwtSub )
                                           , ( "time" , JE.string <| toString <| newgeo.time ) ])
                     |> Push.onOk (\_ -> MessageArrived)
                     |> Push.onError MessageFailed
                _ =  Debug.log "NewMsg write : Encoded" newgeo.point
               in
               { model | geo=newgeo, messageCnt=model.messageCnt-1 } ! [ Phoenix.push lobbySocket push ]
#

 ここで特に注意してほしいのが以下の行です。サブトピック名にユーザ名を指定してChannelにJoinしているのがわかります。

                  Push.init ("room:"++tn.name) "new_msg"

##4-9.geolocation受信機

 受信モードの時にsubscriptionsでは、以下の定義により、発信機から発信された位置情報を受け取ります。

 phoenixSubscription model

 位置情報を受信するとNewMsg MSGが発行されます。受け取った値をdecodeNewMsgでdecodeし、portSetCurLocationでJavaScriptに渡し、自分(受信機)の地図の現在位置マーカを更新します。これによって発信機と受信機のマーカの同期が実現します。

update
update :  Msg -> Model -> ( Model, Cmd Msg )
update msg model =
  case msg of
#
    NewMsg payload -> -- 【受信】発信機の現在位置をchannelから受信する
      let
        dval = JD.decodeValue decodeNewMsg payload
        _ =  Debug.log "NewMsg recieved : Decoded" dval
      in
      case dval of
        Ok msg ->
            let
              oldgeo =  model.geo
              newgeo = { oldgeo | point = msg.point, time=msg.time }
            in
            ({ model | geo = newgeo}, portSetCurLocation newgeo)
        Err err ->
            model ! []
#

 ここで特に注意してほしいのが以下のlobbyの定義です。サブトピック名にユーザ名を指定してChannelにJoinしているのがわかります。このサブトピックに書かれた情報のみを受信して上のNewMsgで処理されます。

lobby
lobby :  Model -> String -> Channel Msg
lobby model userName =
    case model.geo.player of
        True  ->
            Channel.init ("room:"++userName) -- サブトピック名=userName
                |> Channel.withPayload (JE.object [ ( "user_name", JE.string userName ) ])
                |> Channel.withDebug
        False ->
            Channel.init ("room:"++userName)
                |> Channel.withPayload (JE.object [ ( "user_name", JE.string userName ) ])
                |> Channel.on "new_point" (\msg -> NewMsg msg)
                |> Channel.withDebug

##4-10.エラー時の再接続

 socket切断時の再接続の仕組みをもう少し説明します。完全なドキュメントが存在しないので、大半はexampleコードなどを実行した結果からの知見に基づきます。批判的に読んでいただくことをお勧めします。

 socketが何らかのエラーで切断した時は、Socket.reconnectTimerによって再接続されます。この時再接続までの時間をbackoffIterationをもとに計算します。

 また再接続までのカウントダウン状況をSocket.onAbnormalCloseとstatusMessageで表示させています。

Socket.reconnectTimer
    次に再接続するまでの時間(A)を設定し、再接続を実行する

Socket.onAbnormalClose
    次に再接続するまでの時間(A)を引数に、SocketClosedAbnormallyメッセージが送信される。
    (A)をもとに次の再接続時刻(B)を計算し、model.connectionStatus = ScheduledReconnect (B) を設定する

statusMessage (view)
    ScheduledReconnect (B)をmodel.currentTime(現在時刻)と比較して、
    再接続までのカウントダウンを行う。

 以下が関係のあるコードの全体です。分かりやすいようにコメントを付けています。時刻と時間(時刻の差分)の言葉の使い方に注意してください。

#
type ConnectionStatus
    = Connected
    | Disconnected
    | ScheduledReconnect { time : Time } -- time: 次に再接続される時刻

type Msg = GetCurLocation Geo
#
         | SocketClosedAbnormally AbnormalClose -- AbnormalClose.reconnectWait: 次に再接続されるまでの時間
         | ConnectionStatusChanged ConnectionStatus
#
    SocketClosedAbnormally abnormalClose -> -- socket異常終了:再接続時刻を設定する
        { model
            | connectionStatus =
                ScheduledReconnect
                    { time = roundDownToSecond (model.currentTime + abnormalClose.reconnectWait)
                      -- 再接続までの時間 = abnormalClose.reconnectWait <--reconnectTimerで設定される
                    }
        }
            ! []

    ConnectionStatusChanged connectionStatus -> -- socketの状態管理
        { model | connectionStatus = connectionStatus } ! []
#
roundDownToSecond : Time -> Time
roundDownToSecond ms =
    (ms / 1000) |> truncate |> (*) 1000 |> toFloat
#
socket : Socket Msg
socket =
    Socket.init lobbySocket
#
        |> Socket.onAbnormalClose SocketClosedAbnormally -- 再接続の表示に使われる。再接続自体には関係ない。
        |> Socket.reconnectTimer (\backoffIteration -> (backoffIteration + 1) * 5000 |> toFloat) -- 再接続を繰り返しtryする
        -- 次に再接続されるまでの時間 ==> AbnormalClose.reconnectWait 
#
statusMessage : Model -> Html Msg
statusMessage model =
    case model.connectionStatus of
        ScheduledReconnect { time } ->
            let
                remainingSeconds =
                    truncate <| (time - model.currentTime) / 1000

                reconnectStatus =
                    if remainingSeconds <= 0 then
                        "Reconnecting ..."
                    else
                        "Reconnecting in " ++ (toString remainingSeconds) ++ " seconds"
            in
                Html.div [ Attr.class "status-message" ] [ Html.text reconnectStatus ]

        _ ->
            Html.text ""
#

 再接続のコードは以下の個所です。再接続がリトライされる度に backoffIteration = 0, 1, 2, 3... と値が変わります。結果的に5秒、10秒、15秒、20秒...後に再接続が試みられていきます。再接続までの時間がだんだん長くなっていきます。

        |> Socket.reconnectTimer (\backoffIteration -> (backoffIteration + 1) * 5000 |> toFloat)
        -- 再接続tryを繰り返す。次のtryまでの時間 => AbnormalClose.reconnectWait 

(注意)
 DBに蓄えたデータを見ていたら、5秒おきに蓄えられているはずなのに、はるかに多い、しかも不規則なデータを確認しました。一見するとJavaScript側かElmで、予想外のイベントやcallbackが生じているように思えました。爽やかな季節の休日にデバッグにいそしんだ結果、もっと基本的な不具合を見つけました。TimeはFloatとして定義されているのに、他言語のunixtimeの扱いのようにIntとして扱っている箇所がありました。toFloatとかを使って、無理やりコンパイラを通している箇所もありました。

Time
type alias Time = 
    Float

 この基本的な不具合の結果、上記の不具合が発生していたらしいのです。まじめに型を適切なものに修正したら、DBのデータもキチンと5秒ごとに揃いました。今回の件で明示的なエラーが発生せず、不規則なイベントが発生しているかのような現象になるのが怖いと思いました。コンパイラの型エラーはまじめに取り組まないといけませんね。

#5.再現ページ

 再現ページのクライアントプログラムでは、以下のような仕事をします。

(1) DBに指定した時刻幅のgeoデータをリクエスト(API)する
(2) 歩いた地図を描き、軌跡を赤ラインで描画する
(3) 抜粋したポイントの時刻をマーカで表示する
(4)トータルの距離や時間、速度、最高速度などのデータ解析を行う

 (1)はPhoenixにhttpsリクエスト(API)を発行しています。
 (2) - (4) は(1)で得られた情報を加工してJavaScriptのleaflet.jsに指示を出しています。

##5-1.再現ページ(JavaScriptプログラム)

 このHtmlではPage2.elmとLeaflet.jsのコンテナを提供するとともに、前に説明した再現ページのPortsの実装を行っています

lib/multi_leaflet_web/templates/page2/page2.html.eex
<html>
  <head>
    <!-- Required meta tags -->
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">


    <!-- Bootstrap CSS -->
    <link rel="stylesheet" <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css" integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm" crossorigin="anonymous">


    <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>
    <br />
    <div id="elm-area"></div>
    <br /><br />
    <div align="center">
        <div id="mapid"></div>
    </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 &copy; <a href="http://openstreetmap.org">OpenStreetMap</a> contributors, '
        }).addTo(mymap);

        mymap.setView(point0, zoom);


        app.ports.portGetToken.subscribe( (key) => {
            // key : not used
            const token = localStorage.getItem("token");
            const name = localStorage.getItem("name");
            if( token && name ) {
              app.ports.portResToken.send([token,name]);
            } else {
              app.ports.portResToken.send(["",""]);
            }
        })


        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>

 時刻表示のマーカですが、複数表示するのでgroup化します。描き直すときにクリアーしますが、group毎一括して行えるようにするのが目的です。

app.ports.portMarkers.subscribe
        app.ports.portMarkers.subscribe( (geos) => {
          if( grp ) {
            grp.clearLayers()
          }
          grp = L.layerGroup([]);

          geos.forEach(function(geo) {
#
            grp.addLayer(marker);
          });
          grp.addTo(mymap);
        });

 leaflet.jsの関数 latlng1.distanceTo(latlng2) を利用して、2ポイント間の距離を計算し、データ解析を行います。

                dist = latlng1.distanceTo(latlng2);

##5-2.Elmの全コード

assets/elm/Page2.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 JE
import Json.Decode as JD exposing (..)
import Jwt exposing (..)

import Date exposing (..)
import Date.Extra as Date
import Time exposing (Time)


import Bootstrap.Alert as Alert
import Bootstrap.Form.Input as Input
import Bootstrap.Button as Button
import Bootstrap.Card as Card
import Bootstrap.Card.Block as Block
import Bootstrap.Grid as Grid
import Bootstrap.Grid.Col as Col
import Bootstrap.Grid.Row as Row
import Bootstrap.Text as Text
import Bootstrap.Utilities.Spacing as Spacing



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

port portGetToken :  String -> Cmd msg

-- INCOMING PORT
port portResNeo : (List Neo -> msg) -> Sub msg

port portResToken : ((String,String) -> msg) -> Sub msg  --(token,name)

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 TokenName =
    { token : String
    , name : String
    }

type alias JwtToken =
    { aud : String
    , exp : Int
    , iat : Int
    , sub : String
    }


type alias Model =
              { geos : List Geo -- viewで使わないならModelに置く必要はない?
              , neos : List Neo
              , start : String
              , stop : String -- Do not use end! keyword in elixir
              , tokenName : Maybe TokenName
              , jwtSub : String
              , date : Maybe Date
              }

start : String
start = "2018-05-01 06:30:00"


stop : String
stop = "2020-05-01 06:30:00"

initSub = "0"

init : ( Model, Cmd Msg )
init =
    Model [] [] start stop Nothing initSub Nothing
    ! [ portGetToken "token", getDate ]


-- UPDATE
type Msg
    = GetGeos
    -- | MsgNewGeos (Result Http.Error (List Geo))
    | MsgNewGeos (Result JwtError (List Geo))
    | ResNeo (List Neo)
    | ChangeStart String
    | ChangeStop String
    | ResToken (String, String)
    | GetDate
    | SetDate (Maybe Date)

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 } ! []

    ResToken (token, name) ->
      case (String.length token > 0) && (String.length name > 0) of
          False -> model ! []
          True ->
            case (decodeToken tokenDecoder token) of
                Ok jwt ->
                    { model | tokenName = Just (TokenName token name), jwtSub=jwt.sub } ! []
                _ -> model ! []

    GetDate ->
      model ! [ getDate ]
    SetDate jdate ->
      case jdate  of
        Nothing -> model ! []
        Just date ->
            let
              stop = Date.toFormattedString "y-MM-dd HH:mm:ss" date
              date0 = Date.add Date.Hour -1 date
              start = Date.toFormattedString "y-MM-dd HH:mm:ss" date0
            in
            { model | start = start, stop = stop, date = jdate } ! []


getDate : Cmd Msg
getDate =
  Task.perform (Just >> SetDate) Date.now


tokenDecoder =
    JD.map4 JwtToken
        (JD.field "aud" JD.string)
        (JD.field "exp" JD.int)
        (JD.field "iat" JD.int)
        (JD.field "sub" JD.string)


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)
    case model.tokenName of
        Just tn ->
               let
                   pointsUrl = geosUrl ++ "?start=" ++ model.start ++ "&stop=" ++ model.stop
               in
               Jwt.get tn.token pointsUrl ( JD.field "data" ( JD.list geo ) )
                 |> Jwt.send MsgNewGeos
        Nothing ->
               Cmd.none

requestGeos : Model -> Http.Request (List Geo)
requestGeos model =
    let
       pointsUrl = geosUrl ++ "?start=" ++ model.start ++ "&stop=" ++ model.stop
    in
    Http.get pointsUrl ( JD.field "data" ( JD.list geo ) )

geo : JD.Decoder Geo
geo =
    JD.map4 toGeo
        (JD.field "id" JD.int)
        (JD.field "lat" JD.float)
        (JD.field "lng" JD.float)
        (JD.field "time" JD.float)

toGeo : Int -> Float -> Float -> Time -> Geo
toGeo id lat lng time =
    Geo id lat lng time



-- VIEW
view : Model -> Html Msg
view model =
    case model.tokenName of
        Just tn -> viewMain model tn.token tn.name
        --Nothing -> div [] [text "Error: missing token!!!!!"]
        Nothing -> Card.config []
                      |> Card.block []
                         [ Block.link [ href "/"] [ text "ログインページへ" ]
                         ]
                      |> Card.view

viewMain : Model -> String -> String -> Html Msg
viewMain model token name =
  Grid.container []
  [ Grid.row [ Row.centerXs ]
  [ Grid.col [Col.xs12, Col.lg8]
  [ Card.config
      [ Card.light
      , Card.textColor Text.primary
      , Card.attrs [ Spacing.mb3 ]
      --, Card.attrs [ Spacing.mb3, style [ ( "width", "600px" ) ] ]
      ]
        |> Card.headerH3 [] [ text "Map Reproduction" ]
        |> Card.block []
            [ Block.custom <|
                Grid.container []
                  [ Grid.row []
                      [ Grid.col [Col.xs12, Col.lg5] [ Input.text [ Input.attrs [ placeholder "start" ]
                                                 , Input.value model.start
                                                 , Input.onInput ChangeStart
                                                 ]
                                    ]
                      , Grid.col [Col.xs12, Col.lg5] [ Input.text [ Input.attrs [ placeholder "stop" ]
                                                 , Input.value model.stop
                                                 , Input.onInput ChangeStop
                                                 ]
                                    ]
                      , Grid.col [Col.xs3, Col.lg2] [ Button.button [ Button.primary, Button.onClick GetGeos ] [ text "再現" ]
                                    ]
                      ]
                  ]
            ]
        |> Card.block []
            [ Block.custom <|
                Grid.container []
                  [ Grid.row [ Row.rightXs ]
                      [ Grid.col [Col.xs3, Col.lg2] [ Button.button [ Button.secondary, Button.onClick GetDate ] [ text "現在時刻" ]
                                    ]
                      ]
                  ]
            ]
        -- |> Card.block []
            -- [ Block.text [] [ text <| "user_id = " ++ model.jwtSub ++ " name = " ++ name ]
            -- , Block.text [] [ text <| " token = " ++ token ]
            -- [ Block.custom <| viewNeos model
            -- ]
       |> Card.block [ Block.align Text.alignXsRight ]
            [ Block.link [ href "/" ] [ text "トップページ" ]
            ]
        |> Card.view

  , viewNeos model
  ]]]



viewNeos model =
    case (List.length model.geos)<2 || (List.length model.neos)<2 of
        True -> div [] []
        False -> Alert.simpleSuccess [] [ 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
    div []
      [ 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
                , portResToken ResToken
                ]

 以下説明していきますが、JavaScriptとのインターフェースであるPortsについて再度説明しておきます。これを念頭に置いていただければと思います。

Ports(再現ページ)
-- OUTGOING PORT
portLocations : 歩いた軌跡のラインを描きます
portMarkers : ポイントを抜粋して時刻を表示するマーカを描きます
portReqGeo : 2地点の位置情報から距離を計算するためのリクエストです。
portGetToken :  localstorageのJWTを返すように依頼する

-- INCOMING PORT
portResNeo :  2地点の距離の計算結果を返します。
portResToken :  JWTを返す。NULLだったら未ログインと判断する。

##5-3.geolocation再現機

 以下のコードが3つのOUTGOING port呼び出し、歩いた軌跡のラインを描き、複数のマーカを置き、2点の距離を計算するようにJavaScriptに依頼している部分です。

update
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
  case msg of
#
    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)...]
         ]
 #

 以下が基本的なデータ定義です。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
              }

##5-4.データ解析

 まずデータ解析を行うデータを取得する部分の説明です。

 以下の部分が、PhoenixのRest APIにリクエストを投げて、DBの検索結果を受け取る部分です。インターフェースをできるだけシンプルに設計すれば、この程度ならDecoderもシンプルに書けます。

API呼び出し
#
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
 #

 次にデータ解析の部分です。

 以下が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

#6.付録

##6-1.DB関連のコマンド

mix ecto.drop
mix ecto.create
mix ecto.migrate

##6-2.

参照した過去記事

Elmとleafletでつくるgeolocationの発信機・受信機 - Qiita
Elmとleafletでつくるgeolocationの再現機とデータ解析 - Qiita
Phoenix1.3+Guardian1.0でJWT - Qiita
複数のElmアプリで小さなデータを共有する - Local storage - Qiita
Phoenix Channelで作る最先端Webアプリ - topic-subtopic編 - Qiita

/////

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