ReactMotionを使って簡単バブルチャート&アニメーション(Redux + d3.js)

  • 12
    いいね
  • 0
    コメント

React+Reduxで開発していて、svg描画したコンポーネントを表示するときにアニメーションをつけたかったのですが、ReactMotionというモジュールで簡単に実装できたので作業ログしておきます。

インストール

$ npm install react-motion --save
$ npm install d3 --save

など。

実装するもの

test.js
[ { name: "機械",        sum: 3 },
  { name: "写真集",      sum: 4 },
  { name: "Web",        sum: 5 },
  { name: "ファンタジー",   sum: 2 },
  { name: "プログラミング", sum: 2 } ]

こんな感じでデータが与えられているときに、円でその割合を視覚化するような画面を実装します。

test.gif

このままぱっと表示すると何かすごそうじゃないので、アニメーションでちょっと小さめの円からスタートして本来の円に収束させました。

実装

外観の実装

計算して画面を埋めるように円を配置するのを手動計算・・とか無理なので、d3.jsを利用します。
まず、基本のContainerを作成。

containers/SampleContainer.jsx
import { connect } from 'react-redux';
import React, { Component,PropTypes } from 'react';
import { bindActionCreators } from 'redux';

import * as sampleActions from '../actions/sample';

class SampleContainer extends Component {

  componentWillMount() {
    this.props.sampleActionBind.setInitCtSvg();
  }

  render() {
    const { windowData, ctSumCircleList } = this.props;

    return (
      <div>
        <svg
          width={windowData.innerWidth}
          height={windowData.innerHeight}
          ref="ctSvg"
          style={{background: 'rgba(124, 224, 249, .3)'}}
        >
          {ctSumCircleList.map( ctSumCircleData => {
              if( ctSumCircleData.depth >= 1 ){
                return (
                    <g
                      transform={"translate("+ctSumCircleData.x+","+ctSumCircleData.y+")"}
                    >
                        <circle
                          r={ctSumCircleData.r - 5}
                          fill={ctSumCircleData.fill}
                        />
                        <text
                          textAnchor="middle"
                          dy=".3em"
                        >
                          {ctSumCircleData._id.name.substring( 0, ctSumCircleData.r / 3 )}
                        </text>
                    </g>
                );
              }
            }
          )}
        </svg>
      </div>
    );
  }
}

SampleContainer.propTypes = {
  windowData: PropTypes.shape({
    innerWidth:  PropTypes.number.isRequired,
    innerHeight: PropTypes.number.isRequired
  }).isRequired,

  isFetching: PropTypes.bool.isRequired
}

function mapStateToProps( state ){
  const { windowData } = state.window;
  const { ctSumCircleList, isFetching } = state.sample;
  return {
    /* window */
    windowData,
    /* sample */
    ctSumCircleList,
    isFetching,
  };
}

function mapDispatchToProps( dispatch ) {
  return {
    sampleActionBind: bindActionCreators(sampleActions, dispatch)
  };
}

export default connect(
  mapStateToProps,
  mapDispatchToProps
)(SampleContainer);

流れは、
1. windowDataはwindowサイズの変更をトリガーとしてstateを変更・保存
2. componentWillMount()のときに必要なデータをDBから取得/d3.jsで計算してstateに保存
3. render()で描画領域をwindowサイズ(1で取得)と同じにして、2で取得した円の中心とか半径とかを使って描画する

みたいな感じです。
ひとつずつやっていきたいと思います。

window幅・高さをstateに保存

こちらのサイト(『React+Reduxをした時のwindowサイズの変更検知』)とほぼ同じ実装なので割愛します。
『React、Redux、D3を用いたアニメーション』を先に見つけていたので、d3.jsを使っている点だけ相違。やっていることは同じです。)

円の描画に関する情報をstateに保存

まず、actionsでd3.jsを使って計算します。
(実際にはDBから情報取得しているけれど、その辺は割愛。。)

actions/sample.jsx
import d3 from 'd3';
import * as types from 'types';

import * as Colors from 'material-ui/styles/colors';

// 円ごとに色を変えるためのconst:material-uiを使う必要性は全くない
const ciecleFills = [
  Colors.lightBlue100,
  Colors.green200,
  Colors.yellow200,
  Colors.purple200,
  Colors.lime200,
  Colors.pink200,
  Colors.orange300,
  Colors.lightGreen300,
  Colors.deepOrange300,
  Colors.blueGrey200,
];

export function setInitCtSvg(){
  return ( dispatch, getState ) => {
    const { innerWidth, innerHeight } = getState().window.windowData;

    // 実際にはDBから情報取得
    const ctSum =
      [ { name: "機械",        sum: 3 },
        { name: "写真集",      sum: 4 },
        { name: "Web",        sum: 5 },
        { name: "ファンタジー",   sum: 2 },
        { name: "プログラミング", sum: 2 } ];

    // d3.jsの仕様にあわせてJsonデータを作成。
    // 詳しくは参考URL『データの視覚化: 第 2 回 D3 のコンポーネント・レイアウトを使用する』参照
    const jsonCtSum = {
      "name": "data",
      "children": ctSum
    };

    // d3.jsで表示する円の情報を自動計算。
    // packで描画サイズ・どの値に基づいて円の配置・サイズを決めるかを渡す
    var pack = d3
                .layout.pack()
                .size([innerWidth, innerHeight])
                .value(function(d) { return d.sum; });
    var packCalculations = pack.nodes(jsonCtSum);

    // 色を手動で入れる
    const pcLen = packCalculations.length;
    for(var i = 1; i < pcLen; i++){
      packCalculations[i]['fill'] = ciecleFills[i % 10];
    }

    // dispatchでstateに保存
    return {
      type: types.SET_CIRCLE_LIST,
      packCalculations
    };
  }
}

