Phoenix Channelで作る最先端Webアプリ - 地図(Geo)拡張編

Phoenix Channelで作る最先端Webアプリ - topic-subtopic編 - Qiita
Phoenix Channelで作る最先端Webアプリ - ETS編 - Qiita
Phoenix Channelで作る最先端Webアプリ - Fault Tolerance編 - Qiita
Phoenix Channelで作る最先端Webアプリ - 地図(Geo)拡張編 - Qiita
Phoenix Channelで作る最先端Webアプリ - DynamicSupervisor編 - Qiita
Phoenix Channelで作る最先端Webアプリ - Elixier Application編 - Qiita
Phoenix Channelで作る最先端Webアプリ - Reactチャット編 - Qiita

====> チャットWebアプリ Live!(書き込み禁止)

 今回はReact側をもう少し充実させ、このWebアプリの完成形としたいと思います。Twitterの位置情報をもとに、React側に地図情報として表示させます。

1.Phoenix Channelで作る最先端Webアプリのポイント

 今回の分も含めて、シリーズのポイントを簡単に振り返りたいと思います。

 Reactチャット編では、ReactChat Applicationを作成しました。Phoenix Channelを使えばブラウザ同士のチャットプログラムが簡単に構築できることを見ました。しかも通信はWebsocketで行われます。

 Elixier Application編では、全く別の独立したApplicationとして、ListenTweets Applicationを構築しました。ListenTweets Applicationはブラウザとは別のchannelクライアントの機能を持ち、チャットchannelに参加します。ブラウザ同士のチャットに加えて、ListenTweets ApplicationがTwitterから拾ったTweetsをチャットに流し込みます。ここで重要なのは、ReactChat ApplicationがListenTweets Applicationを組み込む組み込み方です。ReactChat ApplicationはListenTweets ApplicationをDependencyとして組み込みます。このやり方はElixirにおけるやり方で、まったく新しいものです。ListenTweets Applicationは独立したものとして開発されているにかかわらず、ReactChat Applicationからフルアクセスが可能になっているのです。

 DynamicSupervisor編では、ReactChat ApplicationからListenTweets ApplicationのListenerを動的に起動可能になりました。複数個同時に起動できます。しかも起動時に名前(キーワード)を渡して、その名前でプロセス管理ができます。

 地図(Geo)拡張編ではTweetsの位置情報を、Reactに地図情報として表示させます。このWebアプリの完成です。通信は全てwebsocketで行われていて、リアルタイムを実現しています。もはやレガシーなhttpは初期画面のロードでしか出番がありません。

2.画像で見るアプリの全体像

 Webアプリの全体像を掴むため4枚の画像を示します。まずブラウザをひとつ開いて「さんど」という名前でチャットchannelにjoinします。次にキーワード登録で「ラーメン」と「花見」というキーワードを登録します。1回のキーワード登録は1つのListenerプロセスを起動します。この場合、Listenerプロセスは2つ起動されています。Listenerプロセスは与えられたキーワードでチャットchannelにjoinします。ですから参加者欄には「ラーメン」と「花見」というPresenceが現れています。さらにListenerプロセスは与えられたキーワードでTwitterのTweetsをリッスンし、見つけたTweetsをチャットchannelに書き込みます。そのTweetsはブラウザにチャットの書き込みとして表示されていきます。

(1)チャットのメインです。チャットの参加者が表示されています。
image.png

(2)チャットのタイムラインです。「ラーメン」と「花見」のキーワードを含むTweetsが表示されていきます。もちろん他のブラウザから人間が書き込んだチャットも同じように表示されます。ブラウザはchannelを使っているのでリアルタイムに自動更新されます。Material UIのテキストアバターを使って、書き込みユーザ(ラーメンと花見)をアバターで表示しています。面白いですね。
image.png

(3)Material UIのTabを使って画面を切り替えます。「チャットマップ」Tabを表示します。Geo(位置)情報を含むTweetsを地図上にマップしています。
image.png

