Phoenix.LiveViewでリアルタイムにグラフうねうね

https://qiita.com/advent-calendar/2021/elixir

[Elixir]のリモートもくもく会[autoracex](https://autoracex.connpass.com/)でおなじみのオーサムさん（@torifukukaiou）が昨年の[名前は聞いたことあるけど使ったことないやつをせっかくだから使ってみる Advent Calendar 2020 - 25 日目(最終日) ](https://qiita.com/advent-calendar/2020/sekkaku)で発表された[グラフうねうね (Elixir/Phoenix)](https://qiita.com/torifukukaiou/items/e3056efc3d2c62600fa2)が面白そうだったので、やってみました。記事の内容を参考にしつつ自分なりのアプローチで実装ができたので、グラフの部分のみに焦点を絞り二番煎じしてみようと思います。

https://qiita.com/torifukukaiou/items/e3056efc3d2c62600fa2

## やりたいこと

- [Phoenix.LiveView]と[Chart.js] + [chartjs-plugin-streaming]を利用し、リアルタイムにグラフを更新
- [LiveView]から[Chart.js]への[プッシュ駆動](https://nagix.github.io/chartjs-plugin-streaming/2.0.0/guide/data-feed-models.html#push-model-listening-based)（[LiveView]主導）
- [LiveView]には状態をもたせない（[LiveView]は受け取った値を`phx-hook`経由でグラフに渡すのみ）
- [LiveView]に[PubSub]経由で複数のクライエント（ユーザー）からデータを受け取るのを想定（今回この部分はタイマーとダミーデータで再現）
- x 軸: [JavaScript]コードが点を追加する時点の時間
- y 軸: [LiveView]から受信した値
- 一意のユーザー名ごとにデータセットを生成

![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: