基于Canvas的热力图绘制方法

一. 介绍

最近参与的一个项目Marmot中需要根据点坐标绘制热力图。

热力图

以特殊高亮的形式显示访客热衷的页面区域或访客所在的地理区域

特点为:

1. 可以显示不可点击区域发生的事情。你将发现用户经常会点击那些不是链接的地方,也许你应该在那个地方放置一个资源链接。比如:如果你发现人们总是在点击某个产品图片,你能想到的是,他们也许想看大图,或者是想了解该产品的更多信息。 同样,他们可能会错误地认为特别的图片就是导航链接。

2. 热力图同时还能告诉你,页面的哪些部分吸引了大多数用户的注意。这对那些对web分析数据没有很多经验的产品人员非常有用。

3. 如果你在一个页面上有多个链接指向同一个URL,例如:如果有不同位置的3个链接指到同一个特定的产品页面 ,那么热力图将会显示你的访客最喜欢点击哪一个链接,这将帮助你提升网页的设计并让它对用户更加友好,不过实现这个功能需要一些设置。

…………

实例如下:

需要注意的是上图实例粒度粗,梯度小,容差大。反映了热力图的一个属性:趋势相关。不过,热力图也可以做到粒度细,梯度大,容差小。这完全是依据采样数据的精确性以及分析需求来做的。给个例子(Google的眼动分析[焦点梯度]图):

下面介绍热力图绘制的方法,注意,以下代码并没有检测数据有效性,也没有对数据进行过滤,剔除脏数据,同时没有处理异常。实际使用时请不要忽略此类情况,否则会对最终结果造成干扰……

二. 绘制

问题描述:

假设有一块画布,1200px*2000px尺寸,一组坐标数据,格式为[x,y]二维数组,量级为10000~100000,采样粒度为7*7。依据点坐标的分布密度绘制热力图

方法一


思路:使用canvas元素标签将所有点绘制到画布上,每个点给予较低的透明度。然后获取画布每个点的位数据,根据其alpha值(alpha ∈ [0, 255])的大小计算每一位的r,g,b的值,得出所有新的位数据之后,重新绘制。使之呈现为红色↔蓝色渐变。

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
/*假设点坐标为aXY,二维数组*/
var aXY  =  [ [x1 , y1 ] ,  [x2 , y2 ] ,  [x3 , y3 ] ,  [x4 , y4 ]... ] ;
//获取canvas的context
var context  = canvas. getContext ( '2d' ) ;
var pi2  = Math. PI  *  2 ;
//设置填充样式,透明度为0.1
context. fillStyle  =  'rgba(255,30,0,0.1)' ;
for  ( var i  = 0 , len  = aXY. length ; i  < len ; i ++ )  {
     var x  = aXY [i ] [0 ] , y  = aXY [i ] [1 ] ;
    context. beginPath ( ) ;
     //绘制圆点
    context. arc (x , y , 6 , 0 , pi2 ,  true ) ;
    context. closePath ( ) ;
    context. fill ( ) ;
}
//获取这个画布的位数据
var imgd  = context. getImageData (0 , 0 , 1200 , 2000 ) ;
var pix  = imgd. data ;
// 循环计算rgb,使之根据alpha值映射到红蓝渐变
for  ( var i  = 0 , n  = pix. length ; i  < n ; i  +=  4 )  {
     //位数据的格式为[rgbargbargba……],每个rgba代表了每个点的rgba四个通道的值
     var a  = pix [i +3 ] ;  //alpha
     //red
    pix [i   ]  = 128  * Math. sin ( (/ 256  * a  - 0.5  )  * Math. PI  )  +  200 ;
     //green
    pix [i +1 ]  = 128  * Math. sin ( (/ 128  * a  - 0.5  )  * Math. PI  )  +  127 ;
     //blue,128之后直接衰减为0
    pix [i +2 ]  = 256  * Math. sin ( (/ 256  * a  + 0.5  )  * Math. PI  ) ;
    pix [i +3 ]  = pix [i +3 ]  *  0.8 ;
}
context. putImageData (imgd , 0 , 0 ) ;

上面的代码将会呈现:

image

显而易见,这并不是热力图,但是可以精确反映每个点的分布密度,红色表示在该区域的点数据较多,浅,蓝色表示密度小。那么如何改进?

使用径向渐变代替圆点的绘制,用以表示每一个点向周围的点的辐射,渐变色的叠加可以展现梯度变换的效果。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
var aXY  =  [ [x1 , y1 ] ,  [x2 , y2 ] ,  [x3 , y3 ] ,  [x4 , y4 ]... ] ;
var context  = canvas. getContext ( '2d' ) ;
for  ( var i  = 0 , len  = aXY. length ; i  < len ; i ++ )  {
     var x  = aXY [i ] [0 ] , y  = aXY [i ] [1 ] ;
     //绘制径向渐变
     var radgrad  =  this. context. createRadialGradient (x , y , 1 , x , y , 8 ) ;
     //锚点
    radgrad. addColorStop (  0 ,  'rgba(255,30,0,1)' ) ;
     //锚点
    radgrad. addColorStop (  1 ,  'rgba(255,30,0,0)' ) ;
    context. fillStyle  = radgrad ;
    context. fillRect ( x  - 8 , y  - 8 , 16 , 16 ) ;
}

效果如下:

image

方案度量:这是比较简单的实现方案,稍微麻烦的地方在于根据alpha值计算红蓝绿值,使得alpha高的地方显示红色,alpha低的显示蓝色,中间部分显示黄/绿色(考虑到效率与简单性,使用了简单的三角函数,如果需要更为精确的色相渐变,可以使用幂次变换)。同时这个方案的缺点也十分明显:在点数据量低的时候效率很高,但是点数据超过10000之后就会有明显的时间延迟>3s,原因在于循环绘制渐变色会消耗资源。其次该方案的性能也会取决于画布的大小。画布大的情况,比如画布尺寸为1200*3000,对其取位数据的时候,将会循环360万次,同时进行3*360万sin运算~~对于客户端性能是个问题。

方法二

思路:对所有点数据进行计算,得出每个点的密度值,然后依据密度值由低到高,绘制点数据。

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
var points  =  [ [x1 , y1 ] ,  [x2 , y2 ] ,  [x3 , y3 ] ,  [x4 , y4 ]... ] ;
var cache  =  { } ;
//计算每个点的密度
for  ( var i  = 0 , len  = points. length ; i  < len ; i ++ )  {
     for  ( var j  = 0 , len2  = points [i ]. length ; j  < len2 ; j ++ )  {
         var key  = points [i ] [j ] [ 0 ]  +  '*'  + points [i ] [j ] [1 ] ;
         if  (cache [key ] )  {
            cache [key ]  ++;
         }  else  {
            cache [key ]  =  1 ;
         }
     }
}
//点数据还原
var oData  =  [ ] ;
for  ( var m  in cache )  {
     if  (==  '0*0' )  continue ;
     var x  = parseInt (m. split ( '*' ) [0 ] , 10 ) ;
     var y  = parseInt (m. split ( '*' ) [1 ] , 0 ) ;
    oData. push ( [x , y , cache [m ] ] ) ;
}
//简单排序,使用数组内建的sort
oData. sort ( function (a , b ) {
     return a [2 ]  - b [2 ] ;
} ) ;
var max  = oData [oData. length  - 1 ] [2 ] ;
var pi2  = Math. PI  *  2 ;
//设置阈值,可以过滤掉密度极小的点
var threshold  =  this._points_min_threshold  * max ;
//alpha增强参数
var pr  =  (Math. log (245 ) -1 ) / 245 ;
for  ( var i  = 0 , len  = oData. length ; i  < len ; i ++ )  {
     if  (oData [i ] [2 ]  0  ? 0  : 1 ) ;
     //q参数用于平衡梯度差,使之符合人的感知曲线log2N,如需要精确梯度,去掉log计算
     var q  = parseInt (Math. log (oData [i ] [2 ] )  / Math. log (max )  * 255 ) ;
     var r  = parseInt (128  * Math. sin ( (/ 256  * q  - 0.5  )  * Math. PI  )  + 200 ) ;
     var g  = parseInt (128  * Math. sin ( (/ 128  * q  - 0.5  )  * Math. PI  )  + 127 ) ;
     var b  = parseInt (256  * Math. sin ( (/ 256  * q  + 0.5  )  * Math. PI  ) ) ;
     var alp  =  (0.92  * q  + 20 )  /  255 ;
     //如果需要灰度增强,则取消此行注释
     //var alp = (Math.exp(pr * q + 1) + 10) / 255
     var radgrad  =  this. context. createRadialGradient (oData [i ] [0 ] , oData [i ] [1 ] , 1 , oData [i ] [0 ] ,oData [i ] [1 ] , 8 ) ;
    radgrad. addColorStop (  0 ,  'rgba('  + r  +  ','  + g  +  ',' + b  +  ','  + alp  +  ')' ) ;
    radgrad. addColorStop (  1 ,  'rgba('  + r  +  ','  + g  +  ',' + b  +  ',0)' ) ;
     this. context. fillStyle  = radgrad ;
     this. context. fillRect ( oData [i ] [0 ]  - 8 , oData [i ] [1 ]  - 8 , 16 , 16 ) ;
}

以上代码结果如下:

image

大约处理了25000个点,用时大约700ms(鄙人的小本性能还行)。属于可接受范围内。

方案度量:此方案性能比方案一有明显优势。目前Marmot采用此方案。

  • 0
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值