数据可视化 浅谈热力图如何在前端实现

在公司写了篇关于介绍热力图实现原理的文章,也算一段时间学习的积累,放链接:

数据可视化:浅谈热力图如何在前端实现 - 知乎

https://zhuanlan.zhihu.com/p/49929203

当我们需要用更直观有效的形式来展现各类大数据信息时,热力图无疑是一种很好的方式。作为一种密度图,热力图一般使用具备显著颜色差异的方式来呈现数据效果,热力图中亮色一般代表事件发生频率较高或事物分布密度较大,暗色则反之。值得一提的是,热力图最终效果常常优于离散点的直接显示,可以在二维平面或者地图上直观地展现空间数据的疏密程度或频率高低。

那么制作一张完整的热力图,需要前端做哪些工作呢?接下来,我将基于自己在工作过程中的实践,为大家详细解析热力图在前端的实现过程。

首先给大家看一张完整的热力图实现效果图:

image

关于热力图的实现原理:

一般可大致归纳为以下几个步骤:

1.为每个数据点设置一个从中心向外灰度渐变的圆;

2.利用灰度可以叠加的原理,计算每个像素点数据交叉叠加得到的灰度值;

3.根据每个像素计算得到的灰度值,在一条彩色色带中进行颜色映射,最后对图像进行着色,得到热力图。

当热力图基于前端技术的具体实现时,又可分为以下四个步骤,接下来为大家详细解析:

1.准备热力图数据格式

由于热力图使用场景一般为地图,所以,数据源需要提供经纬度作为位置信息,以及count作为数据点的权重值。具体数据格式示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
[{
lat: '36.123026258681',
lng: '106.352062717014',
count: 29293
},
{
lat: '37.002255316841',
lng: '103.185955946181',
count: 44356
},
...
]

2.数据填充到地图上

基于canvas绘制热力图,其中热力图每个数据点的半径大小会直接影响到热力图展现效果,一般要结合使用地图的缩放级别以及数据精度来进行设置,本示例默认设为15px。

1
2
3
4
5
6
7
8
9
10
11
12
let radius = 15
let context = this.getContext()
points.forEach(point => {
context.beginPath()
context.arc(point[0], point[1], radius, 0, Math.PI * 2, true) // 绘制一个圆
let gradient = context.createRadialGradient(point[0], point[1], 0, point[0], point[1], radius) // 创建一条放射渐变
gradient.addColorStop(0, 'rgba(0,0,0,1)')
gradient.addColorStop(1, 'rgba(0,0,0,0)')
context.fillStyle = gradient
context.closePath()
context.fill()
})

上述步骤画出的每个点的样式如下图,是一个由内向外放射渐变的灰色圆:

image

所有点叠加在地图上的效果如下图所示:

image

  1. 叠加显示,权重(密度)算法

上面的绘制结果,没有使用到权重值,这样每个数据点圆中心点的灰度值都是1,不能直接用于颜色映射。根据离散点缓冲区的叠加来确定热力分布密度。每一个热点都有一个位置和权重,权重越大,则该点越显著,也就代表其渐变的一个衰变因素。我们需要根据不同的count设置出不同的alpha值。本文根据count最小值对应alpha0,最大值对应1的映射计算方式,求得每个数据点绘制的alpha:

1
alpha = (point[2] - minCount) / (maxCount - minCount)

结合上一步骤,在canvas中完整的绘制方式如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let radius = 15;
let context = this.getContext()
points.forEach(point => {
context.beginPath()
let alpha = (point[2] - minCount) / (maxCount - minCount) // 根据权重count计算出当前点的alpha
alpha = alpha > 1 ? 1 : alpha
context.globalAlpha = alpha // 设置 Alpha 透明度
context.arc(point[0], point[1], radius, 0, Math.PI * 2, true)
let gradient = context.createRadialGradient(point[0], point[1], 0, point[0], point[1], radius)
gradient.addColorStop(0, 'rgba(0,0,0,1)')
gradient.addColorStop(1, 'rgba(0,0,0,0)')
context.fillStyle = gradient
context.closePath()
context.fill()
})

绘制效果如下所示,从实例图的对比可以看出一个好的权重映射方法对热力图的显示效果起着重要作用。

image

4.颜色映射

最后根据画布上每个像素点的累计得到的灰度值,从彩色映射色带中得到对应位置的颜色。

如何得到画布上每个像素点信息?可以使用canvas提供的getImageData()方法,返回 ImageData 对象,该对象拷贝了画布指定矩形的像素数据。ImageData对象中的每个像素,都包含RGBA四项信息:

1
2
3
4
5
6
7
8
9
10
11
12
// RGBA的值范围
R - 红色 (0-255)
G - 绿色 (0-255)
B - 蓝色 (0-255)
A - alpha 通道 (0-255; 0 是透明的,255 是完全可见的)
// ImageData对象示例
ImageData:{
width: 200,
height: 200,
data:[0,0,255,0,0,128,255,128,....] // 其中每四项分别对应一个像素点的RGBA值
}

相对应的通过canvas提供的putImageData()方法,将像素级的数据放回到画布中。

在热力图绘制过程中,利用这两个方法,可以从上一步骤绘制得到的热力图中得到每个像素点叠加得到的alpha通道的灰度值(0~255),再建立一条长度为256px彩色色带,从中映射得到该像素点的对应颜色的RGB值。

建立一条长度为256px彩虹条的过程如下代码所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function getColorPaint () {
let paletteCanvas = document.createElement('canvas')
let paletteCtx = paletteCanvas.getContext('2d')
let gradientConfig = { // 自定义颜色
'0.2': 'rgba(0,0,255,0.2)',
'0.3': 'rgba(43,111,231,0.3)',
'0.4': 'rgba(2,192,241,0.4)',
'0.6': 'rgba(44,222,148,0.6)',
'0.8': 'rgba(254,237,83,0.8)',
'0.9': 'rgba(255,118,50,0.9)',
'1.0': 'rgba(255,64,28,1)'
}
paletteCanvas.width = 256
paletteCanvas.height = 1
let gradient = paletteCtx.createLinearGradient(0, 0, 256, 1) // 创建一个长256px的线性渐变条
for (let key in gradientConfig) {
gradient.addColorStop(key, gradientConfig[key])
}
paletteCtx.fillStyle = gradient
paletteCtx.fillRect(0, 0, 256, 1)
return paletteCtx.getImageData(0, 0, 256, 1).data
}

自定义颜色得到的彩色条:

image

从彩虹条中映射颜色的过程如下代码所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
let palette = getColorPaint()
let img = context.getImageData(0, 0, this.option['real-width'], this.option['real-height'])
let imgData = img.data
let len = imgData.length
for (let i = 3; i < len; i += 4) {
let alpha = imgData[i]
let offset = alpha * 4
if (!offset) {
continue
}
// 映射颜色RGB值
imgData[i - 3] = palette[offset]
imgData[i - 2] = palette[offset + 1]
imgData[i - 1] = palette[offset + 2]
}
context.putImageData(img, 0, 0, 0, 0, this.option['real-width'], this.option['real-height'])

最终,我们得到的热力图效果如下:

image

最后,提供一个热力图的性能优化方法,由于热力图一次性加载过多的点,会出现卡顿性能问题。而前端在渲染热力图时,可以进行热力图的点聚合优化。点聚合的思路是,将视窗划分成为网格进行,判断热力图数据点在网格中所处的位置,如果同时几个点处于一个网格,则合并这几个点,以此降低渲染成本。

判断每个点在网格中分布位置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function findGridinfo (viewPort, mapbox, interval, point) {
let clientW = viewPort.width;
let clientH = viewPort.height;
let lngRange = mapbox[2] - mapbox[0];
let latRange = mapbox[3] - mapbox[1];// 每个格子的经纬差
let xNum = Math.floor(clientW / interval) + 1;
let yNum = Math.floor(clientH / interval) + 1;
let gridLng = lngRange / xNum;
let gridLat = latRange / yNum;
let x = Math.floor((point[0] - mapbox[0]) / gridLng);
let y = Math.floor((point[1] - mapbox[1]) / gridLat);
return {
idx: (y - 1) * xNum + x,
minmax: [
mapbox[0] + gridLng * x,
mapbox[1] + gridLat * y,
mapbox[0] + gridLng * (x + 1),
mapbox[1] + gridLat * (y + 1)
]
}
}

网格划分以及点和聚合方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function heatmapPoly (viewPort, mapbox, datas, interval = 5) {
let clientW = viewPort.width;
let clientH = viewPort.height; // 可视窗口像素宽高
let xNum = Math.floor(clientW / interval) + 1;
let yNum = Math.floor(clientH / interval) + 1; // 格子数
let mapDataGrid = new Array(xNum * yNum);
datas.data.forEach(data => {
let point = data.subject.split(',');
let gridinfo = findGridinfo(viewPort, mapbox, interval, point);
let idx = gridinfo.idx;
let minmax = gridinfo.minmax;
let centerPoint = [(minmax[0] + minmax[2]) / 2, (minmax[1] + minmax[3]) / 2];
mapDataGrid[idx] = mapDataGrid[idx] || {subject: '', value: 0};
mapDataGrid[idx].subject = mapDataGrid[idx].subject || centerPoint.join(',');
mapDataGrid[idx].value += data.value;
})
return mapDataGrid.filter(data => !!data.value)
}

参考文章:

ArcGIS desktop——“热力图”实现方法比较

数据可视化之热力图

热点图简介 Heat Map Hotspot Map

你不知道的前端算法之热力图的实现