reducerもいちおう。。

reducers/index.js
import {
  SET_CIRCLE_LIST
} from 'types';

export default function sample(state = {
  ctSumCircleList: [],
  isFetching: false,
}, action = {}) {
  switch (action.type) {
    case SET_CIRCLE_LIST:
      return Object.assign({}, state, {
        isFetching: false,
        ctSumCircleList: action.packCalculations,
      });
    default:
      return state;
  }
}

これで最初に戻ってctSumCircleListに値が入り、render()で描画される。

アニメーションの実装

ここからめっちゃ簡単です。

containers/SampleContainer.jsx
import { connect } from 'react-redux';
import React, { Component,PropTypes } from 'react';
import { bindActionCreators } from 'redux';

+import { Motion, spring } from 'react-motion';

import * as sampleActions from '../actions/sample';

class SampleContainer extends Component {

  componentWillMount() {
    this.props.sampleActionBind.setInitCtSvg();
  }

  render() {
    const { windowData, ctSumCircleList } = this.props;

    return (
      <div>
        <svg
          width={windowData.innerWidth}
          height={windowData.innerHeight}
          ref="ctSvg"
          style={{background: 'rgba(124, 224, 249, .3)'}}
        >
          {ctSumCircleList.map( ctSumCircleData => {
              if( ctSumCircleData.depth >= 1 ){
+               const startR = ctSumCircleData.r - 20;
+               const endR = ctSumCircleData.r - 1;
                return (
+                   <Motion defaultStyle={{r: startR}} style={{r: spring(endR)}}>
+                   { ( value ) =>
                    <g
                      transform={"translate("+ctSumCircleData.x+","+ctSumCircleData.y+")"}
                    >
                        <circle
-                         r={ctSumCircleData.r}
+                         r={value.r}
                          fill={ctSumCircleData.fill}
                        />
                        <text
                          textAnchor="middle"
                          dy=".3em"
                        >
                          {ctSumCircleData._id.name.substring( 0, ctSumCircleData.r / 3 )}
                        </text>
                    </g>
+                   }
+                   </Motion>
                );
              }
            }
          )}
        </svg>
      </div>
    );
  }
}
(変更ないので以下略)

アニメーション使いたいところを<Motion>タグで囲って、開始(defaultStyle)と終了(style)の値を指定しておくだけで、タグの間に入れたvalueの値を自動でひゅーっと変えていってくれます。
(ひゅーっと変えてくれるのはspring()の部分)
左からフェードインさせたかったらxの値を指定すれば良いし、びっくりするくらい簡単にアニメーションできてしまうという素敵さ!

ちなみに、spring()の第二引数でパラメータを渡すと、動作を変更できます。
spring(value, { stiffness: スピードを数値で, damping: 跳ね具合/数値で(数値が低いほど跳ねる) })
という形です。

例:{stiffness: 320, damping: 10}
10.gif

{stiffness: 320, damping: 20}
20.gif

この例は、中心の円のx,yからスタートして、自分の位置までspring()で移動させています。

components/Circles.jsx
<svg
  width={windowData.innerWidth}
  height={windowData.innerHeight}
>
  <g
    transform={"translate("+mainCircleData.x+","+mainCircleData.y+")"}
  >
    <circle
      r={mainCircleData.r}
      fill="#aaa"
      stroke-width="3"
      stroke="#111"
    />
    <text
      textAnchor="middle"
      dy=".3em"
    >
      { mainCircleData.text }
    </text>
  </g>

  { linkedCircleList.map( linkedCircleData => {
      var startXY = { x: mainCircleData.x, y: mainCircleData.y },
          endXY   = { x: spring(linkedCircleData.x, {stiffness: 320, damping: 20}),
                      y: spring(linkedCircleData.y, {stiffness: 320, damping: 20}) };
      return (
        <Motion
          key={ linkedCircleData._id }
          defaultStyle={ startXY }
          style={ endXY }
        >
          { ( value ) => (
            <g
              transform={"translate("+value.x+","+value.y+")"}
            >
              <circle
                r={linkedCircleData.r}
                fill={linkedCircleData.fill}
              />
              <text
                textAnchor="middle"
                dy=".3em"
              >
                { linkedCircleData.text }
              </text>
            </g>
          ) }
        </Motion>
      );
    } )
  }
</svg>

ReactMotion注意点

タグ内部でonClickを実装する場合、

e.stopPropagation();

を最初に記述しないと刻んだ分だけ処理が実行されてえらいことになるので注意です。

参考URL