Chart.jsでヒートマップを描く

# はじめに
JSでグラフを描く際に頻繁に利用されるChart.js。簡単に綺麗なグラフが描けて便利なのですが、残念ながら普通に使うとヒートマップが描けません。過去にプラグインがあったようですが、更新が止まってしまっており最新のChart.jsでは利用できません。
ここでは、工夫をこらしてChart.jsでヒートマップを描く方法をご紹介します。

# 結論
最初に結論から述べると、積み上げ棒グラフを使ってヒートマップっぽいグラフを作ります。
Chart.jsのIssuesに載っている方法なので以下を読んで、「なるほど、わかった。」という方には以降の解説は不要かと思いますので、これでおしまいです。
https://github.com/chartjs/Chart.js/issues/4627

以降では、Issuesの投稿にもう少し丁寧にサンプルコードを足して紹介します。

# ヒートマップの描き方
## マス目の作成
本来の積み上げ棒グラフの存在意義を無視して、X個全てのデータが「１」のデータセットをY個用意し、積み上げ棒グラフで表示します。その際、X/Y軸の目盛やテキスト、凡例などを全て取り除き、棒グラフの幅をかなり太目に調節すると、以下のようなマス目のグラフが出来上がります。このグラフは20×40のマス目なので、40個のデータを持つデータセット20個を積み上げています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/91332/6e2ef6df-6c5a-d6b2-13c9-3351b6125259.png)
ソースコードは以下の通り、htmlは以降変更無しで使いまわします。

```html:index.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="utf-8">
    <title>Chart.js Heatmap Sample</title>
    <link rel='stylesheet' href='https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.8.0/Chart.min.css'>
    <script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.8.0/Chart.min.js"></script>
</head>
<body>
    <div style="height:400px; width:800px">
        <canvas id="heatMap"></canvas>
    </div>
    <script src="main.js"></script>
</body>
</html>
```

```javascript:main.js
'use strict'

const mapHeight = 20;
const mapWidth = 40;

// データセットの生成
const generateDatasets = function(){
  const datasets = []
  for(let i=0; i<mapHeight; i++){
    datasets.push({
      data: new Array(mapWidth).fill(1),
      borderWidth: 0.2,
      borderColor: "#FFFFFF",
      backgroundColor: 'skyblue'
    })
  }
  return datasets    
}

// データラベルの生成
const generateLabels = function(){
  let labels = []
  for (var i=1; i<mapWidth+1; i++){
    labels.push(i)
  }
  return labels
}

const ctx = document.getElementById('heatMap').getContext('2d')
const heatMap = new Chart(ctx, {
  type: 'bar',
  data: {
    datasets: generateDatasets(),
    labels: generateLabels()
  },
  options: {
    title: {
      display: true,
      text: 'Heat Map Sample',
      fontSize: 18,
    },
    legend: {
      display: false
    },
    scales: {
      xAxes: [{
        gridLines: {
          color: '#FFFFFF',
        },
        barPercentage: 0.99,
        categoryPercentage: 0.99,
        stacked: true,
        ticks: {
          min: 0,
          display: false,
        }
      }],
      yAxes: [{
        gridLines: {
          color: '#FFFFFF',
          zeroLineWidth: 0
        },
        stacked: true,
        ticks: {
          min: 0,
          stepSize: 1,
          display: false
        }
      }]
    },
  }
});
```

このグラフに任意の色を付ければヒートマップの出来上がりです。

## 色の付け方
上記の例では`datasets.backgroundColor`に`skyblue`を固定で渡していますが、この項目は`datasets.data`と同じ長さの配列を渡してやることで、値一つ一つの表示色を変更することができます。以下では、マス目の縦×横の座標の積で色の透過度を変えてグラデーションをかけています。
あくまで"積み上げ"棒グラフなので、`datasets[0]`はマス目の一番下の行になります。ここら辺が少し直感に反するところです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/91332/b1a353b6-c3ae-c33a-6f29-41c5fc0d7a0a.png)
ソースコードは以下の通り変更しました。

```javascript:main.js
'use strict'

const mapHeight = 20;
const mapWidth = 40;
const maxVal = 741;  // 追加

// データセットの生成
const generateDatasets = function(){
  const datasets = []
  for(let i=0; i<mapHeight; i++){
    datasets.push({
      data: new Array(mapWidth).fill(1),
      borderWidth: 0.2,
      borderColor: "#FFFFFF",
      backgroundColor: generateColor(i)   // 変更
    })
  }
  return datasets    
}

// 色配列の生成 (追加）
const generateColor = function(y){
  const datasetColors = []
  for(let x=0; x<mapWidth; x++){
    const opa = ((x * y / maxVal)*0.7 + 0.3).toFixed(2);
    datasetColors.push("rgba(135,206,235," + opa + ")")
  }
  return datasetColors
}

// データラベルの生成
//（以降変更無いため省略）
```

外部からデータが渡された場合、データから任意の色にマッピングしていけばOKです。以下ではランダムなデータ配列を左上のマスから順にマッピングしています。結構ヒートマップっぽくなってきました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/91332/9944438e-cc14-4db0-4b04-db8b0e4a4bc6.png)
ソースコードは以下の通り変更しました。

```javascript:main.js
'use strict'

const mapHeight = 20;
const mapWidth = 40;
// データのランダム生成（追加）
const datalist = (function(){
  const dlist = []
  for(let i=0; i < mapHeight * mapWidth; i++){
    dlist.push(Math.random())
  }
  return dlist
})()


// データセットの生成
// (変更無いため省略）

// 色配列の生成
const generateColor = function(y){
  const datasetColors = []
  for(let x=0; x<mapWidth; x++){
    const opa = ((datalist[x + (mapHeight-y-1) * mapWidth])*0.7 + 0.3).toFixed(2); // 変更
    datasetColors.push("rgba(135,206,235," + opa + ")")
  }
  return datasetColors
}

// データラベルの生成
// (以降変更無いため省略）
```

## ツールチップの付け方
ツールチップはコールバック関数内で処理して表示します。引数として渡される`tooltipItem`、`tooltipItems`にマス目のX/Yのindexが格納されているので、これを利用します。繰り返しになるますが、datasetIndexは一番下の行が`[0]`なので左上からデータを敷き詰めている場合は注意が必要です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/91332/08535e42-37bb-9052-2f24-ee594493a63c.png)
ソースコードは以下。

```javascript:main.js
//(ここまで変更無いため省略）
const ctx = document.getElementById('heatMap').getContext('2d')
const heatMap = new Chart(ctx, {
  type: 'bar',
  data: {
      ...
  },
  options: {
    title: {
      ...
    },
    legend: {
      ...
    },
    scales: {
      ...
    },
    tooltips: {
      callbacks: {
        title: function (tooltipItems, data) {
          const y = mapHeight - tooltipItems[0].datasetIndex - 1
          const x = tooltipItems[0].index
          return x + " x " + y + ": "
        },
        label: function (tooltipItem, data) {
          const y = mapHeight - tooltipItem.datasetIndex - 1
          const x = tooltipItem.index
          const val = datalist[x + y * mapWidth];
          return 'dataVal - ' + val
        }
      }
    }
  }
});
```

# 実用例
2018年の東京の最高気温をサーモグラフィっぽくヒートマップで表現してみました。本当はY軸に上からJan, Feb...とラベルを付けたかったのですが、棒グラフはY軸にカテゴリカルな意味を持たせられないようなので無理でした。おそらくHorizontal Barなら実現可能と思いますが、そこまでは試していません。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/91332/f85af830-3d3f-cbf4-3ee0-c451f6a5a71b.png)
なお、サーモグラフィっぽい色表現は以下を参考にさせてもらいました。
https://qiita.com/masato_ka/items/c178a53c51364703d70b
また、データは気象庁のサイトからダウンロードしたものを使っています。

ソースコードは以下。

```javascript:main.js
'use strict'

const highest_temp = [
  13, 10.8, 8.6, 9.6, 6.3, 10.5, 11.6... //省略
]
// 月ごとの日
const days = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
const mapHeight = 12;
const mapWidth = 31;

// データセットの生成
const generateDatasets = function(){
// （今までと同じなので省略）
}

// 色配列の生成
const generateColor = function(month){
  const datasetColors = []
  for(let date=1; date<mapWidth+1; date++){
    if (date > days[month-1]){
      datasetColors.push("rgba(255,255,255,1)")
    }else{
      const total_days = month == 1 ? 0 : days.slice(0,month-1).reduce((ac,cv)=>ac+cv)
      datasetColors.push(thermo(highest_temp[date + total_days - 1]/40))
    }
  }
  return datasetColors
}

// サーモグラフィの作成
const thermo = function(val){
  const sigmoid = function(x, offset, gain) {
    return ((Math.tanh(((x + offset ) * gain )/2)+1)/2);
  }

  const toHex = function(num) {
    let hex = Math.floor(num).toString(16);

    if (hex.length == 1) {
        hex = '0' + hex;
    }
    return hex;
  }

  val = (val*2) - 1;
  let red = sigmoid(val, -0.2, 10);
  let blue = 1 - sigmoid(val, 0.2, 10);
  let green = sigmoid(val, 0.6, 10) + (1 - sigmoid(val, -0.6, 10))
  green = green - 1.0;

  return '#' + toHex(red*255) + toHex(green*255*0.9) + toHex(blue*255);
}

// データラベルの生成
const generateLabels = function(){
// （今までと同じなので省略）
}

const ctx = document.getElementById('heatMap').getContext('2d')
const heatMap = new Chart(ctx, {
// （今までと同じなので途中まで省略）
    tooltips: {
      callbacks: {
        title: function (tooltipItems, data) {
          const month = mapHeight - tooltipItems[0].datasetIndex
          const date = tooltipItems[0].index + 1
          return date > days[month-1] ? '' : month + '/' + date      
        },
        label: function (tooltipItem, data) {
          const month = mapHeight - tooltipItem.datasetIndex
          const date = tooltipItem.index + 1
          const total_days = month == 1 ? 0 : days.slice(0,month-1).reduce((ac,cv)=>ac+cv)
          return highest_temp[date + total_days - 1].toFixed(1) + '℃'
        }
      }
    }
  }
});
```

# さいごに
この記事が全国のヒートマッパーの助けになれば幸いです。