第3回 React OSS Rader Scopeのソースコード解説 (3/3)

技術コラム

【第3回】React OSS Radar Scopeのソースコード解説

OSS Radar Scope

3)レーダーコンポーネント

レーダーコンポーネント

今までのコンポーネントはHTMLをレンダリングしていましたが、レーダーコンポーネントはSVG でレンダリングします。HTMLかSVG かの違いだけで、大きな違いはありません。なお、SVG はIE9以降のモダンなブラウザであれば対応していますので、ほとんどの環境で問題なく描画できます。IE6,7,8にも対応させるのであれば、VMLという技術も合わせて使う必要がありますが、今回は対応しません。VML(Vector Markup Language)は、2次元コンピュータグラフィックスをコンピュータ内部で表現するデータ形式であるベクター形式の画像を描画するための XML 言語です。

まず、Radar.jsxを見ていきます。

var React = require('react');
var RadarForeground = require('./RadarForeground');
var RadarBackground = require('./RadarBackground');
var Constants = require('../Constants');
 
var Radar = React.createClass({
  renderfunction() {
    return (
      <svg key="Radar" width={Constants.RADER_WIDTH} height={Constants.RADER_HEIGHT}>
        <RadarBackground yearMonth={this.props.yearMonth} categories={this.props.categories} isChildCategory={this.props.isChildCategory}/>
        <RadarForeground dotPosition={this.props.dotPosition} selectedOssId={this.props.selectedOssId}/>
      </svg>
    );
  }
});
 
module.exports = Radar;

上記のコードをみると分かりますが、svgタグの内部に二つのコンポーネントがあります。

メソッド名 描画するもの
RadarBackground レーダーの背景
RadarForeground ランキングを表すドット

実際の描画はそれぞれのコンポーネント内で行われます。まずは、RadarBackgroundから見ていきましょう。

RadarBackground

background

  renderfunction() {
    return (
      <g>
        { this._circles() }
        { this._borders() }
        { this._ranks() }
        { this._categoryName() }
      </g>
    );
  },

renderメソッドは見ると、4つのメソッドから成り立っています。それぞれを順番に見ていきます。

メソッド名 説明
_circles 背景の円を描画
_borders 円を区切る直線
_ranks 1,2,3,4と描かれている目盛り
_categoryName カテゴリ名

_circlesメソッド

circles

このメソッドは静的に円を4つ描きます。生成されるSVG は下記の通りです。特に難しいことはしていません。

<circle cx="320" cy="330" r="70" fill="none" stroke="#666666" data-reactid=".0.4.0.0.0:$circle-0"></circle>
<circle cx="320" cy="330" r="140" fill="none" stroke="#666666" data-reactid=".0.4.0.0.0:$circle-1"></circle>
<circle cx="320" cy="330" r="210" fill="none" stroke="#666666" data-reactid=".0.4.0.0.0:$circle-2"></circle>
<circle cx="320" cy="330" r="280" fill="none" stroke="#666666" data-reactid=".0.4.0.0.0:$circle-3"></circle>

上記のDOM を生成するための配列を返します。circleタグを配列に詰めて返すだけです。ソースコードは下記の通りです。

_circlesfunction() {
  var circles = [];
  for (var i = 0; i < 4; i++) {
    circles.push(<circle key={"circle-" + i} cx={Constants.RADER_CENTER_X} cy={Constants.RADER_CENTER_Y} r={(+ 1) * Constants.RADER_SPACING} fill={Constants.RADER_FILL} stroke={Constants.RADER_COLOR}></circle>);
  }
  return circles;
},

_bordersメソッド

borders

このメソッドはカテゴリの数によって引く線の数を変更します。カテゴリ名が5つであれば、5本。カテゴリ名が6つであれば、6本です。引く本数によって角度を計算し、描画します。実際に生成されるSVG は下記の通りです。

<path fill="none" stroke="#666666" d="M320,330l1.8369701987210297e-14,-300z" data-reactid=".0.4.0.0.1:$borders-0"></path>
<path fill="none" stroke="#666666" d="M320,330l259.8076211353316,-150z" data-reactid=".0.4.0.0.1:$borders-1"></path>
<path fill="none" stroke="#666666" d="M320,330l259.8076211353316,149.99999999999994z"  data-reactid=".0.4.0.0.1:$borders-2"></path>
<path fill="none" stroke="#666666" d="M320,330l1.8369701987210297e-14,300z" data-reactid=".0.4.0.0.1:$borders-3"></path>
<path fill="none" stroke="#666666" d="M320,330l-259.80762113533154,150.0000000000001z" data-reactid=".0.4.0.0.1:$borders-4"></path>
<path fill="none" stroke="#666666" d="M320,330l-259.8076211353317,-149.99999999999983z" data-reactid=".0.4.0.0.1:$borders-5"></path>

これをソースコードで表すと下記のようになります。このとき、カテゴリの数で描画する線の数は変わるので、カテゴリの数から角度を計算し、三角関数を使用してXY座標に変換します。

