●
同学们,开学啦!
●
新春的气息尚未走远
假期立的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
目标动画
2
开始动手
注:
this
上挂载了 canvas.getContext('2d')
获取的 ctx
。ctx.width
是在获取到 ctx
的时候手动挂载上去方便使用的。source
为处理后的数据。R1
、R2
分别表示圆环的内径和外径。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',}];
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;}
现在的效果:
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
方法得到下图:
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;}
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();}
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();}
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); });}
目前效果如下:
3
让动画动起来
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) {}}
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);}
看看效果
/** * @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前端团队