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

热力图简介

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

可视化组件

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

热力图实现

数据准备

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

实现原理

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


热力图原理

我们可以直观的感受到:

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

渐变圆形

颜色映射

可见图中的透明度已能代表数据强弱及辐射效果,且在相交处进行了线性的叠加。我们现在要给图形上色,需要使用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);

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

热力图

性能优化

离屏渲染

如果我们在地图上呈现热力图,随着地图的移动,数据点的坐标会变化,但其对应的圆形图像其实是不变的。所以为了避免更新坐标时重复地创建渐变色、设置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时如果使用了浮点数坐标,浏览器为了达到抗锯齿的效果,会做额外计算,渲染子像素。所以尽量使用整数坐标。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,378评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,356评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,702评论 0 342
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,259评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,263评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,036评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,349评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,979评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,469评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,938评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,059评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,703评论 4 323
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,257评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,262评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,485评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,501评论 2 354
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,792评论 2 345

推荐阅读更多精彩内容

  • 一:canvas简介 1.1什么是canvas? ①:canvas是HTML5提供的一种新标签 ②:HTML5 ...
    GreenHand1阅读 4,663评论 2 32
  • canvas元素的基础知识 在页面上放置一个canvas元素,就相当于在页面上放置了一块画布,可以在其中进行图形的...
    oWSQo阅读 10,270评论 0 19
  • 一、canvas简介 1.1 什么是canvas?(了解) 是HTML5提供的一种新标签 Canvas是一个矩形区...
    Looog阅读 3,936评论 3 40
  • 01三口旧锅 “三口十年前的旧锅,送给了拾荒的老人。他高高兴兴,我清清爽爽。哼,再也不看着你们碍眼了[转圈][转圈...
    白兰姑娘阅读 381评论 13 5
  • 未来遇上过去 碰巧产生对比 波折平稳反复 胜过无奇 雷同无趣 若诗画能为其耳目 若书墨能为其口鼻 若岁月漫漫不负韶...
    清风_1ec7阅读 277评论 3 3