Edited at

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

More than 1 year has passed since last update.

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