背景介绍

在app开发中,经常需要使用到一些图表的开发,大多数我们会使用第三方的库直接实现,如果第三方没有提供我们想要的效果,这个时候修改起来就比较麻烦了;

本篇文章主要介绍基于canvas使用CanvasRenderingContext2D和Path2D相关API来实现折线图/饼状图/柱状图/雷达图。

CanvasRenderingContext相关API:

  • 通过moveTo路径从当前点移动到指定点。
  • 通过lineTo从当前点到指定点进行路径连接。
  • 通过rect创建矩形路径。
  • 通过stroke绘制线条。
  • 通过fill绘制填充区域。
  • 通过globalAlpha设置透明度。
  • 通过font设置文字大小。
  • 通过strokeColor设置线条(画笔)的颜色。
  • 通过fillStyle设置填充的颜色。
  • 通过textAlign设置文字对齐方式。
  • 通过fillText绘制文字。
  • 通过measureText获取文字尺寸。
  • 通过arc圆弧绘制。

path2D相关API:

  • 通过moveTo移动点(笔)。
  • 通过lineTo画线。
  • 通过closePath将路径的当前点移回到路径的起点。
  • 通过stroke根据指定的路径,进行边框绘制操作。

2.1场景一:折线图

效果图如下所示:

具体实现:

1:绘制表格用到的属性如下,

private settings: RenderingContextSettings = new RenderingContextSettings(true)
private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings)
private path2Db: Path2D = new Path2D()
// 画布的宽度
private canvasWidth = 350
//表格与画布的间距
private gridGap = 35
//X抽上每个表格的宽度
private gridWidth = 0
//Y抽上每个表格的宽度
private gridHeight = 0
//Y抽上每个表格的宽度
private  y_List:string[] = ['0','10','20','30','40'];
//X抽上数据
private  x_List:string[] = ['星期一','星期二','星期三','星期四','星期五','星期六','星期日'];
//折线图数据
private  dataList:number[] = [13,23,21,33,3,17,6];
//折线图数据对应的坐标点
private  positionList:PositionModel[] =[] ;

2:根据画布宽度canvasGap与展示表格的间距gridGap,以及X轴和Y轴对应的数据,可以计算出表格每一个网格的尺寸, 根据给定的折线图数据dataList,可以得到对应的PositionList存储每个绘制点对应的坐标集合。

aboutToAppear() {
  //计算Y抽上每个表格的宽度
  this.gridHeight = (this.canvasWidth - 2 * this.gridGap) / this.y_List.length
  //计算X抽上每个表格的宽度
  this.gridWidth = (this.canvasWidth - 2 * this.gridGap) / this.x_List.length
  //计算折线图数据对应的坐标点
  for (let index = 0; index < this.dataList.length; index++) {
    let x = this.gridGap + this.gridWidth * index +  this.gridWidth / 2
    let y = this.canvasWidth - this.gridGap - this.dataList[index] / 10 * this.gridHeight;
    let model = new PositionModel(x,y);
    this.positionList.push(model)
  }
}

3:根据Y抽方向给定的数组,使用CanvasRenderingContext的moveTo和lineTo绘制X抽对应的6条表格直线,

通过font设置文字大小,通过fillStyle设置文字颜色,通过textAlign设置文字对齐方式,再根据fillText传入展示的文本和起始坐标点来绘制文字。

//画X方向线条和Y抽对应的刻度和文字
public drawYLine(){
  this.context.fillStyle = '#666666' //画笔填充颜色
  //this.context.strokeStyle = '#666666' //画笔线条颜色
  this.context.lineWidth = 2
  //画X方向线条和Y抽对应的刻度和文字
  for (let index = 0; index < this.y_List.length + 1; index++) {
    this.context.beginPath()
    this.context.moveTo(this.gridGap - 5, this.canvasWidth - this.gridGap - this.gridHeight * index)
    this.context.lineTo(this.gridGap + this.x_List.length * this.gridWidth,  this.canvasWidth - this.gridGap - this.gridHeight * index)
    this.context.stroke()

    this.context.font = '30px sans-serif'
    this.context.fillStyle = '#333333'
    this.context.textAlign = "right"
    this.context.fillText(this.y_List[index],this.gridGap - 10, this.canvasWidth - this.gridGap - this.gridHeight * index + 3)
  }
  this.context.fillText('温度',this.gridGap + 15, this.gridGap - 5)
}

