Web数据可视化-手把手教你实现热力图

栏目: 数据库 · 发布时间: 6年前

内容简介:位置数据是连接线上线下的重要信息资源,在前端借助于图形化的手段将数据有效呈现是进行数据分析的重要手段。基于此,我们开发了基于地图的数据可视化组件,以附加库的形式加入到JSAPI中,目前主要包括热力图、散点图、区域图、迁徙图。热力图是以颜色来表现数据强弱大小及分布趋势的可视化类型,如上图左上角所示,热力图可应用于人口密度分析、活跃度分析等。呈现热力图的数据主要包括离散的坐标点及对应的强弱数值。本文只关心热力图的基础实现,无论你是用于地图,还是网页焦点分析还是其他场景,均需将对应场景的坐标转化为Canvas画布

位置数据是连接线上线下的重要信息资源,在前端借助于图形化的手段将数据有效呈现是进行数据分析的重要手段。基于此,我们开发了基于地图的数据可视化组件,以附加库的形式加入到JSAPI中,目前主要包括热力图、散点图、区域图、迁徙图。

Web数据可视化-手把手教你实现热力图

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

热力图实现

数据准备

本文只关心热力图的基础实现,无论你是用于地图,还是网页焦点分析还是其他场景,均需将对应场景的坐标转化为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}
];
复制代码

实现原理

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

Web数据可视化-手把手教你实现热力图

我们可以直观的感受到:

  1. 在热力图中,每个数据点所呈现的是一个填充了径向渐变色的圆形(所谓径向渐变即由圆心随着半径增加而逐渐变化),而这个渐变圆表现的是数据由强变弱的辐射效果
  2. 两个圆之间可以相互叠加,且是线性的叠加,其实质表现的是数据强弱的叠加
  3. 数据强弱的数值与颜色一一映射,一般表现为红强蓝弱的线性渐变,当然你也可以设计自己的强度色谱

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

  1. 将每一个数据映射为一个圆形
  2. 选定一个线性维度表示数据强弱,颜色以该维度进行渐变并填充圆形
  3. 将圆形进行叠加
  4. 以强度色谱进行颜色映射

以上步骤中需要注意的是第2步,我们并非直接以强度色谱填充圆形,因为这样得到的颜色是3个维度的,在叠加的时候不是线性的。本文选取了 alpha 即颜色中的透明度作为表示强弱的维度,你也可以选取 r 或者 g 或者其他,后文会解释选择 alpha 的好处。

动手实现

绘制圆形

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

arc(x, y, radius, startAngle, endAngle, anticlockwise)
复制代码

xy 对应到数据的坐标, radius 可自由设置, startAngleendAngle 表示起止角度,分别取 02 * Math.PIanticlockwise 表示是否逆时针,可不设置。

渐变色

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 为数据最大值,至此,我们得到的图形如下:

Web数据可视化-手把手教你实现热力图

颜色映射

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

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

Web数据可视化-手把手教你实现热力图

如上图所示,我们可以创建一个跨度为 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)
putImageData(myImageData, left, top)

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

// 像素着色
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);
复制代码

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

Web数据可视化-手把手教你实现热力图

性能优化

离屏渲染

如果我们在地图上呈现热力图,随着地图的移动,数据点的坐标会变化,但其对应的圆形图像其实是不变的。所以为了避免更新坐标时重复地创建渐变色、设置 globalAlpha 、绘制及填充颜色等,我们可以预先绘制好每个数据点的图像,通过一个不在文档流中的Canvas保存下来,在重新渲染的时候通过 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);
};
复制代码

2019.1.14更新:经过性能测试发现,上文所述有误。离屏渲染主要应用于局部绘制过程较复杂,而该局部又被重复绘制的场景下;同时应保证这个离屏的画布大小适中,因为复制过大的画布会带来很大的性能损耗。在上文中,局部绘制过程其实很简单,与直接使用 drawImage 的耗时相差无几,所以无需使用离屏渲染。

避免浮点数坐标

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


以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

万万没想到

万万没想到

万维钢(同人于野) / 电子工业出版社 / 2014-10-1 / 39.80元

本书精选了万维钢老师的文章和书评,以“用理工科思维理解世界”为导向。作者常用有趣的实验、数据来解读感性的事物,其理工科思维涉及行为经济学、认知心理学、社会学、统计学、物理等许多学科,以前沿的科学视角解读生活,为人们提供了认知的新方法。读完本书相当于精读了十几本经过筛选 、再创作及通俗化处理的巨著,不仅有趣还十分有营养。一起来看看 《万万没想到》 这本书的介绍吧!

RGB HSV 转换
RGB HSV 转换

RGB HSV 互转工具

RGB CMYK 转换工具
RGB CMYK 转换工具

RGB CMYK 互转工具

HEX HSV 转换工具
HEX HSV 转换工具

HEX HSV 互换工具