Real-time charting with Elixir Phoenix.LiveView + Chart.js
Tags: elixir, phoenix, liveview, chartjs
Published: 2021-11-29 14:13:28

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

## Goals

- Real-time charting with [Phoenix.LiveView] + [Chart.js] + [chartjs-plugin-streaming]
- Drive the charting by [push](https://nagix.github.io/chartjs-plugin-streaming/2.0.0/guide/data-feed-models.html#push-model-listening-based) from [LiveView] to [Chart.js]
- [LiveView] has no state
- [LiveView] receives new data from multiple clients via [PubSub], which we simulate by using timer and fake data
- [LiveView] sends data to the chart via `phx-hook`
- x axis: a point of time our [JavaScript] code adds a datapoint to the chart
- y axis: a value sent from our [LiveView]
- Create a dataset per unique user name

![graph uneune Screen Recording 2021-11-28 at 5 33 48 PM](https://user-images.githubusercontent.com/7563926/143788979-ef7a606f-d2e9-47b5-bad3-f8a79bf18312.gif)

[Elixir]: https://elixir-lang.org/docs.html
[Chart.js]: https://www.chartjs.org/
[Phoenix.LiveView]: https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html
[LiveView]: https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html
[`phx-hook`]: https://hexdocs.pm/phoenix_live_view/js-interop.html#client-hooks-via-phx-hook
[PubSub]: https://hexdocs.pm/phoenix_pubsub/Phoenix.PubSub.html
[JavaScript]: https://developer.mozilla.org/en-US/docs/Web/JavaScript
[Phoenix]: https://hexdocs.pm/phoenix/Phoenix.html
[chartjs-plugin-streaming]: https://nagix.github.io/chartjs-plugin-streaming/latest/guide/getting-started.html#installation

## 前提

- 既存の[Phoenix]アプリがあり、それにグラフ用の[LiveView]を追加する前提とします。

```
erlang             24.1.7
elixir             1.13.0-otp-24
phoenix            1.6.2
phoenix_live_view  0.17.1
```

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
```

```js
// package.json

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

## グラフを操作する[JavaScript]を定義

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

```js
// 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
  }
}
```

## [LiveView]と[JavaScript]との間で通信するためのフックを定義

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

```js
// 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()
  }
}
```

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

```js
// assets/js/live_view_hooks/index.js

import LineChart from './line_chart_hook'

export default {
  LineChart
}
```

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

```js
// 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
  }
})

// ...
```

## グラフを表示する[LiveView]を定義

```elixir
# 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
```

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

```elixir
# lib/mnishiguchi_web/router.ex

defmodule MnishiguchiWeb.Router do
  use MnishiguchiWeb, :router

  # ...

  scope "/", MnishiguchiWeb do
    pipe_through :browser

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

  # ...
```

![graph uneune Screen Recording 2021-11-28 at 5 33 48 PM](https://user-images.githubusercontent.com/7563926/143788979-ef7a606f-d2e9-47b5-bad3-f8a79bf18312.gif)

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

:tada::tada::tada:

## Resources

- https://online.pragmaticstudio.com/courses/liveview-pro/modules/31
- https://qiita.com/torifukukaiou/items/e3056efc3d2c62600fa2