Canvas弧形进度条

在可视化应用中,弧形进度条应用也比较广泛,在“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>

  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值