背景
在开发中涉及到显示各种类型的图表,我们往往会考虑使用echarts或者antv之类的库,毕竟成熟示例多(CV还方便)。在小程序中常使用echarts-for-weixin库进行图形的绘制,相较于web环境,小程序对于打包体积有一定的要求,主包的打包体积需要小于2M。常规手段可以通过定制echart文件,配置分包减少主包的体积。
而手头开发的项目随着调整了好几轮的需求,图表部分进行了大规模的调整,最后仅剩下启动页和tabBar页面保留几个功能简单的饼图和柱状图。单单为了实现简单图表的效果,使用ECharts就需要占用500多kb,实在是没太大的必要了。 所以在性能优化阶段,开始着手实现图表效果替代ECahrts,以优化打包体积,加快小程序的启动速度。
由于优化的主要目的是减少打包的体积的同时实现饼图需求,因此尽量不考虑引入其他第三方库。 其实也就是一个展示用的饼图组件,不需要做啥交互逻辑,直接拿Canvas画好了。
需求如下:
- 环形饼图,中间需要有间隔,从12点开始顺时针渲染
- 添加视觉引导线
- 显示文本标签,要求全部显示的标签且不遮挡
实现
由于微信小程序开发的方式比较多,有uniapp,taro,原生小程序等,故代码仅供一个可行的参考思路,对应框架请自行转换,见谅。以下代码均为web端的React代码,部分代码片段为伪代码,完整代码请见编辑器
最终实现效果:
Canvas不清楚的API可以查询下张鑫旭大佬的在线网站,示例还是比较齐全的
canvas API中文网 - Canvas API中文文档首页地图
基础部分搭建
canvas有两个尺寸,一个是元素本身的css尺寸,一个是绘上下文的尺寸(画布的尺寸),默认情况下,canvas的画布尺寸和元素尺寸都是300*150.
canvas画布尺寸是通过直接在元素上添加width和height属性设置的,css尺寸则是通过css样式属性设置的。
需要注意的是:
1.如果仅在元素上添加width和height属性来设置画布尺寸,实际表现则同时设置了画布尺寸和css尺寸为相同的大小;、
2.如果仅设置了css尺寸,则画布尺寸还是默认的300*150,并不会改变
3.如果同时设置了画布尺寸和css尺寸,浏览器会缩放画布尺寸来适应css尺寸。
📐在移动端的场景下,还需要考虑设备的像素比dpr = 物理像素 / 逻辑像素(CSS和JS设置的代码),否则会出现绘制出来的canvas模糊的问题。
dp:物理像素,屏幕是由像素点组成的。
dpi:逻辑像素,设备独立像素,只是为了方便计算,但是每个逻辑像素所代表的物理像素却不是确定的。
dpr:设备像素比,为了确定未缩放情况下,物理像素和逻辑像素的关系.DPR = 物理像素 / 逻辑像素(CSS和JS设置的代码)
以经典的iphone6 为例子,它的DPR=2,同样大小的屏幕上,物理像素比DPR=1多了一倍.这时可以利用第三点把画布放大 DPR 倍,也就是把 canvas 变大 DPR 倍,而 css 的大小保持不变,虽然 canvas 变大了,但是最终在页面上绘制时会缩放成CSS的尺寸。
// 获取设备物理像素分辨率CSS像素分辨率之比
pc 端:const dpr = window.devicePixelRatio
小程序:const res = wx.getSystemInfoSync().pixelRatio
const centerX = width / 2
const centerY = height / 2
canvas.width = dpr * width
canvas.height = dpr * height
const ctx = canvas.getContext('2d')
ctx.scale(devicePixelRatio, devicePixelRatio)
✴️PS: 当小程序端使用getSystemInfoSync获取设备信息时,如果渲染比较频繁,建议将getSystemInfoSync结果缓存,不要渲染一次执行一次,这样可以减少同步API的调用,有助于代码注入优化。
import { getSystemInfoSync} from 'Taro'
export const sysInfo = Taro.getSystemInfoSync()
环形饼图
没啥好说的,统计占数据占圆弧的百分比,用Canvas的arc就可以搞定👌。 绘制完成后中心再画个圆形就搞定了。
// 绘制扇形
const drawArc: DrawArcProps = (ctx, color, x, y, radius, startPostion, endPostion) => {
if (ctx) {
ctx.save()
ctx.beginPath()
ctx.fillStyle = color
ctx.strokeStyle = color
ctx.moveTo(x, y)
ctx.arc(x, y, radius, startPostion, endPostion)
ctx.fill()
ctx.closePath()
ctx.restore()
}
}
// 绘制外层圆形区域
data.forEach((item) => {
drawArc(context, item[0], centerX, centerY, radius, start, end)
})
// 绘制中心空白区域
drawArc(context, '#fff', centerX, centerY, emptyRadius, 0, 2 * Math.PI)
绘制白色间隔区域扇形区域,使用arc绘制白色间隔区域也会为扇形,效果比较差。
因此采用绘制矩形的方案,绘制可在中心点旋转绘制一个矩形,使用translate改变其变换的中心,将坐标轴移动到中心,进行旋转,绘制完成后将坐标轴移动回原点,效果如下:
// 绘制白色间隔区域
innerCircle.forEach((item) => {
drawArc(context, color, centerX, centerY, radius, start, end)
// 绘制白色边界
context.save()
// 默认旋转点是canvas的左上角(0,0)
// 如果希望改变旋转中心点,以canvas画布的中心旋转,需要使用translate()位移旋转中心
context.translate(centerX, centerY)
context.rotate(start)
context.translate(-centerX, -centerY)
context.fillStyle = '#fff'
context.fillRect(centerX, centerY, radiusCircle, 10)
context.restore()
})
绘制结果如下:
标签显示
标签的绘制包括视觉引导线和文本。 视觉引导线可以利用从绘制扇形的角度中心进行绘制,后根据其角度在圆形的整体位置绘制。 文本没啥特殊需求,直接往上面摆就是了。
// 默认绘制的起点为3点钟方向,将饼图绘制的起点设置为12点方向
startAngle = -0.5 * Math.PI
dataInfo.forEach((item) => {
let endAngle = startAngle + arcAngle
let midAngle = (startAngle + endAngle) / 2
// 放置文本的位置
let textX = centerX + Math.cos(midAngle) * radiusCircle* 1.12
let textY = centerY + Math.sin(midAngle) * radiusCircle* 1.12
// 绘制到文本的直线
context.beginPath()
context.strokeStyle = color
context.lineWidth = 2
// 绘制直线,乘了点系数不从饼图直接出发
context.moveTo(
centerX + Math.cos(midAngle) * radiusCircle * 1.02,
centerY + Math.sin(midAngle) * radiusCircle * 1.02,
)
context.lineTo(textX, textY)
context.stroke()
// 根据位置绘制文本和横线
const direction = findDeirection(midAngle)
context.font = '12px Arial'
context.fillStyle = '#000'
// 根据绘制扇形的中心位置控制文本绘制的方向和视觉引导线横向部分
context.lineTo(textX + 0.1 * radiusCircle, textY)
context.stroke()
context.textAlign = 'left'
context.fillText(item[2], textX, textY - 0.02 * radiusCircle)
context.closePath()
// 更新绘制的起始角度,用于下一次的绘制
startAngle = endAngle
})
结果如下:
改进
1.极端数据情况
是不是看起来还行?很可惜,采用这种方案仅仅在数据分布均匀时显示才是正常,当数据呈现极端分布的情况下,数据量小的label就会全部重叠在一起.毕竟是手动绘制的,标签之间并没有相互的感知能力.
方案1:
记录下label的位置,文本标签所占的空间可通过同context.measureText(text)
,加上绘制的位置已知,就可以获取到label的位置.放置在随机位置,后进行绘制的标签不能接触之前绘制的区域.显示效果也会很奇怪,且实现起来也很麻烦,如果碰上需要实时更新数据的场景就更加不适合了.
方案2:
单独设置文本标签的位置,为了视觉效果,也将文本标签的位置设置在一个圆上. 因为是在数据小的情况下会发生文本的折叠, 那就通过设置一个阈值,当角度小于阈值则文本标签的位置设置为一个固定的角度,这样相加的整体角度就会大于2Pi,所以用2Pi去除该相加的整体角度,就会获取到一个转换参数,所有的扇形角度都需要乘上这样的转换参数.这样就获取了一个标签的角度位置.通过这种方式扩大了小角度下的标签文本绘制范围,规避了一部分标签遮挡的情况.
未修改前标签重叠
修改后绘制的区域更大
// 获取整体转换的角度
let addInner = 0
// 获取内圈数据
data.forEach((item) => {
// 数据全为0则均分饼图
let arcAngle =
total === 0 ? (2 / data.length) * Math.PI : (item.value / total) * 2 * Math.PI
let endAngle = sliceAngle + startAngle
let midAngle = (startAngle + endAngle) / 2
dataInfo.push([arcAngle, midAngle, item.name, item.color])
addInner += arcAngle < THRESHOLD ? 0.2 * Math.PI : arcAngle
innerCircle.push([item.color, startAngle, endAngle, endAngle - sperateAngle])
startAngle = endAngle
})
// 获取转换的系数,为了使得整体的绘制角度还是2Pi
const transfromPra = (2 * Math.PI) / addInner
// 将饼图绘制的起点设置为12点方向
startAngle = -0.5 * Math.PI
dataInfo.forEach((item) => {
// 所有的饼图角度全部都乘上这个系数
let endAngle = startAngle + (arcAngle < THRESHOLD ? 0.2 * Math.PI : arcAngle)*transfromPra
let midAngle = (startAngle + endAngle) / 2
// 放置文本的位置
let textX = centerX + Math.cos(midAngle) * radiusCircle* 1.12
let textY = centerY + Math.sin(midAngle) * radiusCircle* 1.12
// 绘制到文本的直线
context.beginPath()
context.strokeStyle = color
context.lineWidth = 2
// 绘制直线,乘了点系数不从饼图直接出发
context.moveTo(
centerX + Math.cos(midAngle) * radiusCircle * 1.02,
centerY + Math.sin(midAngle) * radiusCircle * 1.02,
)
context.lineTo(textX, textY)
context.stroke()
startAngle = endAngle
})
结果如下:
2.视觉引导线效果优化
本以为没啥问题,但是走查的时候被UI疯狂吐槽视觉引导线锐角太难看,你说是那就是喽。
可以参考ECharts设置一个配置视觉引导线的最小夹角minTurnAngle,但是先要获取到对应的夹角。
或者采用投机取巧的方式,将整个视图分为左右两侧,左侧只有为锐角时才会绘制引导线的第一段终点x坐标减去起点x坐标才会为负数,这种情况下将第一段终点x坐标设置成起点的x坐标,就可以显示直角效果,右侧同理。
// 对角度进行处理,处理成直角
const dx = 第一段终点x坐标 - 第一段起点x坐标
if (direction === TOP_LEFT || direction === BOTTOM_LEFT) {
if (dx > 0) {
textX = 第一段起点x坐标
}
}
if (direction === BOTTOM_RIGHT || direction === TOP_RIGHT) {
if (dx < 0) {
textX = 第一段起点x坐标
}
}
结论
按照本文的方法可基于Canvas模拟一个饼图,基本能实现需求。不得不感慨Echarts还是强啊,即便是这种看起来简单的图表,想要完整实现功能也是很麻烦的.如若有不足之处或者其他更优秀的实现方式,欢迎指出,谢谢!