前言
正好之前写了篇文章(Canvas 写的酷炫动画代码分析),所以对 Canvas 动画大概的有了了解,揭开了神秘面纱。
动画的解释是指采用逐帧拍摄对象并连续播放而形成运动的影像技术(因为视觉残像所造成)。所以 Canvas 动画的原理也是一样,通过调用 requestAnimationFrame 在下一帧前,重新绘制。
代码量比较多,代码直接放在文章最后面了。
Canvas 绘制后的清晰度问题
Canvas 不是矢量图,是像图片一样的位图模式。而不同显示器之间像素大小的比率(物理像素分辨率与 CSS 像素分辨率之比)不同,也就是说浏览器使用多少屏幕实际像素来绘制单个CSS像素是不同。可以通过 devicePixelRatio 去获取到设备像素比。
在 Canvas context 中也存在一个 backingStorePixelRatio 的属性,该属性的值决定了浏览器在渲染canvas之前会用几个像素来存储画布信息。
// 屏幕的设备像素比
var devicePixelRatio = window.devicePixelRatio || 1;
// 浏览器在渲染canvas之前存储画布信息的像素比
var backingStoreRatio =
ctx.webkitBackingStorePixelRatio ||
ctx.mozBackingStorePixelRatio ||
ctx.msBackingStorePixelRatio ||
ctx.oBackingStorePixelRatio ||
ctx.backingStorePixelRatio ||
1;
// canvas的实际渲染倍率
var ratio = devicePixelRatio / backingStoreRatio;
canvas.style.width = canvas.width + "px";
canvas.style.height = canvas.height + "px";
canvas.width = canvas.width * ratio;
canvas.height = canvas.height * ratio;
ctx.scale(ratio, ratio);
椭圆
虽然可以直接用 CanvasRenderingContext2D.ellipse() (Canvas 2D API 添加椭圆路径的方法) 方法去绘制。但因为考虑到浏览器兼容性的问题,所以还需要有个备选方案。
Canvas中绘制椭圆的方法有压缩法,计算法,贝塞尔曲线法等多种方式,下面代码中用的是最简单的压缩法。
function ellipse(
x,
y,
radiusX,
radiusY,
rotation,
startAngle,
endAngle,
anticlockwise,
) {
ctx.beginPath();
if (ctx.ellipse) {
ctx.ellipse(x, y, radiusX, radiusY, rotation, startAngle, endAngle, anticlockwise);
} else {
let r = radiusX > radiusY ? radiusX : radiusY; //用大的数为半径
let scaleX = radiusX / r; //计算缩放的x轴比例
let scaleY = radiusY / r; //计算缩放的y轴比例
ctx.save(); //保存当前环境的状态。
ctx.translate(x, y); //移动到圆心位置
ctx.rotate(rotation); //进行旋转
ctx.scale(scaleX, scaleY); //进行缩放
ctx.arc(0, 0, r, startAngle, endAngle, anticlockwise); //绘制圆形
ctx.restore(); //返回之前保存过的路径状态和属性。
}
ctx.stroke();
}
其他
我里面写了2个简单的动画,一个是圆逐渐扩大透明(每一帧改变填充圆的大小和透明度),一个是曲线上有一个简单动效(每一帧弧线上加的曲线绘制在下一个位置)。总的来讲,对于了解 Canvas 的人来说,这些是很简单的。
- 还有个功能,需要点击对应的圆时,圆变色,相关联的曲线也变色,有个曲线上的简单动效。
我是通过外层再套个父组件,根据鼠标点击的位置,去判断的,里面涉及到一个 children 比较少用到的一个点。
<props.children.type {...props.children.props} chestnutProps={}></props.children.type>
或
{React.cloneElement(props.children, { chestnutProps: [] })}
可以看下这里
代码
import { useState, useEffect, useRef } from "react";
function GroupCanvas(props) {
const ref = useRef(null);
const nowCanves = useRef(null);
//透明度值
const transparencValue = useRef(1);
//保存requestAnimationFrame回调的ID
const circulationId = useRef("");
//高亮时动态曲线的移动进度
const ledRate = useRef(0);
const speed = 0.03, //速度
long = 0.3; //长度
useEffect(() => {
if (!ref.current.getContext) return;
let ctx = ref.current.getContext("2d");
// 屏幕的设备像素比
var devicePixelRatio = window.devicePixelRatio || 1;
// 浏览器在渲染canvas之前存储画布信息的像素比
var backingStoreRatio =
ctx.webkitBackingStorePixelRatio ||
ctx.mozBackingStorePixelRatio ||
ctx.msBackingStorePixelRatio ||
ctx.oBackingStorePixelRatio ||
ctx.backingStorePixelRatio ||
1;
// canvas的实际渲染倍率
var ratio = devicePixelRatio / backingStoreRatio;
ref.current.style.width = ref.current.width + "px";
ref.current.style.height = ref.current.height + "px";
ref.current.width = ref.current.width * ratio;
ref.current.height = ref.current.height * ratio;
ctx.scale(ratio, ratio);
nowCanves.current = ctx;
}, []);
useEffect(() => {
allDraw();
return () => {
window.cancelAnimationFrame(circulationId.current);
};
}, [props.textInCircle, props.selects]);
function allDraw() {
let {
groupSum,
circleCenterX,
circleCenterY,
textInCircle,
firstFontYInCircleY,
fontSpacing,
maxRound,
selects,
} = { ...props };
//每帧透明度进行变化
if (transparencValue.current <= 0.05) {
transparencValue.current = 1;
} else {
transparencValue.current -= 0.01;
}
let ctx = nowCanves.current;
//高亮时动态曲线的角度
let startPlace = speed * ledRate.current * Math.PI,
endPlace = long + speed * ledRate.current * Math.PI;
if (endPlace > Math.PI) {
ledRate.current = 0;
} else {
ledRate.current += 1;
}
//清空canvas
ctx.clearRect(0, 0, ref.current.width, ref.current.height);
ctx.textAlign = "center";
ctx.font = "400 12px SourceHanSansCN";
for (let i = 0; i < groupSum; i++) {
let circleName = ["A", "B", "C", "D", "E"];
//绘制圆形
roundness(
ctx,
circleCenterX[i],
circleCenterY,
selects.includes(circleName[i])
);
// 绘制圆内的字
ctx.save();
ctx.fillStyle = "#1D1D1D";
for (let t = 0; t < textInCircle[i].length; t++) {
//字体长度太长,省略号
if (t === 0 && textInCircle[i][0].length > 3) {
let newSte = textInCircle[i][0].slice(0, 3);
newSte += "...";
ctx.fillText(
newSte,
circleCenterX[i],
circleCenterY - maxRound + firstFontYInCircleY + fontSpacing * t
);
} else {
ctx.fillText(
textInCircle[i][t],
circleCenterX[i],
circleCenterY - maxRound + firstFontYInCircleY + fontSpacing * t
);
}
}
ctx.restore();
// 绘制上曲线
if (i === groupSum - 1 && groupSum > 3) {
let isbright = false;
if (
selects.includes("A") ||
selects.includes(String.fromCharCode(64 + groupSum))
) {
isbright = true;
}
//最长上曲线
let curveRadiusX = (circleCenterX[groupSum - 1] - circleCenterX[0]) / 2;
ellipse(
ctx,
circleCenterX[0] + curveRadiusX,
circleCenterY - maxRound,
curveRadiusX,
80,
0,
-0.03 * Math.PI,
1.03 * Math.PI,
true,
isbright ? "#DDDD6F" : "rgba(201,224,255,0.3)"
);
//动曲线
if (isbright) {
dynamicCurve(
ctx,
circleCenterX[0] + curveRadiusX,
circleCenterY - maxRound,
curveRadiusX,
80,
0,
-startPlace,
-endPlace,
true,
"#fff"
);
}
trilateral(
ctx,
{ x: circleCenterX[0], y: circleCenterY - maxRound },
"bottom",
15,
isbright ? "#DDDD6F" : "rgba(201,224,255,0.3)"
);
trilateral(
ctx,
{
x: circleCenterX[groupSum - 1],
y: circleCenterY - maxRound,
},
"bottom",
345,
isbright ? "#DDDD6F" : "rgba(201,224,255,0.3)"
);
} else if (i < groupSum - 1) {
let curveRadiusX = (circleCenterX[i + 1] - circleCenterX[i]) / 2,
curveName = ["AB", "BC", "CD", "DE"],
isbright = false;
for (let str of selects) {
if (curveName[i].includes(str)) {
isbright = true;
break;
}
}
ellipse(
ctx,
circleCenterX[i] + curveRadiusX,
circleCenterY - maxRound,
curveRadiusX,
18,
0,
-0.11 * Math.PI,
1.11 * Math.PI,
true,
isbright ? "#DDDD6F" : "rgba(201,224,255,0.3)"
);
//动曲线
if (isbright) {
dynamicCurve(
ctx,
circleCenterX[i] + curveRadiusX,
circleCenterY - maxRound,
curveRadiusX,
18,
0,
-startPlace,
-endPlace,
true,
"#fff"
);
}
trilateral(
ctx,
{ x: circleCenterX[i], y: circleCenterY - maxRound },
"bottom",
45,
isbright ? "#DDDD6F" : "rgba(201,224,255,0.3)"
);
trilateral(
ctx,
{
x: circleCenterX[i + 1],
y: circleCenterY - maxRound,
},
"bottom",
315,
isbright ? "#DDDD6F" : "rgba(201,224,255,0.3)"
);
}
//下曲线
if (groupSum > 2 && i >= 2) {
let curveName = ["AC", "BD", "CE"],
isbright = false;
for (let str of selects) {
if (curveName[i - 2].includes(str)) {
isbright = true;
break;
}
}
ellipse(
ctx,
circleCenterX[i - 1],
circleCenterY + maxRound,
(circleCenterX[i] - circleCenterX[i - 2]) / 2,
35,
0,
0.07 * Math.PI,
0.93 * Math.PI,
false,
isbright ? "#DDDD6F" : "rgba(201,224,255,0.3)"
);
//动曲线
if (isbright) {
dynamicCurve(
ctx,
circleCenterX[i - 1],
circleCenterY + maxRound,
(circleCenterX[i] - circleCenterX[i - 2]) / 2,
35,
0,
startPlace,
endPlace,
false,
"#fff"
);
}
trilateral(
ctx,
{ x: circleCenterX[i - 2], y: circleCenterY + maxRound },
"top",
-35,
isbright ? "#DDDD6F" : "rgba(201,224,255,0.3)"
);
trilateral(
ctx,
{ x: circleCenterX[i], y: circleCenterY + maxRound },
"top",
35,
isbright ? "#DDDD6F" : "rgba(201,224,255,0.3)"
);
}
// 当组团有5个时
if (groupSum === 5 && i >= 3) {
let curveName = ["AD", "BE"],
isbright = false;
for (let str of selects) {
if (curveName[i - 3].includes(str)) {
isbright = true;
break;
}
}
let curveRadiusX = (circleCenterX[i] - circleCenterX[i - 3]) / 2;
ellipse(
ctx,
circleCenterX[i] - curveRadiusX,
circleCenterY + maxRound,
curveRadiusX,
75,
0,
0.03 * Math.PI,
0.97 * Math.PI,
false,
isbright ? "#DDDD6F" : "rgba(201,224,255,0.3)"
);
//动曲线
if (isbright) {
dynamicCurve(
ctx,
circleCenterX[i] - curveRadiusX,
circleCenterY + maxRound,
curveRadiusX,
75,
0,
startPlace,
endPlace,
false,
"#fff"
);
}
trilateral(
ctx,
{ x: circleCenterX[i - 3], y: circleCenterY + maxRound },
"top",
-15,
isbright ? "#DDDD6F" : "rgba(201,224,255,0.3)"
);
trilateral(
ctx,
{ x: circleCenterX[i], y: circleCenterY + maxRound },
"top",
15,
isbright ? "#DDDD6F" : "rgba(201,224,255,0.3)"
);
}
}
//循环
circulationId.current = window.requestAnimationFrame(allDraw);
}
//绘制三角形
function trilateral(
ctx,
where,
direction = "top",
rotate = 0,
color = "rgba(201,224,255,0.3)"
) {
ctx.save();
ctx.beginPath();
ctx.moveTo(where.x, where.y);
ctx.translate(where.x, where.y);
ctx.rotate((rotate * Math.PI) / 180);
if (direction === "top") {
ctx.lineTo(-5, +9);
ctx.lineTo(+5, +9);
} else {
ctx.lineTo(-5, -9);
ctx.lineTo(+5, -9);
}
ctx.fillStyle = color;
ctx.fill();
ctx.restore();
}
//绘制椭圆曲线
function ellipse(
ctx,
x,
y,
radiusX,
radiusY,
rotation,
startAngle,
endAngle,
anticlockwise,
color = "rgba(201,224,255,0.3)"
) {
ctx.save();
ctx.strokeStyle = color;
ctx.beginPath();
if (ctx.ellipse) {
ctx.ellipse(
x,
y,
radiusX,
radiusY,
rotation,
startAngle,
endAngle,
anticlockwise
);
ctx.stroke();
} else {
let r = radiusX > radiusY ? radiusX : radiusY;
let scaleX = radiusX / r;
let scaleY = radiusY / r;
ctx.translate(x, y);
ctx.rotate(rotation);
ctx.scale(scaleX, scaleY);
ctx.arc(0, 0, r, startAngle, endAngle, anticlockwise);
}
ctx.stroke();
ctx.restore();
}
//绘制动圆
function roundness(ctx, x, y, isbright) {
ctx.save();
let colors = isbright ? ["#DDDD6F", "#DD8B6F"] : ["#84B8FF", "#AACEFF"],
tbArc = isbright
? ["rgba(221,221,111,1)", "rgba(221,221,111,0)"]
: ["rgba(201, 224, 255, 1)", "rgba(201, 224, 255, 0)"];
let grd = ctx.createLinearGradient(
x,
y - props.maxRound,
x,
y + props.maxRound
);
grd.addColorStop(0, colors[0]);
grd.addColorStop(1, colors[1]);
ctx.fillStyle = grd;
//背景圆
ctx.beginPath();
ctx.globalAlpha = 0.05;
ctx.arc(x, y, props.maxRound, 0, 2 * Math.PI);
ctx.fill();
//逐渐放大圆
ctx.beginPath();
ctx.globalAlpha = transparencValue.current - 0.01;
ctx.arc(
x,
y,
props.initialRound +
(props.maxRound - props.initialRound) * (1 - transparencValue.current),
0,
2 * Math.PI
);
ctx.fill();
// 中间基础圆
ctx.beginPath();
ctx.globalAlpha = 1;
ctx.arc(x, y, props.initialRound, 0, 2 * Math.PI);
ctx.fill();
// 上下曲线
ctx.beginPath();
let grd2 = ctx.createLinearGradient(x, y - props.maxRound, x, y);
grd2.addColorStop(0, tbArc[0]);
grd2.addColorStop(0.4, tbArc[1]);
ctx.strokeStyle = grd2;
ctx.arc(x, y, props.maxRound, 0.2 * Math.PI, 0.8 * Math.PI, true);
ctx.stroke();
ctx.beginPath();
let grd3 = ctx.createLinearGradient(x, y + props.maxRound, x, y);
grd3.addColorStop(0, tbArc[0]);
grd3.addColorStop(0.4, tbArc[1]);
ctx.strokeStyle = grd3;
ctx.arc(x, y, props.maxRound, 0.2 * Math.PI, 0.8 * Math.PI);
ctx.stroke();
ctx.restore();
}
//增加的动态曲线
function dynamicCurve() {
arguments[0].save();
arguments[0].globalCompositeOperation = "source-atop";
ellipse(...arguments);
arguments[0].restore();
}
return (
<canvas
ref={ref}
width={160 + props.groupSum * 150 + (props.groupSum - 1) * 60}
height={386}
style={{
...props.outStyle,
}}
></canvas>
);
}
export default React.memo(GroupCanvas);
GroupCanvas.defaultProps = {
//内圆半径
initialRound: 25,
//背景圆半径
maxRound: 55,
//圆的xy坐标
circleCenterX: [155, 367, 579, 791, 1003],
circleCenterY: 200,
//圆内的字
textInCircle: [
["A区团", "10%"],
["A区团", "10%"],
["A区团", "10%"],
["A区团", "10%"],
["A区团", "10%"],
],
//圆内第一个字距离圆顶部距离
firstFontYInCircleY: 55,
//圆内字体间距
fontSpacing: 15,
//数量目前最多限定5个
groupSum: 5,
//被选中高亮
selects: [],
};
参考链接:
https://blog.csdn.net/gao_xu_520/article/details/58588020
https://www.cnblogs.com/flybeijing/p/canvas_ellipse.html
https://www.html.cn/archives/9297