带着canvas去流浪4绘制散点图

35次阅读

共计 4650 个字符,预计需要花费 12 分钟才能阅读完成。

示例代码托管在:http://www.github.com/dashnowords/blogs

博客园地址:《大史住在大前端》原创博文目录

华为云社区地址:【你要的前端打怪升级指南】

[TOC]

一. 任务说明

使用原生 canvasAPI 绘制散点图。(截图以及数据来自于百度 Echarts 官方示例库【查看示例链接】)。

二. 重点提示

学习过折线图的绘制后,如果数据点只有坐标数据,则通过基本的坐标转换在对应的点上绘制出散点并不难实现。而在气泡图中,当我们直接将百度 Echarts 示例中的数据拿来经过一定的线性缩小后作为半径直接绘制散点时,就会出现一些问题,数据集的范围跨度较大,导致大部分点呈现后都非常小,这个时候就需要使用某种方法从真实数据值映射到散点圆半径进行映射,来缩小它们之间的差异,否则一旦数据集中有一个偏离度较大的点,就会造成其他点所对应的散点半径都很大或者都很小,对数据呈现来说是不可取的。例如在下面的示例中,当使用几种不同的映射方法来处理数据后,可以看到绘制的散点图是不一样的。

// 求散点半径时所使用的公式
//1. 直接数值
r = value * 5 / 100000000;
//2. 求对数
r = Math.log(value);
//3. 求指数
r = Math.pow(value,0.4) / 100;

所绘制出的散点图如下所示:

坐标映射 的实现思路其实并不算复杂,它的概念可以参考算法的时间复杂度来进行理解,挑选一个增长更快的映射函数来区分相近的点,或者挑选一个增长更慢的映射函数来减小大跨度数据之间的差异,在数据可视化中是非常实用的技巧。本文示例中的效果是笔者自己手动调的,如果要实现根据数据集自动挑选适当的映射函数,还需要设计一些计算方法,感兴趣的读者可以自行研究。

三. 示例代码

气泡散点图绘制示例代码(坐标轴的绘制过程在前述博文中已经出现过很多次,故不再赘述,有需要的小伙伴可以直接翻看这个系列之前的博文或者查看本篇的 demo):

/* 数据点来自于百度 Echarts 官方示例库,每个数值分别表示[横坐标,纵坐标,数值,国家,年份]
*[28604,77,17096869,'Australia',1990]
*/

/**
 * 绘制数据
 */
function drawData(options) {
    let data = options.data;// 获取数据集
    let xLength = (options.chartZone[2] - options.chartZone[0]);
    let yLength = (options.chartZone[3] - options.chartZone[1]);
    let gap = xLength / options.xAxisLabel.length;

    // 遍历两个年份
    for(let i = 0; i < data.length ;i++){
        let x,y,r,c;
        context.fillStyle = options.colorPool[i];// 从颜色池中选取颜色
        context.globalAlpha = 0.8;// 为避免点覆盖,采取半透明绘制
        // 遍历各个数据点
        for(let j = 0; j < data[i].length ; j++){
            // 计算坐标
             x = options.chartZone[0] + xLength * data[i][j][0] / 70000;
             y = options.chartZone[3] - yLength * (data[i][j][4] - 55) / (85 - 55);

             // 直接数值
             r = data[i][j][5] * 5 / 100000000;
             // 求对数
             r = Math.log(data[i][j][6]);
             // 开根号
             r = Math.pow(data[i][j][7],0.4) / 100;
             // 绘制散点
             context.beginPath();
             context.arc(x, y , r , 0 , 2*Math.PI,false);
             context.fill();
             context.closePath();}
    }
}

浏览器中可查看效果:

四. 散点 hover 交互效果的实现

4.1 基本算法

在散点图上实现 hover 交互效果的基本算法如下:

  1. canvas 元素上监听鼠标移动事件,将鼠标坐标转换为 canvas 坐标系的坐标值。
  2. 遍历数据点查看是否存在当前鼠标点距离某个数据中心点的距离小于其散点的绘制半径,如果有则认为鼠标在该点之上。
  3. 利用之前缓存的该点绘图数据,调整绘图样式,增大数据点的绘图半径覆盖式绘图即可。
  4. 当鼠标距离任何数据点的距离都大于该点的绘图半径,或鼠标从一个 hover 数据点移动到另一个 hover 点时,均需要调用一次 resetHover() 方法清除之前的 hover 状态。
  5. 为了恢复 hover 前的状态,可以使用 【离屏 canvas 技术】 缓存首次绘图后的结果,然后使用 drawImage() 方法将对应区域恢复到 hover 前的状态。

4.2 参考代码

hover 效果的关键代码如下,完整示例代码请在 demo 中获取,或访问【我的 github 仓库】

