↑のGitHubのContributions Graph(GCG)のようなヒートマップカレンダーを出力するReactコンポーネントのnpmモジュールを作ってみました。
使い方なんかは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);
});
色の決め方
- 色の配列を作ります
- 今回は手軽に定数にしましたが、動的に生成してもOKですし、色数も可変です。
- 集計データからデータ件数/日の最大数を取ります
- 色数と最大件数から色ごとの最大値を持つ配列を作ります
// 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セクションから実物を見られます。