🌱

VueでGitHubの草を実装してみた

2024/08/27に公開

はじめに

こんにちは、hiro です。
現在、個人開発でちょっとしたウェブアプリケーションを開発しています。このアプリケーションの機能の一つとして、コンテンツの投稿機能を実装しています。開発を進める中で、ユーザーが投稿した日付や数を可視化できれば、ユーザーのモチベーション向上につながるのではないかと考えました。

実際、私自身も GitHub の「草」のおかげで、毎日少しでも個人開発にコミットすることができています。
そこで今回、Cal-Heatmap というライブラリを使用して、GitHub の「草」のような機能を実装した過程を、備忘録的に記事にまとめることにしました。
この記事が、同様の機能を実装したい方々の参考になれば幸いです。


デモ画像です。
私のプロジェクトがダークモード非対応なので白ベースですが頑張ればこんな感じになります。
ツールチップや、本家にはない今日の日付を強調することも出来ます。

選定理由

  • Vue に特化したヒートマップ用ライブラリが適切なものがなかった

    • 存在はしているが、Composition API 未対応だったり、メンテナンスが不活発だった
  • 対照的に、React には React-heatmap という整備されたライブラリが存在

    • サードパーティライブラリの充実度が React 採用のメリットの一つだと再認識
  • JavaScript(TypeScript)で記述されており、特定のフレームワークに依存しない

    • ReactVue など、どのフレームワークでも使用可能。今後技術スタックを変更する際にもスムーズに移行できる

そのためこの記事はどちらかというとReactユーザーよりもVueユーザー向けになるかなと思いますが、このライブラリを使用する上で記述法などは大きく変わらないので問題はないかと思います。
https://cal-heatmap.com/

実行環境(採用技術など)

ライブラリ名(バージョン)

  • Vue.js(3.4.15)
    プロジェクト自体は TypeScript,Composition API で記述していますが、本記事では便宜上、JavaScript を使用します。
  • Cal-Heatmap(4.2.4)
    本記事メインのライブラリです
  • Day.js(1.11.11)
    日付操作のために使用しています。
  • Tailwind CSS(3.4.1)
    スタイリングのために使用しています。こちらも詳しい使用法などは解説しないのであらかじめご了承ください。

インストールとか

CDN と NPM に対応しています。(筆者は NPM でインストールしました)

CDN を使用する場合

<head>タグ内に以下の記述をします

index.html
<script src="https://d3js.org/d3.v7.min.js"></script>
<script src="https://unpkg.com/cal-heatmap/dist/cal-heatmap.min.js"></script>
<link rel="stylesheet" href="https://unpkg.com/cal-heatmap/dist/cal-heatmap.css">

NPM を使用する場合

まずはインストール

npm install cal-heatmap

そしてインポート

main.ts
import CalHeatmap from 'cal-heatmap';
import 'cal-heatmap/cal-heatmap.css';

上記のようにエントリーポイントであるファイルに読み込ませるのが一般的かと思います。

私は以下のように、CalHeatmapは使用するコンポーネントで、cal-heatmap/cal-heatmap.cssはエントリーポイントのファイルで読み込みました。

Heatmap.vue
<script setup>
import CalHeatmap from 'cal-heatmap';

// その他の読み込み
</script>
main.js
import 'cal-heatmap/cal-heatmap.css';

基本的な使い方

公式には以下のように記載されています。

  1. Install the script
  2. Insert <div id="cal-heatmap"></div> where you want to render the calendar in your page
  3. Instantiate and paint the calendar with your desired options/plugins

要は、

  1. まずインストールして、(済)
  2. カレンダー(ヒートマップ)を描画したいページに<div id="cal-heatmap"></div>を追加して、(未実施)
  3. カレンダー(ヒートマップ)をインスタンス化し、希望のオプション/プラグインでペイントする。(未実施)

という 3 ステップのみで OK だよ、ということみたいです。

カレンダー(ヒートマップ)を描画したいページを用意する

公式にある通り<div id="cal-heatmap"></div>という記述をカレンダーを描画したい部分に追加します。
id="cal-heatmap" と指定されていますが、この値は後でセレクタとして使用するために指定されています。そのため、必ずしも cal-heatmap である必要はなく、任意の値に変更しても構いません。

Heatmap.vue
<script setup>
import CalHeatmap from 'cal-heatmap';
</script>
<template>
  <div id="cal-heatmap"></div>
</template>

カレンダー(ヒートマップ)をインスタンス化し表示する

3 ステップ目です。
公式には以下のサンプルコードが記載されています。

