ECharts
作为前端强大的图表、K线、地图等封装库可以说无比风骚。但用户和产品的需求永远是一个库满足不了的,除非产品和设计的基础是在图表库基础上进行。我们前端移动端作为产品的排面就应该让其独具特色,别具一格。所以自定义从产品设计
、技术岗位
、亿万用户不同需求
...出发,"自定义极其重要"。
一、自定义
今天看了看ArkTs对绘制API的封装,可谓是和JS一模一样。大家根据官方API粗略浏览。至于自定义从零基础到高手,我相信只要花费一下午时间,阅读、练习、理解 前端都是手写ECharts ?就足够了,学不会我手把手教你。
二、需求分析
最近项目中产品要求扇形展示票务的所占比例。产品不知道哪里拿了截图,截图如下,说实话有丑到我。于是花了一下午时间实现了一个比较好看的效果。支持了【手势旋转,指示器跟随滑动,文字分段,点击选中显示指示器等效果】
我让产品和设计加上手势转动,他们觉得如果可以,更好。
文字过长问题,我说可以限制显示区域,分行显示,他们觉得能实现更好。
票务类型过多,避免不了重叠问题,即使分行显示,我说要不手势触摸相应区域让其对应文字出现,其他隐藏,他们觉得这也太棒了。
触摸到对应区域,显示当前区域文字指示器和对称指示器。而不是全部显示避免重叠。
大概效果如下:
在技术角度来看,我们需要的是基本的绘制API,和手势旋转相关的API。使用的语言是ArkTS。
官方画布基础API地址
https://developer.huawei.com/consumer/cn/doc/harmonyos-references-V2/js-components-basic-chart-0000001428061800-V2
官方手势基础API地址
https://developer.huawei.com/consumer/cn/doc/harmonyos-references-V2/4_2_u57fa_u7840_u624b_u52bf-0000001427902444-V2
三、编写代码
1、项目创建
创建一个ArkTS项目,新建页面CanvasPage.ets并根据官方画布基础API进行简单的扇形区域绘制。觉得难的先看完前端都是手写ECharts ?基础绘制。
@Entry
@Component
struct CanvasDemoPage {
private settings: RenderingContextSettings = new RenderingContextSettings(true)
private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings)
build() {
Column() {
Canvas(this.context)
.width('100%')
.height('100%')
.backgroundColor(Color.White)
.onReady(() => {
let width = this.context.width
let height = this.context.height
//将坐标圆心变换到屏幕中心即圆心,方便操作
this.context.translate(width / 2, height / 2)
//绘制开始,每次绘制不同内容,都需要开始和之前的绘制不在交织。
this.context.beginPath()
//开始绘制地方
this.context.moveTo(0, 0)
//在屏幕中心(0,0)绘制一个半径为100像素的扇形区域,扇形角度为45度。可以看到水平X轴是扇形弧度开始地方。顺时针绘制。
this.context.arc(0, 0, 100, 0, Math.PI * 0.25)
//关闭路径,这样弧度结尾会自动连接起时的圆心区域
this.context.closePath()
this.context.fillStyle = 'rgb(111,212,124)'
this.context.fill()
})
}
.height('100%')
}
}
2、数据绘制
创建数据类,初始化一个数据集合
class CirclePieChartMaxBean {
valueData: number//票数
title: string//指示器标题
sub: string//指示器副标题
color: ResourceColor | string//扇形区域颜色
constructor(valueData: number, title: string, sub: string, color: ResourceColor | string) {
this.valueData = valueData
this.title = title
this.sub = sub
this.color = color
}
}
let dataList: Array<CirclePieChartMaxBean> = [
new CirclePieChartMaxBean(
30,
"Compose",
"60%",
'rgba(53,158,255,1.00)'
),
new CirclePieChartMaxBean(
30,
"Flutter",
"30%",
'rgba(67, 223, 210, 1.00)'
),
new CirclePieChartMaxBean(
10,
"ArkTS",
"5%",
'rgba(255, 212, 81, 1.00)'
),
new CirclePieChartMaxBean(
20,
"Xml",
"3%",
'rgba(155, 115, 226, 1.00)'
),
new CirclePieChartMaxBean(
10,
"Vue",
"10%",
'rgba(239, 184, 200, 1.00)'
)
]
首先自定义绘制的是用户需求的数据,而数据并非角度,用户的数据和角度如何进行映射是关键。我们知道一个扇形统计图一周的角度是360度,即对应数据的总和。而每一个语言数据所占的比例不难求出(某语言类型数据/所有语言总和)。从而可以求出每一份语言所占的角度公式 = 某语言数/语言总和。
我们不难求出每个编程语言所对应的角度。
import { dataList } from '../data/model/CirclePieChartMaxBean'
@Entry
@Component
struct CanvasDemoPage {
private settings: RenderingContextSettings = new RenderingContextSettings(true)
private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings)
build() {
Column() {
Canvas(this.context)
.width('100%')
.height('100%')
.backgroundColor(Color.White)
.onReady(() => {
let width = this.context.width
let height = this.context.height
this.context.translate(width / 2, height / 2)
this.context.beginPath()
this.context.moveTo(0, 0)
this.context.arc(0, 0, 100, 0, Math.PI * 0.25)
this.context.closePath()
this.context.fillStyle = 'rgb(111,212,124)'
this.context.fill()
//求和
let sum: number = dataList.reduce((accumulator, currentValue) => accumulator + currentValue.valueData, 0);
for (let index = 0; index < dataList.length; index++) {
const angleData = dataList[index];
let sweepAngle = angleData.valueData / sum * 360
console.log("sweepAngle",sweepAngle.toString())
}
})
}
.height('100%')
}
}
打印结果
05-17 16:41:59.335 A03d00/JSAPP com.examp...kts_unit I sweepAngle 108
05-17 16:41:59.336 A03d00/JSAPP com.examp...kts_unit I sweepAngle 108
05-17 16:41:59.336 A03d00/JSAPP com.examp...kts_unit I sweepAngle 36
05-17 16:41:59.336 A03d00/JSAPP com.examp...kts_unit I sweepAngle 72
05-17 16:41:59.336 A03d00/JSAPP com.examp...kts_unit I sweepAngle 36
已经知道角度如何绘制?我们需要明确,每次绘制都必须在上一个绘制的弧度结尾接着绘制。所以需要每次绘制完记录一下绘制结尾的弧度。下次绘制在这个弧度基础上绘制。
@Entry
@Component
struct CanvasDemoPage {
private settings: RenderingContextSettings = new RenderingContextSettings(true)
private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings)
build() {
Column() {
Canvas(this.context)
.width('100%')
.height('100%')
.backgroundColor(Color.White)
.onReady(() => {
let radius = 80
//记录上一个绘制结束的角度。
let routeAngle = 0
let width = this.context.width
let height = this.context.height
this.context.translate(width / 2, height / 2)
let sum: number = dataList.reduce((accumulator, currentValue) => accumulator + currentValue.valueData, 0);
for (let index = 0; index < dataList.length; index++) {
const angleData = dataList[index];
//每个扇形区域
let sweepAngle = angleData.valueData / sum * 360
console.log("sweepAngle",sweepAngle.toString())
//绘制扇形区域开始
this.context.save()
//我习惯垂直方向向上作为弧度绘制开始,而不喜欢水平,这个应该由产品决定,而我这里选择了-90到了12点位置作为绘制起始
//为了让扇形每一份都朝着自己弧度中心向外traslate,所以我这里让会让坐标系横轴X转到每个扇形弧度中间,接着直接translate(间隙距离,0)就可以达到间隙效果。
let rotateAngle = (routeAngle + sweepAngle / 2 - 90)
this.context.rotate(rotateAngle / 180 * Math.PI)
//这个让绘制坐标系每次偏离5像素,最终会让每个扇形之间有间隙。
this.context.translate(5, 0)
this.context.beginPath()
this.context.moveTo(0, 0)
//记得测试X方向如下图二,所以-sweepAngle/2即12点钟方向。测试坐标系0角度即x轴方向。这里好好理解一下,理解不了去看之前的文章。
this.context.arc(0, 0, radius, Math.PI * (-sweepAngle / 2 / 180), Math.PI * (sweepAngle / 2 / 180))
this.context.closePath()
this.context.fillStyle = angleData.color.toString()
this.context.shadowBlur = 10
this.context.shadowOffsetX = 1
this.context.shadowColor = 'rgb(0,0,0)'
this.context.fill()
this.context.restore()
//每绘制完一个区域,都需要在之前基础上加上次角度,便于后面在此之上接着绘制
routeAngle += sweepAngle
}
})
}
.height('100%')
}
}
弧度绘制完成。
3、指示线绘制
在上面旋转到弧度中间的基础上我们利用勾股定理,进行计算线的位置。不难求出A点、B点、C点位置。这里需要知道文字长宽策略API为:let textMeasure = this.context.measureText(angleData.title),开发者可以通过textMeasure.height和textMeasure.width获取文字的长度来得到C点的坐标,用于绘制文字线。
private drawTextLine(radius: number, halfAngle: number, angleData: CirclePieChartMaxBean) {
//上图A点
const pointToCircle = 20
const titleLineToPDistance = 20
const mTitleMarginStar = 10
let centerX = (radius + pointToCircle) * Math.sin(this.degreesToRadians(halfAngle + 90))
let centerY = -(radius + pointToCircle) * Math.cos(this.degreesToRadians(halfAngle + 90))
let lineTwoPointX = (radius + pointToCircle + titleLineToPDistance) * Math.sin(
this.degreesToRadians(
halfAngle + 90
)
)
let lineTwoPointY = -(radius + pointToCircle + titleLineToPDistance) * Math.cos(
this.degreesToRadians(
halfAngle + 90
)
)
//计算文字长度
let textTitleMeasure = this.context.measureText(angleData.title)
let textSubMeasure = this.context.measureText(angleData.sub)
this.context.beginPath()
this.context.moveTo(centerX, centerY)
this.context.lineTo(lineTwoPointX, lineTwoPointY)
this.context.lineTo(
lineTwoPointX + textTitleMeasure.width + mTitleMarginStar,
lineTwoPointY
)
let textDrawStartX = lineTwoPointX + mTitleMarginStar
let textSubStartX = lineTwoPointX + textTitleMeasure.width + mTitleMarginStar - textSubMeasure.width
this.context.lineWidth = 1
this.context.shadowBlur = 4
this.context.shadowOffsetY = 0.5
this.context.shadowColor = 'rgb(0,0,0)'
this.context.strokeStyle = 'rgba(53,158,255,1.00)'
this.context.stroke()
}
不难发现在第一象限和第二象限是没啥大问题,但是三四象限貌似反向折叠了。所以我们需要不同象限进行绘制,如果花费5分钟草稿进行分析,不难发现第一第二象限绘制基本是一致的,三四象限绘制是一致的。所以我们需要根据象限来不同的计算绘制文字线。大家花5分钟分析一下三四象限。
需要明白,产品效果是可以跟随手势任意旋转的,每一个扇形在用户手上可能被旋转个百八十圈。那每个扇形都可能被旋转到任意角度吧?但是需要清楚,再牛逼还是在360度的可视化范围内。而正余弦函数大家应该知道是周期性函数,360度为一个周期,后面循环往复。所以任意旋转角度都能够被换算到360度以内,也就可以根据角度计算出所在象限。
//进行象限获取
determineQuadrant(angleDegrees: number): number {
// 将角度标准化到0-360度之间
let standardizedAngle = angleDegrees % 360;
// 将负角度转换为对应的正角度
if (standardizedAngle < 0) {
standardizedAngle += 360;
}
// 判断角度所在的象限
if (standardizedAngle >= 0 && standardizedAngle < 90) {
return 1;
} else if (standardizedAngle >= 90 && standardizedAngle < 180) {
return 2;
} else if (standardizedAngle >= 180 && standardizedAngle < 270) {
return 3;
} else {
return 4;
}
}
所以不难绘制出所有的指示线吧,文字绘制加上看看效果,如果难就画图勾股定理安排。
private drawTextLine(radius: number, halfAngle: number, angleData: CirclePieChartMaxBean) {
const pointToCircle = 20
const titleLineToPDistance = 20
const mTitleMarginStar = 10
this.context.font = "44px"
let centerX = (radius + pointToCircle) * Math.sin(this.degreesToRadians(halfAngle + 90))
let centerY = -(radius + pointToCircle) * Math.cos(this.degreesToRadians(halfAngle + 90))
if (this.determineQuadrant(halfAngle + 90.0) == 1 || this.determineQuadrant(halfAngle + 90.0) == 2) {
let centerX =
(radius + pointToCircle) * Math.sin(this.degreesToRadians(halfAngle + 90))
let centerY =
-(radius + pointToCircle) * Math.cos(this.degreesToRadians(halfAngle + 90))
let lineTwoPointX =
(radius + pointToCircle + titleLineToPDistance) * Math.sin(
this.degreesToRadians(
halfAngle + 90
)
)
let lineTwoPointY =
-(radius + pointToCircle + titleLineToPDistance) * Math.cos(
this.degreesToRadians(
halfAngle + 90
)
)
let textTitleMeasure = this.context.measureText(angleData.title)
let textSubMeasure = this.context.measureText(angleData.sub)
this.context.beginPath()
this.context.moveTo(centerX, centerY)
this.context.lineTo(lineTwoPointX, lineTwoPointY)
this.context.lineTo(
lineTwoPointX + textTitleMeasure.width + mTitleMarginStar,
lineTwoPointY
)
let textDrawStartX = lineTwoPointX + mTitleMarginStar
let textSubStartX = lineTwoPointX + textTitleMeasure.width + mTitleMarginStar - textSubMeasure.width
this.context.lineWidth = 1
this.context.shadowBlur = 4
this.context.shadowOffsetY = 0.5
this.context.shadowColor = 'rgb(0,0,0)'
this.context.strokeStyle = 'rgba(53,158,255,1.00)'
this.context.stroke()
//绘制线头圆点
this.context.beginPath()
this.context.arc(centerX, centerY, 5, 0, Math.PI * 2)
this.context.fillStyle = 'rgba(53,158,255,1.00)'
this.context.fill()
this.context.beginPath()
this.context.arc(centerX, centerY, 3, 0, Math.PI * 2)
this.context.fillStyle = 'rgba(255,255,255,1.00)'
this.context.fill()
//绘制文字
this.context.shadowBlur = 2
this.context.shadowOffsetY = 0.5
this.context.fillStyle = 'rgba(53,158,255,1.00)'
this.context.fillText(angleData.sub, textSubStartX, lineTwoPointY - textSubMeasure.height / 2)
this.context.fillStyle = 'rgb(0,0,0)'
this.context.fillText(angleData.title, textDrawStartX, lineTwoPointY + textTitleMeasure.height)
} else if (this.determineQuadrant(halfAngle + 90.0) == 3 || this.determineQuadrant(halfAngle + 90.0) == 4) {
let centerX =
(radius + pointToCircle) * Math.sin(this.degreesToRadians(halfAngle + 90))
let centerY =
-(radius + pointToCircle) * Math.cos(this.degreesToRadians(halfAngle + 90))
let lineTwoPointX =
(radius + pointToCircle + titleLineToPDistance) * Math.sin(
this.degreesToRadians(
halfAngle + 90
)
)
let lineTwoPointY =
-(radius + pointToCircle + titleLineToPDistance) * Math.cos(
this.degreesToRadians(
halfAngle + 90
)
)
let textTitleMeasure = this.context.measureText(angleData.title)
let textSubMeasure = this.context.measureText(angleData.sub)
this.context.beginPath()
this.context.moveTo(lineTwoPointX - textTitleMeasure.width - mTitleMarginStar, lineTwoPointY)
this.context.lineTo(lineTwoPointX, lineTwoPointY)
this.context.lineTo(
centerX, centerY
)
this.context.stroke()
//绘制文字
let textDrawStartX = lineTwoPointX - textTitleMeasure.width - mTitleMarginStar
let textSubStartX = textDrawStartX
this.context.lineWidth = 1
this.context.shadowBlur = 4
this.context.shadowOffsetY = 0.5
this.context.shadowColor = 'rgb(0,0,0)'
this.context.strokeStyle = 'rgba(53,158,255,1.00)'
this.context.stroke()
//绘制线头圆点
this.context.beginPath()
this.context.arc(centerX, centerY, 5, 0, Math.PI * 2)
this.context.fillStyle = 'rgba(53,158,255,1.00)'
this.context.fill()
this.context.beginPath()
this.context.arc(centerX, centerY, 3, 0, Math.PI * 2)
this.context.fillStyle = 'rgba(255,255,255,1.00)'
this.context.fill()
//绘制文字
this.context.shadowBlur = 2
this.context.shadowOffsetY = 0.5
this.context.fillStyle = 'rgba(53,158,255,1.00)'
this.context.fillText(angleData.sub, textSubStartX, lineTwoPointY - textSubMeasure.height / 2)
this.context.fillStyle = 'rgb(0,0,0)'
this.context.fillText(angleData.title, textDrawStartX, lineTwoPointY + textTitleMeasure.height)
}
}
效果如下:
4、增加手势
好用的统计图表必须不仅仅是要好看,还要好的交互。而扇形统计图更适合增加手势旋转。官方手势基础API地址
手势API能拿到用户旋转的角度,在此案例中只需要将当前旋转的角度交给context.rotate即可。
手势使用也很简单:下图所示,rotation就是用户手势旋转角度。
手势角度作为绘制弧度的其实角度,所以在进行旋转之前角度需要加上手势角度rotation。我们最后进行封装为draw()方法,在手势每次触发旋转回调函数中进行调用重新绘制。
@Entry
@Component
struct CanvasDemoPage {
private settings: RenderingContextSettings = new RenderingContextSettings(true)
private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings)
@State rotation: number = 0
@State rotateValue: number = 0
build() {
Column() {
Canvas(this.context)
.width('100%')
.height('100%')
.backgroundColor(Color.White)
// 双指旋转触发该手势事件
.gesture(
RotationGesture()
.onActionStart((event: GestureEvent) => {
console.info('Rotation start')
})
.onActionUpdate((event: GestureEvent) => {
this.rotation = this.rotateValue + event.angle
this.draw()
})
.onActionEnd(() => {
this.rotateValue = this.rotation
console.info('Rotation end')
})
)
.onReady(() => {
this.draw()
})
}
.height('100%')
}
private draw() {
let radius = 80
//记录上一个绘制结束的角度。
let routeAngle = 0
let defaultAngle = -90 + this.rotation
let width = this.context.width
let height = this.context.height
this.context.translate(width / 2, height / 2)
let sum: number = dataList.reduce((accumulator, currentValue) => accumulator + currentValue.valueData, 0)
for (let index = 0; index < dataList.length; index++) {
const angleData = dataList[index]
//每个扇形区域
let sweepAngle = angleData.valueData / sum * 360
let halfAngle = defaultAngle + sweepAngle / 2
console.log("sweepAngle", sweepAngle.toString())
//绘制扇形区域开始
this.drawArc(routeAngle, sweepAngle, radius, angleData)
//开始绘制指示线
this.drawTextLine(radius, halfAngle, angleData)
defaultAngle += sweepAngle
routeAngle += sweepAngle
}
}
}
效果如上,运行之后,手势触发旋转,会在右下角重新绘制。其实并非在右下角,应该会在对角直线上一直往前绘制,在js和arkTs中绘制需要明白,每次调用绘制函数,是需要清空画布重新绘制,避免保留上一次的绘制干扰当前绘制。
//清空画布,避免重绘,否则每次drawSector都会绘制一次。最后重叠交错
this.context.clearRect(0, 0, width, height)
并在内部绘制一个白色圆圈挡住中间部分,形成圆环。
代码:
private draw() {
this.context.save()
//设置字体大小
this.context.font = "30px"
let radius = 80
let routeAngle = 0
let startAngle = 0
let defaultAngle = -90 + this.rotation
let sum: number = dataList.reduce((accumulator, currentValue) => accumulator + currentValue.valueData, 0);
let width = this.context.width
let height = this.context.height
//清空画布,避免重绘,否则每次drawSector都会绘制一次。最后重叠交错
this.context.clearRect(0, 0, width, height)
this.context.save()
//将坐标圆心变换到屏幕中心即圆心
this.context.translate(width / 2, height / 2)
for (let index = 0; index < dataList.length; index++) {
const angleData = dataList[index]
//每个扇形区域
let sweepAngle = angleData.valueData / sum * 360
let halfAngle = defaultAngle + sweepAngle / 2
console.log("sweepAngle", sweepAngle.toString())
//绘制扇形区域开始
this.drawArc(routeAngle, sweepAngle, radius, angleData)
//开始绘制指示线
this.drawTextLine(radius, halfAngle, angleData)
defaultAngle += sweepAngle
routeAngle += sweepAngle
}
this.drawTextCenter(radius)
this.context.restore()
}
//绘制中心白圆和文字。
private drawTextCenter(radius: number) {
this.context.arc(0, 0, radius - 25, 0, Math.PI * 2)
this.context.fillStyle = Color.White
this.context.shadowBlur = 0
this.context.shadowOffsetY = 0
this.context.fill()
this.context.fillStyle = 'rgba(53,158,255,1.00)'
this.context.shadowBlur = 6
this.context.shadowOffsetY = 3
this.context.shadowColor = 'rgba(53,158,255,1.00)'
let centerTextMeasure = this.context.measureText("APP")
this.context.font = "40px"
this.context.fillText("APP", -centerTextMeasure.width / 2, centerTextMeasure.height / 2)
}
至于文字分段显示、展示手势按下部分🈯️示线等功能也不难,大家可以自行实现,由于时间问题,这篇文章到此结束。有疑问或者错误可以评论区指出。
四、总结
看完官方语言基础和状态装饰器文档,试了这篇自定义内容,还是能够直接上手的。自定义对于UI的排面来说是极其重要的,如下是最近要出小册案例的冰山一角,小册内容极其丰富,里面涵盖了View,Compose,Js以及ArkTs大家可以关注点赞,希望对大家所有帮助。
作者:路很长OoO
链接:https://juejin.cn/post/7370008254719950887
关注我获取更多知识或者投稿