查看原文
其他

用华为鸿蒙手写ECharts

路很长OoO 郭霖
2024-07-22


/   今日科技快讯   /

近日,数字经济正成为全球经济增长的主引擎,而操作系统作为数字基础设施的底座,发挥着承接上层软件和底层硬件资源的重要作用,同时也是推动产业数字化、智能化发展的核心力量。5月25日,在OpenHarmony开发者大会上,华为常务董事、终端BG董事长余承东表示,自2020年将HarmonyOS的基础能力贡献给开放原子开源基金会以来,已有2000多名华为开发者支持OpenHarmony社区发展,累计贡献核心代码6200多万行。

/   作者简介   /

大家周一好,新的一周继续加油!

本篇文章来自路很长OoO的投稿,文章主要分享了如何使用鸿蒙手写 ECharts,相信会对大家有所帮助!同时也感谢作者贡献的精彩文章。

路很长OoO的博客地址:
https://juejin.cn/user/4019470242152616/posts

/   前言   /

ECharts作为前端强大的图表、K线、地图等封装库可以说无比风骚。但用户和产品的需求永远是一个库满足不了的,除非产品和设计的基础是在图表库基础上进行。我们前端移动端作为产品的排面就应该让其独具特色,别具一格。所以自定义从产品设计、技术岗位、亿万用户不同需求...出发,"自定义极其重要"。

/   自定义   /

今天看了看ArkTs对绘制API的封装,可谓是和JS一模一样。大家根据官方API粗略浏览。至于自定义从零基础到高手,我相信只要花费一下午时间,阅读、练习、理解前端都是手写ECharts ?就足够了,学不会我手把手教你。

/   需求分析   /

最近项目中产品要求扇形展示票务的所占比例。产品不知道哪里拿了截图,截图如下,说实话有丑到我。于是花了一下午时间实现了一个比较好看的效果。支持了【手势旋转,指示器跟随滑动,文字分段,点击选中显示指示器等效果】


我让产品和设计加上手势转动,他们觉得如果可以,更好。文字过长问题,我说可以限制显示区域,分行显示,他们觉得能实现更好。


票务类型过多,避免不了重叠问题,即使分行显示,我说要不手势触摸相应区域让其对应文字出现,其他隐藏,他们觉得这也太棒了。


触摸到对应区域,显示当前区域文字指示器和对称指示器。而不是全部显示避免重叠。


大概效果如下:


在技术角度来看,我们需要的是基本的绘制API,和手势旋转相关的API。使用的语言是ArkTS。

/   编写代码   /

项目创建

创建一个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%')
  }
}


数据绘制

创建数据类,初始化一个数据集合。

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%')
  }
}


弧度绘制完成。

指示线绘制

在上面旋转到弧度中间的基础上我们利用勾股定理,进行计算线的位置。不难求出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)
  }
}

效果如下:


增加手势

好用的统计图表必须不仅仅是要好看,还要好的交互。而扇形统计图更适合增加手势旋转。

手势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大家可以关注点赞,希望对大家所有帮助。更多效果图可以去原文查看。



推荐阅读:
我的新书,《第一行代码 第3版》已出版!
原创:写给初学者的Jetpack Compose教程,用derivedStateOf提升性能
Koltin中的变与不变

欢迎关注我的公众号
学习技术或投稿


长按上图,识别图中二维码即可关注
继续滑动看下一个
向上滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存