Ptengineのヒートマップ描画ロジックの探求：JavaScript実装の要点

ウェブサイトの分析では、ヒートマップは一般的なデータ可視化ツールであり、ユーザーのウェブページ上での行動の熱度と分布を直感的に示すことができます。プロフェッショナルなウェブサイト分析ツールであるPtengineのヒートマップ機能は非常に強力です。本記事では、Ptengineのヒートマップ描画ロジックを詳しく探り、クリックヒートマップを例にとり、JavaScriptコードで実装します。前提として、中級レベルのJavaScriptとCanvasの知識を持っていることが必要です。
![1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3600361/e3af24c0-76e3-aaad-e4b4-397a7f6dfe09.png)
以下のクリックヒートマップの例は、[Ptengine Heatmap Demo](https://www.ptengine.jp/demo/feature-highlights?ptengine_heatmap_token=4611f55d2e80d966a53881972693f49c5&pt_lang=jp&pt_smart_report=3&pt_sid=566d12f9&utm_source=qita&utm_medium=article&utm_campaign=heatmap)で確認できます。

# 描画前の準備

## テストデータ

ヒートマップを描く前に、対応するデータを準備する必要があります。Ptengineのクリックヒートマップデータは、クライアントページでのクリック行動の記録から取得されます。具体的なデータ収集ロジックはここでは詳述しませんが、以下のデータを取得したと仮定し、このデータ構造に従って描画します：
```javascript
/**
 * x: 座標 X
 * y: 座標 Y
 * v: クリック数 
 */
const clickData = [
	{x: 100, y: 100, v: 1},
    {x: 110, y: 110, v: 2},
	{x: 160, y: 100, v: 4},
	{x: 220, y: 100, v: 1},
];
```
## カラースキーム

カラースキームはヒートマップの重要な部分で、通常は暖色から寒色へのグラデーションを使用します。通常、赤色は最も熱い領域を、青色は最も冷たい領域を表します。以下はPtengine Heatmapのカラースキームの例です：

```javascript
const palette = {
  '0.45': 'rgb(0, 0, 255)',   // 冷色、青色
  '0.55': 'rgb(0, 255, 255)', // 冷暖の過渡、シアン
  '0.65': 'rgb(0, 255, 0)',   // 中性、緑色
  '0.9': 'rgb(255, 255, 0)',  // 暖色、黄色
  '1.0': 'rgb(255, 0, 0)'     // 熱色、赤色
};
```
## HTML構造

次に、ヒートマップを描画するための基本的なHTML構造を作成します。Canvas要素を使用して描画します：

```html
<!DOCTYPE html>
<html>
<head>
  <title>Heatmap Example</title>
</head>
<body>
  <canvas id="heatmapCanvas" width="500" height="500"></canvas>
  <canvas id="paletteCanvas" width="10" height="256"></canvas>
  
  <script src="heatmap.js"></script>
</body>
</html>
```
```javascript
// heatmap.js

const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
```

# 円を描く

なぜ円形を選ぶのか？ 詳細は [Ptengine Heatmap Demo](https://www.ptengine.jp/demo/feature-highlights?ptengine_heatmap_token=4611f55d2e80d966a53881972693f49c5&pt_lang=jp&pt_smart_report=3&pt_sid=566d12f9&utm_source=qita&utm_medium=article&utm_campaign=heatmap) をご覧ください。クリックヒートマップはクリックした座標位置に基づいて、色のグラデーションが付いた円形を描いていることがわかります。したがって、まずグラデーションカラーの円を描く方法を学ぶ必要があります。

Canvasで円を描くのは比較的簡単で、方法はたくさんあります、例えば: [arc](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/arc)、[`arcTo`](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/arcTo)、[`bezierCurveTo`](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/bezierCurveTo) 。

以下は具体的な実装コードと描画効果です。
![2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3600361/5361ce35-3101-2b8e-1dea-7728b3639cba.png)
```javascript
// heatmap.js

useArc(100, 100, 20);
useArcTo(140, 80, 20, 40, 40);
useBezierCurveTo(220, 100, 20, 10);

// arc
function useArc(x, y, radius){
	ctx.beginPath();
	ctx.arc(x, y, radius, 0, Math.PI * 2);
	ctx.closePath();
	ctx.fill();
}

// arcTo
function useArcTo(x, y, radius){
	ctx.beginPath();
	ctx.moveTo(x + radius, y);
	ctx.arcTo(x + width, y, x + width, y + height, radius);
	ctx.arcTo(x + width, y + height, x, y + height, radius);
	ctx.arcTo(x, y + height, x, y, radius);
	ctx.arcTo(x, y, x + width, y, radius);
	ctx.closePath();
	ctx.fill();
}

// bezierCurveTo
function useBezierCurveTo(x, y, radius, controlPoint){
	ctx.beginPath();
	ctx.moveTo(x + radius, y);
	ctx.bezierCurveTo(x + radius, y - controlPoint, x + controlPoint, y - radius, x, y - radius);
	ctx.bezierCurveTo(x - controlPoint, y - radius, x - radius, y - controlPoint, x - radius, y);
	ctx.bezierCurveTo(x - radius, y + controlPoint, x - controlPoint, y + radius, x, y + radius);
	ctx.bezierCurveTo(x + controlPoint, y + radius, x + radius, y + controlPoint, x + radius, y);
	ctx.closePath();
	ctx.fill();
}
```
# ホットスポットの描画

円形ができたら、次はグラデーションカラーを追加してホットスポットのスタイルにします。[**`createRadialGradient`**](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/createRadialGradient) メソッドを利用して放射状グラデーションを描くことができます。このメソッドは円形にグラデーション効果を追加するための径向グラデーションオブジェクトを作成します。

以下は、円形の描画に基づいてグラデーションカラーを追加するコード例です。以前の[配色スキーム](#カラースキーム)を参考にしてください。
![3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3600361/c48450c0-046d-fdb7-7c3e-17ac59a02fed.png)
```javascript
// heatmap.js

function getRadialGradient(ctx, x, y, radius) {
  const gradient = ctx.createRadialGradient(x, y, 0, x, y, radius);
  gradient.addColorStop(0, 'rgba(255, 0, 0, 0.75)');
  gradient.addColorStop(0.3, 'rgba(255, 255, 0, 0.75)');
  gradient.addColorStop(0.45, 'rgba(0, 255, 0, 0.75)');
  gradient.addColorStop(0.55, 'rgba(0, 255, 255, 0.75)');
  gradient.addColorStop(0.65, 'rgba(0, 0, 255, 0.75)');
  gradient.addColorStop(1, 'rgba(0, 0, 255, 0)');
  return gradient;
}

function useArc(x, y, radius, fillStyle){
	ctx.beginPath();
	ctx.arc(x, y, radius, 0, Math.PI * 2);
	ctx.closePath();
  fillStyle && (ctx.fillStyle = fillStyle);
	ctx.fill();
}

// 円形の描画を呼び出し、円形のスタイルを引数として渡します。
useArc(100, 100, 20, getRadialGradient(ctx, 100, 100, 20));
```
# すべてのホットスポットを描く

ホットスポットを1つ描くことに成功したので、次に[テストデータ](#テストデータ)に基づいてすべてのホットスポットを生成する必要があります。以下は、データを遍歴してすべてのホットスポットを描く方法のコード例です：
![4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3600361/ada42e14-3e45-6c2e-6bab-645e5dfdb978.png)
```javascript
// heatmap.js

function drawHeatmap(){
  const clickData = [
    {x: 100, y: 100, v: 1},
    {x: 110, y: 110, v: 2},
    {x: 160, y: 100, v: 4},
    {x: 220, y: 100, v: 1},
  ];
  
  clickData.forEach(point => {
    useArc(point.x, point.y, 20, getRadialGradient(ctx, point.x, point.y, 20));
  });
}

drawHeatmap();
```
# **ヒートマップ描画ロジックの最適化**

先ほどの結果からわかるように、コードはシンプルですが明確な問題があります。

**問題1：** ヒートマップが重なると色の融合が不足しています。

**問題2：** クリック数がヒートマップの描画ロジックに反映されていません。

これらの問題を解決するために、ヒートマップの描画ロジックを再考する必要があります。一度に描画と着色を行わず、重なりを処理した後、カラースキームに基づいて描画する必要があります。

## **重なり描画効果の実現**

重なり描画の問題を解決するために、色が単純に重なるだけでなく、色が混ざらないように透明度を使用して一貫性のある色を保持する必要があります。最初に透明度の付いた単色のヒートマップを描画し、ここでは黒色を使用します。以下に、調整後の効果とコードを示します：
![5.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3600361/af50f076-6753-762e-9c52-0195e43e1591.png)
```javascript
// heatmap.js

function drawHeatmapV2(){
  const clickData = [
    {x: 100, y: 100, v: 1},
    {x: 110, y: 110, v: 2},
    {x: 160, y: 100, v: 4},
    {x: 220, y: 100, v: 1},
  ];
  
  clickData.forEach(point => {
    var gradient = ctx.createRadialGradient(point.x, point.y, 3, point.x, point.y, 20);
    gradient.addColorStop(0, 'rgba(0, 0, 0, 1)');
    gradient.addColorStop(1, 'rgba(0, 0, 0, 0)');
    
    useArc(point.x, point.y, 20, gradient);
  });
}

drawHeatmapV2();
```
## **V値の計算を描画ロジックに組み込む**

これまでのコードで色の一貫性を確保し、クリック数に応じて透明度を調整して透明度の重ね合わせ効果を実現しました。しかし、[テストデータ](#テストデータ)によると、すべてのホットスポットの透明度が同じであるべきではありません。クリック数が最も多い場所の透明度が最大であり、逆もまた然りです。したがって、コードを最適化し、クリック数（V値）を描画ロジックに組み込む必要があります。具体的な効果とコードは以下の通りです：
![6.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3600361/390da2f4-a392-701f-a797-832c362d7e32.png)

```javascript
//heatmap.js

function drawHeatmapV3(){
  const clickData = [
    {x: 100, y: 100, v: 1},
    {x: 110, y: 110, v: 2},
    {x: 160, y: 100, v: 4},
    {x: 220, y: 100, v: 1},
  ];
  var maxV = Math.max(...clickData.map(o => o.v));
  
  clickData.forEach(point => {
    var alpah = point.v / maxV;
    var gradient = ctx.createRadialGradient(point.x, point.y, 3, point.x, point.y, 20);
    gradient.addColorStop(0, `rgba(0, 0, 0, ${alpah})`);
    gradient.addColorStop(1, 'rgba(0, 0, 0, 0)');
    
    useArc(point.x, point.y, 20, gradient);
  });
}
drawHeatmapV3();
```
# 色染め

描画ロジックの最適化を経て、求めているヒートマップのスタイルに非常に近づいています。今度は透明度付きのグラデーション形状ができましたので、次は色塗りが必要です。透明度が高いほどヒートマップが熱いことを示し、対応する色は赤に近づくべきです。そのため、カラーパレットを作成し、透明度と色の対応関係を見つける必要があります。

## カラーパレット

配色情報に基づいて、高さが256pxのカラーパレットを描画します([なぜ256なのか？](#マッピング関係の確立))。新しいCanvasを作成し、ここにカラーパレットを描画します。ここでは、[**`fillRect`**](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/fillRect)を使用して矩形を描画し、そして[**`createLinearGradient`**](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/createLinearGradient)を使って線形グラデーションを追加します。
![7.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3600361/e760a0ff-68f2-d2e9-5112-1370a69ac92e.png)
```javascript
// heatmap.js

function drawPalette(){
  var canvas = document.getElementById("paletteCanvas");
	var paletteCtx = canvas.getContext("2d");
	var palette = {
	  '0.45': 'rgb(0, 0, 255)',
	  '0.55': 'rgb(0, 255, 255)',
	  '0.65': 'rgb(0, 255, 0)',
	  '0.9': 'rgb(255, 255, 0)',
	  '1.0': 'rgb(255, 0, 0)'
	};
	var gradient = paletteCtx.createLinearGradient(0, 0, 1, 256);
	for (const key in palette) {
	  gradient.addColorStop(Number(key), palette[key]);
	}
	paletteCtx.fillStyle = gradient;
	paletteCtx.fillRect(0, 0, 10, 256);
}

drawPalette();
```
## 色塗り

カラーパレットが用意できたら、次は色塗りが最も重要なステップとなります。ヒートマップのCanvas内の各ピクセルに対して正確な色を決定する必要があります。重要なのは、ピクセルの **`alpha`** 値を使用してカラーパレットから対応する色値を見つけることです。これを実現するには、以下の2つのポイントを抑える必要があります：

1. Canvas上のすべてのピクセルデータを取得する方法
2. ピクセルデータをカラーパレットと対応させる方法

### ピクセルデータの取得

Canvas上のすべてのピクセルデータは、 **`getImageData`** メソッドを使用して取得できます。このメソッドは、ピクセルデータを含む **`ImageData`** オブジェクトを返します。このオブジェクトの **`data`** プロパティは **`Uint8ClampedArray`** 型の配列で、各ピクセルのデータが含まれています。各ピクセルのデータはRGBA値で表され、連続する4つの配列要素で構成されています。

各ピクセルは以下のように表されます：

- 最初の値：赤チャンネル（R），範囲は0から255
- 2番目の値：緑チャンネル（G），範囲は0から255
- 3番目の値：青チャンネル（B），範囲は0から255
- 4番目の値：透明度チャンネル（A），範囲は0から255

透明度チャンネル（A）はピクセルの不透明度を示し、0は完全に透明、255は完全に不透明を表します。以下に例を示します：

```javascript
// 画像データをロードする
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;

// 各ピクセルを処理する
for (let i = 0; i < data.length; i += 4) {
    const red = data[i];      // red
    const green = data[i + 1];// green
    const blue = data[i + 2]; // blue
    const alpha = data[i + 3];// alpha
    console.log(`R: ${red}, G: ${green}, B: ${blue}, A: ${alpha}`);
}
```

### マッピング関係の確立

**ImageData** オブジェクトの **`alpha`** 値を通じて、その値の範囲が0から255であることがわかります。これが、カラーパレットの高さが256pxである理由です。ヒートマップのピクセルの **`alpha`** 値を使用して、カラーパレット内の対応する色値を見つけるには、幅が1px、高さが256pxのカラーパレットのピクセルデータを取得する必要があります。これにより、長さが 256 * 4 の **`Uint8ClampedArray`** 配列が返され、これは **`alpha`** の範囲分布に正確に対応し、各 **`alpha`** 値が色値に対応できることを保証します。

### 色塗りのコード例

以下は、ヒートマップキャンバス内のピクセルの透明度に基づいて、カラーパレット内の対応する位置の色値を取得し、最終的に色塗りつぶしためのコード例です：
![8.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3600361/63f86ad8-a281-a11f-7463-6a69910b69be.png)
```javascript
// heatmap.js

function colorize(){
  const heatmapImageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
  const paletteImageData = paletteCtx.getImageData(0, 0, 1, 256).data;

  const imgData = heatmapImageData.data;
  const len = imgData.length;
  for (let i = 3; i < len; i += 4) {
    let alpha = imgData[i];
    if (alpha) {
      const offset = alpha * 4;
      imgData[i - 3] = paletteImageData[offset];     // red
      imgData[i - 2] = paletteImageData[offset + 1]; // green
      imgData[i - 1] = paletteImageData[offset + 2]; // blue
      imgData[i] = alpha; // alpha
    }
  }
  ctx.putImageData(heatmapImageData, 0, 0);
}
colorize();
```
# まとめ

ここまで、クリックヒートマップの描画ロジックを完成しました。完全な例のコードをご覧になりたい場合は、こちらを参照してください：[Heatmap Code Demo](https://codepen.io/ao-wei/pen/ExzZbOz)。

Ptengine Heatmapの描画ロジックは、大まかに述べた通りです。同じ原理に基づいて、他の形式のヒートマップも実装しています。例えば、注目ヒートマップ（以下の画像参照）などです。詳細は [Ptengine Heatmap Demo](https://www.ptengine.jp/demo/feature-highlights?ptengine_heatmap_token=4611f55d2e80d966a53881972693f49c5&pt_lang=jp&pt_smart_report=3&pt_sid=566d12f9&utm_source=qita&utm_medium=article&utm_campaign=heatmap) をご覧ください。ご意見やご提案がありましたら、お気軽にご連絡ください！
![9.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3600361/52ba1a78-f300-9f9f-846c-7ba8298af780.png)