/* 简单 hover 效果 */
canvas.onmousemove = function (event) {
    // 转换鼠标坐标为相对 canvas
    let pos = {
        x: event.clientX - rect.left,
        y: event.clientY - rect.top
    }
    // 获取当前 hover 点坐标
    let hoverPoint = checkHover(options, pos);
    /**
     * 如果当前有聚焦点
     */
    if (hoverPoint) {
        // 如果当前点和上一次记录的 hover 点是不同的点,则先调一次 reset 方法,然后把 hover 点更改为当前的点
        let samePoint = options.hoverData === hoverPoint ? true : false; 
        if (!samePoint) {resetHover();
            options.hoverData = hoverPoint;
        }
        // 绘制当前点的 hover 状态
        paintHover();} else{
        // 第一次尝试手动恢复
        // resetHover();
        // 使用离屏 canvas 恢复
        resetHoverWithOffScreen();}
} 


/* 检测是否 hover 在散点之上 */
function checkHover(options,pos) {
    let data = options.paintingData;
    let found = false;
    for(let i = 0; i < data.length; i++){
        found = false;
        for(let j = 0; j < data[i].length; j++){if (Math.sqrt(Math.pow(pos.x - data[i][j].x , 2) + Math.pow(pos.y - data[i][j].y , 2)) < data[i][j].r) {found = data[i][j];
                break;
            }
        }
        if (found) break;
    }
    return found;
}

/* 绘制 hover 状态 */
function paintHover() {let {x,y,r,c} = options.hoverData;
    let step = 0.5;
    context.globalAlpha = 1;
    context.fillStyle = c;
    // 逐帧增加 hover 点的绘图半径,重新绘制 hover 状态的散点
    for(let i = 0 ; i < 30; i++){context.beginPath();
       context.arc(x,y,r + i * step, 0 , 2*Math.PI,false);
       context.fill();
       context.closePath();}
}

/* 首次尝试的取消高亮状态的函数 */
function resetHover() {if (!options.hoverData) return;
    let {x,y,r,c} = options.hoverData;
    let step = 0.5;
    context.globalAlpha = 1;
    for(let i = 29; i>0; i--){context.save();
       // 绘制外圆范围
       context.beginPath();
       context.arc(x,y,r + 30 * step, 0 , 2*Math.PI,false);
       context.closePath();
       // 设置剪裁区域
       context.clip();
       // 用全局背景色绘制剪裁区背景
       context.globalAlpha = 1;
       context.fillStyle = options.globalGradient;
       context.fill();
       // 绘制内圆
       context.beginPath();
       context.arc(x,y,r + i * step, 0 , 2*Math.PI,false);
       context.closePath();
       context.fillStyle = c;
       context.globalAlpha = 0.8;
       // 填充内圆
       context.fill();
       context.restore();}
    options.hoverData = null;
    console.log('清除 hover 效果');
}

// 利用离屏 canvas 恢复 hover 前的状态
function  resetHoverWithOffScreen() {if (!options.hoverData) return;
    let {x,y,r,c} = options.hoverData;
    let step = 0.5;
    context.globalAlpha = 1;
    for(let i = 29; i>0; i--){context.save();
       // 将 hover 状态下数据点圆所在的正方形范围恢复为 hover 前的状态
       context.drawImage(canvas2, x - r - 30 * step, y - r - 30 * step , 2 * (r + 30 * step),2*(r + 30 * step),x - r - 30 * step, y - r - 30 * step , 2*(r + 30 * step),2*(r + 30 * step));
       // 绘制内圆
       context.beginPath();
       context.arc(x,y,r + i * step, 0 , 2*Math.PI,false);
       context.closePath();
       context.fillStyle = c;
       context.globalAlpha = 0.8;
       // 填充内圆
       context.fill();
       context.restore();}
    options.hoverData = null;
    console.log('清除 hover 效果');
}

4.3 Demo 中的小问题

  1. 为了简化代码,demo 中的一些绘图数据并没有参数化,而是采取直接写死的形式放在代码里,尤其是逐帧绘图的代码,一般开发中此处都会配合动画来进行实现。
  2. 为了重置某个数据点的 hover 状态,笔者最初的实现思路是在每一帧中,使用 context.clip() 方法裁切出绘图区域,先用全局背景绘制出背景图,缩小数据点半径,然后再绘制数据点,直到半径缩小至 hover 前的值。但在实现后发现这种方式存在一个问题,那就是数据点之间出现重叠时,如果只是简单地背景重绘,就会将部分重叠区域清除掉,造成其他数据点无法复原,如下图所示:

所以最终采用离屏 canvas 的方法,将初次绘制后的数据点先暂存下来,然后在清除 hover 状态时,使用 context.drawImage() 方法将有关区域的数据复制粘贴过来,以替代原来的使用背景图填充该区域的做法,这样就可以在数据点之间有重叠时重现 hover 前的状态。

正文完
 0