手把手教你实现热力图!_热力图实现原理和方法-程序员宅基地

技术标签: 热力图  地图api  jsapi  地图开发  

以下内容转载自腾讯位置服务公众号的文章《硬核干货来了!鹅厂前端工程师手把手教你实现热力图!》

作者:腾讯位置服务

链接:https://mp.weixin.qq.com/s/bgS7uFlyLtK8WtusKfv8lA

来源:微信公众号

著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

各位小伙伴们,还记得今年年初时我们推出的数据可视化组件吗?《助你开启“上帝视角” 数据可视化组件全新上线》。这些基于地图的数据可视化组件,以附加库的形式加入到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版可视化组件也即将上线,展示效果更加酷炫,还请各位开发者小伙伴持续关注!

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接:https://blog.csdn.net/weixin_45653970/article/details/103291511

智能推荐

JWT(Json Web Token)实现无状态登录_无状态token登录-程序员宅基地

文章浏览阅读685次。1.1.什么是有状态?有状态服务,即服务端需要记录每次会话的客户端信息,从而识别客户端身份,根据用户身份进行请求的处理,典型的设计如tomcat中的session。例如登录:用户登录后,我们把登录者的信息保存在服务端session中,并且给用户一个cookie值,记录对应的session。然后下次请求,用户携带cookie值来,我们就能识别到对应session,从而找到用户的信息。缺点是什么?服务端保存大量数据,增加服务端压力 服务端保存用户状态,无法进行水平扩展 客户端请求依赖服务.._无状态token登录

SDUT OJ逆置正整数-程序员宅基地

文章浏览阅读293次。SDUT OnlineJudge#include<iostream>using namespace std;int main(){int a,b,c,d;cin>>a;b=a%10;c=a/10%10;d=a/100%10;int key[3];key[0]=b;key[1]=c;key[2]=d;for(int i = 0;i<3;i++){ if(key[i]!=0) { cout<<key[i.

年终奖盲区_年终奖盲区表-程序员宅基地

文章浏览阅读2.2k次。年终奖采用的平均每月的收入来评定缴税级数的,速算扣除数也按照月份计算出来,但是最终减去的也是一个月的速算扣除数。为什么这么做呢,这样的收的税更多啊,年终也是一个月的收入,凭什么减去12*速算扣除数了?这个霸道(不要脸)的说法,我们只能合理避免的这些跨级的区域了,那具体是那些区域呢?可以参考下面的表格:年终奖一列标红的一对便是盲区的上下线,发放年终奖的数额一定一定要避免这个区域,不然公司多花了钱..._年终奖盲区表

matlab 提取struct结构体中某个字段所有变量的值_matlab读取struct类型数据中的值-程序员宅基地

文章浏览阅读7.5k次,点赞5次,收藏19次。matlab结构体struct字段变量值提取_matlab读取struct类型数据中的值

Android fragment的用法_android reader fragment-程序员宅基地

文章浏览阅读4.8k次。1,什么情况下使用fragment通常用来作为一个activity的用户界面的一部分例如, 一个新闻应用可以在屏幕左侧使用一个fragment来展示一个文章的列表,然后在屏幕右侧使用另一个fragment来展示一篇文章 – 2个fragment并排显示在相同的一个activity中,并且每一个fragment拥有它自己的一套生命周期回调方法,并且处理它们自己的用户输_android reader fragment

FFT of waveIn audio signals-程序员宅基地

文章浏览阅读2.8k次。FFT of waveIn audio signalsBy Aqiruse An article on using the Fast Fourier Transform on audio signals. IntroductionThe Fast Fourier Transform (FFT) allows users to view the spectrum content of _fft of wavein audio signals

随便推点

Awesome Mac:收集的非常全面好用的Mac应用程序、软件以及工具_awesomemac-程序员宅基地

文章浏览阅读5.9k次。https://jaywcjlove.github.io/awesome-mac/ 这个仓库主要是收集非常好用的Mac应用程序、软件以及工具,主要面向开发者和设计师。有这个想法是因为我最近发了一篇较为火爆的涨粉儿微信公众号文章《工具武装的前端开发工程师》,于是建了这么一个仓库,持续更新作为补充,搜集更多好用的软件工具。请Star、Pull Request或者使劲搓它 issu_awesomemac

java前端技术---jquery基础详解_简介java中jquery技术-程序员宅基地

文章浏览阅读616次。一.jquery简介 jQuery是一个快速的,简洁的javaScript库,使用户能更方便地处理HTML documents、events、实现动画效果,并且方便地为网站提供AJAX交互 jQuery 的功能概括1、html 的元素选取2、html的元素操作3、html dom遍历和修改4、js特效和动画效果5、css操作6、html事件操作7、ajax_简介java中jquery技术

Ant Design Table换滚动条的样式_ant design ::-webkit-scrollbar-corner-程序员宅基地

文章浏览阅读1.6w次,点赞5次,收藏19次。我修改的是表格的固定列滚动而产生的滚动条引用Table的组件的css文件中加入下面的样式:.ant-table-body{ &amp;amp;::-webkit-scrollbar { height: 5px; } &amp;amp;::-webkit-scrollbar-thumb { border-radius: 5px; -webkit-box..._ant design ::-webkit-scrollbar-corner

javaWeb毕设分享 健身俱乐部会员管理系统【源码+论文】-程序员宅基地

文章浏览阅读269次。基于JSP的健身俱乐部会员管理系统项目分享:见文末!

论文开题报告怎么写?_开题报告研究难点-程序员宅基地

文章浏览阅读1.8k次,点赞2次,收藏15次。同学们,是不是又到了一年一度写开题报告的时候呀?是不是还在为不知道论文的开题报告怎么写而苦恼?Take it easy!我带着倾尽我所有开题报告写作经验总结出来的最强保姆级开题报告解说来啦,一定让你脱胎换骨,顺利拿下开题报告这个高塔,你确定还不赶快点赞收藏学起来吗?_开题报告研究难点

原生JS 与 VUE获取父级、子级、兄弟节点的方法 及一些DOM对象的获取_获取子节点的路径 vue-程序员宅基地

文章浏览阅读6k次,点赞4次,收藏17次。原生先获取对象var a = document.getElementById("dom");vue先添加ref <div class="" ref="divBox">获取对象let a = this.$refs.divBox获取父、子、兄弟节点方法var b = a.childNodes; 获取a的全部子节点 var c = a.parentNode; 获取a的父节点var d = a.nextSbiling; 获取a的下一个兄弟节点 var e = a.previ_获取子节点的路径 vue

推荐文章

热门文章

相关标签