前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >用Canvas实现一个动态甜甜圈图表

用Canvas实现一个动态甜甜圈图表

作者头像
用户1097444
发布2022-06-29 14:49:18
5330
发布2022-06-29 14:49:18
举报
文章被收录于专栏:腾讯IMWeb前端团队

同学们,开学啦!

新春的气息尚未走远

假期立的Flag犹在眼前

伴着正午的太阳

“欣欣然张开了眼”

摸摸自己的小肚子

仿佛还可以吃的更圆

然而

假期却已来到了最后一天

没错

你愿,或者不愿意

开学就在那里

不近不远

背上小书包,一起上学堂

学期伊始

总要有几许壮志豪言

新学期,新开始

校会君带着新年Flag与大家共勉

No.1 “我要当学霸”

Flag:

上课不玩手机不睡觉

课前预习,课后复习

认真完成作业

带着钻研精神学习

不耻下问,多向老师和同学请教

No.2 “我要更健康”

Flag:

早睡早起身体好

每天锻炼1小时

多吃蔬菜瓜果,保持膳食平衡

少喝奶茶少喝奶茶少喝奶茶

维持合理体重

No.3 “我要更自律”

Flag:

学会定日目标、周目标、月目标

今日事今日毕

不拖沓,不赶Deadline

No.4 “常和父母联系”

Flag:

每周和家人视频几次

及时回复父母短信,不让他们为你担心

常回家看看

No.5 “新年新计划”

Flag:

今年我要去旅行

今年我要多交几个好朋友

今年我要学会烘焙

今年我要拿到一次奖学金

今年我要拿到自己满意的offer

你是否也在追赶着朝阳般梦想的路上呢

是否也立起flags准备好策马奔腾了呢

是否也望到了路上的荆棘却依旧一往无前呢

小线用一句话和所有山大追梦者共勉:

开学,你好

排版:135编辑器

图片素材:来源网络(侵删)

文案:来源网络(侵删)

运用时建议根据自身需要更换文字及图片

同学们,开学啦!

新春的气息尚未走远

假期立的Flag犹在眼前

伴着正午的太阳

“欣欣然张开了眼”

摸摸自己的小肚子

仿佛还可以吃的更圆

然而

假期却已来到了最后一天

没错

你愿,或者不愿意

开学就在那里

不近不远

背上小书包,一起上学堂

学期伊始

总要有几许壮志豪言

新学期,新开始

校会君带着新年Flag与大家共勉

No.1 “我要当学霸”

Flag:

上课不玩手机不睡觉

课前预习,课后复习

认真完成作业

带着钻研精神学习

不耻下问,多向老师和同学请教

No.2 “我要更健康”

Flag:

早睡早起身体好

每天锻炼1小时

多吃蔬菜瓜果,保持膳食平衡

少喝奶茶少喝奶茶少喝奶茶

维持合理体重

No.3 “我要更自律”

Flag:

学会定日目标、周目标、月目标

今日事今日毕

不拖沓,不赶Deadline

No.4 “常和父母联系”

Flag:

每周和家人视频几次

及时回复父母短信,不让他们为你担心

常回家看看

No.5 “新年新计划”

Flag:

今年我要去旅行

今年我要多交几个好朋友

今年我要学会烘焙

今年我要拿到一次奖学金

今年我要拿到自己满意的offer

你是否也在追赶着朝阳般梦想的路上呢

是否也立起flags准备好策马奔腾了呢

是否也望到了路上的荆棘却依旧一往无前呢

小线用一句话和所有山大追梦者共勉:

开学,你好

排版:135编辑器

图片素材:来源网络(侵删)

文案:来源网络(侵删)

运用时建议根据自身需要更换文字及图片

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

1

目标动画

动画分析

  • 元素分析
    1. 多部分组成的环并带有线性渐变效果
    2. 环的两端有椭圆
    3. 从环上衍生出去的线条
    4. 在线条末尾的图例
    5. 环正中的标题
  • 动画拆解
    1. 环有一个 ease-in-out 的展开动画
    2. 线有一个延伸动画
    3. 图例有一个透明度渐变动画

2

开始动手

注:

  • 下面代码中的 this 上挂载了 canvas.getContext('2d') 获取的 ctx
  • 下面代码中使用的 ctx.width 是在获取到 ctx 的时候手动挂载上去方便使用的。
  • 下面代码中 source 为处理后的数据。
  • R1R2 分别表示圆环的内径和外径。
  • 下面代码中存在一些未给出实现的工具函数常量定义,可拉取项目查看。项目地址:https://github.com/chym123/donut-graph-demo
  • 构造数据
    1. text 表示项目名
    2. per 表示占比
    3. startColor、stopColor 表示渐变色区间
    4. ellipseColor 表示椭圆颜色
代码语言:javascript
复制
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 画一条粗线条:
代码语言:javascript
复制
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); },

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

代码语言:javascript
复制
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 线性渐变可以通过下面方法计算出来:

代码语言:javascript
复制
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;}

现在的效果:

  • 画椭圆 先分析一下: 椭圆在每个部分的起点和终点,并且存在一定的旋转角度,长轴和半径在一条直线上; canvas 里先绘制的像素会被后绘制的像素覆盖,所以要确保绘制顺序正确。 实现椭圆绘制方法:
代码语言:javascript
复制
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 方法得到下图:

  • 画图例 图例和圆环的位置相关,所以把图例相关的绘制工作封装成图例类:
代码语言:javascript
复制
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 直接画:
代码语言:javascript
复制
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 位置,需要注意图例在左侧还是右侧:
代码语言:javascript
复制
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();}
  • 图例的图标 图例图标是一个带渐变的圆角矩形,需要注意的是,如果图例在右侧,图标绘制时需要依赖于图例文字的宽度。
代码语言:javascript
复制
/** * @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();}
  • 图例的文字 这里需要提前计算文字的宽度,让图例图标绘制在正确的位置,所以我将文字属性作为一个计算好的量传入函数。
代码语言:javascript
复制
/** * @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 类需要的参数并传入。
代码语言:javascript
复制
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 方法:

代码语言:javascript
复制
draw() {  const { source } = this;
  source.forEach((s) => {    // ...    this.drawPartLegend(s);  });}

目前效果如下:

3

让动画动起来

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

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

代码语言:javascript
复制
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 函数:
代码语言:javascript
复制
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);}

    看看效果

  • 让图例也动起来 由于代码结构类似,这里只说两个比较特殊的情况:
代码语言:javascript
复制
/** * @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);}

看看最后的效果

4

其他思考

  • 文本宽度溢出的时候,或许需要多行省略(可看源码)
  • 每个部分的颜色如何分配
  • 当两个部分占比很小,图例可能会重叠
  • 空间有限,过小占比图例应该省略
  • ...

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

欢迎点 star 鼓励!

IMWeb 团队隶属腾讯公司,是国内最专业的前端团队之一。

我们专注前端领域多年,负责过 QQ 资料、QQ 注册、QQ 群等亿级业务。目前聚焦于在线教育领域,精心打磨 腾讯课堂、企鹅辅导 及 ABCMouse 三大产品。

扫码关注 腾讯IMWeb前端团队

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2020-10-15,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 腾讯IMWeb前端团队 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 动画分析
    • canvas的动画实际上是一帧一帧画出来的,所以这里要求我们手动实现帧动画绘制。要让动画变得流畅,我们需要使用requestAnimationFrame。
    相关产品与服务
    短信
    腾讯云短信(Short Message Service,SMS)可为广大企业级用户提供稳定可靠,安全合规的短信触达服务。用户可快速接入,调用 API / SDK 或者通过控制台即可发送,支持发送验证码、通知类短信和营销短信。国内验证短信秒级触达,99%到达率;国际/港澳台短信覆盖全球200+国家/地区,全球多服务站点,稳定可靠。
    领券
    问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档