Skip to content

Ptengine热图绘制逻辑探究:JavaScript实现精要

在网站分析中,热图是一种常见的数据可视化工具,能够直观地展示用户在网页上的行为热度和分布情况。作为一款专业的网站分析工具,Ptengine的热图功能十分强大。在本文中,我们将深入探讨Ptengine热图的绘制逻辑,并以点击热图为示例,使用JavaScript代码实现。前提是你已经掌握了中级水平的JavaScript和Canvas知识。

Untitled

以上点击热图示例可以在 Ptengine Heatmap Demo 中查看。

绘制前准备

测试数据

在绘制热图之前,我们需要准备相应的数据。Ptengine的点击热图数据来源于客户页面的点击行为记录。虽然具体的数据采集逻辑不在此详述,但假设我们已经获取了以下数据,并将按照此数据结构进行绘制:

jsx
/**
 * 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},
];
/**
 * 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的配色示例:

jsx
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)'     // 热色,红色
};
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结构如下,后续代码示例将基于此结构进行演示:

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>
<!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>
jsx
// heatmap.js

const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
// heatmap.js

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

绘制一个圆

为什么选择圆形?仔细查看 Ptengine Heatmap Demo,你会发现点击热图根据点击的坐标位置绘制出一个个带有颜色渐变的圆形。因此,我们首先需要学习如何绘制一个带有渐变色的圆形。

Cavans实现圆比较简单,方法有很多种:arcarcTobezierCurveTo

下面是具体的实现代码及绘制效果:

Untitled

jsx
// 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();
}
// 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方法绘制放射性渐变,这种方法能够创建径向渐变对象,为圆形添加渐变效果。

以下是如何在绘制圆形的基础上添加渐变色的代码示例,参考之前的配色方案

Untitled

jsx
// 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));
// 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));

绘制所有热点

我们已经成功绘制了一个热点,接下来需要基于测试数据遍历生成所有的热点。以下是如何遍历数据并绘制所有热点的代码示例:

Untitled

jsx
// 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();
// 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();

优化热图绘制逻辑

从上述结果可以看出,代码虽然简单,但存在明显问题。

问题一: 热图重叠后色值没有融合

问题二: 点击次数没有参与热图绘制逻辑

为了解决这些问题,我们需要重新分析热图的绘制逻辑。不能一次性完成绘制及着色,需要将绘制和着色分开。首先完成重叠,然后根据配色方案进行绘制。

实现重叠绘制效果

要解决重叠绘制问题,不能简单地让颜色彼此叠加,否则颜色会混杂不清。我们需要保持色值一致性,通过不同透明度实现叠加效果。我们先绘制一个纯色值带透明度的热图,示例中使用黑色。以下是调整后的效果及代码:

Untitled

jsx
// 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();
// 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值)纳入绘制逻辑中。具体效果及代码如下:

Untitled

jsx
//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();
//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),然后添加一个线性渐变色(createLinearGradient)即可。

Untitled

jsx
// 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();
// 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 值找到调色板中的对应色值。为了实现这一点,需要掌握两个知识点:

  1. 如何获取 Canvas 上的所有像素点数据
  2. 如何将像素点数据与调色板建立对应关系

获取像素点

Canvas 中可以使用 getImageData 方法获取绘制的所有像素点数据。该方法返回一个包含像素数据的 ImageData 对象,其中的 data 属性是一个 Uint8ClampedArray 类型的数组。每个像素点的数据由四个连续的数组元素表示,分别是 RGBA 值。

每个像素点用四个连续的数组元素表示如下:

  • 第一个值:红色通道(R),范围 0-255
  • 第二个值:绿色通道(G),范围 0-255
  • 第三个值:蓝色通道(B),范围 0-255
  • 第四个值:透明度通道(A),范围 0-255

透明度通道(A)表示像素的不透明度,其中 0 表示完全透明,255 表示完全不透明。示例如下:

jsx
// 获取图像数据
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}`);
}
// 获取图像数据
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 值都能对应到一个色值。

着色代码示例

以下是根据热图画布中像素点的透明度获取调色板中对应位置的色值,并最终实现着色的代码示例:

Untitled

jsx
// heatmap.js

function colorize(){
	// 获取 热图 画布中所有 imageData
  const heatmapImageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
  
  // 获取 调色板 画布中所有的 imageData
  const paletteImageData = paletteCtx.getImageData(0, 0, 1, 256).data;
	
	// 遍历 heatmap imageData,获取 alpha 值。
  const imgData = heatmapImageData.data;
  for (let i = 3; i < imgData.length; i += 4) {
    let alpha = imgData[i];
    if (alpha) {
    
	    // 根据 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.js

function colorize(){
	// 获取 热图 画布中所有 imageData
  const heatmapImageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
  
  // 获取 调色板 画布中所有的 imageData
  const paletteImageData = paletteCtx.getImageData(0, 0, 1, 256).data;
	
	// 遍历 heatmap imageData,获取 alpha 值。
  const imgData = heatmapImageData.data;
  for (let i = 3; i < imgData.length; i += 4) {
    let alpha = imgData[i];
    if (alpha) {
    
	    // 根据 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

Ptengine Heatmap 的绘制逻辑大致如此。基于相同原理,我们还实现了其他形式的热图,例如注意力热图(如下图所示),详情请查看 Ptengine Heatmap Demo。如果有任何建议或想法,非常欢迎你的反馈! Untitled