DEV Community

Masatoshi Nishiguchi
Masatoshi Nishiguchi

Posted on • Updated on

Real-time charting with Elixir Phoenix.LiveView + Chart.js

This is written in Japanese. I might convert it to English later, maybe.

Goals

graph uneune Screen Recording 2021-11-28 at 5 33 48 PM

前提

  • 既存のPhoenixアプリがあり、それにグラフ用のLiveViewを追加する前提とします。
erlang             24.1.7
elixir             1.13.0-otp-24
phoenix            1.6.2
phoenix_live_view  0.17.1
Enter fullscreen mode Exit fullscreen mode

https://nagix.github.io/chartjs-plugin-streaming/2.0.0/guide/getting-started.html#integration

依存関係をインストール

npm install --save --prefix assets \
  chart.js luxon chartjs-adapter-luxon chartjs-plugin-streaming
Enter fullscreen mode Exit fullscreen mode
// package.json

{
  "dependencies": {
    "chart.js": "^3.6.1",
    "chartjs-adapter-luxon": "^1.1.0",
    "chartjs-plugin-streaming": "^2.0.0",
    "luxon": "^2.1.1",
  }
}
Enter fullscreen mode Exit fullscreen mode

グラフを操作するJavaScriptを定義

  • assets/js/line_chart.js
  • Chart.jsChartのラッパー
  • グラフの挙動を定義
  • グラフ初期化の関数
  • グラフに座標を追加する関数
// assets/js/line_chart.js

// https://www.chartjs.org/docs/3.6.1/getting-started/integration.html#bundlers-webpack-rollup-etc
import Chart from 'chart.js/auto'
import 'chartjs-adapter-luxon'
import ChartStreaming from 'chartjs-plugin-streaming'
Chart.register(ChartStreaming)

// A wrapper of Chart.js that configures the realtime line chart.
export default class {
  constructor(ctx) {
    this.colors = [
      'rgba(255, 99, 132, 1)',
      'rgba(54, 162, 235, 1)',
      'rgba(255, 206, 86, 1)',
      'rgba(75, 192, 192, 1)',
      'rgba(153, 102, 255, 1)',
      'rgba(255, 159, 64, 1)'
    ]

    const config = {
      type: 'line',
      data: { datasets: [] },
      options: {
        datasets: {
          // https://www.chartjs.org/docs/3.6.0/charts/line.html#dataset-properties
          line: {
            // 線グラフに丸みを帯びさせる。
            tension: 0.3
          }
        },
        plugins: {
          // https://nagix.github.io/chartjs-plugin-streaming/2.0.0/guide/options.html
          streaming: {
            // 表示するX軸の幅をミリ秒で指定。
            duration: 60 * 1000,
            // Chart.jsに点をプロットする猶予を与える。
            delay: 1500
          }
        },
        scales: {
          x: {
            // chartjs-plugin-streamingプラグインの機能をつかうための型。
            type: 'realtime'
          },
          y: {
            // あらかじめY軸の範囲をChart.jsに教えてあげると、グラフの更新がスムーズです。
            suggestedMin: 50,
            suggestedMax: 200
          }
        }
      }
    }

    this.chart = new Chart(ctx, config)
  }

  addPoint(label, value) {
    const dataset = this._findDataset(label) || this._createDataset(label)
    dataset.data.push({x: Date.now(), y: value})
    this.chart.update()
  }

  destroy() {
    this.chart.destroy()
  }

  _findDataset(label) {
    return this.chart.data.datasets.find((dataset) => dataset.label === label)
  }

  _createDataset(label) {
    const newDataset = {label, data: [], borderColor: colors.pop()}
    this.chart.data.datasets.push(newDataset)
    return newDataset
  }
}
Enter fullscreen mode Exit fullscreen mode

LiveViewJavaScriptとの間で通信するためのフックを定義

LiveView がマウントされたときに実行する処理を書きます。

// assets/js/live_view_hooks/line_chart_hook.js

// 前項で定義したJSファイルをインポートする。
import RealtimeLineChart from '../line_chart'

export default {
  mounted() {
    // グラフを初期化する。
    this.chart = new RealtimeLineChart(this.el)

    // LiveViewから'new-point'イベントを受信時、座標を追加する。
    this.handleEvent('new-point', ({ label, value }) => {
      this.chart.addPoint(label, value)
    })
  },
  destroyed() {
    // 使用後はちゃんと破壊する。
    this.chart.destroy()
  }
}
Enter fullscreen mode Exit fullscreen mode

個人的にindex.jsファイルで整理するスタイルが気に入ってます。

// assets/js/live_view_hooks/index.js

import LineChart from './line_chart_hook'

export default {
  LineChart
}
Enter fullscreen mode Exit fullscreen mode

assets/js/app.jsファイルでLiveSocketにフックを登録します。

// assets/js/app.js

import 'phoenix_html'
import { Socket } from 'phoenix'
import { LiveSocket } from 'phoenix_live_view'
import topbar from '../vendor/topbar'

import LiveViewHooks from './live_view_hooks'

let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute('content')
let liveSocket = new LiveSocket('/live', Socket, {
  hooks: LiveViewHooks,
  params: {
    _csrf_token: csrfToken
  }
})

// ...
Enter fullscreen mode Exit fullscreen mode

グラフを表示するLiveViewを定義

# lib/mnishiguchi_web/live/chart_live.ex

defmodule MnishiguchiWeb.ChartLive do
  use MnishiguchiWeb, :live_view

  @impl Phoenix.LiveView
  def mount(_params, _session, socket) do
    if connected?(socket) do
      # 本来はPubSubでデータを受信するところだが、今回そこはタイマーで再現する。
      :timer.send_interval(1000, self(), :update_chart)
    end

    {:ok, socket}
  end

  @impl Phoenix.LiveView
  def render(assigns) do
    ~H"""
    <div>
      <!--
      フックをセットする。
      本LiveViewにおいてグラフ更新はJavascriptの責任範囲なので、あらかじめ`phx-update="ignore"`により
      LiveViewにグラフ更新されないようにしておく。
      -->
      <canvas
        id="chart-canvas"
        phx-update="ignore"
        phx-hook="LineChart"></canvas>
    </div>
    """
  end

  @impl Phoenix.LiveView
  def handle_info(:update_chart, socket) do
    # ダミーデータを生成し、"new-point"イベントを発信する。
    {:noreply,
     Enum.reduce(1..5, socket, fn i, acc ->
       push_event(
         acc,
         "new-point",
         %{label: "User #{i}", value: Enum.random(50..150) + i * 10}
       )
     end)}
  end
end
Enter fullscreen mode Exit fullscreen mode

LiveViewのルートを忘れずに定義する。

# lib/mnishiguchi_web/router.ex

defmodule MnishiguchiWeb.Router do
  use MnishiguchiWeb, :router

  # ...

  scope "/", MnishiguchiWeb do
    pipe_through :browser

    # ...
    live "/chart", ChartLive
  end

  # ...
Enter fullscreen mode Exit fullscreen mode

graph uneune Screen Recording 2021-11-28 at 5 33 48 PM

比較的少ないコード記述量でリアルタイムグラフうねうねの実装ができました。

🎉🎉🎉

Resources

Discussion (0)