【入门到精通】鸿蒙next开发:基于Canvas实现数据可视化:折线图/柱状图/饼状图/雷达图_鸿蒙canvas绘制折线图
往期鸿蒙5.0全套实战文章必看:(文中附带全栈鸿蒙5.0学习资料)
-
鸿蒙开发核心知识点,看这篇文章就够了
-
最新版!鸿蒙HarmonyOS Next应用开发实战学习路线
-
鸿蒙HarmonyOS NEXT开发技术最全学习路线指南
-
鸿蒙应用开发实战项目,看这一篇文章就够了(部分项目附源码)
基于Canvas实现数据可视化:折线图/柱状图/饼状图/雷达图
背景介绍
在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场景二:实心柱状图
效果图如下所示:
具体实现:
柱状表格的实现与折线不同的是,柱形绘制使用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\")