32
21

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Last updated at Posted at 2017-01-11

image

↑の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);
});

色の決め方

  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

32
21
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
32
21

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?