(4)マークをクリックするとTweetsがポップアップで表示されます。
image.png

3.React画面にTweetsの位置情報をマップ表示する

 最初に、ListenTweets ApplicationのListenerで、Tweetsがgeoデータを含むときは、それもchannelに書き込むことにします。

lib/listen_tweets/listener.ex
#
  def init(key) do
    channel =
        get_pid()
        |> get_socket()
        |> get_channel(key)

    if channel == :error do
      IO.puts("error!!!")
    else
       PhoenixChannelClient.join(channel)
       pid = spawn(fn ->
           stream = ExTwitter.stream_filter(track: key)
           for tweet <- stream do
             if tweet.geo != nil do
               PhoenixChannelClient.push(channel, "new_msg", %{msg: tweet.text, geo: tweet.geo})
             else
               PhoenixChannelClient.push(channel, "new_msg", %{msg: tweet.text})
             end
           end
       end)
    end
    { :ok, key }
  end
#

 次にReactChat Applicationでgeo情報を含んだnew_msgを処理できるようにします。そのためにhandle_in("new_msg", %{"msg" => msg, "geo" => geo}, socket)の関数を増やしました。geoデータが無い場合は geo=nilでも渡して、1関数にまとめることができますが、今回はgeoが無い場合と明確に分けるようにしました。またmsgの現在時間(time)も送るようにしました。

lib/react_chat_web/channels/room_channel.ex
#
  def handle_in("new_msg", %{"msg" => msg, "geo" => geo}, socket) do
IO.inspect geo
    user_name = socket.assigns[:user_name]
    broadcast(socket, "new_msg", %{msg: msg, user_name: user_name, time: now(), geo: geo})
    {:reply, :ok, socket}
  end

  def handle_in("new_msg", %{"msg" => msg}, socket) do
    user_name = socket.assigns[:user_name]
    broadcast(socket, "new_msg", %{msg: msg, user_name: user_name, time: now()})
    {:reply, :ok, socket}
  end
#

 最後にReactアプリを地図表示できるように修正します。そのためにleafletライブラリをインストールします。

cd assets
npm install leaflet react-leaflet --save

 またReact上でLeafletを使って地図を描くためには、以下のようなスタイルシートを指定しておくことが必要です。これを忘れると嵌ります。

lib/react_chat_web/templates/layout/app.html.eex
#
  <link rel="stylesheet" href="//cdnjs.cloudflare.com/ajax/libs/normalize/7.0.0/normalize.min.css">
  <link rel="stylesheet" href="//cdnjs.cloudflare.com/ajax/libs/leaflet/1.1.0/leaflet.css">
    <style>
      h1, h2, p {
        font-family: sans-serif;
        text-align: center;
      }
      .leaflet-container {
        height: 480px;
        width: 80%;
        margin: 0 auto;
      }
    </style>
#

 Chat.jsを修正します。Material-UIのTabsを使って、今までのチャット画面(タイムライン)とマップ画面に切り替えることができるようにします。今までのmessages(geoデータがあるものと無いものの両方)はそのままthis.state.messagesに蓄え、geoデータ付きのものはthis.state.geo_messagesに蓄えるようにします。前者はタイムライン画面に表示され、後者はマップ画面に表示されます。
 Material-UIのTabsは使い方が簡単です。またLefletで地図を描くことも簡単です。ですがそれらを組み合わせて使うと、一見複雑なプログラムに見えてしまいます。Elmの時もそうでした。クライアント側はなかなかシンプルには書けませんね。

assets/js/Chat.js
#
import {Map, Marker, Popup, TileLayer, LayerGroup} from 'react-leaflet'
import {Tabs, Tab} from 'material-ui/Tabs';
import FontIcon from 'material-ui/FontIcon';
import {List, ListItem} from 'material-ui/List';
import Subheader from 'material-ui/Subheader';
import Avatar from 'material-ui/Avatar';

const max_messages_length =10; //メッセージ配列の最大値

//unixtimeを表示形式に変換
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 );
}


