一、效果预览
现在我们要用 canvas 实现这样的一个仪表盘效果,要怎么实现呢?
二、结构分析
我们先来分析一下这个仪表盘包含的元素
- 灰色的底部表盘,为半圆
- 绿色、黄色、红色的表盘,为半圆
- 刻度
- 中间浅灰色的半圆,和外部表盘圆心位置一致
- 三角相形状的指针
- 表盘上圆形指针
- 文案
三、准备工作
创建一个 id 为 dashboard 的 canvas,并将其宽设置为 300,高设置为 128 。这个宽高可以根据自己的需求去做调整。
<canvas id="dashboard"></canvas>
const canvasWidth = 300; // canvas宽度
const canvasHeigth = 128; // canvas高度
const canvas = document.getElementById("dashboard");
canvas.width = canvasWidth;
canvas.height = canvasHeigth;
const ctx = canvas.getContext("2d");
根据 canvas 的宽高,并且我们知道 canvas 的坐标原点是在其左上角,我们便可以得到仪表盘底部的中心坐标了 (canvasWidth/2, canvasHeigth)
,也就是表盘圆心所在的位置。
然后我们使用 translate 方法把 canvas 的坐标原点进行修改,改到表盘的圆心位置,这样方便我们后续的位置计算。
const centerX = canvasWidth / 2; // 半圆的圆心-X
const centerY = canvasHeigth; // 半圆的圆心-Y
ctx.translate(centerX, centerY);
canvas 的坐标系是 x 轴向右是正,y轴向下是正
四、开始画图
1. 画灰色表盘
这个灰色的表盘,是一个半圆的弧线,我们只要使用足够粗的线,就能把这个画出来。画弧线可以用 arc 方法,不过有两点需要注意一下:
- arc 方法中用的是弧度;以 x 轴为基准。
- arc 默认绘制方向为顺时针,从 startAngle 开始绘制,到 endAngle 结束。
所以我们需要一个角度转弧度的方法,方便我们比较直观的进行计算
// 角度转弧度
function degToRad(deg) {
return (Math.PI / 180) * deg;
}
然后再定义一下弧度半径和线条宽度:
const outterRadius = 90; // 大半圆的半径
const outterLineWidth = 20; // 大半圆的线条宽度
我们便可以开始画半圆了,从-180度顺时针画到0度的位置:
// 灰色表盘
ctx.beginPath();
ctx.arc(
0, // 圆心 x
0, // 圆心 y
outterRadius, // radius
this.degToRad(-180), // startAngle
this.degToRad(0), // endAngle
);
ctx.strokeStyle = "#DDDDDD"; // 线条颜色
ctx.lineWidth = outterLineWidth; // 线条宽度
ctx.lineCap = "butt"; // 线段末端以方形结束
ctx.stroke();
这样便画出来了灰色表盘了
2. 画彩色表盘
彩色表盘也是使用弧线来画的,不过分为了三段,一段一个颜色。现在我们分配一下颜色及它的角度范围:
- 绿色占 1/4: -180 ~ -135
- 黄色占 1/2:-135 ~ -45
- 红色占 1/4:-45 ~ 0
画弧线的逻辑和上面是一样的:
const colorList = [
{
color: "#1BBE7C", // 绿
start: -180,
end: -135,
},
{
color: "#FF881A", // 黄
start: -135,
end: -45,
},
{
color: "#D81D1D", // 红
start: -45,
end: 0,
},
];
// 颜色线段
for (let index = 0; index < colorList.length; index++) {
const option = colorList[index];
ctx.beginPath();
ctx.arc(
0,
0,
outterRadius,
degToRad(option.start),
degToRad(option.end),
);
ctx.strokeStyle = option.color;
ctx.lineWidth = outterLineWidth;
ctx.lineCap = "butt";
ctx.stroke();
}
效果是这样的:
3. 表盘刻度
我们可以看下有指针的钟表表盘,表盘的所有刻度向内延伸的交汇点是表盘的圆心。
所以,我们只要从表盘中心延伸出一条直线,然后按固定角度旋转,旋转后与彩色弧线相交的部分,就是我们需要的刻度。
核心逻辑是:
- 按15度一个刻度,一共需要 180 / 15 = 12 个刻度;
- 划线:我们只需要画出和表盘相交部分的线段就可以了,不需要从原点开始画。
- 使用 rotate 方法进行旋转。注意这个是按顺时针旋转的,并且旋转中心是 canvas 坐标原点。
这里有一个问题,我们怎么确定刻度线的开始和结束位置的?我们先画一条辅助线,看一下弧线宽度为1时,这个弧线是在什么位置:
// 辅助线
ctx.beginPath();
ctx.arc(0, 0, outterRadius, degToRad(-180), degToRad(0));
ctx.strokeStyle = "#000";
ctx.lineWidth = 1;
ctx.lineCap = "butt";
ctx.stroke();
我们可以直观得看到,圆心到弧线中间位置才是圆的真实半径:
所以,刻度线的位置是从 -(outterRadius + outterLineWidth / 2)
到 -(outterRadius - outterLineWidth / 2)
的。
然后再优化一下开始和结束的刻度,我们就可以得到完整的刻度了:
const splitRad = this.degToRad(15);
const splitCount = 180 / 15;
ctx.save();
// 开始和结尾处不需要分割线
ctx.rotate(splitRad);
for (let index = 0; index < splitCount - 1; index++) {
ctx.beginPath();
ctx.lineWidth = 1;
ctx.strokeStyle = "#ffffff";
ctx.lineCap = "butt";
// 画刻度线
ctx.moveTo(
-(outterRadius + outterLineWidth / 2),
0
);
ctx.lineTo(
-(outterRadius - outterLineWidth / 2),
0
);
ctx.stroke();
// 旋转
ctx.rotate(splitRad);
}
ctx.restore();
4. 画灰色半圆
这就比较简单了,只是画一个半圆,我们定义一下半圆的半径 innerRadius 为 50 :
const innerRadius = 50; // 小半圆的半径
// 灰色半圆
ctx.beginPath();
ctx.arc(
0,
0,
innerRadius,
degToRad(-180),
degToRad(0),
);
ctx.strokeStyle = "#DDDDDD";
ctx.lineWidth = 1;
ctx.lineCap = "butt";
ctx.fillStyle = "#F9F9F9";
ctx.closePath();
ctx.stroke();
ctx.fill();
注意:不要忘记用 closePath 进行曲线闭合。
5. 画指针-三角形
上面我们说了刻度是旋转后画上去的。那同理,三角指针我们只需要贴着内部的灰色的圆的边画出来一个三角形就行了。不同的位置,使用 rotate 进行旋转就行了。
ctx.save();
// ctx.rotate(0.15);
// 画一个等腰三角形
ctx.beginPath();
ctx.moveTo(-innerRadius, -10);
ctx.lineTo(-innerRadius, 10);
ctx.lineTo(-(innerRadius + 16), 0);
ctx.fillStyle = "#D81D1D";
ctx.fill();
ctx.restore();
因为超出了canvas 范围,被截取掉了,所以我们稍微做点旋转让它出现在 canvas 范围内
ctx.rotate(0.15);
6. 画指针-圆形
原型指针和三角形指针的逻辑是一样的,不过圆心的初始位置是在表盘的中心位置。这里也稍微做些旋转,让圆出现在 canvas 内:
// 指针-圆
ctx.save();
ctx.rotate(0.15);
ctx.beginPath();
ctx.arc(
-outterRadius,
0,
outterLineWidth / 2,
degToRad(0),
degToRad(360)
);
ctx.strokeStyle = "#D81D1D";
ctx.lineWidth = 8;
ctx.lineCap = "butt";
ctx.fillStyle = "#FFFFFF";
ctx.closePath();
ctx.stroke();
ctx.fill();
ctx.restore();
7. 画文案
文案只要确定好位置,就比较好画出来,具体计算逻辑就不细说了
// 文字 - x条
ctx.textAlign = "center";
ctx.fillStyle = "#D81D1D";
ctx.font = "22px sans-serif";
ctx.fillText(`${10}条`, 0, -5);
// 文字 - 通过
ctx.textAlign = "right";
ctx.fillStyle = "#A4A4A4";
ctx.font = "22px sans-serif";
ctx.fillText("开始", -outterRadius - 15, -5);
// 文字 - 复议
ctx.textAlign = "center";
ctx.fillStyle = "#A4A4A4";
ctx.font = "22px sans-serif";
ctx.fillText("中间", 0, -(outterRadius + outterLineWidth));
// 文字 - 拒绝
ctx.textAlign = "left";
ctx.fillStyle = "#A4A4A4";
ctx.font = "22px sans-serif";
ctx.fillText("结束", outterRadius + 15, -5);
五、动起来
现在我们只是把所有元素都画出来,那怎么动起来呢?动起来之前我们先做一些其他的准备:
- 不同角度下指针的颜色不同,需要跟表盘在对应区间所展示的颜色一致
- 不同角度下中间半圆上文案的颜色需要跟指针颜色一致
- 表盘有颜色的部分需要跟着指针旋转而改变,未转到的部分应该是灰色的
这样的话,我们至少得需要一个方法去计算指针旋转的角度、指针在当前角度下的颜色、表盘绘制的范围及颜色。
1. 指针旋转的角度
指针从开始文案的位置开始顺时针旋转的话,初始时指针和x轴正方向的夹角是 -180度;旋转的角度是指针与x轴负方向的夹角的角度,初始时旋转角度为 0。而开始顺时针旋转后,比如旋转到绿色和黄色交界位置,旋转的角度是45度,此时指针和x轴正方向的夹角是-135度。
这里我们以指针与x轴正方向夹角的度数为输出参数,所以范围就是 [-180, 0],这样就能计算出指针的旋转弧度,然后把当时我们写死的旋转弧度改为计算结果就行了:
let currentProgress = -135
// 指针旋转弧度
let pointerOffsetRad = degToRad(currentProgress + 180);
// 做部分角度兼容
if (pointerOffsetRad < 0.15) {
pointerOffsetRad = 0.15;
}
if (pointerOffsetRad > Math.PI - 0.15) {
pointerOffsetRad = Math.PI - 0.15;
}
// 省略部分代码
// ctx.rotate(0.15);
ctx.rotate(pointerOffsetRad);
这还做了一个旋转弧度的优化,我们上面有看到旋转角度为0时,会展示不全,所以在旋转角度范围做了一个限制,让指针可以展示全。
2. 改变表盘绘制范围和指针颜色
我们最开始的时候定义了 colorList 这个变量,并根据面定义的颜色及范围做了弧线的绘制。那我们只需要根据输入的角度,计算出需要绘制哪段弧线以及该弧线的开始结束位置,并以结束弧度的颜色作为指针颜色便可以了。
我们用 colorConfig 保存原始弧线信息,需要画出来的弧线信息我们保存在 colorList 中。
我们以-120度为例:
- 发现 -120 大于 end -135,将数组中第一段弧线信息保存到 colorList 中,将继续遍历下一个;
- 发现 -120 小于 end -45 ,并且 start -135 小于 -120,表示指针指向的是这段弧线,即指针颜色是黄色;但是这段弧线开始和结束位置应该修改为 -135 ~ -120。
- 结束遍历
- 绘制 colorList 中的弧线,并改变指针和文字的颜色
const colorConfig = [
{
color: "#1BBE7C", // 绿
start: -180,
end: -135,
},
{
color: "#FF881A", // 黄
start: -135,
end: -45,
},
{
color: "#D81D1D", // 红
start: -45,
end: 0,
},
];
let pointerColor = "#D81D1D";
let maxPointerOffset = 0;
const colorList = [];
// 计算弧线段及其范围
colorConfig.forEach((option) => {
if (currentProgress > option.end) {
colorList.push(option);
} else if (currentProgress >= option.start) {
const start = option.start;
const end = currentProgress;
colorList.push({
start: start,
end: end,
color: option.color,
});
pointerColor = option.color;
}
});
// 省略代码
// 三角修改填充颜色
ctx.fillStyle = pointerColor;
// 圆形指针修改边框颜色
ctx.strokeStyle = pointerColor;
// 半圆上的问题修改颜色
ctx.fillStyle = pointerColor;
3. 执行动画
我们用 requestAnimationFrame 执行动画,当然也可以用 setTimeout 或 setInterval 。我们把上面绘制内容的代码封装到 draw 中,然后调用 start 执行动画:
function draw(currentProgress) {
// 省略
}
let progress = -180;
function start() {
if (progress < 0) {
ctx.clearRect(-centerX, -centerY, canvasWidth, canvasHeigth);
progress++;
draw(progress);
window.requestAnimationFrame(start);
}
}
start();
唯一需要注意的是,执行下一帧的时候,需要调用 clearRect 把上一次的绘制内容清空,并且在开始时我们转换了坐标系,所以清空的范围的左上角坐标不是 (0, 0) ,而是 (-centerX, -centerY)
这边是最终效果
完整示例代码: GitHub