React+Reduxで開発していて、svg描画したコンポーネントを表示するときにアニメーションをつけたかったのですが、ReactMotionというモジュールで簡単に実装できたので作業ログしておきます。
インストール
$ npm install react-motion --save
$ npm install d3 --save
など。
実装するもの
[ { name: "機械", sum: 3 },
{ name: "写真集", sum: 4 },
{ name: "Web", sum: 5 },
{ name: "ファンタジー", sum: 2 },
{ name: "プログラミング", sum: 2 } ]
こんな感じでデータが与えられているときに、円でその割合を視覚化するような画面を実装します。
このままぱっと表示すると何かすごそうじゃないので、アニメーションでちょっと小さめの円からスタートして本来の円に収束させました。
実装
外観の実装
計算して画面を埋めるように円を配置するのを手動計算・・とか無理なので、d3.jsを利用します。
まず、基本のContainerを作成。
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);
流れは、
- windowDataはwindowサイズの変更をトリガーとしてstateを変更・保存
2.componentWillMount()
のときに必要なデータをDBから取得/d3.jsで計算してstateに保存
3.render()
で描画領域
みたいな感じです。
ひとつずつやっていきたいと思います。
window幅・高さをstateに保存
こちらのサイト(『React+Reduxをした時のwindowサイズの変更検知』)とほぼ同じ実装なので割愛します。
(『React、Redux、D3を用いたアニメーション』を先に見つけていたので、d3.jsを使っている点だけ相違。やっていることは同じです。)
円の描画に関する情報をstateに保存
まず、actions
でd3.jsを使って計算します。
(実際にはDBから情報取得しているけれど、その辺は割愛。。)
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もいちおう。。
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()
で描画される。
アニメーションの実装
ここからめっちゃ簡単です。
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}
この例は、中心の円のx,yからスタートして、自分の位置までspring()で移動させています。
<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();
を最初に記述しないと刻んだ分だけ処理が実行されてえらいことになるので注意です。