Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

一种热力图绘制方法 #2

Open
ajccom opened this issue Oct 26, 2017 · 1 comment
Open

一种热力图绘制方法 #2

ajccom opened this issue Oct 26, 2017 · 1 comment

Comments

@ajccom
Copy link
Owner

ajccom commented Oct 26, 2017

一种热力图绘制方法

在百度统计中有一个查看网页热力图的功能,对于查看网页上用户热区十分有用。

在公司项目中也希望能够加上热力图功能作为运营的一个参考,于是翻看了一些做热力图的代码库(其实主要是看了 heatmap.js),所以这里将吸收融汇到的一种热力图绘制方法记录一下,权当笔记。

虽然我使用前端方法绘制热力图,但其中原理适用于其他技术领域。

绘制步骤

1. 准备工作

准备 HTML

首先准备一个网页文件,添加一个 canvas 元素,这个元素会用来呈现最终结果。

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Document</title>
</head>
<body>
  <canvas id="canvas" width="800" height="800"></canvas>
</body>
</html>

需要注意,我通过 canvas 元素的属性设置画布高宽为 800 x 800,而不是样式。虽然通过样式也可以设置 canvas 元素大小,但是样式设置的高宽并不会改变画布的像素总量,可以理解为样式高宽只是将画布进行了缩放。

这一点对于具有交互性操作的画布应用来说是必须要注意的,但并不是本文要阐述的重点,大家知道即可。

准备数据

再准备一个数据生成器,生成的数据模拟用户点击数据,具有 xy 坐标和该坐标下的点击量。

function generateData () {
  const MAXX = 800,
    MAXY = 800,
    MAXV = 100
  let result = []
    
  for (let i = 0; i < 1000; i++) {
    result.push([Math.ceil(Math.random() * MAXX), Math.ceil(Math.random() * MAXY), Math.ceil(Math.random() * MAXV)])
  }
  
  return result
}

这个函数返回一个包含一万条数据的数组,每一项内容是 xy 坐标和点击量。

需要注意返回的数组中可能含有相同 xy 坐标的数据,作为讲解原理的假数据并无大碍但还是请大家能认识到它的小小错误。

准备调色盘

我们需要用颜色来表示热力图的高亮程度,使用一个 256 x 1 的画布,通过线性渐变绘制得到一个调色盘。调色盘中的颜色将会用于渲染热力图。

function getPalette () {
  let config = {0.25: 'rgb(0,0,0)', 0.55: 'rgb(0,255,0)', 0.85: 'yellow', 1.0: 'rgb(255,0,0)'},
    canvas = document.createElement('canvas'),
    ctx = canvas.getContext('2d')

  canvas.width = 256
  canvas.height = 1

  let gradient = ctx.createLinearGradient(0, 0, 256, 1);
  for (let key in config) {
    gradient.addColorStop(key, config[key])
  }

  ctx.fillStyle = gradient
  ctx.fillRect(0, 0, 256, 1)
  
  return ctx.getImageData(0, 0, 256, 1).data
}

通过调用 getPalette 方法我们获得一个调色盘图像数据,这个数据的高宽 256 x 1 是故意为之,为什么呢?大家可以带着这个疑问继续往下看。

图像化数据

准备工作完成后,接下来需要进行数据图像化的工作。图像化数据的目的是为了能够让数据转换成图像,一条数据对应一个羽化的圆,通过向圆形填充径向渐变得到。

羽化是 PS 工具中的一个术语,效果是图形外围边框由透明过度到颜色。我们可以使用径向透明渐变实现这个效果,但是在设置径向渐变对象之前,还需要确定圆的半径。

设置阈值计算半径

数据中的点击量值,反应了该坐标的热力程度。点击量和图像高亮效果成正比,点击量越高,区域越高亮。

我使用了一种由点击量数据的最大最小值决定的范围进行阈值划分,以确定圆形半径的方法,用以根据不同点击量阈值内数值得到相应半径值。

首先获取点击量最大和最小值:

let dataArray = generateData()

let min = 9999, max = -1

let arr = dataArray.map((data) => data[2])

arr.sort((a, b) => (a - b))

min = arr[0]
max = arr[arr.length - 1]

接着获取点击量区间的阈值,然后自定义一个圆形半径的取值范围,并通过每条数据的点击量值获取对应的半径数值:

function getRadius (data) {
  let step = Math.floor((max - min) / 10) // 分隔阈值范围

  let radiusRange = [10, 20] // 半径取值范围 10 到 20 像素

  let value = data[2] // 点击量

  let index = Math.ceil((value - min) / step) // 计算点击量落在哪个阈值范围

  let radius = radiusRange[0] + (radiusRange[1] - radiusRange[0]) / 10 * index // 得到半径值
  
  return radius
}

在 shadow canvas 中绘制圆

获得半径值后,就可以设置径向渐变,绘制出这条数据对应的圆形。

注意,为了能够提升性能,并不需要每一条数据都绘制圆。

由于数据通过阈值被划分之后,对应的半径取值的可能性也只有相应的几种可能。所以我通过 shadow canvas (影子画布,即用户看不到的画布)来绘制圆形,当遇到已经绘制过的相同半径的圆形时则直接使用之前已完成的圆形图像。

let shadowCanvasHashmap = {}

function getPointCanvas (radius) {
  if (shadowCanvasHashmap[radius]) {
    return shadowCanvasHashmap[radius]
  } else {
    let canvas = document.createElement('canvas'),
      ctx = canvas.getContext('2d'),
      blur = 0.75, // 透明区域范围(0 到 1区间,以外围为 0)
      l = radius * 2
      
    canvas.width = l
    canvas.height = l

    let gradient = ctx.createRadialGradient(radius, radius, 1 - blur, radius, radius, radius)

    gradient.addColorStop(0, 'rgba(0, 0, 0, 1)')
    gradient.addColorStop(1, 'rgba(0, 0, 0, 0)')

    ctx.fillStyle = gradient
    ctx.fillRect(0, 0, l, l) // 直接填充整个画布即可
    shadowCanvasHashmap[radius] = canvas
    
    return canvas
  }
}

绘制热力图

上面的代码展示了如何绘制一条数据对应的圆,接下来我们要将所有数据对应的圆绘制到一个和热力图画布一样大小的 shadow canvas 中,姑且称之为“副本”画布。

let copyCanvas = document.createElement('canvas'),
  ctx = copyCanvas.getContext('2d')

copyCanvas.width = 800
copyCanvas.height = 800  
  
dataArray.map((data) => {
  let radius = getRadius(data)
  
  let pointCanvas = getPointCanvas(radius)
  
  ctx.drawImage(pointCanvas, data[0] - radius, data[1] - radius)
})

这里我们新建了“副本”画布,并在上面绘制出所有的圆。

得到的效果如下:

黑白画布效果

接下来是最重要的一步,就是给图像上色。

通过读取副本画布的图像数据,对图像中各个像素点的透明度通道(alpha 通道)值 x 对应到调色盘 x 位置的像素值,然后使用该处像素的 RGB 值进行上色。这种方法可以保证热力图上呈现的颜色具有连续性。

这下知道为什么调色盘宽度是 256 了吧?

let imageData = copyCanvas.getContext('2d').getImageData(0, 0, 800, 800),
  palette = getPalette()

let data = imageData.data,
  l = data.length, alpha = 0, offset = 0

for (let i = 0; i < l; i += 4) {
  alpha = data[i + 3] // alpha 值的取值范围和 R、B、G 相同是 0 - 255
  offset = alpha * 4 // 对应到调色盘图像数据位置
  
  data[i] = palette[offset]
  data[i + 1] = palette[offset + 1]
  data[i + 2] = palette[offset + 2]
  data[i + 3] = palette[offset + 3]
}

imageData.data.set(data)
document.getElementById('canvas').getContext('2d').putImageData(imageData, 0, 0)

好,到这里,热力图已经成功渲染出来了。

热力图效果

性能优化

图像处理的范围优化

刚才的过程中,我们对“副本”画布的所有像素点进行了遍历,这是一个比较耗性能的地方。

优化方法是对热力图高亮区域进行范围计算,然后只读取这个范围内的图像数据,可以节省遍历次数,提高性能。

优化范围

内存优化

在对数据进行绘制圆的过程中,新增了很多 shadow canvas 元素存储不同半径的原形图像,这样可以有效减少重复绘制次数。但是注意要在最后对这些元素进行清理哦。

相关项目

ajccom/heatmap

pa7/heatmap.js

@Aaronphy
Copy link

Aaronphy commented Jun 1, 2021

我觉得这样不够,如果页面是响应式的,是不准确的

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants