21

用Canvas实现一个简单的甜甜圈图表

 3 years ago
source link: https://mp.weixin.qq.com/s?__biz=MzUxMDk2MTAyMA%3D%3D&%3Bmid=2247486364&%3Bidx=1&%3Bsn=3ed0031fb06cea486d590be4e8dda4a5
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

在实现复杂动画或复杂图表的时候,css 往往不能或难以简洁方便的实现;而 canvas 给了你一张白纸和多彩的画笔,给与你无限的想象空间。

效果图

VrU7riU.gif!mobile

动画分析

元素组成

  1. 多部分组成的环并带有线性渐变效果

  2. 环的两端有椭圆

  3. 从环上衍生出去的线条

  4. 在线条末尾的图例

  5. 环正中的标题

动画拆解

  1. 有一个 ease-in-out 的展开动画
  2. 线有一个延伸动画

  3. 图例有一个透明度渐变动画

开始动手

  • this
    canvas.getContext('2d')
    ctx
    
  • 下面代码中使用的 ctx.width 是在获取到 ctx 的时候手动挂载上去方便使用的。
  • 下面代码中 source 为处理后的数据。
  • R1R2 分别表示圆环的内径和外径。
  • 下面代码中存在一些未给出实现的 工具函数常量定义 ,可拉取项目查看。

构造数据

  1. text 表示项目名
  2. per 表示占比
  3. startColorstopColor 表示渐变色区间
  4. ellipseColor 表示椭圆颜色
const donutData = [{
per: 0.45,
text: '学习课',
startColor: '#FFEA33', // 黄色
stopColor: '#d8b616',
ellipseColor: '#FFD333',
}, {
per: 0.25,
text: '复习课',
startColor: '#7bc31f', // 绿色
stopColor: '#96ec26',
ellipseColor: '#8FD43D',
}, {
per: 0.3,
text: '拓展课',
startColor: '#f0870c', // 橙色
stopColor: '#ff9413',
ellipseColor: '#FF8221',
}];

画环

常见的绘制方法是用 ctx.arc 定义弧线,然后用 ctx.stroke 画一条粗线条:

drawRing(startDeg, endDeg, strokeStyle, ellipseColor) {
const { ctx } = this;
ctx.save();
ctx.strokeStyle = strokeStyle;
ctx.beginPath();
ctx.lineWidth = R2 - R1;
ctx.arc(ctx.width / 2, ctx.height / 2, (R1 + R2) / 2, arcDeg(startDeg), arcDeg(endDeg));
ctx.stroke();
ctx.restore();
// this.drawEllipse(startDeg, ellipseColor);
// this.drawEllipse(endDeg, ellipseColor);
},

现在我们利用上面这个方法把环画出来:

draw() {
const { source } = this;

source.forEach((s) => {
const { startPer, per, lgr, ellipseColor } = s;
const startDeg = startPer * ANGLE_360;
const endDeg = (startPer + per) * ANGLE_360;

this.drawRing(startDeg, endDeg, lgr, ellipseColor);
});
}

lgr 线性渐变可以通过下面方法计算出来:

getLinearGradient(startColor, stopColor) {
const { ctx } = this;
const lgr = ctx.createLinearGradient(ctx.width / 2 - R2, ctx.height / 2, ctx.width / 2 + R2, ctx.height / 2);
lgr.addColorStop(0, startColor);
lgr.addColorStop(1, stopColor);
return lgr;
}

现在的效果: neiMBja.png!mobile

画椭圆

先分析一下:

  • 椭圆在每个部分的起点和终点,并且存在一定的旋转角度,长轴和半径在一条直线上

  • canvas 里先绘制的像素会被后绘制的像素覆盖,所以要确保绘制顺序正确

实现椭圆绘制方法:

drawEllipse(rotate, color) {
const { ctx } = this;

rotate = deg(rotate);

// 不使用画布旋转时的坐标计算方法
// const x = ctx.width / 2 + (R1 + R2) / 2 * Math.cos(rotate);
// const y = ctx.height / 2 + (R1 + R2) / 2 * Math.sin(rotate);

// 画布旋转时,只需要让椭圆圆心定位在弧线的 0 度处
const x = 0;
const y = -(R1 + R2) / 2;

ctx.save();
// 设置 canvas 中心到画布中心并旋转
ctx.translate(ctx.width / 2, ctx.height / 2);
ctx.rotate(rotate);

ctx.moveTo(x, y);
ctx.beginPath();
ctx.fillStyle = color;
// 某些情况下 ellipse 的第五个参数 rotate 有兼容性问题无法旋转,但是椭圆可以画出来
// ctx.ellipse(x, y, EllipseR2, EllipseR1, rotate, 0, 2 * Math.PI);
ctx.ellipse(x, y, EllipseR2, EllipseR1, 0, 0, 2 * Math.PI);
ctx.fill();

ctx.restore();
}

现在取消我们在 drawRing 函数内注释掉的 drawEllipse 方法得到下图: reyArei.png!mobile 由于最后一部分最后绘制,终点部分椭圆覆盖了第一部分的椭圆,所以我们要重新绘制第一部分的椭圆:

this.drawEllipse(0, source[0].ellipseColor);

得到下图: Zb2iMfa.png!mobile

画图例

图例和圆环的位置相关,所以我们把图例相关的绘制工作封装成一个 Legend 图例类:

class Legend {
constructor({ ctx, x, y, textMaxWidth, endX, startColor, stopColor, text }) {
this.ctx = ctx;
this.x = x; // 横线的起点 x 坐标
this.y = y; // 横线的 y 坐标
this.endX = endX; // 横线的终点 x 坐标
this.textMaxWidth = textMaxWidth; // 图例文字最大宽度
this.text = text; // 图例文字
this.dot = { // 图例起点小圆点属性
r: 2.5,
opacity: 0.8,
};
this.icon = { // 图例 icon 属性
h: 12,
w: 12,
r: 5,
marginRight: 4,
startColor, // 渐变色起点
stopColor // 渐变色终点
};
}

// 图标和文字距离横线的数值
static MARGIN_BOTTOM = 4;
// 文字的行高
static LINE_HEIGHT = 14;
}

起点小圆点

这只是一个半透明的小圆点,用 arc 直接画:

drawLegendDot() {
const { ctx, x, y } = this;
const { r, opacity } = this.dot;

ctx.save();
ctx.globalAlpha = opacity;
ctx.beginPath();
ctx.fillStyle = '#FFFFFF';
ctx.arc(x, y, r, 0, 2 * Math.PI);
ctx.fill();
ctx.restore();
}

横线

起点在小圆点边缘,终点在 endX 位置,这里需要注意图例在左侧还是右侧:

drawLegendLine() {
const { ctx, x, y, endX } = this;
const { r } = this.dot;
const lineStart = endX > x ? x + r : x - r; // 图例可以在左侧也可以在右侧,所以线条存在延伸方向
const lineEnd = endX;

ctx.save();
ctx.beginPath();
ctx.moveTo(lineStart, y);
ctx.lineTo(lineEnd, y);
ctx.strokeStyle = '#E6E6E6';
ctx.strokeWidth = 0.5;
ctx.stroke();
ctx.restore();
}

图标

图例图标是一个带渐变的圆角矩形,需要注意的是,如果图例在右侧,图标绘制时需要依赖于图例文字的宽度。

/**
* @param {number} iconX 图例 x 坐标
*/

drawLegendIcon(iconX) {
const { ctx, x, y } = this;
const { w, h, r, startColor, stopColor } = this.icon;
const iconY = y - h - Legend.MARGIN_BOTTOM; // 算出图例左上角 y 坐标

ctx.save();

const lgr = ctx.createLinearGradient(x, iconY, x, iconY + h);
lgr.addColorStop(0, startColor);
lgr.addColorStop(1, stopColor);

ctx.fillStyle = lgr;
drawRoundedRect(ctx, iconX, iconY, w, h, r); // 这只是一个画矩形的方法,具体可以看看源码
ctx.fill();

ctx.restore();
}

