At the beginning

A chart, pie chart, it is very common to use any of the charts library can easily render out, however, our interaction idea, layout is unpredictable, pie chart is nothing variable itself, but the legend of form a complete set, through ECharts configuration document is not to come out, so there are two way can choose, one is told interaction not achieve them, The persuasion interaction is laid out in a library of diagrams, but the general interaction may ask the question of your soul, why can’t you do it when everyone else can? So I’m gonna go with the second one and make my own.

It is very simple to implement a pie chart with Canvas, so this article will review some knowledge points of Canvas by the way during the implementation process of using vue to copy an ECharts pie chart. Let’s first take a look at the results of this time:

Layout and initialization

The layout is very simple, a div container, a Canvas element.

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

The width and height of the canvas need to be set using its own properties width and height. It is better not to use CSS to set the width and height of the canvas, because the default width and height of the canvas is 300*150. Using CSS will not change the original width and height of the canvas, but will stretch it to the CSS width and height you set. So there’s the problem of deformation.

// Set to container width and height
let { width, height } = this.$refs.container.getBoundingClientRect()
let canvas = this.$refs.canvas
canvas.width = width
canvas.height = height
Copy the code

The drawing API is hung in the drawing context of the canvas, so get the following:

this.ctx = canvas.getContext("2d")
Copy the code

Canvas coordinate system origin of the default in the upper left corner, the pie chart drawing are generally in the middle of the canvas, so every time when drawing arc circle to convert it to the center of the canvas, this case as long as the center of a conversion is not trouble, but if in more complex scenarios, all is very troublesome to conversion, so in order to avoid, You can use the translate method to set the frame origin of the canvas to the center of the canvas:

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

Next, the radius of the pie chart needs to be calculated. If the pie chart is too full, it will not look good, so it is tentatively set as 90% of the half of the short side of the canvas area:

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

Finally, take a look at the structure of the data to render:

this.data = [
    {
        name: 'name'.num: 10.color: ' '/ / color
    },
    // ...
]
Copy the code

The pie chart

The pie chart is actually a circle composed of a bunch of sectors with different areas. Both the circle and the pie chart are drawn using the ARC method. It has six parameters: center x, center Y, radius R, radian at the beginning of the arc, radian at the end of the arc, counterclockwise or clockwise.

The area of the sector represents the percentage of the data, which can be expressed as the percentage of the Angle, which needs to be converted into radians. The formula for the Angle of radians is: Radians = Angle *(Math.pi /180).

// The total is the sum of all the data
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])/ / Angle
    this.$set(this.data[index], 'radian', [curTotalAngle * r, cruEndAngle * r])/ / radian
    curTotalAngle += curAngle
});
Copy the code

Transform to radians and then iterate through angleData to draw the sector:

/ / function 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()
});
Copy the code

The effect is as follows:

The beginPath method is used to start a new path. It will clear all the subpaths of the current path. Otherwise, calling the fill method to close the path would connect all the subpaths, which is not what we want.

The moveTo method is used to move the starting point of the new path to the origin of the coordinate.

Reason is that the arc method just draw a circular arc, so the end to end is the effect of it, but the fan is need the arc and circle close together, the arc method call if the current path already exists subpaths will use the end of a line segment to the current subpath and the starting point of the arc, so let’s move the starting point of the path to the center of the circle, And then when you close it, you end up with a fan.

The reason why we subtract math. PI/2 from both the beginning and the end radians is because 0 radians is in the positive direction of the X-axis, which is to the right, but we generally think of the starting point at the top, so subtract 1/4 of the circle to move it to the top.

animation

When we use the ECharts pie chart, we can see that it has a little animation when rendering:

Canvas is used to implement the basic principles of animation is changing the drawing data, and then constantly refresh canvas, sounds like bullshit, so one way to achieve dynamic modification to draw the radian of the end of the arc current, has been changing from 0 to 2 * Math. The PI, so that you can grow more achieve this effect, but here we use another, Use the clip method.

Clip is used to create a clipping path in the current path. After clipping, the subsequent drawing information will only appear in the clipping path. Based on this, we can create a fan cliping area that changes from 0 radians to 2* Math.pi radians to achieve the animation effect.

Let’s take a look at how to clear the canvas:

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

The clearRect method clears all content that has been drawn in the width, height, and height range starting from (x,y). The clearing principle is to make all pixels in this range transparent, since the origin is moved to the center of the canvas, so the top left corner of the canvas is (-this.centerx, -this.centery).

The open source community has many animation libraries to choose from, but since we only need a simple animation function, there is no need to introduce a library, so simply write one yourself.

/ / animation curve function, more function may refer to: http://robertpenner.com/easing/
// t: current time, b: begInnIng value, c: change In value, d: duration
const ease = {
    / / bounce
    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; }},// Slow in and slow out
    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
    }
}
/* Animating function from: start value to: target value dur: transition time, ms callback: real-time callback done: animating end callback: easing: animating curve function */
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
        // Call the easing function to calculate the current ratio
        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)
    }
}
Copy the code

The animation function makes it easy to change the fan:

// The reason for going from -0.5 to 1.5 is the same as the reason for subtracting Math.pi /2 when drawing the sector above
move(-0.5.1.5.1000.(cur) = > {
    this.ctx.save()
    // Draw the fan shear path
    this.ctx.beginPath()
    this.ctx.moveTo(0.0)
    this.ctx.arc(
        0.0.this.radius,
        -0.5 * Math.PI,
        cur * Math.PI// The end of the arc becomes larger
    )
    this.ctx.closePath()
    // After the cut, draw
    this.ctx.clip()
    this.renderPie()
    this.ctx.restore()
});
Copy the code

The effect is as follows:

The save method is used to save the current state of the drawing. If you change the state later and call the restore method, you can restore to the previously saved state. These two methods are saved by the stack, so you can save more than one, as long as the restore method corresponds correctly. In canvas, the drawing state includes: current transformation matrix, current clipping area, current dotted line list, and drawing style properties.

The reason for using these two methods is that if the clipping area already exists, a call to the clip method will set the clipping area to the intersection of the current clipping area and the current path, so the clipping area may get smaller and smaller. To be safe, put it between the save and restore methods whenever you use the clip method.

Mouse over the highlight

Another effect of ECharts pie chart is to highlight the fan where the mouse is moving over, which is actually a small animation. The highlight principle is that the radius of the fan is enlarged. As usual, you just need to run the change in radius to the animation function.

To do this, you need to know which sector the mouse has moved over. Bind the mouse movement event to the element:

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

To determine whether a point is in a path, use isPointInPath. This method detects whether a point is in the current path. So we can add this check to the previous loop that iterates over the sector:

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
    }
}
Copy the code

So what we do in the onCanvasMousemove method is to compute the above (x,y) and then call this method:

onCanvasMousemove(e) {
    let rect = this.$refs.canvas.getBoundingClientRect()
    let x = e.clientX - rect.left
    let y = e.clientY - rect.top
    // Check the current sector
    this.curHoverIndex = this.getHoverAngleIndex(x, y)
}
Copy the code

After obtaining the sector index, we can make the radius of the sector move. If the radius is larger, we can multiply it by a multiple, such as 0.1 times. Then we can use the animation function to transition the multiple from 0 to 0.1, and then modify the radius value in the above traversal sector drawing method, and refresh and redraw constantly.

But first, add a field to the data structure defined above:

this.data = [
    {
        name: 'name'.num: 10.color: ' '.hoverDrawRatio: 0// This field represents the multiple of the current sector
    },
    // ...
]
Copy the code

The reason why I want to have a multiple field for each sector is that there’s not necessarily only one sector that’s changing at the same time, so if I move quickly from one sector to another, the radius of this sector is growing while the radius of the previous sector is recovering, so it’s changing at the same time.

onCanvasMousemove(e) {
   	// ...
    // Check the current sector
    this.curHoverIndex = this.getHoverAngleIndex(x, y)
    // Let the multiples move
    if (this.curHoverIndex ! = =null) {
        move(
            this.data[hoverIndex].hoverDrawRatio,// Default is 0
            0.1.300.(cur) = > {
                // Change the sector multiple in real time
                this.data[hoverIndex].hoverDrawRatio = cur
                // Redraw
                this.renderPie()
            },
            null."easeOutBounce"// Refer to ECharts, select bounce animation here)}}// Get the sector index to which the mouse moved
getHoverAngleIndex(x, y) {
    this.ctx.save()
    let index = this.renderPie(true, x, y)
    this.ctx.restore()
    return index
}
Copy the code

Next, modify the drawing function:

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)--
        // Change the radius from written dead to add the current fan magnification value
        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
    }
}
Copy the code

However, the above code does not achieve the desired effect, and there is a problem to be solved. Moving onCanvasMousemove within the same sector will continue to trigger and call the move method when it detects the index in which it is currently located. It is possible that an animation has not yet finished, and moving within the same sector only needs to be animated once, so you need to make a decision:

onCanvasMousemove(e) {
   	// ...
    this.curHoverIndex = this.getHoverAngleIndex(x, y)
    if (this.curHoverIndex ! = =null) {
        // Add a field to record last sector index
        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}}Copy the code

This method is called in the onCanvasMousemove function, because when you move from one sector to another, or from the inside of the circle to the outside of the circle, you need to determine whether to restore:

resume() {
    this.data.forEach((item, index) = > {
        if( index ! = =this.curHoverIndex &&// The sector where the mouse is currently located does not need to be restoreditem.hoverDrawRatio ! = =0 &&// If the current sector magnification is not 0, it needs to be restored
            this.data[index].stop === null// Since this method is called over and over again as the mouse moves, it is necessary to check whether the current sector is in the animation. If so, it is not necessary to repeat the process
        ) {
            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"); }}); },Copy the code

The effect is as follows:

Ring figure

You can also use the clip method to create a circle path:

The so-called circle is a large circle and a small circle, but there are two regions, one is the inner region of the small circle, and the other is the region between the small circle and the large circle, so how can the clip method know which region to cut? This fillRule indicates that judgment is a point in the path or path algorithm type, the default is to use a nonzero around principle, and there’s a parity around principles, nonzero around the principle is very simple, is in a region to draw a line, the line segment intersection, with the path is heshun clockwise cross line 1, and counterclockwise line cross the minus 1, Finally, check whether the counter is 0. If it is 0, it is not filled. If it is not 0, it is filled.

If we use two arc method draw circle path, here we need to fill is the circle part, so to draw a line from the ring is only one intersection, then will be populated, but a small circle inside draw the line of the counter is 1 + 1 = 2, 0 will not be populated, so is not circle but a great circle, So you need to set one of the circular paths to counterclockwise with the last parameter of the arc method:

clipPath() {
    this.ctx.beginPath()
    this.ctx.arc(0.0.this.radiusInner, 0.Math.PI * 2)// Inner circle is clockwise
    this.ctx.arc(0.0.this.radius, 0.Math.PI * 2.true)// The outer circle is counterclockwise
    this.ctx.closePath()
    this.ctx.clip()
}
Copy the code

This method is called before calling renderPie, which iterates over the sector:

// wrap it as a new function. All previous calls to renderPie to draw will be replaced by drawPie
drawPie() {
    this.clear()
    this.ctx.save()
    // Cut the ring area
    this.clipPath()
    // Draw the ring
    this.renderPie()
    this.ctx.restore()
}
Copy the code

The problem with this is that the outer circle radius of the shear circle is radius. If a fan is magnified, it will not be displayed. Therefore, you need to traverse the fan data in real time to get the current maximum 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
        }
    }
}
Copy the code

The effect is as follows:

As you can see, there is a bug in the image above, which is that moving the mouse over the inner circle still causes a protruding animation effect. The solution is simple. In the previous getHoverAngleIndex method, we first check to see if the mouse is moving over the inner circle.

getHoverAngleIndex(x, y) {
    this.ctx.save();
    // Move to inner circle does not trigger, create a path of inner circle size, call isPointInPath method to check
    if (this.checkHoverInInnerCircle(x, y)) 
        return null;
    }
    let index = this.renderPie(true, x, y);
    this.ctx.restore();
    return index;
}
Copy the code

The Nightingale Rose

Finally, the Nightingale Rose, defined by a person named Nightingale, is a kind of circular histogram, which is equivalent to pulling a bar graph into a circle and using the radius of the fan to represent the size of the data. In fact, the realization is that the radius of the fan in the ring graph is also separated by proportion.

RenderPie: renderPie: renderPie: renderPie: renderPie: renderPie: renderPie: 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)
        // ++
        // The size ratio of the fan to the largest fan is converted to the proportion of the circle
        let nightingaleRadius =
            (1 - item.num / this.max) * // The rest of the ring minus the ratio
            (this.radius - this.radiusInner)// The size of the ring
        let _radius = this.radius - nightingaleRadius// The radius of the outer circle minus the excess
        let _radius = _radius + _radius * item.hoverDrawRatio
        this.ctx.arc(0.0, _radius, startRadian, endRadian)
        // ...
    });
    // ...
}
Copy the code

The effect is as follows:

conclusion

This article reviews some basic knowledge of Canvas through a simple pie chart. Canvas also has many useful and advanced features, such as isPointInStroke can be used to detect whether a point is on a path, matrix transformation can also support rotation and scaling, can also be used to process images, and so on. If you are interested, you can find out for yourself.

The code has been uploaded to Github: github.com/wanglin2/pi…