关于javascript:高仿一个echarts饼图

32次阅读

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

结尾

饼图,很常见的一种图表,应用任何一个图表库都能轻松的渲染进去,然而,我司的交互想法千奇百怪,布局捉摸不透,自身饼图是没啥可变的,然而配套的图例变幻无穷,翻遍 ECharts 配置文档都还原不进去,那么有两条路能够选,一是跟交互说实现不了,压服交互按图表库的布局来,然而个别交互可能会对你灵魂拷问,为什么他人都能做进去,你做不进去?所以我选第二种,本人做一个得了。

canvas 实现一个饼图很简略,所以本文在介绍应用 vue 高仿一个 ECharts 饼图的实现过程中会顺便回顾一下 canvas 的一些知识点,先来看一下本次的成绩:

布局及初始化工作

布局很简略,一个 div 容器,一个 canvas 元素即可。

<template>
  <div class="chartContainer" ref="container">
    <canvas ref="canvas"></canvas>
  </div>
</template>

容器的宽高写死,canvas的宽高须要通过自身的属性 widthheight来设置,最好不要应用 css 来设置,因为 canvas 画布默认的宽高是 300*150,应用 css 不会扭转画布原始的宽高,而是会将其拉伸到你设置的 css 宽高,所以会呈现变形的问题。

// 设置为容器宽高
let {width, height} = this.$refs.container.getBoundingClientRect()
let canvas = this.$refs.canvas
canvas.width = width
canvas.height = height

绘图的 api 都是挂在 canvas 的绘图上下文中,所以先获取一下:

this.ctx = canvas.getContext("2d")

canvas坐标系默认的原点在左上角,饼图的绘制个别都是在画布两头,所以每次绘制圆弧的时候圆心都要换算一下设置到画布的中心点,这个示例中只有换算一个中心点并不麻烦,然而如果在更简单的场景,所有都要换算是很麻烦的,所以为了防止,能够应用 translate 办法将画布的坐标系原点设置到画布中心点:

this.centerX = width / 2
this.centerY = height / 2
this.ctx.translate(this.centerX, this.centerY)

接下来须要计算一下饼图的半径,画的太满不太好看,所以暂定为画布区域短边一半的 90%:

this.radius = Math.min(width, height) / 2 * 0.9

最初看一下要渲染的数据的构造:

this.data = [
    {
        name: '名称',
        num: 10,
        color: ''// 色彩
    },
    // ...
]

饼图

饼图其实就是一堆面积不一的扇形组成的一个圆,画圆和扇形都是应用 arc 办法,它有 6 个参数,别离是圆心 x、圆心 y、半径 r、圆弧终点弧度、圆弧起点弧度、逆时针还是顺时针绘制。

扇形的面积代表数据的占比,能够用角度的占比来示意,那就须要转成弧度,角度转弧度公式为:弧度 = 角度 *(Math.PI/180)

// 遍历数据进行转换,total 是所有数据的数量总和
let curTotalAngle = 0
let r = Math.PI / 180
this.data.forEach((item, index) => {let curAngle = (item.num / total) * 360
    let cruEndAngle = curTotalAngle + curAngle
    this.$set(this.data[index], 'angle', [curTotalAngle, cruEndAngle])// 角度
    this.$set(this.data[index], 'radian', [curTotalAngle * r, cruEndAngle * r])// 弧度
    curTotalAngle += curAngle
});

转换为弧度之后再遍历 angleData 来进行扇形绘制:

// 函数 renderPie
this.data.forEach((item, index) => {this.ctx.beginPath()
    this.ctx.moveTo(0, 0)
    this.ctx.fillStyle = item.color
    let startRadian = item.radian[0] - Math.PI/2
    let endRadian = item.radian[1] - Math.PI/2
    this.ctx.arc(0, 0, this.radius, startRadian, endRadian)
    this.ctx.fill()});

成果如下:

beginPath办法用来开始一段新的门路,它会把以后门路的所有子门路都给革除掉,否则调用 fill 办法闭合门路时会把所有的子门路都首尾连接起来,那不是咱们要的。

另外这里应用 moveTo 办法将这个新门路的终点移到了坐标原点,为什么要这样能够先看不这样的成果:

起因是因为 arc 办法只是绘制一段圆弧,所以把它的首尾相连就是上述成果,然而扇形是须要这段圆弧和圆心一起闭合,arc办法调用时如果以后门路上曾经存在子门路会用一段线段把以后子门路的起点和这段圆弧的终点连接起来,所以咱们先把门路的终点移到圆心,这样最初闭合现成的就是一个扇形。

至于为什么起始弧度和完结弧度都减了Math.PI/2,是因为 0 弧度是在 x 轴的正方向,也就是左边,然而个别咱们认为的终点在顶部,所以减掉 1 / 4 圆让它的终点移到顶部。

动画

咱们在应用 ECharts 饼图的时候会发现它渲染的时候是会有一小段动画的:

canvas 实现动画的基本原理就是一直扭转绘图数据,而后一直刷新画布,听起来像是废话,所以一种实现形式是动静批改以后绘制完结的圆弧的弧度,从 0 始终变动到 2*Math.PI,这样就能够实现这个缓缓变多的成果,然而这里咱们应用另外一种,用clip 办法。

clip用来在以后门路中创立一个剪裁门路,剪裁之后,后续绘制的信息只会呈现在该剪裁门路内。基于此,咱们能够创立一个从 0 弧度变动到 2*Math.PI 弧度的扇形剪裁区域,即可实现这个动画成果。

先看一下革除画布的办法:

this.ctx.clearRect(-this.centerX, -this.centerY, this.width, this.height)

clearRect办法用来革除以 (x,y) 为终点,宽 widthheight范畴内的所有曾经绘制的内容。革除原理就是将这个范畴内的像素都设置成通明,因为原点被咱们移到了画布核心,所以画布左上角是(-this.centerX, -this.centerY)。

开源社区有很多动画库能够抉择,然而因为咱们只须要一个简略的动画函数,引入一个库没必要,所以本人简略写一个就好了。

// 动画曲线函数,更多函数可参考:http://robertpenner.com/easing/
// t: current time, b: begInnIng value, c: change In value, d: duration
const ease = {
    // 弹跳
    easeOutBounce(t, b, c, d) {if ((t /= d) < (1 / 2.75)) {return c * (7.5625 * t * t) + b;
        } else if (t < (2 / 2.75)) {return c * (7.5625 * (t -= (1.5 / 2.75)) * t + .75) + b;
        } else if (t < (2.5 / 2.75)) {return c * (7.5625 * (t -= (2.25 / 2.75)) * t + .9375) + b;
        } else {return c * (7.5625 * (t -= (2.625 / 2.75)) * t + .984375) + b;
        }
    },
    // 慢进慢出
    easeInOut(t, b, c, d) {if ((t /= d / 2) < 1) return c / 2 * t * t * t + b
        return c / 2 * ((t -= 2) * t * t + 2) + b
    }
}
/*
    动画函数
    from:起始值
    to:目标值
    dur:过渡工夫,ms
    callback:实时回调函数
    done:动画完结的回调函数
    easing:动画曲线函数
*/
function move(from, to, dur = 500, callback = () => {}, done = () => {}, easing = 'easeInOut') {
    let difference = to - from
    let startTime = Date.now()
    let isStop = false
    let timer = null
    let run = () => {if (isStop) {return false}
        let curTime = Date.now()
        let durationTime = curTime - startTime
        // 调用缓动函数来计算以后的比例
        let ratio = ease[easing](durationTime, 0, 1, dur)
        ratio = ratio > 1 ? 1 : ratio
        let step = difference * ratio + from
        callback && callback(step)
        if (ratio < 1) {timer = window.requestAnimationFrame(run)
        } else {done && done()
        }
    }
    run()
    return () => {
        isStop = true
        cancelAnimationFrame(timer)
    }
}

有了动画函数就能够很不便实现扇形的变动:

// 从 -0.5 到 1.5 的起因和下面绘制扇形时减去 Math.PI/ 2 一样
move(-0.5, 1.5, 1000, (cur) => {this.ctx.save()
    // 绘制扇形剪切门路
    this.ctx.beginPath()
    this.ctx.moveTo(0, 0)
    this.ctx.arc(
        0,
        0,
        this.radius,
        -0.5 * Math.PI,
        cur * Math.PI// 完结圆弧一直变大
    )
    this.ctx.closePath()
    // 剪切完后进行绘制
    this.ctx.clip()
    this.renderPie()
    this.ctx.restore()});

成果如下:

这里应用了 saverestore办法,save办法用来将以后的绘图状态保存起来,你在之后如果批改了状态再调用 restore 办法能够又复原到之前保留的状态,这两个办法是通过栈来进行保留,所以能够保留多个,只有 restore 办法正确对应上,在 canvas 中,绘图状态包含:以后的变换矩阵、以后的剪切区域、以后的虚线列表,绘图款式属性。

这里要应用这两个办法是因为如果以后曾经存在裁剪区域,再调用 clip 办法时会将剪切区域设置为以后裁剪区域和以后门路的交加,所以剪切区域可能会越来越小,保险起见,在应用 clip 办法时都将它放在 saverestore办法之间。

鼠标移上的突出显示

ECharts的饼图还有一个成果就是鼠标移上去所在的扇形会突出显示,其实也是一个小动画,突出的原理实际上就是这个扇形的半径变大了,按之前的套路,只有把半径的变动值交给动画函数跑一下就能够了。

不过这之前须要先要晓得鼠标移到了哪个扇形上,先给元素绑定一下鼠标挪动事件:

<template>
  <div class="chartContainer" ref="container">
    <canvas ref="chart" @mousemove="onCanvasMousemove"></canvas>
  </div>
</template>

获取一个坐标点是否在某个门路内能够应用isPointInPath,该办法能够检测某个点是否在以后的门路内,留神,是以后门路。所以咱们能够在之前的遍历绘制扇形的循环办法里加上这个检测:

renderPie (checkHover, x, y) {
    let hoverIndex = null// ++
    this.data.forEach((item, index) => {this.ctx.beginPath()
        this.ctx.moveTo(0, 0)
        this.ctx.fillStyle = item.color
        let startRadian = item.radian[0] - Math.PI/2
        let endRadian = item.radian[1] - Math.PI/2
        this.ctx.arc(0, 0, this.radius, startRadian, endRadian)
        // this.ctx.fill();--
        // ++
        if (checkHover) {if (hoverIndex === null && this.ctx.isPointInPath(x, y)) {hoverIndex = index}
        } else {this.ctx.fill()
        }
    })
    // ++
    if (checkHover) {return hoverIndex}
}

那么在 onCanvasMousemove 办法里要做的就是计算一下下面的(x,y),而后调用一下这个办法:

onCanvasMousemove(e) {let rect = this.$refs.canvas.getBoundingClientRect()
    let x = e.clientX - rect.left
    let y = e.clientY - rect.top
    // 检测以后所在扇形
    this.curHoverIndex = this.getHoverAngleIndex(x, y)
}

获取到所在的扇形索引后就能够让该扇形的半径动起来,半径变大能够乘一个倍数,比方变大 0.1 倍,那咱们就能够通过动画函数让这个倍数从 0 过渡到 0.1,再批改下面的遍历绘制扇形办法里的半径值,一直刷新重绘即可。

不过在此之前,要先去下面定义的数据结构里加一个字段:

this.data = [
    {
        name: '名称',
        num: 10,
        color: '',
        hoverDrawRatio: 0// 这个字段示意以后扇形绘制时的倍数
    },
    // ...
]

要给每个扇形都独自加一个倍数字段的起因是同一时刻不肯定只有一个扇形的倍数在变动,比方我从一个扇形疾速移到另一个扇形,这个扇形的半径在变大的同时前一个扇形的半径还在复原,所以是会同时变动的。

onCanvasMousemove(e) {
       // ...
    // 检测以后所在扇形
    this.curHoverIndex = this.getHoverAngleIndex(x, y)
    // 让倍数动起来
    if (this.curHoverIndex !== null) {
        move(this.data[hoverIndex].hoverDrawRatio,// 默认是 0
            0.1,
            300,
            (cur) => {
                // 实时批改该扇形的倍数
                this.data[hoverIndex].hoverDrawRatio = cur
                // 从新绘制
                this.renderPie()},
            null,
            "easeOutBounce"// 参考 ECharts,这里抉择弹跳动画
        )
    }
}
// 获取鼠标移到的扇形索引
getHoverAngleIndex(x, y) {this.ctx.save()
    let index = this.renderPie(true, x, y)
    this.ctx.restore()
    return index
}

接下来革新绘制函数:

renderPie (checkHover, x, y) {
    let hoverIndex = null
    this.data.forEach((item, index) => {this.ctx.beginPath()
        this.ctx.moveTo(0, 0)
        this.ctx.fillStyle = item.color
        let startRadian = item.radian[0] - Math.PI/2
        let endRadian = item.radian[1] - Math.PI/2
        // this.ctx.arc(0, 0, this.radius, startRadian, endRadian)--
        // 半径从写死的批改成加上以后扇形的放大值
        let _radius = this.radius + this.radius * item.hoverDrawRatio
        this.ctx.arc(0, 0, _radius, startRadian, endRadian)
        if (checkHover) {if (hoverIndex === null && this.ctx.isPointInPath(x, y)) {hoverIndex = index}
        } else {this.ctx.fill()
        }
    });
    if (checkHover) {return hoverIndex}
}

然而下面的代码并不会实现预期的成果,有个问题须要解决。在同一个扇形外面挪动 onCanvasMousemove 会继续触发并检测到以后所在索引调用 move 办法,可能是一个动画还没完结,而且在同一个扇形里挪动只有动画一次就够了,所以须要做个判断:

onCanvasMousemove(e) {
       // ...
    this.curHoverIndex = this.getHoverAngleIndex(x, y)
    if (this.curHoverIndex !== null) {
        // 减少一个字段来记录上一次所在的扇形索引
        if (this.lastHoverIndex !== this.curHoverIndex) {// ++
            this.lastHoverIndex = this.curHoverIndex// ++
            move(this.data[hoverIndex].hoverDrawRatio,
                0.1,
                300,
                (cur) => {this.data[hoverIndex].hoverDrawRatio = cur
                    this.renderPie()},
                null,
                "easeOutBounce"
            )
        }
    } else {// ++
        this.lastHoverIndex = null
    }
}

最初加一下由大变回去的动画办法,遍历数据,判断哪个扇形以后的放大倍数不为 0,就给它加个动画,这个办法的调用地位是在 onCanvasMousemove 函数里,因为当你从一个扇形移到另一个扇形,或从圆外部移到内部都须要判断是否要复原:

resume() {this.data.forEach((item, index) => {
        if (
            index !== this.curHoverIndex &&// 以后鼠标所在的扇形不须要复原
            item.hoverDrawRatio !== 0 &&// 以后扇形放大倍数不为 0 代表须要复原
            this.data[index].stop === null// 因为这个办法会在鼠标挪动过程中一直调用,所以要判断一下以后扇形是否曾经在动画中了,在的话就不须要反复进行了,stop 字段同样须要在上述的数据结构里先增加一下
        ) {this.data[index].stop = move(
                item.hoverDrawRatio,
                0,
                300,
                (cur) => {this.data[index].hoverDrawRatio = cur;
                    this.renderPie();},
                () => {this.data[index].hoverDrawRatio = 0;
                    this.data[index].stop = null;
                },
                "easeOutBounce"
            );
        }
    });
},

成果如下:

环图

环图其实就是饼图两头挖了个洞,同样能够应用 clip 办法来实现,具体就是创立一个圆环门路:

所谓圆环也就是一大一小两个圆,然而这样会存在两个区域,一个是小圆外部区域,一个是小圆和大圆之间的区域,那么 clip 办法怎么晓得剪切哪个区域呢,clip办法其实是有参数的,clip(fillRule),这个 fillRule 示意判断一个点是在门路内还是门路外的算法类型,默认是应用非零盘绕准则,还有一个是奇偶盘绕准则,非零盘绕准则很简略,就是在某个区域向外画一条线段,这条线段与门路会有交叉点,和顺时针的线段穿插时加 1,和逆时针线段穿插了减 1,最初看计数器是否是 0,是 0 就不填充,非 0 就填充。

如果咱们应用两个 arc 办法画两个圆形门路,这里咱们须要填充的是这个圆环局部,所以从圆环里向外画一条线只有一个交叉点,那么必定会被填充,然而从小圆外部画出的线段最终的计数器是 1 +1=2,不为 0 也会被填充,这样就不是圆环而是一个大圆了,所以须要通过 arc 办法最初一个参数来设置其中一个圆形门路为逆时针方向:

clipPath() {this.ctx.beginPath()
    this.ctx.arc(0, 0, this.radiusInner, 0, Math.PI * 2)// 内圆顺时针
    this.ctx.arc(0, 0, this.radius, 0, Math.PI * 2, true)// 外圆逆时针
    this.ctx.closePath()
    this.ctx.clip()}

这个办法在调用遍历绘制扇形的办法 renderPie 之前调用:

// 包装成新函数,之前所有调用 renderPie 进行绘制的中央都替换成 drawPie
drawPie() {this.clear()
    this.ctx.save()
    // 裁剪圆环区域
    this.clipPath()
    // 绘制圆环
    this.renderPie()
    this.ctx.restore()}

这样会有个问题,就是这个剪切圆环的外圆半径是radius,而如果某个扇形放大了那么就显示不了了,所以须要实时遍历扇形数据来获取到以后最大的半径,能够应用计算属性来做这件事:

{
    computed: {hoverRadius() {
            let max = null
            this.data.forEach((item) => {if (max === null) {max = item.hoverDrawRatio} else {if (item.hoverDrawRatio > max) {max = item.hoverDrawRatio}
                }
            })
            return this.radius + this.radius * max
        }
    }
}

成果如下:

能够看到上图有个 bug,就是鼠标移到内圆里还是会触发凸出的动画成果,解决办法很简略,在之前的 getHoverAngleIndex 办法里咱们先检查一下鼠标是否移到了内圆,是的话就不就行后续扇形检测了:

getHoverAngleIndex(x, y) {this.ctx.save();
    // 移到内圆环不触发,创立一个内圆大小的门路,调用 isPointInPath 办法进行检测
    if (this.checkHoverInInnerCircle(x, y)) 
        return null;
    }
    let index = this.renderPie(true, x, y);
    this.ctx.restore();
    return index;
}

南丁格尔玫瑰图

最初再来实现一下南丁格尔玫瑰图,由一个叫南丁格尔的人明显的,是一种圆形的直方图,相当于把一个柱形图拉成一个圆形,用扇形的半径来示意数据的大小,实现上其实就是把环图里的扇形半径也通过占比来辨别开。

要革新的是 renderPie 办法,绘制的半径由对立的半径乘上一个各自的占比即可:

renderPie (checkHover, x, y) {
    let hoverIndex = null
    this.data.forEach((item, index) => {
        // ...
        // let _radius = this.radius + this.radius * item.hoverDrawRatio --
        // this.ctx.arc(0, 0, _radius, startRadian, endRadian)
        // ++
        // 该扇形和最大的扇形的大小比例换算成占圆环的比例
        let nightingaleRadius =
            (1 - item.num / this.max) * // 圆环减去该占后比剩下的局部
            (this.radius - this.radiusInner)// 圆环的大小
        let _radius = this.radius - nightingaleRadius// 外圆半径减去多出的局部
        let _radius = _radius + _radius * item.hoverDrawRatio
        this.ctx.arc(0, 0, _radius, startRadian, endRadian)
        // ...
    });
    // ...
}

成果如下:

总结

本文通过一个简略的饼图来回顾了一下 canvas 的一些基础知识,canvas还有很多有用和高级的个性,比方 isPointInStroke 能够用来检测一个点是否在一条门路上,矩阵变换同样反对旋转和缩放,也能够用来解决图像等等,有趣味的能够自行理解。

代码已上传到 github:https://github.com/wanglin2/pieChart

正文完
 0