4:根据X轴方向给定的数组,使用CanvasRenderingContext的moveTo和lineTo绘制Y抽直线,以及绘制X抽分割线

通过font设置文字大小,通过fillStyle设置文字颜色,通过textAlign设置文字对齐方式,再根据fillText,传入展示的文本和起始坐标点来绘制文字。

//画Y方向线条//画X轴方向刻度和文字
public drawXLine(){
  this.context.fillStyle = '#666666' //画笔填充颜色
  //this.context.strokeStyle = '#666666' //画笔线条颜色
  this.context.lineWidth = 2
  //画Y方向线条
  this.context.beginPath()
  this.context.moveTo(this.gridGap, this.canvasWidth - this.gridGap)
  this.context.lineTo(this.gridGap, this.canvasWidth - this.gridGap - this.gridHeight * this.y_List.length)
  this.context.stroke()
  //画X轴方向刻度和文字
  for (let index = 0; index < this.x_List.length; index++) {
    this.context.beginPath()
    //2 是线条宽度
    this.context.moveTo(this.gridGap + this.gridWidth - 2 + this.gridWidth * index, this.canvasWidth - this.gridGap )
    this.context.lineTo(this.gridGap + this.gridWidth - 2 + this.gridWidth * index,  this.canvasWidth - this.gridGap + 5)
    this.context.stroke()

    this.context.font = '30px sans-serif'
    this.context.fillStyle = '#333333'
    this.context.textAlign = "center"
    this.context.fillText(this.x_List[index],this.gridGap - 2 + (index + 1) * this.gridWidth - 20, this.canvasWidth - this.gridGap + 15)
  }
}

5:根据PositionList存储的坐标点,使用CanvasRenderingContext的moveTo和lineTo绘制折线图

通过font设置文字大小,通过fillStyle设置文字颜色,通过textAlign设置文字对齐方式,再根据fillText传入展示的文本和起始坐标点来绘制文字。

public drawChart() {
  this.context.strokeStyle = 'rgba(20, 227, 60, 1.00)' //画笔线条颜色
  for (let index = 0; index < this.dataList.length; index++) {
    let model = this.positionList[index]
    let x = model.position_x
    let y = model.position_y

    if (index == 0) {
      this.context.moveTo(x, y)
    }else {
      this.context.lineTo(x, y)
    }
  }
  this.context.stroke()
}
public drawValueInfo() {
  this.context.font = '30px sans-serif'
  this.context.fillStyle = '#ffdb2626'
  this.context.textAlign = "center"
  for (let index = 0; index < this.dataList.length; index++) {
    let model = this.positionList[index]
    this.context.fillText(this.dataList[index].toString(),model.position_x, model.position_y - 5)
  }
}

2.2场景二:实心柱状图

效果图如下所示:

1.柱状表格的实现与折线不同的是,柱形绘制使用CanvasRenderingContext的rect(传入绘制的起始坐标X,Y,和对应size)以及fill来实现。

public drawChart() {
  this.context.fillStyle = 'rgba(20, 227, 60, 1.00)' //画笔填充颜色
  this.context.strokeStyle = 'rgba(20, 227, 60, 1.00)' //画笔线条颜色
  for (let index = 0; index < this.dataList.length; index++) {
    let model = this.positionList[index]
    let x = model.position_x
    let y = model.position_y
    this.context.rect(x, y, 20, this.dataList[index] / 10 * this.gridHeight) // Create a 100*100 rectangle at (20, 20)
    this.context.fill()
  }
}

2.3 场景三:绘制饼图

效果图如下所示:

1:根据给定的数组,和对应的占比,再计算对应比例的角度大小,使用CanvasRenderingContext的arc绘制对应的圆弧。

const expense_categories = ['购物', '出行', '餐饮', '医疗', '美容', '娱乐', '教育', '房租']
// 画扇形
this.context.beginPath()
this.context.arc(centerX, centerY, arcRadius, startAngle, endAngle)
this.context.lineWidth = arcWidth
this.context.strokeStyle = color
this.context.stroke()
this.context.restore()

2:根据各扇形对应的中心点,和半径,以及三角函数math.sin,math.cos,得到折线的起始点

第三个点根据角度大小的判断,调整坐标点,

使用CanvasRenderingContext的moveTo和lineTo绘制各扇形对应的折线。

// 画折线
let centerAngle = startAngle + angle / 2
let r = radius + brokenLineLength / 2

let x1 = centerX + (r - brokenLineLength) * Math.cos(centerAngle)
let y1 = centerY + (r - brokenLineLength) * Math.sin(centerAngle)

let x2 = centerX + r * Math.cos(centerAngle)
let y2 = centerY + r * Math.sin(centerAngle)

let x3 = x2
let y3 = y2
if (centerAngle < Math.PI / 2) {
  this.context.textAlign = 'right'
  x3 = x2 + 15
} else {
  this.context.textAlign = 'left'
  x3 = x2 - 15
}

// 折线
let leaderLineColor = this.options.leaderLineColorFn(item, i)
this.context.beginPath()
this.context.lineWidth = brokenLineWidth
this.context.strokeStyle = leaderLineColor
this.context.moveTo(x1, y1)
this.context.lineTo(x2, y2)
this.context.lineTo(x3, y3)
this.context.stroke()

3.通过measureText获取文字的宽度,根据对应的角度,调整文本的起始点和对齐方式,

通过font设置文字大小,通过fillStyle设置文字颜色,通过textAlign设置文字对齐方式,再根据fillText,传入展示的文本和起始坐标点来绘制文字。

// 画文字
// 设置字体样式
const labelStyle = this.options.labelStyleFn(item, i)
this.context.textBaseline = 'middle'
this.context.fillStyle = labelStyle.fontColor
this.context.font = fp2px(labelStyle.fontSize) + 'px sans-serif'
// 获取文本
let label = this.options.labelFn(data[i], i)
let textWidth = this.context.measureText(label).width
let x4 = x3
let y4 = y3
if (centerAngle < Math.PI / 2) {
  this.context.textAlign = 'right'
  x3 = x2 + 15
  x4 = x3 + textWidth + 3
} else {
  this.context.textAlign = 'left'
  x3 = x2 - 15
  x4 = x3 - textWidth - 3
}

this.context.fillText(label, x4, y4)

2.4场景四:仿雷达图

效果图如下所示:

1:实现雷达图对应属性

//背景
private path2Db: Path2D = new Path2D()
//能力值展示
private ratePath2Db: Path2D = new Path2D()
// 计算正五边形的顶点坐标
private  baseRadius:number = 150; // 设置半径
//画布半径
private  canvasRadius:number = 200; // 设置半径
private angleOffset:number = (Math.PI * 2) / 6; // 计算每个顶点之间的角度间隔
// 圈数
private  count:number = 5;
// 各能力值
private  rateArray:number[] = [0.5,1.0,0.15,0.7,0.4,0.65];
//各能力名称
private  nameList:string[] = ['推进','战绩','生存','团战','发育','输出'];
//各能力对应的坐标点
private  positionList:PositionModel[] =[] ;

2:根据画布的中心点,以及雷达图的半径,用Math.sin(angle)和Math.cos(angle)计算出雷达图各个点所对应的坐标点,