class Chat extends React.Component {
  constructor() {
    super();
    this.state = {
#
      messages: [],
      geo_messages: [], //追加 geoデータを持つmessagesの配列
      presences: {},
      lat: 40,  //追加 初期データ
      lng: -74, //追加 初期データ
      zoom: 6,  //追加
    }
  }
#
  handleJoin(event) {
#
        this.channel.on("new_msg", payload => {
          let messages = this.state.messages;
          messages.push(payload)
          if(messages.length > max_messages_length) {
              messages.shift(); // 先頭を削除
          }
          this.setState({ messages: messages })

          if( !!payload.geo && !!payload.geo.coordinates ) {
              let geo_messages = this.state.geo_messages;
              geo_messages.push(payload);
              if(geo_messages.length > max_messages_length) {
                geo_messages.shift(); // 先頭を削除
              }
              this.setState({ geo_messages: geo_messages })
          }
        })
#
   }
#
  render() {
#
    let messages = this.state.messages.map((message, index) => {
        let geo = "";
        if( !!message.geo && !!message.geo.coordinates ) {
            geo = JSON.stringify(message.geo.coordinates);
        }

        return (
          <List key={index}>
            <ListItem
              leftAvatar={<Avatar>{message.user_name}</Avatar>}
              primaryText={unixTime2ymd(message.time)}
              secondaryText={geo}
              secondaryTextLines={2} >
              {message.msg}
            </ListItem>
          </List>
        )
    });
    messages = messages.reverse();

    let geo_messages = this.state.geo_messages;
    geo_messages =  geo_messages.reverse();
    let center_position = [this.state.lat, this.state.lng]; // 緯度,経度
    if( geo_messages && geo_messages[0]) {
        center_position = [ geo_messages[0].geo.coordinates[0], geo_messages[0].geo.coordinates[1] ];
    }

    let Markers = [];
    for (let i = 0; i < geo_messages.length; i++) {
        let tweet_position = [ geo_messages[i].geo.coordinates[0], geo_messages[i].geo.coordinates[1] ];
        let tweet_coordinates = JSON.stringify(geo_messages[i].geo);
        Markers.push(
            <Marker position={tweet_position} key={i}>
              <Popup>
                <div>
                  <div>{unixTime2ymd(geo_messages[i].time)}</div>
                  <div>{geo_messages[i].msg}</div>
                  <div>{geo_messages[i].user_name}</div>
                  <div>{tweet_coordinates}</div>
                </div>
              </Popup>
            </Marker>
        )
    }
#
    return (
    <Tabs>
      <Tab label="チャット タイムライン">
        <br />
        <h1>チャット タイムライン編</h1>
        <div>
          {form_jsx}
        </div>
      </Tab>
      <Tab label="チャット マップ">
        <br />
        <h1>チャット マップ編</h1>
        <Map center={center_position} zoom={this.state.zoom}>
          <TileLayer attribution='&copy; <a href="http://osm.org/copyright">OpenStreetMap</a> contributors' url='http://{s}.tile.osm.org/{z}/{x}/{y}.png' />
          <LayerGroup>
            {Markers}
          </LayerGroup>
        </Map>
      </Tab>
    </Tabs>
    )
  }
}
export default Chat

 以上で、React画面にTweetsの位置情報をマップ表示するための修正を終わります。

4.修正ソースコードの全リスト

 今回修正したソースの全リストを掲載します。

