如下内容转载自腾讯位置服务公众号的文章《硬核干货来了!鹅厂前端工程师手把手教你实现热力图!》做者:腾讯位置服务javascript
连接:https://mp.weixin.qq.com/s/bg...html
来源:微信公众号前端
著做权归做者全部。商业转载请联系做者得到受权,非商业转载请注明出处。java
各位小伙伴们,还记得今年年初时咱们推出的数据可视化组件吗?《助你开启“上帝视角” 数据可视化组件全新上线》。这些基于地图的数据可视化组件,以附加库的形式加入到JSAPI中,目前主要包括热力图、散点图、区域图、迁徙图。web
想知道这个“上帝视角”是如何开启的吗?想了解这些可视化组件背后的实现原理吗?下面就让腾讯位置服务web开发一线工程师,美貌与智慧并存的totoro同窗为你们揭秘。canvas
因为篇幅有限,本文以热力图为例,描述其背后的实现原理。数组
热力图是以颜色来表现数据强弱大小及分布趋势的可视化类型,热力图可应用于人口密度分析、活跃度分析等。呈现热力图的数据主要包括离散的坐标点及对应的强弱数值。浏览器
数据准备
本文只关心热力图的基础实现,不管你是用于地图,仍是网页焦点分析仍是其余场景,均需将对应场景的坐标转化为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将经纬度坐标转化为像素坐标。微信
让咱们从结果来反推咱们应该如何实现热力图。
[ 热力图原理 ]
一、在热力图中,每一个数据点所呈现的是一个填充了径向渐变色的圆形(所谓径向渐变即由圆心随着半径增长而逐渐变化),而这个渐变圆表现的是数据由强变弱的辐射效果
二、两个圆之间能够相互叠加,且是线性的叠加,其实质表现的是数据强弱的叠加
三、数据强弱的数值与颜色一一映射,通常表现为红强蓝弱的线性渐变,固然你也能够设计本身的强度色谱
一、将每个数据映射为一个圆形
二、选定一个线性维度表示数据强度值,圆形区域内该维度在圆心处达到最大值,沿着半径逐渐变小,直至边缘处为最小值
三、将圆形内的强度值进行叠加
四、以强度色谱进行颜色映射
每每有人对第二、3步有疑问,为何不直接以强度色谱填充圆形呢?
由于没有alpha通道时不会进行混色,重叠的时候颜色会相互覆盖而非叠加;且即便在强度色谱上设置了alpha值,叠加时也是rgb三个通道上分别进行计算,简单来讲就是没法将蓝色与蓝色叠加出现红色。
那须要开一个二维数组存储强度值进行叠加计算吗?
也不用。其实canvas画布自己就能够看做一个二维数组,能够选取alpha单通道做为表示强弱的维度,虽然alpha通道并不是严格的线性叠加,其为a = a1 + a2 - a1 * a2,但也能够知足咱们的需求,以下图所示,其与a = a1 + a2所表示的平面比较贴近。
[ 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为数据最大值,至此,咱们获得的图形以下:
[ 渐变圆形 ]
可见图中的透明度已能表明数据强弱及辐射效果,且在相交处进行了线性的叠加。咱们如今要给图形上色,须要使用ImageData对象对图像进行像素操做,读取每一个像素点的透明度,而后使用其映射后的颜色改写ImageData数值。
先不急着了解像素操做如何进行,咱们首先要肯定的是透明度数值到颜色的映射关系。ImageData中的透明度数值是取值在[0, 255]之间的整数,咱们要建立一个离散的映射函数,使0对应到最弱色(示例中为浅蓝色,你也能够自由设置),255对应到最强色(示例中为正红色)。而这个渐变的过程并非单一维度的递增,好在咱们已有工具解决渐变的问题,即上文已介绍过的createLinearGradient(x1, y1, x2, y2)。
[ 调色盘 ]
如上图所示,咱们能够建立一个跨度为 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三个属性。咱们能够:
一、 经过createImageData(anotherImageData | width, height)来建立一个新对象
二、或者getImageData(left, top, width, height)来建立带有Canvas画布中特定区域的像素数据的对象
三、使用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);
至此,咱们已经完成了热力图的绘制,看看效果吧:
[ 热力图 ]
离屏渲染是指在文档流外的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版可视化组件也即将上线,展现效果更加酷炫,还请各位开发者小伙伴持续关注!