在可视化应用中,弧形进度条应用也比较广泛,在“SVG绘制圆环进度条”和“线形进度条的几种实现方式”这两章之后,今天针对平时开发中关于弧形进度条的实现方案进行一个总结,结合Javascript面向对象的开发方式,参考echarts的实现方式,封装一个可复用、个性化的弧形进度条组件,也算是对原生Canvas相关api进行一下回顾。展示效果如下:
1.我们都知道Canvas绘图是基于像素点进行绘制,而设备的分辨率又是千差万别,为了使我们绘制的图表能适应各种屏幕,通常的做法是绘制2倍或多倍图,1倍展示,也就是将绘制好的Canvas图表(通俗的来说就是一张图片)进行压缩展示,这在高清屏上能明显优化展示效果。本篇文章采用的是另一种方法,采用获取屏幕像素比的方式,解决高清屏下图表模糊问题,这里引入一个辅助性函数,用于获取用户屏幕的像素比。
// 获取屏幕的像素比
getPixelRatio(context) {
var backingStroe = context.backingStorePixelRatio ||
context.webkitBackingStorePixelRatio ||
context.mozBackingStorePixelRatio ||
context.msBackingStorePixelRatio ||
context.oBackingStorePixelRatio ||
1
return (window.devicePixelRatio || 1) / backingStroe
}
2.使用过echarts的同学都知道,echarts个性化图表的实现方式是通过传入配置对象来实现的,因此我们也可以通过此方式实现,提供默认的配置方案,基于用户自己的喜好传入个性化参数,在实际绘制时将二者参数进行合并及覆盖即可实现。
// 默认图表配置项
defaultConfig() {
return {
polar: {
radius: '90%',
arcDeg: 240,
center: ['center', 'center'],
strokeBackgroundColor: '#e1e8ee',
strokeBackgroundWidth: 14,
strokeWidth: 14,
strokeColor: '#6f78cc',
startAngle: 0,
lineCap: 'round'
},
xAxis: {
axisLabel: {
show: false,
offsetCenterY: '50%',
font: '24px Microsoft YaHei',
color: '#6f78cc',
align: 'center',
verticalAlign: 'middle',
formatter: function(param) {
return param.name
},
}
},
animation: {
show: false,
duration: 800,
},
desc: {
show: true,
offsetCenterY: 0,
font: '24px Microsoft YaHei',
color: '#000',
align: 'center',
verticalAlign: 'middle',
formatter: function(param) {
return param.value + param.unit
}
},
tooltip: {
style: {
position: 'absolute',
display: 'none',
whiteSpace: 'nowrap',
zIndex: '9999999',
transition: 'left 0.4s cubic-bezier(0.23, 1, 0.32, 1) 0s, top 0.4s cubic-bezier(0.23, 1, 0.32, 1) 0s',
backgroundColor: 'rgba(50, 50, 50, 0.7)',
borderRadius: '4px',
border: '0px solid rgb(51, 51, 51)',
color: 'rgb(255, 255, 255)',
font: '20px / 30px Microsoft YaHei',
padding: '5px',
left: '0px',
top: '0px',
},
markerTemplate: '<span style="display: inline-block;width:14px;height: 14px;border-radius: 50%;margin-right: 4px;background-color: #"></span>',
show: false,
formatter: function(param) {
return `${param.marker}${param.data.name}:${param.data.value}`
}
}
}
}
3.既然是将对象进行合并,这里就又涉及到一个新的知识点,深拷贝的问题,关于深拷贝和浅拷贝的实现方式,网上不胜枚举,作为一个必考的面试题,这里不做过多描述,仅实现一个简易版的深拷贝功能。
// 深拷贝
deepCopy(result, obj) {
for (var key in obj) {
if (obj.hasOwnProperty(key)) {
if (typeof obj[key] === 'object' && result[key]) {
this.deepCopy(result[key], obj[key]);
} else {
result[key] = obj[key];
}
}
}
return result;
}
4.为了增加配置参数的通用性,用户既可以传入百分比数据,也可以传入具体的数值,因此在类中提供一个简易的处理函数,以提高程序的通用性和复用性
// 处理百分比小数及数值
handleNum(num, value) {
let returnNum = 0
if (num.toString().indexOf('%') > -1) {
returnNum = num.replace("%", "") / 100 * value;
} else if (num > 0 && num <= 1) {
returnNum = num * value
} else {
returnNum = parseInt(num)
}
return returnNum
}
5.在实际的使用场景中,用户可能进行浏览器窗口的拉伸或还原操作,如果不做图表重新渲染会引起图表的拉伸变形等问题,因此需要使用window.onresize监听窗口变化,当窗口宽高发生变化时,进行图表重绘即可解决此问题,另外由于窗口变化重绘会引起不必要的渲染,从节省系统资源方面考虑,我们可以提供一个防抖函数进行性能上的调优
// 防抖函数
debounce(fn, delay) {
let _this = this
let timer = null
return e => {
if (timer != null) {
clearTimeout(timer)
}
timer = setTimeout(() => {
fn.call(_this, e)
}, delay)
}
}
6.加入用户的交互操作,用户鼠标悬浮至图表时,进行提示信息展示。针对Canvas来说,鼠标交互并不像其它dom那样方便,因为整个Canvas画布是一个整体。实现画布内图表组件的鼠标交互操作需要重绘图表,在绘制过程中,通过isPointInStroke或isPointInPath这两个api判断是否在指定上下文对象上即可
// 鼠标移动事件
mousemove(e) {
this.draw(this.resultData, {
callBack: null,
type: 'mousemove',
x: e.offsetX,
y: e.offsetY
})
}
// 点击事件
click(callBack) {
this.canvas.onclick = e => {
this.draw(this.resultData, {
callBack: callBack,
type: 'click',
x: e.offsetX,
y: e.offsetY
})
}
}
// 绘制圆弧
drawArc(arg, arg2) {
let isInStroke = false
this.ctx.beginPath()
this.ctx.arc(this.center.x, this.center.y, arg.radius, arg.startDeg, arg.endDeg, false)
this.ctx.lineCap = arg.lineCap
this.ctx.strokeStyle = arg.stroke
// 判断鼠标是否悬浮在指定的图表组件上
if (arg2 && this.ctx.isPointInStroke(arg2.x * this.pixelRatio, arg2.y * this.pixelRatio)) {
isInStroke = true
}
this.ctx.lineWidth = arg.strokeWidth
this.ctx.stroke()
return isInStroke
}
针对以上分析汇总,以下提供完整的实现方式
class PercentCharts {
// 构造函数,初始化时调用
constructor(arg) {
this.options = this.deepCopy(this.defaultConfig(), arg)
this.parentContainer = typeof this.options.container === 'string' ? document.querySelector(this.options.container) :
this.options.container
this.container = document.createElement('div')
this.tips = document.createElement('div')
// 提示信息样式
this.setStyle(this.tips, this.options.tooltip.style)
this.canvas = document.createElement('canvas')
this.ctx = this.canvas.getContext('2d')
// 获取屏幕像素比,解决高清屏下图表模糊问题
this.pixelRatio = this.getPixelRatio(this.ctx)
this.width = this.parentContainer.offsetWidth
this.height = this.parentContainer.offsetHeight
this.canvas.width = this.width * this.pixelRatio
this.canvas.height = this.height * this.pixelRatio
this.maxRadius = this.canvas.width > this.canvas.height ? this.canvas.height : this.canvas.width
// 中心点坐标
this.center = {
x: this.canvas.width / 2,
y: this.canvas.height / 2
}
// 设置容器及canvas标签样式
this.container.style.cssText = this.canvas.style.cssText =
'position:relative;width:100%;height:100%;overflow:hidden'
this.container.appendChild(this.canvas)
this.container.appendChild(this.tips)
this.parentContainer.appendChild(this.container)
this.radius = this.handleNum(this.options.polar.radius, this.maxRadius / 2)
// 渲染图表的数据集
this.resultData = []
if (this.options.tooltip.show) {
this.canvas.onmousemove = this.debounce(this.mousemove, 20)
}
this.resizeTimer = null
this.animateStartTime = null
this.animateTimer = null
}
// 窗口resize
resize() {
// 防抖处理
if (this.resizeTimer) {
clearTimeout(this.resizeTimer)
this.resizeTimer = null
}
this.resizeTimer = setTimeout(() => {
this.width = this.parentContainer.offsetWidth
this.height = this.parentContainer.offsetHeight
this.canvas.width = this.width * this.pixelRatio
this.canvas.height = this.height * this.pixelRatio
this.maxRadius = this.canvas.width > this.canvas.height ? this.canvas.height : this.canvas.width
this.radius = this.handleNum(this.options.polar.radius, this.maxRadius / 2)
this.center = {
x: this.canvas.width / 2,
y: this.canvas.height / 2
}
this.draw(this.resultData)
}, 20)
}
// 默认图表配置项
defaultConfig() {
return {
polar: {
radius: '90%',
arcDeg: 240,
center: ['center', 'center'],
strokeBackgroundColor: '#e1e8ee',
strokeBackgroundWidth: 14,
strokeWidth: 14,
strokeColor: '#6f78cc',
startAngle: 0,
lineCap: 'round'
},
xAxis: {
axisLabel: {
show: false,
offsetCenterY: '50%',
font: '24px Microsoft YaHei',
color: '#6f78cc',
align: 'center',
verticalAlign: 'middle',
formatter: function (param) {
return param.name
},
}
},
animation: {
show: false,
duration: 800,
},
desc: {
show: true,
offsetCenterY: 0,
font: '24px Microsoft YaHei',
color: '#000',
align: 'center',
verticalAlign: 'middle',
formatter: function (param) {
return param.value + param.unit
}
},
tooltip: {
style: {
position: 'absolute',
display: 'none',
whiteSpace: 'nowrap',
zIndex: '9999999',
transition: 'left 0.4s cubic-bezier(0.23, 1, 0.32, 1) 0s, top 0.4s cubic-bezier(0.23, 1, 0.32, 1) 0s',
backgroundColor: 'rgba(50, 50, 50, 0.7)',
borderRadius: '4px',
border: '0px solid rgb(51, 51, 51)',
color: 'rgb(255, 255, 255)',
font: '20px / 30px Microsoft YaHei',
padding: '5px',
left: '0px',
top: '0px',
},
markerTemplate: '<span style="display: inline-block;width:14px;height: 14px;border-radius: 50%;margin-right: 4px;background-color: #"></span>',
show: false,
formatter: function (param) {
return `${param.marker}${param.data.name}:${param.data.value}`
}
}
}
}
// 批量设置样式
setStyle(obj, sty) {
for (let key in sty) {
obj.style[key] = sty[key]
}
}
// 深拷贝
deepCopy(result, obj) {
for (var key in obj) {
if (obj.hasOwnProperty(key)) {
if (typeof obj[key] === 'object' && result[key]) {
this.deepCopy(result[key], obj[key]);
} else {
result[key] = obj[key];
}
}
}
return result;
}
// 处理百分比小数及数值
handleNum(num, value) {
let returnNum = 0
if (num.toString().indexOf('%') > -1) {
returnNum = num.replace("%", "") / 100 * value;
} else if (num > 0 && num <= 1) {
returnNum = num * value
} else {
returnNum = parseInt(num)
}
return returnNum
}
// 防抖函数
debounce(fn, delay) {
let _this = this
let timer = null
return e => {
if (timer != null) {
clearTimeout(timer)
}
timer = setTimeout(() => {
fn.call(_this, e)
}, delay)
}
}
// 鼠标移动事件
mousemove(e) {
this.draw(this.resultData, {
callBack: null,
type: 'mousemove',
x: e.offsetX,
y: e.offsetY
})
}
// 点击事件
click(callBack) {
this.canvas.onclick = e => {
this.draw(this.resultData, {
callBack: callBack,
type: 'click',
x: e.offsetX,
y: e.offsetY
})
}
}
// 获取屏幕的像素比
getPixelRatio(context) {
var backingStroe = context.backingStorePixelRatio ||
context.webkitBackingStorePixelRatio ||
context.mozBackingStorePixelRatio ||
context.msBackingStorePixelRatio ||
context.oBackingStorePixelRatio ||
1
return (window.devicePixelRatio || 1) / backingStroe
}
// 绘制图表
draw(resultData, arg) {
if (this.animateTimer) {
window.cancelAnimationFrame(this.animateTimer)
}
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
if (!resultData) return;
this.resultData = resultData
let flagArr = []
let halfDeg = (this.options.polar.arcDeg / 2 / 360) * Math.PI * 2
let startDeg = Math.PI * 3 / 2 - halfDeg - (this.options.polar.startAngle / 360) * (Math.PI * 2)
let percent = 1
if (!arg && this.options.animation.show) {
if (!this.animateStartTime) {
percent = 0
this.animateStartTime = new Date()
} else {
percent = (new Date() - this.animateStartTime) / this.options.animation.duration
}
if (percent >= 1) {
percent = 1
this.animateStartTime = null
window.cancelAnimationFrame(this.animateTimer)
this.animateTimer = null
} else {
this.animateTimer = window.requestAnimationFrame(() => {
this.draw(this.resultData)
})
}
}
this.drawArc({
startDeg: startDeg,
endDeg: Math.PI * 3 / 2 + halfDeg,
stroke: this.options.polar.strokeBackgroundColor,
strokeWidth: this.options.polar.strokeBackgroundWidth,
radius: this.radius,
lineCap: this.options.polar.lineCap
})
flagArr.push(this.drawArc({
startDeg: startDeg,
endDeg: startDeg + (halfDeg * 2) * (this.resultData.value / 100) * percent,
stroke: this.options.polar.strokeColor,
strokeWidth: this.options.polar.strokeWidth,
radius: this.radius,
lineCap: this.options.polar.lineCap
}, arg))
// 绘制底部文字
if (this.options.xAxis.axisLabel.show) {
this.drawText({
x: 0,
y: this.handleNum(this.options.xAxis.axisLabel.offsetCenterY, this.maxRadius / 2),
font: this.options.xAxis.axisLabel.font,
fillColor: this.options.xAxis.axisLabel.color == 'auto' ? this.options.polar.strokeColor : this.options.xAxis.axisLabel
.color,
text: this.options.xAxis.axisLabel.formatter(resultData),
align: this.options.xAxis.axisLabel.align,
verticalAlign: this.options.xAxis.axisLabel.verticalAlign,
})
}
// 绘制中心文字
if (this.options.desc.show) {
this.drawText({
x: 0,
y: this.handleNum(this.options.desc.offsetCenterY, this.maxRadius / 2),
font: this.options.desc.font,
fillColor: this.options.desc.color == 'auto' ? this.options.polar.strokeColor : this.options.desc.color,
text: this.options.desc.formatter(resultData),
align: this.options.desc.align,
verticalAlign: this.options.desc.verticalAlign,
})
}
if (arg) {
if (flagArr.some(item => item == true)) {
this.tips.innerHTML = this.options.tooltip.formatter({
marker: this.options.tooltip.markerTemplate.replace('#', this.options.polar.strokeColor),
color: this.options.polar.strokeColor,
data: resultData
})
let tipsPosX = 0
let tipsPosY = 0
if (arg.x + this.tips.offsetWidth + 20 > this.width) {
tipsPosX = arg.x - 20 - this.tips.offsetWidth
} else {
tipsPosX = arg.x + 20
}
if (arg.y + this.tips.offsetHeight + 20 > this.height) {
tipsPosY = arg.y - 20 - this.tips.offsetHeight
} else {
tipsPosY = arg.y + 20
}
this.tips.style.left = `${tipsPosX}px`
this.tips.style.top = `${tipsPosY}px`
this.tips.style.display = 'block'
this.container.style.cursor = 'pointer'
if (arg.callBack) {
arg.callBack(resultData)
}
} else {
this.container.style.cursor = 'default'
this.tips.style.display = 'none'
}
}
}
// 绘制圆弧
drawArc(arg, arg2) {
let isInStroke = false
this.ctx.beginPath()
this.ctx.arc(this.center.x, this.center.y, arg.radius, arg.startDeg, arg.endDeg, false)
this.ctx.lineCap = arg.lineCap
this.ctx.strokeStyle = arg.stroke
if (arg2 && this.ctx.isPointInStroke(arg2.x * this.pixelRatio, arg2.y * this.pixelRatio)) {
isInStroke = true
}
this.ctx.lineWidth = arg.strokeWidth
this.ctx.stroke()
return isInStroke
}
// 绘制文字
drawText(arg) {
this.ctx.save()
this.ctx.beginPath()
this.ctx.translate(this.center.x, this.center.y);
this.ctx.font = arg.font;
this.ctx.fillStyle = arg.fillColor;
this.ctx.textAlign = arg.align;
this.ctx.textBaseline = arg.verticalAlign;
this.ctx.fillText(arg.text, arg.x, arg.y);
this.ctx.restore()
}
}
export default PercentCharts
在组件中使用图表(以vue为例)
<template>
<div class="chart-box">
<div class="container" id="container10"></div>
<div class="container" id="container20"></div>
<div class="container" id="container30"></div>
<div class="container" id="container40"></div>
<div class="container" id="container50"></div>
<div class="container" id="container60"></div>
</div>
</template>
<script>
import PercentCharts from "./PercentChartJS";
export default {
name: "RingChart",
data() {
return {
ringCharts1: null,
ringCharts2: null,
ringCharts3: null,
ringCharts4: null,
ringCharts5: null,
ringCharts6: null,
};
},
mounted() {
this.ringCharts1 = new PercentCharts({
container: "#container10",
tooltip: {
show: true,
formatter: function (param) {
return param.marker + "这是自定义信息";
},
},
desc: {
show: true,
font: "40px Microsoft YaHei",
formatter: function (param) {
return param.value + param.unit;
},
},
animation: {
show: true,
},
xAxis: {
axisLabel: {
show: true,
font: "30px Microsoft YaHei",
formatter: function (param) {
return param.name + "😃";
},
},
},
});
this.ringCharts2 = new PercentCharts({
container: "#container20",
tooltip: {
show: true,
},
polar: {
strokeColor: "#ffc300",
strokeWidth: 20,
},
xAxis: {
axisLabel: {
show: true,
color: "auto",
font: "30px Microsoft YaHei",
formatter: function (param) {
return param.name + "😒";
},
},
},
desc: {
show: true,
font: "40px Microsoft YaHei",
},
});
this.ringCharts3 = new PercentCharts({
container: "#container30",
polar: {
strokeColor: "#07e373",
lineCap: "butt",
},
tooltip: {
show: true,
formatter: function (param) {
return param.marker + "这是自定义信息";
},
},
desc: {
show: true,
font: "40px Microsoft YaHei",
formatter: function (param) {
return param.value + param.unit;
},
},
xAxis: {
axisLabel: {
show: true,
color: "#000",
font: "30px Microsoft YaHei",
formatter: function (param) {
return param.name + "😊";
},
},
},
});
this.ringCharts4 = new PercentCharts({
container: "#container40",
polar: {
arcDeg: 360,
startAngle: 180,
},
tooltip: {
show: true,
},
xAxis: {
axisLabel: {
offsetCenterY: "15%",
show: true,
font: "30px Microsoft YaHei",
formatter: function (param) {
return param.name + "😃";
},
},
},
animation: {
show: true,
},
desc: {
show: true,
offsetCenterY: "-15%",
font: "40px Microsoft YaHei",
},
});
this.ringCharts5 = new PercentCharts({
container: "#container50",
polar: {
arcDeg: 360,
startAngle: 180,
strokeColor: "#ffc300",
strokeWidth: 20,
},
tooltip: {
show: true,
formatter: function (param) {
return param.marker + "这是自定义信息";
},
},
desc: {
show: true,
offsetCenterY: "-15%",
font: "40px Microsoft YaHei",
formatter: function (param) {
return param.value + param.unit;
},
},
xAxis: {
axisLabel: {
show: true,
offsetCenterY: "15%",
color: "auto",
font: "30px Microsoft YaHei",
formatter: function (param) {
return param.name + "😒";
},
},
},
});
this.ringCharts6 = new PercentCharts({
container: "#container60",
polar: {
arcDeg: 360,
startAngle: 180,
strokeColor: "#07e373",
lineCap: "butt",
},
tooltip: {
show: true,
},
xAxis: {
axisLabel: {
show: true,
offsetCenterY: "15%",
font: "30px Microsoft YaHei",
color: "#000",
formatter: function (param) {
return param.name + "😊";
},
},
},
desc: {
show: true,
offsetCenterY: "-15%",
font: "40px Microsoft YaHei",
},
});
this.initChart();
window.addEventListener("resize", this.resize);
},
beforeDestroy() {
window.removeEventListener("resize", this.resize);
},
methods: {
resize() {
this.ringCharts1.resize();
this.ringCharts2.resize();
this.ringCharts3.resize();
this.ringCharts4.resize();
this.ringCharts5.resize();
this.ringCharts6.resize();
},
initChart() {
this.ringCharts1.draw({
name: "正面评论",
value: 50,
unit: "%",
});
this.ringCharts2.draw({
name: "负面评论",
value: 30,
unit: "%",
});
this.ringCharts3.draw({
name: "中立评论",
value: 20,
unit: "%",
});
this.ringCharts4.draw({
name: "正面评论",
value: 60,
unit: "%",
});
this.ringCharts5.draw({
name: "负面评论",
value: 25,
unit: "%",
});
this.ringCharts6.draw({
name: "中立评论",
value: 15,
unit: "%",
});
},
},
};
</script>
<style scoped>
.chart-box {
width: 100%;
height: 100%;
display: flex;
flex-wrap: wrap;
}
.container {
width: 33.3%;
height: 50%;
}
</style>