const cal = new CalHeatmap();
cal.paint({});
render(<div id="cal-heatmap"></div>);

実行している内容は、CalHeatmapクラスをインスタンス化し、paintメソッドを使用してカレンダーを描画します。

私の場合は以下のように記述しました。

Heatmap.vue
<script setup>
import { onMounted } from 'vue';
import CalHeatmap from 'cal-heatmap';

const cal = new CalHeatmap();
onMounted(() => cal.paint({}));
</script>
<template>
  <div id="cal-heatmap"></div>
</template>

結果は以下になります。

CalHeatmapクラスをインスタンス化し、paintメソッドを使用するだけでカレンダーを作成することができましたね。
すごく簡単です。

しかしこのままでは、カレンダーが時間を基準に作成されている上に、UI も GitHub の草には程遠いのでオプションを駆使して修正していこうと思います。

実装したコードの全容

時間がない方はここだけでもご覧になっていって下さい。

data.js
export const heatmapData = [
  { date: '2024-08-01', value: 1 },
  { date: '2024-08-20', value: 4 }
];
Heatmap.vue
<script setup>
import { onMounted } from 'vue';
import { heatmapData } from './data';
import CalHeatmap from 'cal-heatmap';
import Tooltip from 'cal-heatmap/plugins/Tooltip';

const cal = new CalHeatmap();

function paintCalendar() {
  const domain = {
    type: 'month',
    gutter: 2,
    padding: [0, 0, 0, 0],
    label: {
      text: 'M月',
      position: 'top',
      textAlign: 'start',
      offset: {
        x: 0,
        y: 0
      },
      rotate: null
    },
    sort: 'asc'
  };

  const subDomain = {
    type: 'ghDay',
    gutter: 2,
    width: 11,
    height: 11,
    radius: 2,
    label: null
  };

  const date = {
    start: new Date(),
    highlight: [new Date()],
    locale: 'ja',
    timezone: 'Asia/Tokyo'
  };

  const data = {
    source: heatmapData,
    x: 'date',
    y: (d) => +d['value'],
    defaultValue: null
  };

  const scale = {
    color: {
      type: 'threshold',
      range: ['#e6e6e6', '#4dd05a'],
      domain: [0, 1]
    }
  };

  const options = {
    itemSelector: '#cal-heatmap',
    domain,
    subDomain,
    date,
    data,
    scale
  };

  const TOOLTIP_OPTIONS = {
    enabled: true,
    text: (_, value, dayjsDate) => {
      return `${value ?? 0} 件の投稿 ${dayjs(dayjsDate).format('YYYY/MM/DD')}`;
    }
  };

  cal.paint(options, [[Tooltip, TOOLTIP_OPTIONS]]);
}

onMounted(() => paintCalendar());
</script>
<template>
  <div id="cal-heatmap"></div>
</template>

Options

paintメソッドの引数として渡すことのできるオプションは 11 種類あるようです。

オプションの型定義
type Options = {
  itemSelector: Element | string;
  range: number;
  domain: DomainOptions;
  subDomain: SubDomainOptions;
  verticalOrientation: boolean;
  date: DateOptions;
  data: DataOptions;
  label: LabelOptions;
  animationDuration: number;
  scale: ScaleOptions;
  theme: "light" | "dark";
};

https://cal-heatmap.com/docs/options

ただ、今回は GitHub 風のカレンダーヒートマップを実装することが目標なので、実際に使用したオプションのみ紹介・解説していきます。

また、オプションをpaintメソッドの引数にどんどん追加していくのは冗長な記述になってしまうので、optionsというオブジェクトを定義してその中に追加していきたいと思います。
それに伴って、現在は Vue のライフサイクルフックであるonMountedのコールバック関数としてpaintメソッドを呼び出していますが、そのコールバック関数の部分を一つの関数として定義したいと思います。

Heatmap.vue
<script setup>
import { onMounted } from 'vue';
import CalHeatmap from 'cal-heatmap';

const cal = new CalHeatmap();

function paintCalendar() {
  const options = {
    // この中にオプションを追加していく
  };

  cal.paint(options);
}

onMounted(() => paintCalendar());
</script>
<template>
  <div id="cal-heatmap"></div>
</template>

カレンダーを表示する場所を指定する:itemSelector

