React.js + Chart.jsでインタラクティブなグラフを書く

#概要

例えば、以下の様にグラフと表が一緒になっているページがあるとします。使用者が同ページで新規にデータを追加した直後に、表に新データが挿入されるとともにグラフも更新されるようなインタラクティブなページを作りたく、React.jsのアプリケーション内部でChart.jsを使用してみました。React.jsの[状態データ(this.state)](https://facebook.github.io/react/docs/interactivity-and-dynamic-uis.html#how-state-works)を元に[Chart.js](http://www.chartjs.org/)にグラフを書かせる手法です。

Rails、[CoffeeScript](http://coffeescript.org/
)で[JSX](https://facebook.github.io/react/docs/jsx-in-depth.html)を使用せず書きました。

![Screenshot 2015-07-14 16.25.07.png](https://qiita-image-store.s3.amazonaws.com/0/82804/02afbf45-52ce-476f-b304-7d1862f6d2ea.png)

#環境

- ruby 2.2.1
- Rails 4.2.3
- react-rails
- chart-js-rails
- bootstrap
- font-awesome-rails
- 他

#手順

##グラフのキャンバスを定義。
ここで定義したキャンバスを後にメインの部品のrenderメソッドで呼び出し、キャンバスのオブジェクトを作ります。

```coffeescript:グラフのキャンバスを定義
# React.DOMの記述を省略するための変数。
R = React.DOM

PieChartCanvas = React.createClass
  render: ->
    R.canvas
      style: { height: 200, width: 200 }

BarChartCanvas = React.createClass
  render: ->
    R.canvas
      style: { height: 200, width: 400 }
```

##グラフのオブジェクトへのポインターを覚えておく。
Chart.jsによりキャンバス上に作られたグラフのオブジェクトはReact.js管理外ですので、部品全体が更新される際は、自分で破壊する必要があります。そのため、後にアクセスできるようにグラフのオブジェクトを覚えておきます。

```coffeescript:グラフのオブジェクトへのポインターを覚えておく
@MovingRecordsApp = React.createClass

  getInitialState: ->
    records: @props.data
    # グラフのオブジェクトを覚えておくための変数。
    barChartInstance: null
    pieChartInstance: null
```

##[renderメソッド](https://facebook.github.io/react/docs/component-specs.html#render)でテンプレートを定義する。


```coffee:テンプレートを定義する

# React.DOMの記述を省略するための変数。
R = React.DOM

# メインの部品
@MovingRecordsApp = React.createClass

  getInitialState: ->
    records: @props.data  # 外部から供給されるデータ。
    # グラフのオブジェクトを覚えておくための変数。
    barChartInstance: null
    pieChartInstance: null

  getDefaultProps: ->
    records: []

  ...

  # グラフと額縁のテンプレート
  chartsPanel: ->
    R.div
      className: "panel panel-blue"
      R.div
        className: 'panel-heading'
        R.div
          className: "row"
          R.div
            className: "col-xs-3"
            R.div
              className: "fa fa-home fa-5x"
          R.div
            className: "col-xs-9 text-right"
            R.div
              className: 'huge'
              "Total: #{@totalVolume()}"
            R.div null,
              "cubic feet"
      R.div
        className: 'panel-body'
        R.div
          className: 'row text-center'
          # 棒グラフ用キャンバス
          R.div
            className: 'col-sm-6'
            React.createElement BarChartCanvas,
              ref: "bar"  # 後にアクセスするために使用
          # 円グラフ用キャンバス
          R.div
            className: 'col-sm-6'
            React.createElement PieChartCanvas,
              ref: "chart"  # 後にアクセスするために使用

  render: ->
    R.div
      className: "app_wrapper"

      # グラフと額縁
      R.div null, @chartsPanel()
      R.hr null

      # 新規作成フォームの部品（この部品についての本題ではないので、詳細は省略しています。）
      R.h2 null, "Add a new item"
      React.createElement NewMovingRecordForm,
        handleNewRecord: @addRecord
        roomSuggestions: @props.roomSuggestions
        categorySuggestions: @props.categorySuggestions
      R.hr null

      # 表の部品（この部品についての本題ではないので、詳細は省略しています。）
      React.createElement Records,
        records: @state.records,
        handleDeleteRecord: @deleteRecord,
        handleUpdateRecord: @updateRecord

      ...
```

## React.jsの[ライフサイクル](https://facebook.github.io/react/docs/component-specs.html#lifecycle-methods)を利用して、グラフを書く。

React.jsにはライフサイクルメソッドがあり、部品のライフサイクルの中のある時点で処理を実行することができます。Chart.jsは、React.js内部の仮想DOMにはアクセスできないので、部品が実際にDOMに搭載された後にグラフ描写を行います。逆にReact.jsはChart.jsの処理内容を知らないので、DOM更新の度にグラフのオブジェクトを自分で破壊する必要があります。（破壊しないと、更新した後も古いグラフが残ってしまいます。）

```coffee:React.jsのライフサイクルを利用して、グラフを書く
  ...

  # 部品がDOMに搭載された後に、グラフを書く。
  componentDidMount: ->
    @drawCharts()

  # 部品が更新された後に、古いグラフを破壊し新しいグラフを書く。
  componentWillUnmount: ->
    @state.barChartInstance.destroy()
    @state.pieChartInstance.destroy()

  # DOM上のキャンバスを探し、そこにグラフを描画。
  drawCharts: ->
    # 棒グラフ
    canvas = React.findDOMNode(@refs.bar)  # refを手掛かりにキャンバスを探します。
    ctx    = canvas.getContext("2d")       # 絵を書くための場所をゲットします。
    # グラフ用データを渡し、グラフのオブジェクトを作ります。
    # そのオブジェクトを後ほど破壊するためにポインターを保存しておきます。
    @setState.barChartInstance = new Chart(ctx).Bar(@dataForBarChart())

    # 円グラフ
    canvas = React.findDOMNode(@refs.chart)
    ctx    = canvas.getContext("2d")
    @setState.pieChartInstance = new Chart(ctx).Pie(@dataForPieChart())

  # 棒グラフ用データ（これはドキュメンテーションから引用した例）
  # ここで実際は@state.recordsのデータを加工してデータを準備します。
  dataForBarChart: ->
    labels: ["January", "February", "March", "April", "May", "June", "July"]
    datasets: [
      {
        label: "My First dataset"
        fillColor: "rgba(220,220,220,0.5)"
        strokeColor: "rgba(220,220,220,0.8)"
        highlightFill: "rgba(220,220,220,0.75)"
        highlightStroke: "rgba(220,220,220,1)"
        data: [65, 59, 80, 81, 56, 55, 40]
      }
      {
        label: "My Second dataset"
        fillColor: "rgba(151,187,205,0.5)"
        strokeColor: "rgba(151,187,205,0.8)"
        highlightFill: "rgba(151,187,205,0.75)"
        highlightStroke: "rgba(151,187,205,1)"
        data: [28, 48, 40, 19, 86, 27, 90]
      }
    ]

  # 円グラフ用データ（これはドキュメンテーションから引用した例）
  # ここで実際は@state.recordsのデータを加工してデータを準備します。
  dataForPieChart: ->
    [
      {
        value: 300
        color:"#F7464A"
        highlight: "#FF5A5E"
        label: "Red"
      }
      {
        value: 50
        color: "#46BFBD"
        highlight: "#5AD3D1"
        label: "Green"
      }
      {
        value: 100
        color: "#FDB45C"
        highlight: "#FFC870"
        label: "Yellow"
      }
    ]
```
（構造がわかりやすようにするため）上記の例では静的データの一例で置き換えています。
部品の更新をグラフに反映させるためには、動的に@state.recordsを加工してグラフ用データをつくる必要があります。また、グラフの種類によりデータ構造が異なります。詳しくは[ドキュメンテーション](http://www.chartjs.org/docs/)をご覧ください。

例えば、僕はこんな感じでグラフ用のデータを作りました。

```coffeescript:僕はこんな感じでグラフ用のデータを作りました
  dataForPieChart: ->
    source = @volumeSortedBy("room")
    ary = []
    colors = ["#FE2E2E", "#FE9A2E", "#FE9A2E", "#9AFE2E", "#2EFE2E", "#2EFE9A",
              "#2EFEF7", "#2E9AFE", "#2E2EFE", "#9A2EFE", "#FE2EF7", "#FE2E9A"]
    @shuffleArray(colors)
    for item, i in source
      obj =
        value:     item.volume
        color:     colors[i]
        highlight: colors[i]
        label:     item.room
      ary.push(obj)
    ary

  dataForBarChart: ->
    source = @volumeSortedBy("category")
    labels = source.map (obj) -> obj.category
    data   = source.map (obj) -> obj.volume
    datasets = [
        {
          fillColor:       "rgba(151,187,205,0.5)"
          strokeColor:     "rgba(151,187,205,0.8)"
          highlightFill:   "rgba(151,187,205,0.75)"
          highlightStroke: "rgba(151,187,205,1)"
          data:            data
        }
      ]
    { labels: labels, datasets: datasets }
```

#全体像の一例

```coffeescript:App.js.coffee

R = React.DOM

# Canvases for charts
PieChartCanvas = React.createClass
  render: ->
    R.canvas
      style: { height: 200, width: 200 }

BarChartCanvas = React.createClass
  render: ->
    R.canvas
      style: { height: 200, width: 400 }

@MovingRecordsApp = React.createClass

  getInitialState: ->
    records: @props.data
    # Remember Chart.js instances so we can delete them later.
    barChartInstance: null
    pieChartInstance: null

  getDefaultProps: ->
    records: []

  addRecord: (record) ->
    records = React.addons.update(@state.records, { $unshift: [record] })
    @setState records: records

  deleteRecord: (record) ->
    index = @state.records.indexOf record
    records = React.addons.update(@state.records, { $splice: [[index, 1]] })
    @replaceState records: records

  updateRecord: (record, newRecord) ->
    index = @state.records.indexOf record
    records = React.addons.update(@state.records, { $splice: [[index, 1, newRecord]] })
    @replaceState records: records

  chartsPanel: ->
    R.div
      className: "panel panel-blue"
      R.div
        className: 'panel-heading'
        R.div
          className: "row"
          R.div
            className: "col-xs-3"
            R.div
              className: "fa fa-home fa-5x"
          R.div
            className: "col-xs-9 text-right"
            R.div
              className: 'huge'
              "Total: #{@totalVolume()}"
            R.div null,
              "cubic feet"
      R.div
        className: 'panel-body'
        R.div
          className: 'row text-center'
          R.div
            className: 'col-sm-6'
            React.createElement BarChartCanvas,
              ref: "bar"
          R.div
            className: 'col-sm-6'
            React.createElement PieChartCanvas,
              ref: "chart"

  totalVolume: ->
    sum = 0
    for obj in @state.records
      sum += (obj.volume * obj.quantity)
    sum

  render: ->
    R.div
      className: "app_wrapper"
      @chartsPanel()

      R.hr null

      R.h2 null, "Add a new item"
      React.createElement NewMovingRecordForm,
        handleNewRecord: @addRecord
        roomSuggestions: @props.roomSuggestions
        categorySuggestions: @props.categorySuggestions
      R.hr null

      React.createElement Records,
        records: @state.records,
        handleDeleteRecord: @deleteRecord,
        handleUpdateRecord: @updateRecord

  # 部品がDOMに搭載された後に、グラフを書く。
  componentDidMount: ->
    @drawCharts()

  # 部品が更新された後に、古いグラフを破壊し新しいグラフを書く。
  componentWillUnmount: ->
    @state.barChartInstance.destroy()
    @state.pieChartInstance.destroy()

  # DOM上のキャンバスを探し、そこにグラフを描画。
  drawCharts: ->
    # 棒グラフ
    canvas = React.findDOMNode(@refs.bar)  # refを手掛かりにキャンバスを探します。
    ctx    = canvas.getContext("2d")       # 絵を書くための場所をゲットします。
    # グラフ用データを渡し、グラフのオブジェクトを作ります。
    # そのオブジェクトを後ほど破壊するためにポインターを保存しておきます。
    @setState.barChartInstance = new Chart(ctx).Bar(@dataForBarChart())

    # 円グラフ
    canvas = React.findDOMNode(@refs.chart)
    ctx    = canvas.getContext("2d")
    @setState.pieChartInstance = new Chart(ctx).Pie(@dataForPieChart())

  dataForPieChart: ->
    [
      {
        value: 300
        color:"#F7464A"
        highlight: "#FF5A5E"
        label: "Red"
      }
      {
        value: 50
        color: "#46BFBD"
        highlight: "#5AD3D1"
        label: "Green"
      }
      {
        value: 100
        color: "#FDB45C"
        highlight: "#FFC870"
        label: "Yellow"
      }
    ]

  dataForBarChart: ->
    labels: ["January", "February", "March", "April", "May", "June", "July"]
    datasets: [
      {
        label: "My First dataset"
        fillColor: "rgba(220,220,220,0.5)"
        strokeColor: "rgba(220,220,220,0.8)"
        highlightFill: "rgba(220,220,220,0.75)"
        highlightStroke: "rgba(220,220,220,1)"
        data: [65, 59, 80, 81, 56, 55, 40]
      }
      {
        label: "My Second dataset"
        fillColor: "rgba(151,187,205,0.5)"
        strokeColor: "rgba(151,187,205,0.8)"
        highlightFill: "rgba(151,187,205,0.75)"
        highlightStroke: "rgba(151,187,205,1)"
        data: [28, 48, 40, 19, 86, 27, 90]
      }
    ]
```

#苦労したところ
グラフを書き換えるタイミングをライフスタイルのどこで行うのかで最初悩みました。console.logで確認しながら、いろんなパターンを試しました。グラフ更新後に古いグラフが消されていないと、古いグラフが重なってレンダリングされてしまいます。試行錯誤した結果、本件には単純にcomponentDidMountでグラフをセットして、componentWillUnmountでグラフを破壊するとうまくことが判明しました。

#考察
React.jsの部品内部でChart.jsのグラフ描画することにより、ユーザーが入力した結果内容をすぐにグラフに反映させることができることがわかりました。しかも比較的簡単にできました。
現時点では、ひとつのファイルに詰め過ぎていますので、次はグラフの部品を独立した部品としてリファクタリングしようと考えています。

**UPDATE: [ここで学んだ結果に基づき、グラフ部品をカプセル化しました。](http://qiita.com/mnishiguchi/items/aeb6231b405051aba85c)**

#Github
https://github.com/mnishiguchi/InteractiveChartComponent

#参考資料

- http://www.chartjs.org/docs/
- https://facebook.github.io/react/docs/component-specs.html

以上です。