lib/listen_tweets/listener.ex
defmodule ListenTweets.Listener do
  use GenServer, restart: :transient  #child_spec

  def start_link(key) do
    GenServer.start_link(__MODULE__, key, name: via_tuple(key))
  end


  # via_tuple
  def via_tuple(name), do: {:via, Registry, {Registry.Listener, name}}


  def init(key) do
    channel =
        get_pid()
        |> get_socket()
        |> get_channel(key)

    if channel == :error do
      IO.puts("error!!!")
    else
       PhoenixChannelClient.join(channel)
       pid = spawn(fn ->
           stream = ExTwitter.stream_filter(track: key)
           for tweet <- stream do
             #IO.inspect tweet
             #IO.puts tweet.text
             if tweet.geo != nil do
               #IO.puts tweet.text
               #IO.inspect tweet.geo
               PhoenixChannelClient.push(channel, "new_msg", %{msg: tweet.text, geo: tweet.geo})
             else
               PhoenixChannelClient.push(channel, "new_msg", %{msg: tweet.text})
             end
           end
       end)
    end
    { :ok, key }
  end

  def handle_cast({ :update, new_key }, _current_key) do
    { :noreply, new_key }
  end

  def handle_info(mess, key) do
    IO.inspect mess
    { :noreply, key }
  end



  defp get_pid do
    res_pid = PhoenixChannelClient.start_link()
    case res_pid do
      {:ok, pid} -> pid
      _ -> :error
    end
  end


  defp get_socket (:error) do
    :error
  end
  defp get_socket (pid) do
     res_socket = PhoenixChannelClient.connect(pid,
        host: "localhost",
        port: 4000,
        path: "/socket/websocket",
        secure: false,
        heartbeat_interval: 30_000)
      case res_socket do
        {:ok, socket} -> socket
        _ -> :error
      end
  end

  defp get_channel(:error, _key) do
    :error
  end
  defp get_channel(socket, key) do
    channel = PhoenixChannelClient.channel(socket, "room:lobby", %{user_name: key})
    channel
  end
end
lib/react_chat_web/channels/room_channel.ex
defmodule ReactChatWeb.RoomChannel do
  use ReactChatWeb, :channel
  alias ReactChatWeb.Presence
  alias ListenTweets.ListenerSupervisor

  def join("room:lobby", %{"user_name" => user_name}, socket) do
    send(self(), {:after_join, user_name})
    {:ok, socket}
  end


  def handle_in("new_msg", %{"msg" => msg, "geo" => geo}, socket) do
IO.inspect geo
    user_name = socket.assigns[:user_name]
    broadcast(socket, "new_msg", %{msg: msg, user_name: user_name, time: now(), geo: geo})
    {:reply, :ok, socket}
  end

  def handle_in("new_msg", %{"msg" => msg}, socket) do
    user_name = socket.assigns[:user_name]
    broadcast(socket, "new_msg", %{msg: msg, user_name: user_name, time: now()})
    {:reply, :ok, socket}
  end

  def handle_in("new_key", %{"msg" => msg}, socket) do
    ListenerSupervisor.start_listener(msg)
    {:reply, :ok, socket}
  end

  def handle_in("del_key", %{"msg" => msg}, socket) do
    ListenerSupervisor.stop_listener(msg)
    {:reply, :ok, socket}
  end

  def handle_info({:after_join, user_name}, socket) do
    push(socket, "presence_state", Presence.list(socket))
    {:ok, _ref} = Presence.track(socket, user_name, %{online_at: now()})
    {:noreply, assign(socket, :user_name, user_name)}
  end

  def terminate(_reason, socket) do
    {:noreply, socket}
  end

  defp now do
    System.system_time(:seconds)
  end
end
assets/js/Chat.js
import React from "react";
import {Socket, Presence} from "phoenix"
import RaisedButton from 'material-ui/RaisedButton';
import Paper from 'material-ui/Paper';
import Divider from 'material-ui/Divider';
import TextField from 'material-ui/TextField';

import {Map, Marker, Popup, TileLayer, LayerGroup} from 'react-leaflet'
//import Paper from 'material-ui/Paper';
import {Tabs, Tab} from 'material-ui/Tabs';
import FontIcon from 'material-ui/FontIcon';
import {List, ListItem} from 'material-ui/List';
//import Divider from 'material-ui/Divider';
import Subheader from 'material-ui/Subheader';
import Avatar from 'material-ui/Avatar';



const max_messages_length =10;

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 );
}


class Chat extends React.Component {
  constructor() {
    super();
    this.state = {
      isJoined: false,
      inputUser: "",
      inputMessage: "",
      inputKey: "",
      inputKey0: "",
      messages: [],
      geo_messages: [],
      presences: {},
      lat: 40,
      lng: -74,
      zoom: 6,
    }
  }


  handleInputUser(event) {
    this.setState({
      inputUser: event.target.value
    })
  }

  handleInputMessage(event) {
    this.setState({
      inputMessage: event.target.value
    })
  }

  handleInputKey(event) {
    this.setState({
      inputKey: event.target.value
    })
  }

  handleInputKey0(event) {
    this.setState({
      inputKey0: event.target.value
    })
  }

  handleJoin(event) {
    event.preventDefault();
    if(this.state.inputUser!="") {

        // assets/js/socket.jsのデフォルトの定義と同じ
        this.socket = new Socket("/socket", {params:
          {token: window.userToken}
        });
        this.socket.connect();

        this.channel = this.socket.channel("room:lobby",  {user_name: this.state.inputUser});

        // 現在のサーバの状態を初期状態として設定
        this.channel.on('presence_state', state => {
          let presences = this.state.presences;
          presences = Presence.syncState(presences, state);
          this.setState({ presences: presences })
          console.log('state', presences);
        });
        // 初期状態からの差分を更新していく
        this.channel.on('presence_diff', diff => {
          let presences = this.state.presences;
          presences = Presence.syncDiff(presences, diff);
          this.setState({ presences: presences })
          console.log('diff', presences);
        });

        this.channel.on("new_msg", payload => {
          let messages = this.state.messages;
          messages.push(payload)
          if(messages.length > max_messages_length) {
              messages.shift(); // 先頭を削除
          }
          this.setState({ messages: messages })

          if( !!payload.geo && !!payload.geo.coordinates ) {
              let geo_messages = this.state.geo_messages;
              geo_messages.push(message);
              if(geo_messages.length > max_messages_length) {
                geo_messages.shift(); // 先頭を削除
              }
              this.setState({ geo_messages: geo_messages })
          }
        })

        this.channel.join()
          .receive("ok", response => { console.log("Joined successfully", response) })
          .receive('error', resp => { console.log('Unable to join', resp); });

        this.setState({ isJoined: true })
    }
  }

  handleLeave(event) {
    event.preventDefault();
    this.socket.disconnect();
    this.setState({ isJoined: false })
  }


  handleSubmit(event) {
    event.preventDefault();
    this.channel.push("new_msg", {msg: this.state.inputMessage})
    this.setState({ inputMessage: "" })
  }

  handleKey(event) {
    event.preventDefault();
    this.channel.push("new_key", {msg: this.state.inputKey})
    this.setState({ inputKey: "" })
  }

  handleKey0(event) {
    event.preventDefault();
    this.channel.push("del_key", {msg: this.state.inputKey0})
    this.setState({ inputKey0: "" })
  }


  render() {
    const style1 = { margin: '16px 32px 16px 16px', padding: '10px 32px 10px 26px',};
    const style2 = { display: 'inline-block', margin: '1px 8px 1px 4px',};

//--------------------------
    let messages = this.state.messages.map((message, index) => {
        let geo = "";
        if( !!message.geo && !!message.geo.coordinates ) {
            geo = JSON.stringify(message.geo.coordinates);
        }

        return (
          <List key={index}>
            <ListItem
              leftAvatar={<Avatar>{message.user_name}</Avatar>}
              primaryText={unixTime2ymd(message.time)}
              secondaryText={geo}
              secondaryTextLines={2} >
              {message.msg}
            </ListItem>
          </List>
        )
    });
    messages = messages.reverse();

    let geo_messages = this.state.geo_messages;
    geo_messages =  geo_messages.reverse();
    let center_position = [this.state.lat, this.state.lng]; // 緯度,経度
    if( geo_messages && geo_messages[0]) {
        center_position = [ geo_messages[0].geo.coordinates[0], geo_messages[0].geo.coordinates[1] ];
    }

    let Markers = [];
    for (let i = 0; i < geo_messages.length; i++) {
        let tweet_position = [ geo_messages[i].geo.coordinates[0], geo_messages[i].geo.coordinates[1] ];
        let tweet_coordinates = JSON.stringify(geo_messages[i].geo);
        Markers.push(
            <Marker position={tweet_position} key={i}>
              <Popup>
                <div>
                  <div>{unixTime2ymd(geo_messages[i].time)}</div>
                  <div>{geo_messages[i].msg}</div>
                  <div>{geo_messages[i].user_name}</div>
                  <div>{tweet_coordinates}</div>
                </div>
              </Popup>
            </Marker>
        )
    }
//--------------------------

    let presences = [];
    Presence.list(this.state.presences, (name, metas) => {
        presences.push(name);
    });
    let presences_list = presences.map( (user_name, index) =>
      <li key={index} style={style2}>{user_name}</li>
    );

    let form_jsx;
    if(this.state.isJoined===false) {
       form_jsx = (
        <form onSubmit={this.handleJoin.bind(this)} >
          <label>ユーザ名を指定してJoin</label>&nbsp;&nbsp;&nbsp;&nbsp;
          <TextField hintText="ユーザ名" value = {this.state.inputUser} onChange = {this.handleInputUser.bind(this)} />&nbsp;&nbsp;&nbsp;&nbsp;
          <RaisedButton type="submit" primary={true} label="Join" />
        </form>
       );
    } else {
       form_jsx = (
         <div>
           <Paper  style={style1}>
             <label>参加者 : {this.state.inputUser}</label>
             <ul>
               {presences_list}
             </ul>
             <div align="right">
               <form onSubmit={this.handleLeave.bind(this)} >
                 <RaisedButton type="submit" primary={true} label="Leave" />
               </form>
             </div>
           </Paper>
           <Paper  style={style1}>
             <form onSubmit={this.handleKey.bind(this)}>
               <label>キーワード登録</label>&nbsp;&nbsp;&nbsp;&nbsp;
               <TextField hintText="key" value = {this.state.inputKey} onChange = {this.handleInputKey.bind(this)} />&nbsp;&nbsp;&nbsp;&nbsp;
               <RaisedButton type="submit" primary={true} label="Submit" />
             </form>
             <form onSubmit={this.handleKey0.bind(this)}>
               <label>キーワード削除</label>&nbsp;&nbsp;&nbsp;&nbsp;
               <TextField hintText="key" value = {this.state.inputKey0} onChange = {this.handleInputKey0.bind(this)} />&nbsp;&nbsp;&nbsp;&nbsp;
               <RaisedButton type="submit" primary={true} label="Submit" />
             </form>
             <form onSubmit={this.handleSubmit.bind(this)}>
               <label>チャット</label>&nbsp;&nbsp;&nbsp;&nbsp;
               <TextField hintText="Chat Text" value = {this.state.inputMessage} onChange = {this.handleInputMessage.bind(this)} />&nbsp;&nbsp;&nbsp;&nbsp;
               <RaisedButton type="submit" primary={true} label="Submit" />
             </form>
             <Divider />
             <br />
             <div>
               {messages}
             </div>
           </Paper>
         </div>
      );
    }

    return (
    <Tabs>
      <Tab label="チャット タイムライン">
        <br />
        <h1>チャット タイムライン編</h1>
        <div>
          {form_jsx}
        </div>
      </Tab>
      <Tab label="チャット マップ">
        <br />
        <h1>チャット マップ編</h1>
        <Map center={center_position} zoom={this.state.zoom}>
          <TileLayer attribution='&copy; <a href="http://osm.org/copyright">OpenStreetMap</a> contributors' url='http://{s}.tile.osm.org/{z}/{x}/{y}.png' />
          <LayerGroup>
            {Markers}
          </LayerGroup>
        </Map>
      </Tab>
    </Tabs>
    )
  }
}
export default Chat

以上です

Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account log in.