カレンダーがどこに描画されるかを指定するために使用されます。このオプションでは、カレンダーを挿入する HTML 要素を、要素そのものか W3C セレクタ文字列(例: #my-id.myclass)として指定できます。
デフォルト値は#cal-heatmapです。
そのため今までのサンプルコードでは指定していませんでしたが、カレンダーを表示することができていましたが、便宜上明示的に指定します。

Heatmap.vue
 function paintCalendar() {
   const options = {
+    itemSelector: '#cal-heatmap'
   };

   cal.paint(options);
 }

カレンダーの時間単位を設定する:domain

カレンダーの時間単位ごとのまとまり(画像の赤枠部分)を「ドメイン」と呼び、その設定をするために指定します。

domainオプションの中でも細かくプロパティが分かれています。

ドメインの型定義
type DomainOptions: {
  type: string;
  gutter: number;
  padding: [number, number, number, number];
  dynamicDimension: boolean;
  label: LabelOptions;
  sort: 'asc' | 'desc';
}

今回は以下のような指定を記述しました。

Heatmap.vue
 <script setup>
 function paintCalendar() {
+   const domain = {
+      type: 'month',
+      gutter: 2,
+      padding: [0, 0, 0, 0],
+      label: {
+        text: 'M月',
+        position: 'top',
+        textAlign: 'start',
+        offset: {
+          x: 0,
+          y: 0
+        },
+        rotate: null,
+      },
+      sort: 'asc'
+   }

   const options = {
     itemSelector: '#cal-heatmap',
+    domain,  // domain: domain と同義。変数名とプロパティ名が同じなのでこの記法。
   };

   cal.paint(options);
 }
 </script>

使用しているtype gutter padding label sort についてそれぞれ簡単に解説していきます。

type

時間単位を表すドメインの種類を設定します。
デフォルト値はhourです。
利用可能なドメインの種類は 5 種類あり、文字列で指定します。

  • year(年)
  • month(月)
  • week(週)
  • day(日)
  • hour(時間)

GitHub 風の草を実装するにはmonthを選択します。

Heatmap.vue
 function paintCalendar() {
   const domain = {
+    type: 'month',
   };

   const options = {
    itemSelector: '#cal-heatmap',
    domain
   };

   cal.paint(options);
 }

gutter

各ドメイン間のスペースを設定します。
単位はピクセルで、デフォルト値は4です。

GitHub は月同士の間隔がかなり狭い印象なので、私は2を指定しました。

Heatmap.vue
 function paintCalendar() {
   const domain = {
     type: 'month',
+     gutter: 2,
   };

   const options = {
    itemSelector: '#cal-heatmap',
    domain
   };

   cal.paint(options);
 }

padding

各ドメイン同士のパディングを配列で設定します。
デフォルト値は [0, 0, 0, 0]です。
パディングは不要だと判断したので私はデフォルト値を指定しています。

Heatmap.vue
 function paintCalendar() {
   const domain = {
     type: 'month',
     gutter: 2,
+    padding: [0, 0, 0, 0],
   };

   const options = {
    itemSelector: '#cal-heatmap',
    domain
   };

   cal.paint(options);
 }

label

カレンダーのドメインに対してラベルを表示するための設定を行います。これにより、ラベルの内容や位置、テキストの整列方法、回転などを細かくカスタマイズできます。
labelはオブジェクトで、以下のようなプロパティを持っています。

labelプロパティの型定義
type LabelOptions: {
  text?: string | null | ((timestamp: number, element: SVGElement) => string);
  position: 'top' | 'right' | 'bottom' | 'left',
  textAlign: 'start' | 'middle' | 'end',
  offset: {
    x: number,
    y: number,
  },
  rotate: null | 'left' | 'right',
  width: number,
  height: number,
}
text

ラベルの内容を指定します。
デフォルト値はundefinedです。

値の型 説明 例の値 例の出力
undefined 選択されたタイプに基づいてカレンダーが自動的にラベルを決定 なし March(3 月)
string dayjsのフォーマットを指定し、その結果を表示 MMMM March(3 月)
null ラベルを表示しない null
function 関数の戻り値を表示。関数はドメインのタイムスタンプとラベルの SVG 要素を引数に取る function (timestamp) { return new Date(date).toISOString(); } 2022-12-06T20:01:51.290Z

今回は画像のように「〇〇月」としたいのでtext: 'M月'と指定します。

Heatmap.vue
 function paintCalendar() {
   const domain = {
     type: 'month',
     gutter: 2,
     padding: [0, 0, 0, 0],
+    label: {
+      text: 'M月'
+    },
   };

   const options = {
    itemSelector: '#cal-heatmap',
    domain
   };

   cal.paint(options);
 }
position

ラベルの表示位置を指定します。ドメインに対して上下左右(top bottom left right)の 4 つの位置から選べます。
デフォルト値はbottomです。
私はtopを指定しています。

Heatmap.vue
 function paintCalendar() {
   const domain = {
     type: 'month',
     gutter: 2,
     padding: [0, 0, 0, 0],
     label: {
      text: 'M月',
+     position: 'top',
     },
   };

   const options = {
    itemSelector: '#cal-heatmap',
    domain
   };

   cal.paint(options);
 }
textAlign

ラベルの水平方向のテキスト整列方法を指定します。指定可能なのはstart middle endの 3 種類で、デフォルト値はmiddleです。
私はstartを指定しています。

Heatmap.vue
 function paintCalendar() {
   const domain = {
     type: 'month',
     gutter: 2,
     padding: [0, 0, 0, 0],
     label: {
      text: 'M月',
      position: 'top',
+     textAlign: 'start',
     },
   };

   const options = {
    itemSelector: '#cal-heatmap',
    domain
   };

   cal.paint(options);
 }
offset

ラベルの x 軸と y 軸の位置をピクセル単位で微調整できます。
特に調整は必要ないので私はどちらも0を指定しています。

Heatmap.vue
 function paintCalendar() {
   const domain = {
     type: 'month',
     gutter: 2,
     padding: [0, 0, 0, 0],
     label: {
       text: 'M月',
       position: 'top',
       textAlign: 'start',
+       offset: {
+          x: 0,
+          y: 0
+        },
     },
   };

   const options = {
    itemSelector: '#cal-heatmap',
    domain
   };

   cal.paint(options);
 }
rotate

ラベルを回転させて縦書き表示にするオプションです。

  • null: 回転しない(デフォルト)
  • left: 左に回転(縦書き)
  • right: 右に回転(縦書き)
Heatmap.vue
 function paintCalendar() {
   const domain = {
     type: 'month',
     gutter: 2,
     padding: [0, 0, 0, 0],
     label: {
       text: 'M月',
       position: 'top',
       textAlign: 'start',
       offset: {
          x: 0,
          y: 0
       },
     },
+    rotate: null,
   };

   const options = {
    itemSelector: '#cal-heatmap',
    domain
   };

   cal.paint(options);
 }

sort

ドメインの並び順を昇順(asc)降順(desc)で設定します。
デフォルト値はascです。

Heatmap.vue
 function paintCalendar() {
   const domain = {
     type: 'month',
     gutter: 2,
     padding: [0, 0, 0, 0],
     label: {
       text: 'M月',
       position: 'top',
       textAlign: 'start',
       offset: {
          x: 0,
          y: 0
       },
     },
     rotate: null,
+     sort: asc
   };

   const options = {
    itemSelector: '#cal-heatmap',
    domain
   };

   cal.paint(options);
 }

domain内の子要素を設定する:subDomain

domain内の子要素(画像赤枠)に関する設定ができます。


subDomainオプションの中でも細かくプロパティが分かれています。

サブドメインの型定義
type subDomain: {
  type: string,
  gutter: number,
  width: number,
  height: number,
  radius: number,
  label:
    | string
    | null
    | ((timestamp: number, value: number, element: SVGElement) => string);
  color?:
    | string
    | ((timestamp: number, value: number, backgroundColor: string) => string);
}

前述しているdomainと重複する部分も多数あるのが分かりますね。
重複している内容については解説を省略します。

今回subDomainに関しては以下のように記述しました。

Heatmap.vue
 <script setup>
 function paintCalendar() {
   const domain = {
      type: 'month',
      gutter: 2,
      padding: [0, 0, 0, 0],
      label: {
        text: 'M月',
        position: 'top',
        textAlign: 'start',
        offset: {
          x: 0,
          y: 0
        },
        rotate: null,
      },
      sort: 'asc'
   }

+   const subDomain = {
+     type: 'ghDay',
+     gutter: 2,
+     width: 11,
+     height: 11,
+     radius: 2,
+     label: null
+   };

   const options = {
     itemSelector: '#cal-heatmap',
     domain,
+    subDomain,  // subDomain: subDomain と同義。変数名とプロパティ名が同じなのでこの記法。
   };

   cal.paint(options);
 }
 </script>

typeradiusについて軽く解説します。

type

domainで使用しているtypeと同様の役割ですが、利用可能な種類が若干異なります。

  • month
  • week
  • day
  • hour
  • minute
  • xDay
  • ghDay

使用可能なsubDomainは使用しているdomainの種類に依存するので注意が必要です。
私はghDayというsubDomainを採用しました。
ghDayは、各列が 1 週間を表すようにカレンダーが整列し、各ドメインの開始と終了が月の最初と最後の曜日で丸められます。この設定により、各列の中で欠けている日がないことが保証されます。
ちなみにghDaydomainの種類がmonthのみ許容します。

radius

subDomainのセルの border radius をピクセル単位で指定します。
数値が大きければ大きいほどセルが丸みを帯びます。
デフォルト値は0です。

現段階での表示です。
いい感じに完成に近づいてきてます。

カレンダーの時間境界と設定を指定する:date

このオプションを使うことで、カレンダーがどの期間から開始するか、ナビゲーションの際の最小・最大日付の制限、特定の日付を強調表示するかどうか、タイムゾーンや言語の設定などを細かく制御できます。
dateオプションの中でも細かくプロパティが分かれています。

dateの型定義
type DateOptions: {
  start: Date;
  min?: Date;
  max?: Date;
  highlight?: Date[];
  locale: string | Partial<ILocale>;
  timezone?: string
}

今回は、

  • 今日の日付を強調したい
  • タイムゾーンを日本時間にしたい

という要件を満たすためにhighlight timezoneを指定しています。

Heatmap.vue
 <script setup>
 function paintCalendar() {
   const domain = {
      type: 'month',
      gutter: 2,
      padding: [0, 0, 0, 0],
      label: {
        text: 'M月',
        position: 'top',
        textAlign: 'start',
        offset: {
          x: 0,
          y: 0
        },
        rotate: null,
      },
      sort: 'asc'
   }

   const subDomain = {
     type: 'ghDay',
     gutter: 2,
     width: 11,
     height: 11,
     radius: 2,
     label: null
   };

+   const date = {
+     start: new Date(),
+     highlight: [new Date()],
+     locale: 'ja',
+     timezone: 'Asia/Tokyo'
+   };

   const options = {
     itemSelector: '#cal-heatmap',
     domain,
     subDomain,
+    date // date: date と同義。変数名とプロパティ名が同じなのでこの記法。
   };

   cal.paint(options);
 }
 </script>

使用しているstart highlight locale timezone についてそれぞれ簡単に解説していきます。

start

カレンダーの開始日を設定します。
デフォルト値はnew Date()(今日の日付)です。
特定の日付を指定したい場合は、new Date('2024-01-01')のように指定できます。

highlight

強調したい日付の配列を指定します。 強調されたサブドメインセルは、目立つように特別なクラスが与えられます。
デフォルト値は[](空の配列)です。
強調したい日付を Date 型で配列の要素として指定すれば強調できます。

sample.js
const cal = new CalHeatmap();
cal.paint({
  date: {
    highlight: [
      new Date('2020-01-15'),
      new Date(), // Highlight today
    ],
  },
});

highlightのおかげで今日の日付が強調されています。

locale

カレンダーの言語や地域に合わせた日付フォーマットや機能を設定するために使用されます。このオプションにより、曜日の開始日(例: 月曜日や日曜日)など、地域ごとに異なる表示や動作を制御できます。
locale は、カレンダーの基盤となる Day.js ライブラリを通じて動作し、週の開始日、月や曜日の名前、相対時間(例えば「1 日前」など)といったロケール固有の機能を設定します。
locale に文字列を渡すことで、対応する言語にカレンダーの表示が切り替わります。例えば、en(英語)やfr(フランス語)など、Day.js ライブラリがサポートするロケールを指定することが可能です。
デフォルト値はen英語です。

sample.js
const cal = new CalHeatmap();
cal.paint({
  date: { locale: 'fr' },  // フランス語に設定
});

対応している言語は以下のリポジトリで確認できます。
https://github.com/iamkun/dayjs/tree/dev/src/locale

timezone

カレンダーで表示される日付や時間に対して特定のタイムゾーンを設定するために使用されます。デフォルトでは、ユーザーのブラウザから推測されたタイムゾーンが自動的に適用されますが、特定のタイムゾーンを手動で設定することもできます。

sample.js
const cal = new CalHeatmap();
cal.paint({
  date: {  timezone: 'Asia/Tokyo' },
});

使用可能なタイムゾーンは以下を参考にしてください。
https://en.wikipedia.org/wiki/List_of_tz_database_time_zones

カレンダーに表示するデータを取得し、処理する: data

カレンダーに表示するためのデータを取得したり、処理したりするために指定します。

dataの型定義
type DataRecord = Record<string, string | number>;
type DataGroupType = 'sum' | 'count' | 'min' | 'max' | 'average';

type DataOptions = {
  source: string | DataRecord[],
  type: 'json' | 'csv' | 'tsv' | 'txt',
  requestInit: object,
  x: string | ((datum: DataRecord) => number),
  y: string | ((datum: DataRecord) => number),
  groupY:
    | DataGroupType
    | ((values: (number | string | null)[]) => number | string | null),
  defaultValue: null | number | string,
};

サンプルのデータを作成してエクスポートし、コンポーネントでインポートします。

data.js
export const heatmapData = [
  { date: '2024-08-01', value: 1 },
  { date: '2024-08-20', value: 4 }
];
Heatmap.vue
 <script setup>
+ import { heatmapData } from './data';

 function paintCalendar() {
   const domain = {
      type: 'month',
      gutter: 2,
      padding: [0, 0, 0, 0],
      label: {
        text: 'M月',
        position: 'top',
        textAlign: 'start',
        offset: {
          x: 0,
          y: 0
        },
        rotate: null,
      },
      sort: 'asc'
   }

   const subDomain = {
     type: 'ghDay',
     gutter: 2,
     width: 11,
     height: 11,
     radius: 2,
     label: null
   };

   const date = {
     start: new Date(),
     highlight: [new Date()],
     locale: 'ja',
     timezone: 'Asia/Tokyo'
   };

+  const data = {
+    source: heatmapData,
+    x: 'date',
+    y: (d) => +d['value'],
+    defaultValue: null
+  };

   const options = {
     itemSelector: '#cal-heatmap',
     domain,
     subDomain,
     date,
+    data // data: data と同義。変数名とプロパティ名が同じなのでこの記法。
   };

   cal.paint(options);
 }
 </script>

source

使用するデータを文字列(URL など)または配列で指定します。
指定方法は上記のようにローカルのデータを参照する方法と、URL を指定してリモートリソースを参照する方法があります。
以下はリモートリソースを参照する場合の記述方法です。

sample.js
const cal = new CalHeatmap();
cal.paint({
  data: { source: 'https://your-api.com/data.json' },
});

x

データセットの中から、どのプロパティが日付を表しているか、またはどのように日付を計算するかをカレンダーに伝えるためのオプションです。
文字列またはタイムスタンプを返す関数を指定することができます。

以下はxを文字列で指定してあり、column1というプロパティが日付を表しているよ、というのをカレンダーに伝えています。

sample.js
const data = [{ column1: '2012-01-01', column2: 3 }];

cal.paint({
  data: { source: data, x: 'column1' },
});

以下はxにタイムスタンプを返す関数を指定してあり、column1: '2012-01-01'を元にタイムスタンプを返しています。

sample.js
const data = [{ column1: '2012-01-01', column2: 3 }];

cal.paint({
  data: {
    source: data,
    x: datum => {
      return +new Date(datum['column1']);
    },
  },
});

y

データセットの中から、どのプロパティが値を表しているか、またはどのように値を計算するかをカレンダーに伝えるためのオプションです。値として数値または文字列を返すことができます。

  • 文字列:オブジェクト内の値を持つプロパティ名を指定します。カレンダーは、そのプロパティから値を抽出します。
  • 関数:データセットの各オブジェクト(datum)を引数として取り、そのオブジェクトから数値または文字列として返す関数を指定します。

以下はyを文字列で指定してあり、column2というプロパティに対応している3を取得しています。

sample.js
const data = [{ column1: '2012-01-01', column2: 3 }];

cal.paint({
  data: { source: data, y: 'column2' },
});

以下はyに関数を指定してあり、カレンダーには high と low の平均値(この場合、30 + 16 = 46 を 2 で割った 23)がその日付の値として表示されます。つまり、この処理では、複数の値を使って 1 つのデータポイントの値を計算し、その結果をカレンダーに反映しています。

sample.js
const data = [{ date: '2012-01-01', high: '30', low: '16' }];

cal.paint({
  data: {
    source: data,
    y: datum => {
      return +datum['high'] + +datum['low']) / 2;
    },
  },
});

defaultValue

データセットに日付の値がない場合のデフォルト値を数値または文字列で指定します。
デフォルト値はnullです。

ここまでの記述で以下のようになっているかと思います。
若干分かりづらいですが、data内の日付に対応するセルの色が変わっているのが分かります。

カレンダーの色を設定する: scale

いよいよ最終局面です。
このオプションを使うことでカレンダーに色をつけることができ、データのあるセルは色がつくようになります。
任意の色を指定することができるので、GitHub 風に緑色になるように指定していきます。

scaleの型定義
type scaleOptions = {
  opacity?: {
    baseColor: string,
    domain: number[],
    type: string,
  },
  color?: {
    scheme: string,
    range: string[] | d3-scale-chromatic,
    interpolate: string | d3-interpolator,
    domain: number[],
    type: string,
  },
};

今回私は以下のような指定をしました。

Heatmap.vue
 <script setup>
 function paintCalendar() {
   const domain = {
      type: 'month',
      gutter: 2,
      padding: [0, 0, 0, 0],
      label: {
        text: 'M月',
        position: 'top',
        textAlign: 'start',
        offset: {
          x: 0,
          y: 0
        },
        rotate: null,
      },
      sort: 'asc'
   }

   const subDomain = {
     type: 'ghDay',
     gutter: 2,
     width: 11,
     height: 11,
     radius: 2,
     label: null
   };

   const date = {
     start: new Date(),
     highlight: [new Date()],
     locale: 'ja',
     timezone: 'Asia/Tokyo'
   };

  const data = {
    source: heatmapData,
    x: 'date',
    y: (d) => +d['value'],
    defaultValue: null
  };

+ const scale = {
+   color: {
+    range: ['#e6e6e6', '#4dd05a'],
+    domain: [0, 1],
+    type: 'threshold',
+   }
+ };

   const options = {
     itemSelector: '#cal-heatmap',
     domain,
     subDomain,
     date,
     data,
+    scale // scale: scale と同義。変数名とプロパティ名が同じなのでこの記法。
   };

   cal.paint(options);
 }
 </script>

color

2 色以上の色を使ってデータセットを表現します。
種類によって、連続的な色の範囲、または離散的な色のセットを使用します。

range

独自のカラーパレットを定義するために、色の配列、または d3 配色関数を指定します。

  • 配列を指定する場合
    色の配列を使用する場合、最低 2 色が必要です。 認識される色名は、色の名称(red)、16 進カラーコード(#ff0000)、rgb(rgb(0, 0, 255))など、CSS が受け付ける任意の値です。

    sample.js
    const cal = new CalHeatmap();
    cal.paint({
      scale: {
        color: {
          range: ['red', '#0000FF'],
        },
      },
    });
    
  • d3 の配色関数を指定する場合

    以下に定義されているものを使用することができます。
    https://d3js.org/d3-scale-chromatic/diverging#schemeSpectral

    sample.js
    const cal = new CalHeatmap();
    cal.paint({
      scale: {
        color: {
          range: d3.schemeSpectral[3],
        },
      },
    });
    

今回はデータが 1 件もなければ白、1 件以上あれば緑色、というような仕様にしたいので以下を指定しました。

Heatmap.vue
scale: {
  color: {
    range: ['#e6e6e6', '#4dd05a'],
  }
}

domain

少なくとも 2 つの値からなる配列で、データセットの最小値と最大値を指定します。
後に説明するtypethreshold の型を使用する場合、異なる threshold のリストでなければなりません。

type

どのように色を割り当てるかを制御することができます。
指定可能なオプションは全部で 17 種類あり、例えばlinearを指定すると色の変化がデータ値に対して線形に行われ、データの最小値から最大値までが連続して変化するようなものに向いています。温度データを 0 度から 30 度まで連続的に変化する色で表現する場合などにぴったりです。

対してordinalを指定すると離散的なカテゴリーデータに対して色を割り当てることができ、各月ごとに異なる色を割り当てて表示するなど順序がないデータに最適です。

今回は指定された閾値に基づいて、データを複数の範囲に分割し、各範囲に異なる色を割り当てたいのでthresholdを指定しました。
thresholdを使用することで閾値によって適用したい色を変更できるので便利です。
分かりづらいと思うので、以下のコードで解説します。

sample.js
color: {
  type: 'threshold',
  range: ['#e6e6e6', '#4dd05a', '', 'red'],
  domain: [10, 20, 30],
},

上記の例では

  • range#e6e6e6(白)、#4dd05a(緑)、''(空なのでデフォルト)、red(赤)を指定し、
  • domain[10, 20, 30]を指定しています。

これはデータの数値が

  • 0 ~ 10 の範囲 => #e6e6e6
  • 11 ~ 20 の範囲 => #4dd05a
  • 21 ~ 30 の範囲 => ' ' デフォルトの色
  • 31 より大きい 範囲 => red

のように範囲ごとに色を変えることができます。

無事に色が変わりましたね 👏

9 割完成ですが最後の仕上げでツールチップも実装します。
必要ない方はここで読み終えていただいても大丈夫です!
お疲れ様でした!

Plugins

ツールチップを使用するためにはプラグインを使用する必要があります。

インストール

CDN を使用する場合

index.html
<script src="https://unpkg.com/@popperjs/core@2"></script>
<script src="https://unpkg.com/cal-heatmap/dist/plugins/Tooltip.min.js"></script>

NPM を使用する場合

このプラグインは CalHeatmap のコアに組み込まれているのでプラグインを使用したいコンポーネントで import するだけで使用できます。

使用したいコンポーネントファイル
import Tooltip from 'cal-heatmap/plugins/Tooltip';

使い方

使い方も非常に簡単で、paintメソッドの第二引数に配列として渡すだけで使用できます。

const cal = new CalHeatmap();
cal.paint({}, [[Tooltip, TOOLTIP_OPTIONS]]);

ツールチップ実装後のコード

Heatmap.vue
 <script setup>
+ import Tooltip from 'cal-heatmap/plugins/Tooltip';

 function paintCalendar() {
   const domain = {
      type: 'month',
      gutter: 2,
      padding: [0, 0, 0, 0],
      label: {
        text: 'M月',
        position: 'top',
        textAlign: 'start',
        offset: {
          x: 0,
          y: 0
        },
        rotate: null,
      },
      sort: 'asc'
   }

   const subDomain = {
     type: 'ghDay',
     gutter: 2,
     width: 11,
     height: 11,
     radius: 2,
     label: null
   };

   const date = {
     start: new Date(),
     highlight: [new Date()],
     locale: 'ja',
     timezone: 'Asia/Tokyo'
   };

  const data = {
    source: heatmapData,
    x: 'date',
    y: (d) => +d['value'],
    defaultValue: null
  };

  const scale = {
    color: {
     range: ['#e6e6e6', '#4dd05a'],
     domain: [1],
     type: 'threshold',
    }
  };

  const options = {
    itemSelector: '#cal-heatmap',
    domain,
    subDomain,
    date,
    data,
    scale
  };

+  const TOOLTIP_OPTIONS: TooltipOptions = {
+    enabled: true,
+    text: (_, value, dayjsDate) => {
+      return `${value ?? 0} 件の投稿 ${dayjs(dayjsDate).format('YYYY/MM/DD')}`;
+    }
+  };

+  cal.paint(options, [[Tooltip, TOOLTIP_OPTIONS]]);
 }
 </script>

TooltipOptions

ツールチップのオプションを設定できます。

ツールチップオプションの型定義
interface TooltipOptions extends PluginOptions, PopperOptions {
  enabled: boolean;
  text: (timestamp: number, value: number, dayjsDate: dayjs.Dayjs) => string;
}

enabled

ツールチップを有効にするかどうかを設定でき、デフォルト値はtrueです。
ツールチップの UI をカスタマイズするには、CSS で#ch-tooltipを探します。

text

以下 3 つの引数を受け取り、ツールチップの内容を返す関数を指定します。

  • timestamp: 現在のサブドメインのタイムスタンプ。単位はミリ秒。
  • value: 現在のサブドメインの値。
  • dayjsDate: ロケール/タイムゾーンを意識した dayjs オブジェクトで、日付の操作と書式設定を容易にするために提供される。

今回は以下のような記述を行いました。

text: (_, value, dayjsDate) => {
  return `${value ?? 0} 件の投稿 ${dayjs(dayjsDate).format("YYYY/MM/DD")}`;
};

データがなければ「0 件の投稿 ○○○○/○○/○○」
データがあれば 「○ 件の投稿 ○○○○/○○/○○」
というツールチップが表示されるようになります。

ちなみに第一引数の_はこの引数は使用しないけど、構文上必要なので置いておくような場合に使われます。
これも tips として覚えておきましょう。


本当にお疲れ様でした!
ツールチップまで表示できていれば完成です。

最後に

ここまで読んでくださってありがとうございました。
自分でもびっくりしておりますが、ここまで長い記事になるとは思っていませんでした。

ただ、記事のボリュームと裏腹に、実装のハードルはそこまで高くないです。

Cal-heatmap かなりいいのでぜひ個人開発やプロダクトに採用してみてください!
https://cal-heatmap.com/

おまけ

色々な実装例が showcase に記載されているのでぜひ参考にされて下さい。
https://cal-heatmap.com/docs/showcase

Discussion