硬核干货来了!鹅厂前端工程师手把手教你实现热力图!

415 阅读10分钟

各位小伙伴们,还记得今年年初时我们推出的数据可视化组件吗?《助你开启“上帝视角” 数据可视化组件全新上线》。这些基于地图的数据可视化组件,以附加库的形式加入到JSAPI中,目前主要包括热力图、散点图、区域图、迁徙图。

alt

想知道这个“上帝视角”是如何开启的吗?想了解这些可视化组件背后的实现原理吗?下面就让腾讯位置服务web开发一线工程师,美貌与智慧并存的totoro同学为大家揭秘。

由于篇幅有限,本文以热力图为例,描述其背后的实现原理。

热力图简介

热力图是以颜色来表现数据强弱大小及分布趋势的可视化类型,热力图可应用于人口密度分析、活跃度分析等。呈现热力图的数据主要包括离散的坐标点及对应的强弱数值。

热力图实现

数据准备 本文只关心热力图的基础实现,无论你是用于地图,还是网页焦点分析还是其他场景,均需将对应场景的坐标转化为Canvas画布上的二维坐标,最终我们需要的数据格式如下:

// x, y 表示二维坐标; value表示强弱值  
var data = [
    {x: 471, y: 277, value: 25},
    {x: 438, y: 375, value: 97},
    {x: 373, y: 19, value: 71},
    {x: 473, y: 42, value: 63},
    {x: 463, y: 95, value: 97},
    {x: 590, y: 437, value: 34},
    {x: 377, y: 442, value: 66},
    {x: 171, y: 254, value: 20},
    {x: 6, y: 582, value: 64},
    {x: 387, y: 477, value: 14},
    {x: 300, y: 300, value: 80}
];

注:具体到使用场景,比如在地图上应用时,需要借助地图API将经纬度坐标转化为像素坐标。

实现原理

让我们从结果来反推我们应该如何实现热力图。 alt

[ 热力图原理 ]

我们可以直观的感受到:

1、在热力图中,每个数据点所呈现的是一个填充了径向渐变色的圆形(所谓径向渐变即由圆心随着半径增加而逐渐变化),而这个渐变圆表现的是数据由强变弱的辐射效果

2、两个圆之间可以相互叠加,且是线性的叠加,其实质表现的是数据强弱的叠加

3、数据强弱的数值与颜色一一映射,一般表现为红强蓝弱的线性渐变,当然你也可以设计自己的强度色谱

根据我们的直观感受,我们需要做的是:

1、将每一个数据映射为一个圆形

2、选定一个线性维度表示数据强度值,圆形区域内该维度在圆心处达到最大值,沿着半径逐渐变小,直至边缘处为最小值

3、将圆形内的强度值进行叠加

4、以强度色谱进行颜色映射

往往有人对第2、3步有疑问,为什么不直接以强度色谱填充圆形呢?

因为没有alpha通道时不会进行混色,重叠的时候颜色会相互覆盖而非叠加;且即使在强度色谱上设置了alpha值,叠加时也是rgb三个通道上分别进行计算,简单来说就是无法将蓝色与蓝色叠加出现红色。

那需要开一个二维数组存储强度值进行叠加计算吗?

也不用。其实canvas画布本身就可以看作一个二维数组,可以选取alpha单通道作为表示强弱的维度,虽然alpha通道并非严格的线性叠加,其为a = a1 + a2 - a1 * a2,但也可以满足我们的需求,如下图所示,其与a = a1 + a2所表示的平面比较贴近。

alt

[ alpha叠加 ]

动手实现

绘制圆形

Canvas 中绘制弧线或者圆形可以使用arc()方法:

arc(x, y, radius, startAngle, endAngle, anticlockwise)

x和y对应到数据的坐标,radius可自由设置,startAngle和endAngle表示起止角度,分别取0和2 * Math.PI,anticlockwise表示是否逆时针,可不设置。

渐变色

Canvas 中可以使用canvasGradient对象创建渐变色,分为直线渐变createLinearGradient(x1, y1, x2, y2)和径向渐变createRadialGradient(x1, y1, r1, x2, y2, r2),我们采用后者。创建径向渐变色需要定义两个圆,颜色在两个圆之间的区域进行渐变,故而我们将两个圆心都设置在数据的坐标点,而第一个圆半径取0,第二个半径同我们需要绘制的圆形半径一致。

然后我们需要通过addColorStop(position, color)定义在两个圆之间颜色渐变的规则。我们要达到的效果是颜色在某一个维度上的数值从中心随半径增加逐渐变小,而且同时,该维度的数值与数据的value正相关,否则所有数据点绘制出的图形都会一模一样。我们选择了alpha作为变化维度,所以我们可以使用globalAlpha来设置一个全局的透明度,这个透明度与value正相关,这样的话我们就可以统一使用rgba(r,g,b,1)和rgba(r,g,b,0)作为中心点和半径边缘的颜色。

那么我们通过以下代码来实现以上两个步骤:

/*
 * radius: 绘制半径,请自行设置
 * min, max: 强弱阈值,可自行设置,也可取数据最小最大值
 */
data.forEach(point => {
    let {x, y, value} = point; 
    context.beginPath();
    context.arc(x, y, radius, 0, 2 * Math.PI);
    context.closePath();

    // 创建渐变色: r,g,b取值比较自由,我们只关注alpha的数值
    let radialGradient = context.createRadialGradient(x, y, 0, x, y, radius);
    radialGradient.addColorStop(0.0, "rgba(0,0,0,1)");
    radialGradient.addColorStop(1.0, "rgba(0,0,0,0)");
    context.fillStyle = radialGradient;

    // 设置globalAlpha: 需注意取值需规范在0-1之间
    let globalAlpha = (value - min) / (max - min);
    context.globalAlpha = Math.max(Math.min(globalAlpha, 1), 0);

    // 填充颜色
    context.fill();
});

在示例中min为0,max为数据最大值,至此,我们得到的图形如下: alt

[ 渐变圆形 ]

颜色映射

可见图中的透明度已能代表数据强弱及辐射效果,且在相交处进行了线性的叠加。我们现在要给图形上色,需要使用ImageData对象对图像进行像素操作,读取每个像素点的透明度,然后使用其映射后的颜色改写ImageData数值。

先不急着了解像素操作如何进行,我们首先要确定的是透明度数值到颜色的映射关系。ImageData中的透明度数值是取值在[0, 255]之间的整数,我们要创建一个离散的映射函数,使0对应到最弱色(示例中为浅蓝色,你也可以自由设置),255对应到最强色(示例中为正红色)。而这个渐变的过程并不是单一维度的递增,好在我们已有工具解决渐变的问题,即上文已介绍过的createLinearGradient(x1, y1, x2, y2)。

alt

[ 调色盘 ]

如上图所示,我们可以创建一个跨度为 256 像素的直线渐变色,用其填充一个 256*1 的矩形,相当于一个调色盘。在这个调色盘上(0, 0)位置的像素呈现最弱色,(255, 0)位置的像素呈现最强色,所以对于透明度a,(a, 0)位置的像素颜色即为其映射颜色。代码如下:

const defaultColorStops = {
    0: "#0ff",
    0.2: "#0f0",
    0.4: "#ff0",
    1: "#f00",
};
const width = 20, height = 256;

function Palette(opts) {
    Object.assign(this, opts);
    this.init();
}

Palette.prototype.init = function() {
    let colorStops = this.colorStops || defaultColorStops;

    // 创建canvas
    let canvas = document.createElement("canvas");
    canvas.width = width;
    canvas.height = height;
    let ctx = canvas.getContext("2d");

    // 创建线性渐变色
    let linearGradient = ctx.createLinearGradient(0, 0, 0, height);
    for (const key in colorStops) {
        linearGradient.addColorStop(key, colorStops[key]);
    }

    // 绘制渐变色条
    ctx.fillStyle = linearGradient;
    ctx.fillRect(0, 0, width, height);

    // 读取像素数据
    this.imageData = ctx.getImageData(0, 0, 1, height).data;
    this.canvas = canvas;
};

/**
 * 取色器
 * @param {Number} position 像素位置
 * @return {Array.<Number>} [r, g, b]
 */
Palette.prototype.colorPicker = function(position) {
    return this.imageData.slice(position * 4, position * 4 + 3);
};

像素着色

简单介绍一下ImageData对象,其存储着Canvas对象真实的像素数据,包括width, height, data三个属性。我们可以:

1、 通过createImageData(anotherImageData | width, height)来创建一个新对象

2、或者getImageData(left, top, width, height)来创建带有Canvas画布中特定区域的像素数据的对象

3、使用putImageData(myImageData, left, top)来向Canvas画布写入像素数据

基于此,我们先获取画布数据,遍历像素点读取透明度,获取透明度映射颜色,改写像素数据并最终写入画布即可。

// 像素着色
let imageData = context.getImageData(0, 0, width, height);
let data = imageData.data;
for (var i = 3; i < data.length; i+=4) {
    let alpha = data[i];
    let color = palette.colorPicker(alpha);
    data[i - 3] = color[0];
    data[i - 2] = color[1];
    data[i - 1] = color[2];
}
context.putImageData(imageData, 0, 0);

至此,我们已经完成了热力图的绘制,看看效果吧:

alt

[ 热力图 ]

性能优化

离屏渲染

离屏渲染是指在文档流外的canvas中预先绘制好所需图形,然后将其作为纹理绘制到画布上,主要应用于局部绘制过程较复杂,而该局部又被重复绘制的场景下;同时应保证这个离屏的画布大小适中,因为复制过大的画布会带来很大的性能损耗。

那么热力图是否可以使用离屏渲染提升性能呢?考虑一下,如果我们在地图上呈现热力图,随着地图的移动,数据点的坐标会变化,但其对应的圆形图像其实是不变的。所以为了避免更新坐标时重复地创建渐变色、设置globalAlpha、绘制及填充颜色等,我们可以使用离屏渲染预先绘制好每个数据点的图像,

在重新渲染的时候通过drawImage将其绘制到画布上:

function Radiation(opts) {
    Object.assign(this, opts);
    this.init();
}

Radiation.prototype.init = function() {
    let {radius, globalAlpha} = this;

    // 创建canvas
    let canvas = document.createElement("canvas");
    canvas.width = canvas.height = radius * 2;

    // 获取上下文,初始化设置
    let ctx = canvas.getContext("2d");
    ctx.translate(radius, radius);
    ctx.globalAlpha = Math.max(Math.min(globalAlpha, 1), 0);

    // 创建径向渐变色:灰度由强到弱
    let radialGradient = ctx.createRadialGradient(0, 0, 0, 0, 0, radius);
    radialGradient.addColorStop(0.0, "rgba(0,0,0,1)");
    radialGradient.addColorStop(1.0, "rgba(0,0,0,0)");
    ctx.fillStyle = radialGradient;

    // 画圆
    ctx.arc(0, 0, radius, 0, Math.PI * 2);
    ctx.fill();

    this.canvas = canvas;
};

Radiation.prototype.draw = function(context) {
    let {canvas, x, y, radius} = this;
    context.drawImage(canvas, x - radius, y - radius);
};

然而经过性能测试发现,热力图局部绘制过程其实比较简单,与直接使用drawImage的耗时相差无几,所以无需使用离屏渲染。

避免浮点数坐标

使用drawImage时如果使用了浮点数坐标,浏览器为了达到抗锯齿的效果,会做额外计算,渲染子像素。所以尽量使用整数坐标。

怎么样?看完我们tototo同学的细致介绍,不知道你有没有掌握可视化组件背后的秘密?如果有任何问题欢迎在下方直接留言。

当然,如果你对这些底层的技术不是那么关心,那也没有关系。我们腾讯位置服务的愿景就是为了降低开发者门槛,减少开发者成本,解放开发者生产力。所以,totoro同学和她的小伙伴们才把这些复杂的底层实现包装成了组件的形式,方便大家调用。

那么还犹豫什么呢?立即点击这里直接用起来吧!大家对可视化组件的每一次调用,都是 “春哥”和她小伙伴们辛勤工作的一份肯定。

最后,提前剧透一下,基于WebGL开发的3D版可视化组件也即将上线,展示效果更加酷炫,还请各位开发者小伙伴持续关注!