标题文字

这里需要提前计算文字的宽度,让图例图标绘制在正确的位置,所以我将文字属性作为一个计算好的量传入函数。

/**
* @param {number} textW 文字宽度
* @param {number} textH 文字高度
* @param {string} text 文字内容
*/

drawLegendText(textW, textH, text) {
const { ctx, x, y, endX } = this;
const { w, marginRight } = this.icon;

const offsetY = 3; // 用于调整实际渲染与预期的位置偏差

ctx.save();
ctx.font = '12px Arial';
ctx.fillStyle = '#000000';
ctx.textBaseline = 'top';

const textX = endX > x ? endX - textW : endX + w + marginRight;
const textY = y - textH - Legend.MARGIN_BOTTOM + offsetY;

ctx.fillText(text, textX, textY);
ctx.restore();
}

结合起来

计算出 Legend 类需要的参数并传入。

drawPartLegend(part) {
const { ctx } = this;

const { startPer, per, startColor, stopColor, text } = part;
// 计算区域开始角度和结束角度的中间值: middleDeg = 360 * (startPer + (startPer + per)) / 2
// 如果第一部分占比超过 50%,让图例显示在右侧正中,即 90 度位置
const middleDeg = (startPer === 0 && per > 0.5) ? ANGLE_90 : ANGLE_360 * (startPer * 2 + per) / 2;

// 下面是简单的三角函数计算图例在圆环上的起始点
const x = ctx.width / 2 + (R1 + R2) / 2 * Math.cos(arcDeg(middleDeg));
const y = ctx.height / 2 + (R1 + R2) / 2 * Math.sin(arcDeg(middleDeg));
// 限制文字宽度
const textMaxWidth = ctx.width / 2 - R2;

// 小于 180 说明在右边
const endX = middleDeg <= ANGLE_180 ? ctx.width : 0;
const legend = new Legend({ ctx, x, y, textMaxWidth, endX, startColor, stopColor, text });
legend.draw();
}

修改上文使用的 draw 方法:

draw() {
const { source } = this;

source.forEach((s) => {
// ...
this.drawPartLegend(s);
});
}

效果如下: A3AjymE.png!mobile

中心标题

这里需要计算中心可用宽度,让文本溢出省略,这里不再赘述,代码可以在源码查看。

真正的动起来

canvas 的动画实际上是一帧一帧画出来的,所以这里要求我们手动实现帧动画绘制。要让动画变得流畅,我们需要使用 requestAnimationFrame

由于 requestAnimationFrame 的特性是需要递归调用自身,这里我封装了一个 RafRunner (具体可看源码):

class RafRunner {
// 可传入自定义 requestAnimationFrame 函数
constructor(requestAnimationFrame = window.requestAnimationFrame.bind(window)) {
this.requestAnimationFrame = requestAnimationFrame;
this.timingFunction = (x) => x;
}

/**
* 处理器
* @param {Function} handler 处理函数,拥有两个形参
*
* handler = (val, preVal) => void
*/

handler(handler) {}

/**
* 启动
* @param {number} from 开始值
* @param {number} to 结束值
* @param {number} duration (millisecond) 持续时间
* @param {function} timingFunction 可选,默认 linear
*/

start(from, to, duration, timingFunction) {}
}

让环动起来

这里的扇形从 0 度增长到 360 度的过程,是整体上的动作,所以不同部分扇区增长在整体上是连续的,那么在某一帧或存在同时渲染两个扇区的部分。我们让 per (percent) 进行缓动,判断当前 per 值属于哪一个扇区,来渲染对应扇区。

利用刚刚封装的 RafRunner 来修改我们的 draw 函数:

draw() {
const { source } = this;

if (source.length === 0) {
return;
}

const raf = new RafRunner();

// 记录当前 part 下标
let pos = 0;
raf.handler((recPer, prePer) => {
let recentPart = source[pos];
const { startPer, per } = recentPart;

// 渲染完某个部分之后,渲染下一个部分
if (recPer >= startPer + per) {
// 渲染上个部分 -> per 并不会精准的落在每个扇区的结束 percent 上,所以需要补全上个扇区
const startDeg = ANGLE_360 * startPer;
const endDeg = ANGLE_360 * (startPer + per);
this.drawRing(startDeg, endDeg, recentPart.lgr, recentPart.ellipseColor);
this.drawPartLegend(recentPart);

// 跳到下一个部分
pos++;
recentPart = source[pos];
// 已经没有了
if (!recentPart) {
// 记得画上起点的椭圆
this.drawEllipse(0, source[0].ellipseColor);
return;
}
}

// 渲染实时动画帧部分
const startDeg = ANGLE_360 * recentPart.startPer; // recentPart 或已重新赋值,不能使用解构出的 startPer
const endDeg = ANGLE_360 * recPer;
this.drawRing(startDeg, endDeg, recentPart.lgr, recentPart.ellipseColor);

// 第一部分起点椭圆在最上层
this.drawEllipse(0, source[0].ellipseColor);
});
raf.start(0, 1, 800, easeInOut);
}

看看效果 u2QnYfm.gif!mobile

让图例也动起来

由于代码结构类似,这里只说两个比较特殊的情况:

/**
* @param {number} iconX 图例 x 坐标
* @param {number} iconOffsetY 图例 y 偏移,用于适配多行图例标题的情况
*/

drawLegendIcon(iconX, iconOffsetY) {
const { ctx, x, y } = this;
const { w, h, r, startColor, stopColor } = this.icon;
const iconY = y - h - Legend.MARGIN_BOTTOM + iconOffsetY;

const raf = new RafRunner();
raf.handler((opacity) => {
ctx.save();
ctx.globalAlpha = opacity; // 透明度绘制时,要清除上次画的,特别是文字(具体可以自己试一试)
ctx.clearRect(iconX, iconY, w, h); // 背景没有着色时,可以清除区域后再画

const lgr = ctx.createLinearGradient(x, iconY, x, iconY + h);
lgr.addColorStop(0, startColor);
lgr.addColorStop(1, stopColor);

ctx.fillStyle = lgr;
drawRoundedRect(ctx, iconX, iconY, w, h, r);
ctx.fill();

ctx.restore();
});
raf.start(0, 1, Legend.ICON_AND_TITLE_DURATION);
}

drawLegendDot() {
const { ctx, x, y } = this;
const { r, opacity: endOpacity } = this.dot;

const raf = new RafRunner();
raf.handler((opacity, oldOpacity) => {
ctx.save();
// 背景有绘制圆环,所以这里不能直接擦除
// 这里只能是在上一次的基础上画,所以计算透明度差值就好,否则透明度叠加之后透明度(0 ~ 1)会比预期更高
ctx.globalAlpha = opacity - oldOpacity;

ctx.beginPath();
ctx.fillStyle = '#FFFFFF';
ctx.arc(x, y, r, 0, 2 * Math.PI);
ctx.fill();
ctx.restore();
});
raf.start(0, endOpacity, Legend.DOT_AND_LINE_DURATION);
}

看看效果 nu6nmmY.gif!mobile

其他问题思考

  • 文本宽度溢出的时候,或许需要多行省略(可看源码)

  • 每个部分的颜色如何分配

  • 当两个部分占比很小,图例可能会重叠

  • 空间有限,过小占比图例应该省略

  • ...

最后

项目地址:https://github.com/chym123/donut-graph-demo

欢迎点 star 鼓励!


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK