GitHub Contributions Graphの作り方（ロジック成分多め）

![image](https://qiita-image-store.s3.amazonaws.com/0/25588/c43cae79-72ff-fea1-83aa-f419e9fef0ef.png)

↑の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](https://jser.info/)の[ダッシュボード](https://jser.info/data-dashboard/)が公開されました。興味深く見ていたのですが、ビジュアル的にちょっと寂しいなーと思い、ヒートマップカレンダーを表示するようにコントリビュートしようと思いつきました。
で最初はダッシュボードのレポジトリ内に実装していたのですが、このコンポーネントは汎化できるなと思い、勉強がてらnpmモジュールにしてみました。

# 仕様

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

* 入力データは以下のようなデータ（集計されたデータではなく、生のデータを想定）

```js
[
  { 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](https://lodash.com/docs/4.17.4#countBy), [Moment.js](https://momentjs.com/) を使いました。

```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ですし、色数も可変です。
1. 集計データからデータ件数/日の最大数を取ります
1. 色数と最大件数から色ごとの最大値を持つ配列を作ります

```js
//             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日分が空くからです。つまり開始日の週初(日曜)からの日数という意味です。

```js
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クラスにメソッドを追加します。

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

---

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

で、できたのが↓こんな感じです。[JSer.info Data Dashboard](https://jser.info/data-dashboard/)のMetaセクションから実物を見られます。

![image](https://qiita-image-store.s3.amazonaws.com/0/25588/0dc65311-daa7-fa8f-9660-b094f24151af.png)