1. Qiita
  2. 投稿
  3. reactjs

GitHub Contributions Graphの作り方(ロジック成分多め)

  • 13
    いいね
  • 0
    コメント

image

↑のGitHubのContributions Graph(GCG)のようなヒートマップカレンダーを出力するReactコンポーネントのnpmモジュールを作ってみました。

https://github.com/y-takey/react-heat-calendar

使い方なんかはREADMEを見ていただければいいんですが、この記事ではコアのロジック部分を解説してみます。特にReactは関係ないので、他言語や他ライブラリでも実装できると思います。
ちなみにGCGは svg を使っていますが、react-heat-calendarは table タグを使っています。

きっかけ

(この節はポエムなのでスキップ推奨です)

2017年1月初旬にJSer.infoダッシュボードが公開されました。興味深く見ていたのですが、ビジュアル的にちょっと寂しいなーと思い、ヒートマップカレンダーを表示するようにコントリビュートしようと思いつきました。
で最初はダッシュボードのレポジトリ内に実装していたのですが、このコンポーネントは汎化できるなと思い、勉強がてらnpmモジュールにしてみました。

仕様

まずは以下のように仕様を考えました。

  • 入力データは以下のようなデータ(集計されたデータではなく、生のデータを想定)
[
  { date: '2016-12-02T15:00:00.000Z', someAttr: 'foo' },
  { date: '2016-12-03T09:00:00.000Z', someAttr: 'bar' },
  { date: '2016-12-03T21:00:00.000Z', someAttr: 'baz' },
  ...
]
  • 入力データのdateフィールドを基に日毎のデータ件数を集計
  • データ件数に応じて動的に色を決める。(データ件数が多いほど濃い色)
  • 期間を指定可能。指定しない場合は入力データの内、最小・最大の日付
  • ただし出力は指定期間の当該月初〜月末まで
    • 指定期間ジャストだと1週しかない場合に、月の名称を表示するスペースが足りなくて面倒なので。
    • 説明の簡略化のために、以下では指定期間の開始は月初、終了は月末とします。

実装のポイント

集計のやり方

特筆することはなく、手軽に lodash.countBy, Moment.js を使いました。

// this.props.data には冒頭の[{ date: '2016-12-02T15:00:00.000Z', ... }, { date: '2016-12-02T15:00:00.000Z', ... }]のようなデータが入っています。
const summarizedData = countBy(this.props.data, (item) => {
  return moment(item.date).format(DATE_FORMAT);
});

色の決め方

  1. 色の配列を作ります
    • 今回は手軽に定数にしましたが、動的に生成してもOKですし、色数も可変です。
  2. 集計データからデータ件数/日の最大数を取ります
  3. 色数と最大件数から色ごとの最大値を持つ配列を作ります
//             Less                  <                    More
const COLORS = ["#eee", "#d6e685", "#8cc665", "#44a340", "#1e6823"];

const maxNum = Math.max(...Object.values(summarizedData));

// initArrayは第1引数で指定した要素数分、第2引数の関数で初期化された配列を返す自作関数
const limits = initArray(COLORS.length, (_, i) => maxNum * i / (COLORS.length - 1));
// =>たとえば最大件数が20の場合、[0, 5, 10, 15, 20]となり、件数が0件なら#eee, 5件以内なら"#d6e685"...という見方をします

Object.entries(summarizedData).map(([date, count]) => {
  const level = limits.findIndex(num => num >= count)
  return { date, count, level };
})

※最大件数が色数未満の場合はMoreに偏るので、きっちりやるならその辺の微調整も必要です。

カレンダーデータの持ち方

ちょっと微妙かもしれませんが、カレンダーの見た目をそのまま2次元配列として持ちました。
出力する際に扱いやすいように1次元目が曜日(縦)で2次元目が週(横)です。
以下のようにCalendarクラスにしています。

配列を初期化するために、指定期間の週数を求めます。
指定期間の開始日から終了日までの日数を求めて、そこに曜日を考慮して1週間の日数(7日)で除算した結果を切り上げれば週数が求められます。
曜日の考慮とは、例えば開始日が月曜の場合、日曜の1日分が空くからです。つまり開始日の週初(日曜)からの日数という意味です。

const DAYS_IN_A_WEEK = 7;

class Calendar {
  constructor(beginDate, endDate) {
    this.beginDate = moment(beginDate);
    this.endDate = moment(endDate);
    this.data = initArray(DAYS_IN_WEEK, () =>
      initArray(this.weekNum(this.endDate), () => null)
    )
  }

  weekNum(date) {
    if (!moment.isMoment(date)) {
      date = moment(date, DATE_FORMAT);
    }
    const days = date.diff(this.beginDate, "days");
    return Math.ceil((days + this.beginDate.day() + 1) / DAYS_IN_A_WEEK )
  }
}

データのマッピング方法

ここまでで必要なものは揃っているので、マッピングは簡単です。
日付を基に曜日と、指定開始日からの週数を使って2次元配列の該当する箇所に代入します。
先程のCalendarクラスにメソッドを追加します。

class Calendar {
  ~
  set(date, cell) {
    this.data[date.day()][this.weekNum(date) - 1] = cell;
  }
  ~
}

以上が大体の部分で、あとは作った2次元配列をループさせて、tr, tdタグを出していくだけです。

で、できたのが↓こんな感じです。JSer.info Data DashboardのMetaセクションから実物を見られます。

image