用positionList存储

使用path2D的moveTo和lineTo绘制折线图,使用closePath闭合路径,stroke绘制边框,绘制5条不同半径的6边形。

//绘制背景
for (let index = 0; index < 6; index++) {
  this.baseRadius = 150 - (index * 30)
  const firstX = this.baseRadius * Math.sin(0) + this.canvasRadius;
  const firstY = this.baseRadius * Math.cos(0) + this.canvasRadius;
  if (index == 0) {
    let firstModel = new PositionModel(firstX,firstY);
    this.positionList.push(firstModel)
  }
  this.path2Db.moveTo(firstX, firstY)
  for (let i = 1; i < 6; i++) {
    const angle = i * this.angleOffset;
    const x = this.baseRadius * Math.sin(angle) + this.canvasRadius;
    const y = this.baseRadius * Math.cos(angle) + this.canvasRadius;
    this.path2Db.lineTo(x,y);
    if (index == 0) {
      let model = new PositionModel(x,y);
      this.positionList.push(model)
    }
  }
  this.path2Db.closePath()
  this.context.stroke(this.path2Db)
}

3.绘制能力对应的名字,positonList存储的坐标点,通过measureText获取文字尺寸,来调整各点文本对应的绘制位置,

使用CanvasRenderingContext的font设置文字大小,通过fillStyle设置文字颜色,通过textAlign设置文字对齐方式,再根据fillText,传入展示的文本和起始坐标点来绘制文字。

//绘制各坐标对应的名称
this.context.font = '50px sans-serif'
this.context.fillStyle = '#333333'
//可以根据文字得宽高来调整位置 demo里面没有用到
const textWidth = this.context.measureText('推进').width; // 获取文字的长度
const textHeight = this.context.measureText('推进').height; // 获取文字的长度

for(let i = 0; i < this.positionList.length;i++){
  let model = this.positionList[i]
  let name = this.nameList[i]
  if (i == 0) {
    model.position_x -= 15;
    model.position_y += 20;
  }else  if (i == 1 || i == 2) {
    model.position_x += 10;
    model.position_y += 5;
  }else  if (i == 3) {
    model.position_x -= 15;
    model.position_y -= 8;
  }else  if (i == 4 || i == 5) {
    model.position_x -= 40;
    model.position_y += 5;
  }
  this.context.fillText(name, model.position_x, model.position_y)
}
})

4.根据给定的rateArray给出的能力值,根据画布的中心点,以及对应能力值雷达图的半径,用Math.sin(angle)和Math.cos(angle)计算出需要绘制雷达图各个点所对应的坐标点,

使用path2D的moveTo和lineTo绘制折线图,使用closePath闭合路径,

使用CanvasRenderingContext的stroke绘制边框,用fillStyle和globalAlhpa分别设置填充区域颜色和透明度,最后调用fill完成绘制。

//绘制能力值对应的路径
for (let index = 0; index < this.rateArray.length; index++) {
  if (index == 0) {
    let tempRadius:number = this.rateArray[index] * 125 + 25;
    this.ratePath2Db.moveTo(tempRadius * Math.sin(0) + this.canvasRadius  , tempRadius * Math.cos(0) + this.canvasRadius)
  }else {
    let tempRadius:number = this.rateArray[index] * 125 + 25;
    const angle = index * this.angleOffset;
    const x = tempRadius * Math.sin(angle) + this.canvasRadius;
    const y = tempRadius * Math.cos(angle) + this.canvasRadius;
    this.ratePath2Db.lineTo( x ,  y );
  }
}
this.ratePath2Db.closePath()
this.context.stroke(this.ratePath2Db)
this.context.fillStyle = '#00ff00'
this.context.globalAlpha = 0.4
this.context.fill(this.ratePath2Db, "evenodd")

HarmonyOS码上奇行
11.3k 声望4.1k 粉丝

欢迎关注 HarmonyOS 开发者社区:[链接]