29
30

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.

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

Last updated at Posted at 2019-08-14

はじめに

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

結論

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

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

ヒートマップの描き方

マス目の作成

本来の積み上げ棒グラフの存在意義を無視して、X個全てのデータが「1」のデータセットをY個用意し、積み上げ棒グラフで表示します。その際、X/Y軸の目盛やテキスト、凡例などを全て取り除き、棒グラフの幅をかなり太目に調節すると、以下のようなマス目のグラフが出来上がります。このグラフは20×40のマス目なので、40個のデータを持つデータセット20個を積み上げています。
image.png
ソースコードは以下の通り、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>
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.backgroundColorskyblueを固定で渡していますが、この項目はdatasets.dataと同じ長さの配列を渡してやることで、値一つ一つの表示色を変更することができます。以下では、マス目の縦×横の座標の積で色の透過度を変えてグラデーションをかけています。
あくまで"積み上げ"棒グラフなので、datasets[0]はマス目の一番下の行になります。ここら辺が少し直感に反するところです。
image.png
ソースコードは以下の通り変更しました。

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
ソースコードは以下の通り変更しました。

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
}

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

ツールチップの付け方

ツールチップはコールバック関数内で処理して表示します。引数として渡されるtooltipItemtooltipItemsにマス目のX/Yのindexが格納されているので、これを利用します。繰り返しになるますが、datasetIndexは一番下の行が[0]なので左上からデータを敷き詰めている場合は注意が必要です。
image.png
ソースコードは以下。

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.com/masato_ka/items/c178a53c51364703d70b
また、データは気象庁のサイトからダウンロードしたものを使っています。

ソースコードは以下。

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) + ''
        }
      }
    }
  }
});

さいごに

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

29
30
1

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
29
30

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?