_bordersfunction() {
  var borders = [];
  for (var i = 0, len = this.props.categories.length; i < len; i++) {
    var seta = (2 * Math.PI / len) * i;
    var y = Constants.RADER_RADIUS * Math.sin(seta - Math.PI / 2);
    var x = Constants.RADER_RADIUS * Math.cos(seta - Math.PI / 2);
    borders.push(<path key={"borders-" + i} fill={Constants.RADER_FILL} stroke={Constants.RADER_COLOR} d={'M320,330l' + x + ',' + y + 'z'}></path>);
  }
  return borders;
},

_ranksメソッド

ranks

レーダーのランクを表す数字を描画します。これはレーダーによって変わるものではないため、静的に以下のSVG を描画します。

<text x="320" y="295" data-reactid=".0.4.0.0.2:$ranks-1">4</text>
<text x="320" y="225" data-reactid=".0.4.0.0.2:$ranks-2">3</text>
<text x="320" y="155" data-reactid=".0.4.0.0.2:$ranks-3">2</text>
<text x="320" y="85" data-reactid=".0.4.0.0.2:$ranks-4">1</text>

ソースコードは下記の通りです。

_ranksfunction() {
  var ranks = [];
  for (var i = 1; i <= 4; i++) {
    var y = Constants.RADER_CENTER_Y - (Constants.RADER_SPACING * i) + (Constants.RADER_SPACING / 2);
    ranks.push(<text key={"ranks-" + i} className="rank-text" x={Constants.RADER_CENTER_X} y={y} font="10px Arial">{5 - i}</text>);
  }
  return ranks;
},

_categoryNameメソッド

categoryName

カテゴリの数だけカテゴリ名を描画します。実際に描画されるSVG のサンプルは下記の通りです。

<g transform="translate(320,330) rotate(30)" data-reactid=".0.4.0.0.3:$category-label-group-0">
  <text transform="rotate(0,0,-300)" y="-300" stroke="none" fill="#666666" style="font-size:14px;font-family:Arial;text-anchor:middle;" data-reactid=".0.4.0.0.3:$category-label-group-0.$category-label-0">ライブラリ/フレームワーク</text>
</g>
<g transform="translate(320,330) rotate(90)" data-reactid=".0.4.0.0.3:$category-label-group-1">
  <text transform="rotate(0,0,-300)" y="-300" stroke="none" fill="#666666" style="font-size:14px;font-family:Arial;text-anchor:middle;" data-reactid=".0.4.0.0.3:$category-label-group-1.$category-label-1">アプリケーション</text>
</g>
<g transform="translate(320,330) rotate(150)" data-reactid=".0.4.0.0.3:$category-label-group-2">
  <text transform="rotate(180,0,-300)" y="-300" stroke="none" fill="#666666" style="font-size:14px;font-family:Arial;text-anchor:middle;" data-reactid=".0.4.0.0.3:$category-label-group-2.$category-label-2">ツール</text>
</g>
<g transform="translate(320,330) rotate(210)" data-reactid=".0.4.0.0.3:$category-label-group-3">
  <text transform="rotate(180,0,-300)" y="-300" stroke="none" fill="#666666" style="font-size:14px;font-family:Arial;text-anchor:middle;" data-reactid=".0.4.0.0.3:$category-label-group-3.$category-label-3">ミドルウェア</text>
</g>
<g transform="translate(320,330) rotate(270)" data-reactid=".0.4.0.0.3:$category-label-group-4">
  <text transform="rotate(0,0,-300)" y="-300" stroke="none" fill="#666666" style="font-size:14px;font-family:Arial;text-anchor:middle;" data-reactid=".0.4.0.0.3:$category-label-group-4.$category-label-4">プラットフォーム</text>
</g>
<g transform="translate(320,330) rotate(330)" data-reactid=".0.4.0.0.3:$category-label-group-5">
  <text transform="rotate(0,0,-300)" y="-300" stroke="none" fill="#666666" style="font-size:14px;font-family:Arial;text-anchor:middle;" data-reactid=".0.4.0.0.3:$category-label-group-5.$category-label-5">言語処理系</text>
</g>

このとき、先ほど_bordersメソッドと同じように描画した線と線のちょうど中間にカテゴリ名のテキストが描画されるように計算します。

_categoryNamefunction() {
  var _this = this;
  return this.props.categories.map(function(categoryi) {
    var textStyle = {
      fontSize: '14px',
      fontFamily: 'Arial',
      textAnchor: 'middle'
    };
    if (!_this.props.isChildCategory) textStyle.cursor = 'pointer';
    var translate = 'translate(' + Constants.RADER_CENTER_X + ',' + Constants.RADER_CENTER_Y + ')';
    var arc = 360 / _this.props.categories.length;
    var radian = arc * i + arc / 2;
    var rotate = 'rotate(' + radian + ')';
    var transform = translate + ' ' + rotate;
    // 下側に来たラベルは180度回転させる 
    var labelRot = radian > 90 && radian < 270 ? 180 : 0;
    return (
      <g key={'category-label-group-' + i} transform={transform}>
        <text key={'category-label-' + i} className="category-label" data-categoryid={category.id} transform={'rotate(' + labelRot + ',0,-300)'} y="-300" stroke="none" fill="#666666" style={textStyle}>{category.displayName}</text>
      </g>
    );
  });
},

