Canvas 实现一款图表插件

JavaScript | 2020-12-22 23:39:20 871次 1次

一、Canvas 介绍

Canvas 是一个画布容器,通过 JavaScript 来绘制 2D 图形(3D 也可以,使用 three.js)。Canvas 是逐像素进行渲染的,在 Canvas 中,一旦图形被绘制完成,它就不会继续得到浏览器的关注。如果其位置发生变化,那么整个场景也需要重新绘制,包括任何可能已被图形覆盖的对象。也就是说如果我们绘制的图表想要实现一个动画效果,那我们将清除画布的逐步绘制。更好的做法就是做离屏缓存。

Canvas 的默认宽高为 300*150 px,这里是物理像素宽高。如果我们想设置画布宽高需要使用:

<canvas width="500" height="400"></canvas>

也可以使用脚本控制宽高。如果只是单纯地设置 CSS 样式,宽高只是视觉上的改变,画布的像素点不会改变;如果想做自适应的布局就要手动计算宽高,再给 Canvas 设置,否则会出现变形模糊的情况;如果想要再高清点的视觉,可以将 Canvas 实际像素扩大两倍,CSS 样式再进行缩放。

此次的插件开发采用 Webpack 管理,代码拆分为不同的模块,添加和修改功能能够快速追踪定位。

此次图表功能包含折线图、柱状图、扇形图、圆环图、雷达图、圆环进度比图。

84846990-f153-11e8-b7c2-d74e319cbc21.png8eae7e60-f153-11e8-b7c2-d74e319cbc21.png

最终效果:

https://winesu.github.io/charts/dist/index.html?s=22

二、实现基本的一个圆环进度比绘制

Canvas 的 API 这里就不赘述了,可以自查文档,这里的打底圆环代码不能直接运行,最后会统一给 GitHub 链接。

先了解绘制的思路。

1. 首先绘制一个打底圆环

       //设置线条宽度
	ctx.lineWidth = 20;
	//开始一个路径
	ctx.beginPath(); 
	//渐变色实现
	var grd = ctx.createRadialGradient(
               circleValue.x, circleValue.y, 
               circleValue.radius - 10, circleValue.x, 
               circleValue.y, 
               circleValue.radius + 9
             );
	grd.addColorStop(0,"#e9eae9");
	grd.addColorStop("0.8","#fefefe");
	grd.addColorStop("1","#e9eae9");
	ctx.strokeStyle = grd;
	//绘制圆环
	ctx.arc(circleValue.x, 
             circleValue.y, 
             circleValue.radius, 
             circleValue.startAngle, 
             circleValue.endAngle, 
             circleValue.anticlockwise
         );
	//结束路径
	ctx.closePath(); 
	//边框
	ctx.stroke();

2. 展示进度圆环

 //设置线条宽度
	ctx.lineWidth = circleValue.arcWidth;
	ctx.beginPath();
	//线性渐变
	var linear = ctx.createLinearGradient(220,220,380,200);
	linear.addColorStop(0,'#ffc26b');
	linear.addColorStop(0.5,'#ff9a5f');
	linear.addColorStop(1,'#ff8157');
	ctx.strokeStyle = linear;
	ctx.arc(circleValue.x, 
             circleValue.y, 
             circleValue.radius, 
             circleValue.startAngle, 
             circleValue.endAngle*percent, 
             circleValue.anticlockwise
         );
	ctx.stroke();

3. 起点圆角

//进度起点圆角
	ctx.beginPath();
	ctx.fillStyle = '#ff8157';
	ctx.arc(circleValue.x + circleValue.radius, 
            circleValue.y - 1, 
            circleValue.arcWidth/2, 
            circleValue.startAngle, 
            circleValue.endAngle, 
            circleValue.anticlockwise
        );
	ctx.closePath();
	ctx.fill();

4.终点圆角

//终点圆角
	ctx.lineWidth = circleValue.arcWidth - 7.5;
	ctx.beginPath();
	ctx.shadowOffsetX = 0;
	ctx.shadowOffsetY = 0;
	ctx.shadowBlur = 26;
	ctx.shadowColor = '#ff7854';
	ctx.fillStyle = '#ff7854';
	ctx.strokeStyle = '#fff';
	var getX = circleValue.x + circleValue.radius * Math.cos(2 * percent * Math.PI),
		getY = circleValue.y + circleValue.radius * Math.sin(2 * percent * Math.PI);
	ctx.arc(getX , 
            getY, 
            circleValue.arcWidth - 2, 
            circleValue.startAngle, 
            circleValue.endAngle, 
            circleValue.anticlockwise
        );
	ctx.closePath();
	ctx.fill();
	ctx.stroke();

绘制圆就是角度的控制,圆的角度是 [-2pi, 2pi],从第一象限开始计算,起点为 0 则到 2pi 结束,起点为 -2pi 则到 0 结束。


三、 封装插件介绍

把我们开发的功能封装为一个插件,这样可以很方便地各处调用。这里我们将介绍类似 jQuery 的无 new 构建的方式。

;(function(window,undefined){

    var AreaPicker = function(param) {
        // return AreaPicker.prototype.init(); 
        return new AreaPicker.prototype.init();  这里加new,每次创建一个new,允许多次调用分隔作用域
    };

    AreaPicker.prototype  = {
      init: function() {
        this.onchange();
        return this;
      },
      onchange:function() {
		//do something
      },

      areaChange: function() {
		//do something
      }
    };
    //内部指针,重新指向
    AreaPicker.prototype.init.prototype = AreaPicker.prototype;

    if(!window.AreaPicker){
        window.AreaPicker =  AreaPicker;
    }
    })(window,undefined)
	//调用
    var ss = AreaPicker(form,$,city);

new AreaPicker.prototype.init() 主要做了三件事:

  • 创建一个空对象 var obj = {};,obj 对象属性 _proto_ 指向函数 AreaPicker.prototype.init 的 prototype;

  • 将 AreaPicker.prototype.init 函数的 this 替换成 obj 对象,调用 AreaPicker.prototype.init 函数 AreaPicker.prototype.init.call(obj),并返回新对象;

  • 因为 AreaPicker.prototype.init 返回的对象原型是 AreaPicker.prototype.init.prototype,它和 AreaPicker.prototype 并没什么关系,为了使新返回的对象可以继承自 AreaPicker.prototype,所以让 AreaPicker.prototype.init.prototype 指向 AreaPicker.prototype,创建实例并划分作用域。

无 new 构建插件可以直接用这个模板,名字更改下就可以了。接下来我们把上面的圆环进度比集成到插件里面。

四、插件开发

设计插件前先分析需要的功能,再为功能设计参数,比如颜色配置、边距、字体大小、线条宽度等。

  • 考虑哪些参数是调用时候必须传进来的,比如插件需要传入一个父元素 id 再根据父元素的宽高设置 Canvas 宽高,避免不同的样式 Canvas 坐标也需要重新计算的问题。

  • 分析哪些参数不是必须的,则设置为默认的参数,插件内部配置好。基础运行设施配置好后,再拆分看功能,这次分为各个图表,耦合性几乎为 0,我们可以分别创建单独的开发文件,如果全挂载到 prototype 下文件就太长太长了,不是因为 Canvas 的特殊性才这样,其他插件亦是,功能一定要区分各个模块,最后使用总文件包含进来(如果比较简单的文件只需要一个就行,功能还是得明细开来)。

项目结构

537588a0-eed0-11e8-81f4-2b11e4dfe134.png

首先把环境打好,这里使用 Webpack,需要的功能是 ECMAScript 6 转 ECMAScript 5、热更新,所有的开发都在 src 目录下。

先看下 charts 总入口 js(太长,只贴主要结构):

 //固定资源
import utils from './utils';
import "../css/main.css";

//动画
import Animation from './drawAnimation';
//圆环进度
import Cirque from './cirque';
//坐标轴
import {drawAxis, drawPoint, drawLine, drawLineXdash, drawBar}  from './drawAxis';
//扇形图
import drawPie from './drawPie';
//雷达图
import drawRegion from './drawRegion';

;(function(window,undefined){
    function Charts(defaultParam){
        return new Charts.prototype.init(defaultParam);
    }
    Charts.prototype = {
        init: function(defaultParam){
            //初始化元素类
            let _this = this,
                //canva父节点
                _canvasParDom = document.getElementById(defaultParam.id), 
                //容器宽度 
                _parWid = _canvasParDom.clientWidth,
                //容器高度
                _parHei = _canvasParDom.clientHeight,
                //创建Canvas节点
                _canvas = document.createElement("canvas"),
                //默认参数
                setDefault = {
                    styleSet:{
                        borderColor:'#ff984e',
                        lineColor:'#ff984e',
                        pointColor:'#ff7854'
                    },
                    data:[],
                    x: 32,
                    padding: 10,
                    fontSize: '20px',
                    wd: _parWid*2,
                    ht: _parHei*2 - 20,
                    lineWidth: 2,
                    barColor:'#199475',
                    pieColor:['#546570',...]
                };

            //获取当前类ctx
            this.ctx = _canvas.getContext("2d");
            this.canvas = _canvas;

            //设置Canvas实际宽度  为当前父元素两倍宽度  再缩放Canvas的样式宽度  避免手机展示不清晰
            _canvas.width  = _parWid*2;
            _canvas.height = _parHei*2;
            //添加子节点
            _canvasParDom.appendChild(_canvas);
            this.defaultParam = utils.extend(setDefault,defaultParam);
            //去除边界宽度(防止图表区的溢出)
            this.defaultParam.wid = _canvas.width - 10;
            //获取数据的最大值并设置峰值0.82比例
            this.defaultParam.maxPoint = utils.maxData(this.defaultParam.data) / 0.82;
            switch(defaultParam.type){
                case 'cirque':
                    //元环比
                    let circleValue = {
                            x : setDefault.wd / 2,
                            y : setDefault.ht / 2,
                            radius : 200,
                            startAngle : 0,
                            endAngle : 2 * Math.PI,
                            anticlockwise : false,
                            arcWidth: 18,
                            current:80
                        };
                    this.circleValue = utils.extend(circleValue,defaultParam);
                    Animation.call(this,{
                        percent: this.circleValue.current,
                        render: (current)=>{
                            Cirque.call(this,current/100);
                        }
                    });
                break;

                case 'line':
                break;

                ...
            }
            return this;
        }
    }
    ...
})(window,undefined);

总入口的文件配置好后,再去想单独的功能会用到哪些方法,比如参数的合并,图表数据会有一些最大值最小值、数据总和的求解,那么我们可以将这些基础类继承到一个公共函数 utils 里面(部分代码):

        /**
	 * 公共插件
	 */
	let utils = {
		/**
		 * @Author   SuZhe
		 * @DateTime 2018-11-07
		 * @desc     参数合并
		 * @param    {[Object]}   defaults [默认参数]
		 * @param    {[Object]}   newObj   [用户设置参数]
		 */
		extend: function(defaults,newObj){
			for (var i in newObj) {
				defaults[i] = newObj[i];
			}
			return defaults;
		}
	}
	
	export default utils;

接下来我们就可以开始执行绘画的逻辑了,这里先拿折线图做说明,其他图表提供思路。绘制折线图,首先我们要绘制出坐标轴,其次是坐标轴上的点值,折线和折线上的点值。先画出 x、y 坐标轴,然后均分 x 轴,将时间点绘制上去,然后根据数据值,再求出 (x, y) 坐标点,使用 lineTo 绘制出连续折线图。


1. 坐标轴

要确定坐标轴的起始坐标点,x 轴开始点 ( 设置的间距 , Canvas 高度 - 间距 ) 和结束点 ( 宽度 - 间距 , 高度 - 间距 ),y 轴开始点 ( 间距 , 间距 ) 和结束点 ( 间距 , 高度 - 间距 ),有了坐标点,剩下的连线就可以了。

export function drawAxis () {
    let defaultParam = this.defaultParam,
        ctx = this.ctx,
        pad = defaultParam.padding+0.5,
        bottompad = 10.5,
        wd = defaultParam.wd,
        ht = defaultParam.ht;
    ctx.beginPath();
    ctx.lineWidth = 1;
    ctx.setLineDash([1,1]);
    //手机端1px线条问题修复(动画方式下直接在参数上增加0.5  不采用translate方式)
    // ctx.translate(0.5, 0.5);
    //10为默认边界宽度
    ctx.moveTo(pad,pad);
    ctx.lineTo(pad,ht - bottompad);
    ctx.moveTo(pad,ht -bottompad);
    ctx.lineTo(wd - pad,ht -bottompad);
    ctx.strokeStyle = defaultParam.styleSet.borderColor;
    ctx.stroke();
    ctx.closePath();
}

2. 坐标轴上点

循环数组,分别根据各个点的坐标 (( Canvas 宽度/数据长度 )*i + 间距 , Canvas 高度 - Canvas 高度* 数值 [i]/总数值*峰值比 - 上间距 ) 绘制折线上的圆点和数值和 x 轴坐标。

3. x 轴上纵线

这里得需要起点坐标和终点坐标:

  • 上一个点 (( Canvas 宽度/数据长度 )*i+间距 , Canvas 高度 - Canvas 高度*数值 [i]/总数值*峰值比 - 上间距 )

  • 下一个点 (( Canvas 宽度/数据长度 )*(i+1) + 间距 , Canvas 高度 - Canvas 高度*数值 [i+1]/总数值*峰值比-上间距 )

需要注意的是在第一个数据点的纵线只能用上一个点的坐标,否则是画不出的。

动画函数的说明:

export default function Animation(param) {
    let current = 0;
    let looped;
    let ctx      = this.ctx;
    let _canvas  = this.canvas;
    let callback = param.render;
    let otherCall = param.success;
    (function looping(){
        looped = requestAnimationFrame(looping);
        if(current < param.percent){
            ctx.clearRect(0,0,_canvas.width,_canvas.height);
            current = (current + 4) > 100 ? 100 : current+4;
            callback(current);
        }else{
            window.cancelAnimationFrame(looped);
            looped = null;
            if(otherCall){
                otherCall();
            }
        }
    })();
}

动画的思路就是传入一个值(想要执行到哪里就结束渲染)然后清除画布,调用传入的回调函数(绘制函数),使用浏览器的 requestAnimationFrame 来渲染,定时器也行,动画执行完毕 window.cancelAnimationFrame 清除。这个动画函数接受传入的参数,并返回实时的进度值,在总入口我们引入了这个文件,并且调用的时候将 this 传入,就可以使用插件的 this 下的一参数。

 //调用动画加载
Animation.call(this,{
    percent: 100,
    render: (current)=>{
        //绘制圆环进度的函数
        Cirque.call(this,current/100);
    }
});
//直接调用次函数将会没有动画效果  直接渲染
Cirque.call(this,100/100);

4. 折线连接

这里折线链接有一个渐进的过程,所以用到上面的动画函数。

speed 参数就是进度控制的重要一个因素。折线想实现渐进,需要用这个 speed*数据长度并向下取整,然后在循环里面判断当前 i 是否小于等于这个下取整点值,因为只有走到小于这个点的时候才能达到绘制完第一个点再绘制第二个,以此类推。

这绘制时发现这样在各个点之间瞬间完成的效果是很刚硬的,也就是点与点之间的距离是没有动画效果的,需要再加上缓冲。speed 此时是不能帮忙的,需要再计算一个缓冲数据。刚刚得到了一个下取整的数值,那么缓冲值可以计算为

speed*len-Math.floor(speed*len),取值范围是 1=>x>=0

可以设想从 a 点到 b 点,这段距离我们可以用 ( 到达坐标 - 开始坐标 )* 缓冲的值 + 开始坐标,前提条件是 i 必须等于下取整的值时候才需要加这个缓冲计算。可以自己看下效果。

每个函数都传入了 speed 参数,这是动画的关键,Canvas 的动画就是通过不断地更改坐标点绘制而实现的,但是别忘了清除下画布。


绘制扇形和上面描述的绘制圆环是一样的思路,根据圆心角来划分,循环出各个点,计算每个点占总数的比,然后乘以圆心角总数,就是各个部分的占比。再配置上不同的颜色,一个简单的扇形图就完成了,如果想加上线条标注数据,那就要根据象限来判断,绘制路径。

绘制雷达图,先绘制出正多边形,根据圆心角来判断线条路径(可自行查看数学公式)。在绘制覆盖区域时依然根据圆心角来做绘制,雷达图无论是边线还是覆盖区域都是同一个圆心点。

完整代码请移步:

https://github.com/wineSu/charts

1人赞

分享到: