はじめに
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個を積み上げています。
ソースコードは以下の通り、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>
'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]
はマス目の一番下の行になります。ここら辺が少し直感に反するところです。
ソースコードは以下の通り変更しました。
'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です。以下ではランダムなデータ配列を左上のマスから順にマッピングしています。結構ヒートマップっぽくなってきました。
ソースコードは以下の通り変更しました。
'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]
なので左上からデータを敷き詰めている場合は注意が必要です。
ソースコードは以下。
//(ここまで変更無いため省略)
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なら実現可能と思いますが、そこまでは試していません。
なお、サーモグラフィっぽい色表現は以下を参考にさせてもらいました。
https://qiita.com/masato_ka/items/c178a53c51364703d70b
また、データは気象庁のサイトからダウンロードしたものを使っています。
ソースコードは以下。
'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) + '℃'
}
}
}
}
});
さいごに
この記事が全国のヒートマッパーの助けになれば幸いです。