その他のメソッド

componentDidMountメソッドは、このコンポーネントがDOM ツリーに追加された際に実行されます。ここでは、bodyに対してclickイベントを追加しています。bodyタグにclickイベントを割り当てておくことで、下位のDOM に発生したclickイベントを全てここでキャッチすることができます。各要素に対してclickイベントハンドラを割り当てるよりも効率的なため、イベントはなるべく上の階層で受け取るようにするのがベターです。

リスナとして登録している_categoryClickメソッドは下記の通りです。

_onCategoryClickfunction(e) {
  if (e.target.tagName.toUpperCase() !== 'TEXT' || this.props.isChildCategory) return;
  var categoryId = e.target.getAttribute('data-categoryid');
  this.transitionTo(Constants.ROOT_PATH + 'radarScope/category/' + categoryId + '/' + this.props.yearMonth);
},

クリックされたタグがSVG のtextタグであった場合に、画面遷移します。つまり、この場合はカテゴリをクリックされた場合と同義になります。ReactRouterのtransitionToメソッドを実行します。

RadarForeground

foreground

var React = require('react');
var uuid = require('node-uuid');
var Dot = require('./Dot');
var RadarStore = require('../stores/RadarStore');
var Constants = require('../Constants');
 
var RadarForeground = React.createClass({
  renderfunction() {
    var _this = this;
    var positions = this.props.dotPosition;
    var x = {};
    return (
      <g key="foreground">
        {positions.map(function(posi) {
          return <Dot key={'dot-' + pos.product.id} num={pos.num} fill={pos.color} product={pos.product} x={pos.x} y={pos.y} selectedOssId={_this.props.selectedOssId}/>
        })}
      </g>
    );
  },
 
});
 
module.exports = RadarForeground;

RadarForegroundではドットを描画しています。単純にpropsとしてわたされたdotPositionの情報をもとにDotを作成しています。

Dot

Dot

Dotはそれぞれ上記のドット一つ一つを表します。単体で考えるとなにも難しくありません。ただし、Dotコンポーネントはポジションが変わった際に、アニメーションを行います。この機能にはSVG SMIL アニメーション機能のanimateTransformタグを使用していますが、React.jsがこのタグをうまく認識してくれなかったため、componentDidUpdateメソッドでanimateTransformタグを作成します。

実際のDOM は以下のようになります。

<g transform="translate(388 147)" data-reactid=".0.4.0.1.$dot-148">
  <circle r="8" style="fill:#297acc;stroke:#297acc;" data-reactid=".0.4.0.1.$dot-148.$dot-110-circle"></circle>
  <text y="3" style="font-family:Arial;stroke:none;fill:#ffffff;text-anchor:middle;font-size:9px;font-weight:bold;" data-reactid=".0.4.0.1.$dot-148.$dot-110-text">110</text>
  <animateTransform attributeName="transform" type="translate" dur="1s" from="461,195" to="388,147" fill="freeze" begin="indefinite" end="indefinite"></animateTransform>
</g>

また、SMIL に対応していないブラウザでも同じように表示するために、分岐が発生しています。

renderfunction() {
  var circleStyle = {
    fill: this.props.fill,
    stroke: this.props.fill
  };
  var textStyle = {
    fontFamily: 'Arial',
    stroke: 'none',
    fill: '#ffffff',
    textAnchor: 'middle',
    fontSize: '9px',
    fontWeight: 'bold'
  };
  if (!smilSupport || !this._prevX) {
    var transform = 'translate(' + this.props.x + ',' + this.props.y + ')';
  } else {
    var transform = 'translate(' + this._prevX + ',' + this._prevY + ')';
  }
  var key = "dot-" + this.props.num;
  return (
    <g key={key} ref="g" transform={transform}>
      {this._selectedCircle(key)}
      <circle key={key + '-circle'} r="8" style={circleStyle} onMouseOver={this._onMouseOver}></circle>
      <text key={key + '-text'} y="3" style={textStyle} onMouseOver={this._onMouseOver}>{this.props.num}</text>
    </g>
  );
},

animateTransformタグについてはcomponentDidUpdateメソッドで直接DOM を操作しています。

終わりに

業務時間外の時間を費やした1ヶ月でしたが、React.jsを使用してOSS Radar Scope® を再実装することができました(※Internet Explorer へは未対応のため、他のブラウザで表示してください。また、表示されましたら、左の列の"表示月"を指定してみてください)。

React.jsを使用するとあまり複雑化せずに、見通しの良いコードを書くことができると感じています。React.jsを使用した複数人での開発は未経験ですが、コンポーネントを明確に分けることができるので、従来のjQueryよりは開発がしやすいのではないかと思います。今後、実際の開発案件でも使うようになったら良いと思っております。