官网地址:https://developers.weixin.qq.com/miniprogram/dev/framework/ability/canvas.html
一、canvas介绍
canvas 组件 提供了绘制界面,可以在之上进行任意绘制
二、入门demo
wxml
<!-- 2d 类型的 canvas --> <canvas id="myCanvas" type="2d" style="border: 1px solid; width: 300px; height: 150px;" />
首先需要在 WXML 中添加 canvas 组件。
指定 id="myCanvas" 唯一标识一个 canvas,用于后续获取 Canvas 对象。
指定 type 用于定义画布类型,本例子使用 type="2d" 示例。
效果如下:

旧版本
<canvas canvas-id="myCanvas" /> <!-- 修改为以下 --> <canvas id="myCanvas" type="2d" />
旧版 canvas 接口使用 canvas-id 属性唯一标识 canvas;新版 Canvas 2D 可直接使用 id 标识。
另外需要给 canvas 添加 type="2d" 属性标识为新版 Canvas 2D 接口。
获取 Canvas 对象和渲染上下文
onLoad(options) { wx.createSelectorQuery() .select('#myCanvas') // 在 WXML 中填入的 id .fields({ node: true, size: true }) .exec((res) => { // Canvas 对象 const canvas = res[0].node // 渲染上下文 const ctx = canvas.getContext('2d') }) },
通过 SelectorQuery 选择上一步的 canvas,可以获取到 Canvas 对象。
再通过 Canvas.getContext,我们可以获取到 渲染上下文 RenderingContext。
后续的画布操作与渲染操作,都需要通过这两个对象来实现。
旧版本
const context = wx.createCanvasContext('myCanvas') // // 修改为以下 // wx.createSelectorQuery() .select('#myCanvas') // 在 WXML 中填入的 id .node(({ node: canvas }) => { const context = canvas.getContext('2d') }) .exec()
旧版 canvas 接口使用 wx.createCanvasContext 同步获取 CanvasContext。
新版 Canvas 2D 接口需要先通过 SelectorQuery 异步获取 Canvas 对象,再通过 Canvas.getContext 获取渲染上下文 RenderingContext。
初始化Canvas
onLoad(options) { wx.createSelectorQuery() .select('#myCanvas') // 在 WXML 中填入的 id .fields({ node: true, size: true }) .exec((res) => { // Canvas 对象 const canvas = res[0].node // 渲染上下文 const ctx = canvas.getContext('2d') // Canvas 画布的实际绘制宽高 const width = res[0].width const height = res[0].height // 初始化画布大小 const dpr = wx.getWindowInfo().pixelRatio canvas.width = width * dpr canvas.height = height * dpr ctx.scale(dpr, dpr) }) },
注意:如果canvas画布不清晰或者看不到,可以添加以下代码,通过设置分辨率来解决:
const dpr = wx.getSystemInfoSync().pixelRatio canvas.width = res[0].width * dpr canvas.height = res[0].height * dpr ctx.scale(dpr, dpr)
canvas 的宽高分为渲染宽高和逻辑宽高:
- 渲染宽高为 canvas 画布在页面中所实际占用的宽高大小,即通过对节点进行 boundingClientRect 请求获取到的大小。
- 逻辑宽高为 canvas 在渲染过程中的逻辑宽高大小,如绘制一个长方形与逻辑宽高相同,最终长方形会占满整个画布。逻辑宽高默认为
300 * 150。
不同的设备上,存在物理像素和逻辑像素不相等的情况,所以一般我们需要用 wx.getWindowInfo 获取设备的像素比,乘上 canvas 的渲染大小,作为画布的逻辑大小。
旧版本
旧版 canvas 不能修改宽高
旧版 canvas 接口的画布大小是根据实际渲染宽度决定的,开发者无法修改。
新版 Canvas 2D 接口允许开发者自由修改画布的逻辑大小,默认宽高为 300*150。
三、进行绘制
通过 渲染上下文 上的绘图 api,我们可以在画布上进行任意的绘制。
1、矩形
填充矩形:fillRect(number x, number y, number width, number height)
常见参数如下:
number x:矩形路径左上角的横坐标。number y:矩形路径左上角的纵坐标。number width:矩形路径的宽度。number height:矩形路径的高度。
onLoad(options) { wx.createSelectorQuery() .select('#myCanvas') // 在 WXML 中填入的 id .fields({ node: true, size: true }) .exec((res) => { // Canvas 对象 const canvas = res[0].node // 渲染上下文 const ctx = canvas.getContext('2d') // Canvas 画布的实际绘制宽高 const width = res[0].width const height = res[0].height // 初始化画布大小 const dpr = wx.getWindowInfo().pixelRatio canvas.width = width * dpr canvas.height = height * dpr ctx.scale(dpr, dpr) // 清空画布 ctx.clearRect(0, 0, width, height) ctx.fillRect(10, 10, 50, 50); }) },
效果:

如果没有填充颜色,则默认是黑色
现在填充颜色
ctx.fillStyle="red" // ctx.fillStyle = 'rgb(200, 0, 0)'; ctx.fillRect(10, 10, 50, 50);
注意:填充颜色只能在fillRect前面才能生效
效果:

描边矩形
ctx.strokeStyle = 'rgb(200, 0, 0)'; ctx.rect(10, 10, 150, 75) ctx.stroke()
效果如下:

清除画布上在该矩形区域内的内容
clearRect 并非画一个白色的矩形在地址区域,而是清空,为了有直观感受,对 canvas 加了一层背景色。
ctx.fillStyle = 'rgb(200, 0, 0)'; ctx.fillRect(0, 0, 150, 200); ctx.fillStyle = 'rgba(0, 0, 200, 0.5)'; ctx.fillRect(150, 0, 150, 200); ctx.clearRect(10, 10, 150, 75);
效果如下:

2、线段
ctx.moveTo(10, 10) ctx.lineTo(100, 10) ctx.moveTo(10, 60) ctx.lineTo(110, 60) ctx.moveTo(10, 10) // ctx.rect(10, 10, 100, 50) ctx.lineTo(110, 60) ctx.stroke()
效果如下:

3、弧线
CanvasContext.arc(number x, number y, number r, number sAngle, number eAngle, boolean counterclockwise)
创建一条弧线。
- 创建一个圆可以指定起始弧度为 0,终止弧度为 2 * Math.PI。
- 用
stroke或者fill方法来在canvas中画弧线。
参数:number x:圆心的 x 坐标;number y:圆心的 y 坐标;number r:圆的半径;number sAngle:起始弧度,单位弧度(在3点钟方向);number eAngle:终止弧度;boolean counterclockwise:弧度的方向是否是逆时针;
弧度方向<false顺时针,true逆时针,默认是顺时针>
// 画圆 ctx.arc(100, 75, 50, 0, 2 * Math.PI) ctx.fillStyle='#EEEEEE' ctx.fill() // 画xy坐标 ctx.beginPath() ctx.moveTo(40, 75) ctx.lineTo(160, 75) ctx.moveTo(100, 15) ctx.lineTo(100, 135) ctx.fillStyle='#AAAAAA' ctx.stroke() // 写文本 ctx.fontSize=12 ctx.fillStyle='black' ctx.fillText('0', 165, 78) ctx.fillText('0.5*PI', 83, 145) ctx.fillText('1*PI', 15, 78) ctx.fillText('1.5*PI', 83, 10) // 画点 ctx.beginPath() ctx.arc(100, 75, 2, 0, 2 * Math.PI) // 画一个点 ctx.fillStyle='lightgreen' ctx.fill() // 画点 ctx.beginPath() ctx.arc(100, 25, 2, 0, 2 * Math.PI) ctx.fillStyle='blue' ctx.fill() // 画点 ctx.beginPath() ctx.arc(150, 75, 2, 0, 2 * Math.PI) ctx.fillStyle='red' ctx.fill() // 画圆弧 ctx.beginPath() ctx.arc(100, 75, 50, 0, 1.5 * Math.PI) ctx.strokeStyle='#333333' ctx.stroke()
效果如下:

4、路径
ctx.moveTo(10, 10) ctx.lineTo(100, 10) ctx.lineTo(100, 100) ctx.stroke()
效果

stroke() 描绘的的路径是从 beginPath() 开始计算,但是不会将 strokeRect() 包含进去。
// begin path ctx.rect(10, 10, 100, 30) ctx.strokeStyle='yellow' ctx.stroke() // begin another path ctx.beginPath() ctx.rect(10, 40, 100, 30) // only stoke this rect, not in current path ctx.strokeStyle='blue' ctx.strokeRect(10, 70, 100, 30) ctx.rect(10, 100, 100, 30) // it will stroke current path ctx.strokeStyle='red' ctx.stroke()
效果如下:

(2)、路径填充fill
对当前路径中的内容进行填充。默认的填充色为黑色。
如果当前路径没有闭合,fill() 方法会将起点和终点进行连接,然后填充。
ctx.moveTo(10, 10) ctx.lineTo(100, 10) ctx.lineTo(100, 100) ctx.fill()
效果:

fill() 填充的的路径是从 beginPath() 开始计算,但是不会将 fillRect() 包含进去。
// begin path ctx.rect(10, 10, 100, 30) ctx.fillStyle='yellow' ctx.fill() // begin another path ctx.beginPath() ctx.rect(10, 40, 100, 30) // only fill this rect, not in current path ctx.fillStyle='blue' ctx.fillRect(10, 70, 100, 30) ctx.rect(10, 100, 100, 30) // it will fill current path ctx.fillStyle='red' ctx.fill()
效果如下:

(3)、创建路径:ctx.beginPath()
开始创建一个路径,需要调用fill或者stroke才会使用路径进行填充或描边。
Tip: 在最开始的时候相当于调用了一次 beginPath()。
Tip: 同一个路径内的多次fillStyle、strokeStyle、lineWidth等设置,以最后一次设置为准。
// begin path ctx.rect(10, 10, 100, 30) ctx.fillStyle='yellow' ctx.fill() // begin another path ctx.beginPath() ctx.rect(10, 40, 100, 30) // only fill this rect, not in current path ctx.fillStyle='blue' ctx.fillRect(10, 70, 100, 30) ctx.rect(10, 100, 100, 30) // it will fill current path ctx.fillStyle='red' ctx.fill()
效果:

(4)、关闭路径:ctx.closePath()
Tip: 关闭路径会连接起点和终点。
Tip: 如果关闭路径后没有调用 fill() 或者 stroke() 并开启了新的路径,那之前的路径将不会被渲染。
ctx.lineTo(100, 10) ctx.lineTo(100, 100) ctx.closePath() ctx.stroke()
效果:

5、文本
fillText:在画布上绘制被填充的文本
CanvasContext.fillText(string text, number x, number y, number maxWidth)
参数:string text:在画布上输出的文本;number x:绘制文本的左上角 x 坐标位置;number y:绘制文本的左上角 y 坐标位置;number maxWidth:需要绘制的最大宽度,可选;
ctx.fontSize=10 ctx.fillText('Hello', 20, 20) ctx.fillText('MINA', 100, 100)
效果

6、渐变
(1)、线性渐变
创建一个线性的渐变颜色。返回的CanvasGradient对象需要使用 CanvasGradient.addColorStop() 来指定渐变点,至少要两个。
CanvasGradient CanvasContext.createLinearGradient(number x0, number y0, number x1, number y1)
参数: number x0:起点的 x 坐标; number y0:起点的 y 坐标; number x1:终点的 x 坐标; number y1:终点的 y 坐标;
// Create linear gradient const grd = ctx.createLinearGradient(0, 0, 200, 0) grd.addColorStop(0, 'red') grd.addColorStop(1, 'white') // Fill with gradient ctx.fillStyle=grd ctx.fillRect(10, 10, 150, 80)
效果如下:

(2)、圆形渐变
创建一个圆形的渐变颜色。起点在圆心,终点在圆环。返回的CanvasGradient对象需要使用 CanvasGradient.addColorStop() 来指定渐变点,至少要两个。
CanvasGradient CanvasContext.createCircularGradient(number x, number y, number r)
参数:number x:圆心的 x 坐标;number y:圆心的 y 坐标;number r:圆的半径;
var grd=ctx.createRadialGradient(0,0,0,0,0,200); grd.addColorStop(0,'yellow'); grd.addColorStop(0.2,'red'); grd.addColorStop(0.4,'pink'); grd.addColorStop(0.6,'green'); grd.addColorStop(0.8,'orange'); grd.addColorStop(1,'blue'); ctx.fillStyle=grd; ctx.fillRect(10,10,200,200);
效果:

7、阴影
设定阴影样式
CanvasContext.setShadow(number offsetX, number offsetY, number blur, string color)
参数:number offsetX:阴影相对于形状在水平方向的偏移,默认值为 0。number offsetY:阴影相对于形状在竖直方向的偏移,默认值为 0。number blur:阴影的模糊级别,数值越大越模糊。范围 0- 100。,默认值为 0。string color:阴影的颜色。默认值为 black。
ctx.fillStyle='red' // ctx.setShadow(10, 50, 50, 'blue') // 设置阴影颜色 ctx.shadowColor = 'blue'; // 偏移量X ctx.shadowOffsetX = 10; // 偏移量Y ctx.shadowOffsetY = 50; // 模糊度 ctx.shadowBlur = 50; ctx.fillRect(10, 10, 150, 75)
效果:

8、旋转
(1)、scale
在调用后,之后创建的路径其横纵坐标会被缩放。多次调用倍数会相乘
CanvasContext.scale(number scaleWidth, number scaleHeight)
参数
number scaleWidth:横坐标缩放的倍数 (1 = 100%,0.5 = 50%,2 = 200%);number scaleHeight:纵坐标轴缩放的倍数 (1 = 100%,0.5 = 50%,2 = 200%)
ctx.strokeRect(10, 10, 25, 15) ctx.scale(2, 2) ctx.strokeRect(10, 10, 25, 15) ctx.scale(2, 2) ctx.strokeRect(10, 10, 25, 15)
效果:

(2)、rotate
以原点为中心顺时针旋转当前坐标轴。多次调用旋转的角度会叠加。原点可以用 translate 方法修改。
CanvasContext.rotate(number rotate)
旋转角度,以弧度计 degrees * Math.PI/180;degrees 范围为 0-360
ctx.strokeRect(100, 10, 50, 50) ctx.rotate(20 * Math.PI / 180) ctx.strokeRect(100, 10, 50, 50) ctx.rotate(20 * Math.PI / 180) ctx.strokeRect(100, 10, 50, 50)
效果:

(3)、translate(x坐标平移量,y坐标平移量)对坐标原点进行缩放
CanvasContext.translate(number x, number y)
number x:水平坐标平移量;number y:竖直坐标平移量;
ctx.strokeRect(10, 10, 50, 80) ctx.translate(20, 20) ctx.strokeRect(10, 10, 50, 80) ctx.translate(20, 20) ctx.strokeRect(10, 10, 50, 80)
效果:

9、线条样式
(1)、设置线条的宽度
CanvasContext.setLineWidth(number lineWidth)
示例:
ctx.beginPath() ctx.moveTo(10, 10) ctx.lineTo(150, 10) ctx.stroke() ctx.beginPath() ctx.lineWidth=5 ctx.moveTo(10, 30) ctx.lineTo(150, 30) ctx.stroke() ctx.beginPath() ctx.lineWidth=10 ctx.moveTo(10, 50) ctx.lineTo(150, 50) ctx.stroke() ctx.beginPath() ctx.lineWidth=15 ctx.moveTo(10, 70) ctx.lineTo(150, 70) ctx.stroke()
效果:

(2)、线条的交点样式
线条的结束交点样式,lineJoin值:'bevel'、'round'、'miter'
CanvasContext.setLineJoin(string lineJoin)
示例:
ctx.beginPath() ctx.moveTo(10, 10) ctx.lineTo(100, 50) ctx.lineTo(10, 90) ctx.stroke() ctx.beginPath() ctx.lineJoin='bevel' ctx.lineWidth=10 ctx.moveTo(50, 10) ctx.lineTo(140, 50) ctx.lineTo(50, 90) ctx.stroke() ctx.beginPath() ctx.lineJoin='round' ctx.lineWidth=10 ctx.moveTo(90, 10) ctx.lineTo(180, 50) ctx.lineTo(90, 90) ctx.stroke() ctx.beginPath() ctx.lineJoin='miter' ctx.lineWidth=10 ctx.moveTo(130, 10) ctx.lineTo(220, 50) ctx.lineTo(130, 90) ctx.stroke()
效果:

10、全局画笔透明度
CanvasContext.setGlobalAlpha(number alpha)
透明度。范围 0-1,0 表示完全透明,1 表示完全不透明
ctx.fillStyle='red' ctx.fillRect(10, 10, 150, 100) ctx.globalAlpha=0.2 ctx.fillStyle='blue' ctx.fillRect(50, 50, 150, 100) ctx.fillStyle='yellow' ctx.fillRect(100, 100, 150, 100)
效果:

11、保存/恢复
在绘制一个整体的元素,特别是使用了图像变换时头尾必须采用save(),restore()
保存绘图上下文
// save the default fill style ctx.save() ctx.fillStyle='red' ctx.fillRect(10, 10, 150, 100) // restore to the previous saved state ctx.restore() // 恢复 ctx.fillRect(50, 50, 150, 100)
效果:

12、线条的端点样式
线条的结束端点样式:
| 值 | 说明 | 最低版本 |
|---|---|---|
| butt | 向线条的每个末端添加平直的边缘。 | |
| round | 向线条的每个末端添加圆形线帽。 | |
| square | 向线条的每个末端添加正方形线帽。 |
示例:
ctx.beginPath() ctx.moveTo(10, 10) ctx.lineTo(150, 10) ctx.stroke() ctx.beginPath() ctx.lineCap='butt' ctx.lineWidth=10 ctx.moveTo(10, 30) ctx.lineTo(150, 30) ctx.stroke() ctx.beginPath() ctx.lineCap='round' ctx.lineWidth=10 ctx.moveTo(10, 50) ctx.lineTo(150, 50) ctx.stroke() ctx.beginPath() ctx.lineCap='square' ctx.lineWidth=10 ctx.moveTo(10, 70) ctx.lineTo(150, 70) ctx.stroke()
效果

13、虚线样式
CanvasContext.setLineDash(Array.<number> pattern, number offset)
Array.<number> pattern:一组描述交替绘制线段和间距(坐标空间单位)长度的数字;number offset:虚线偏移量;
ctx.setLineDash([10, 20], 5); ctx.beginPath(); ctx.moveTo(0,100); ctx.lineTo(400, 100); ctx.stroke();
效果:

14、文字的对齐
CanvasContext.setTextAlign(string align)
align 的合法值
| 值 | 说明 | 最低版本 |
|---|---|---|
| left | 左对齐 | |
| center | 居中对齐 | |
| right | 右对齐 |
示例:
ctx.strokeStyle='red' ctx.moveTo(150, 20) ctx.lineTo(150, 170) ctx.stroke() ctx.fontSize=15 ctx.textAlign='left' ctx.fillText('textAlign=left', 150, 60) ctx.textAlign='center' ctx.fillText('textAlign=center', 150, 80) ctx.textAlign='right' ctx.fillText('textAlign=right', 150, 100)
效果:
旧版本
// 若干绘制调用 context.fillRect(0, 0, 50, 50) context.fillRect(20, 20, 50, 50) context.draw(false, () => { // 这里绘制完成 console.log('draw done') }) // // 修改为以下 // // 绘制前清空画布 context.clearRect(0, 0, canvas.width, canvas.height) // 若干绘制调用 context.fillRect(0, 0, 50, 50) context.fillRect(20, 20, 50, 50) // 这里绘制完成 console.log('draw done')
旧版 canvas 接口绘制需要调用 CanvasContext.draw 才会进行绘制,并且绘制过程是异步的,需要等待绘制完成回调才能进行下一步操作。
新版 Canvas 2D 接口不再需要调用 draw 函数,所有绘制方法都会同步绘制到画布上。
需要注意的是 CanvasContext.draw 函数第一个参数控制在绘制前是否保留上一次绘制(默认值为 false,即不保留),若设置为 false,则迁移至新接口后,需要在绘制前通过 clearRect 清空画布。
四、canvas进阶
使用 ctx.drawImage 即可将图片绘制到 canvas 上
onLoad(options) { wx.createSelectorQuery() .select('#myCanvas') // 在 WXML 中填入的 id .fields({ node: true, size: true }) .exec((res) => { // Canvas 对象 const canvas = res[0].node // 渲染上下文 const ctx = canvas.getContext('2d') // Canvas 画布的实际绘制宽高 const width = res[0].width const height = res[0].height // 初始化画布大小 const dpr = wx.getWindowInfo().pixelRatio canvas.width = width * dpr canvas.height = height * dpr ctx.scale(dpr, dpr) // 清空画布 ctx.clearRect(0, 0, width, height) // 创建图片对象 const image = canvas.createImage() // 图片加载完成回调 image.onload = () => { // 将图片绘制到 canvas 上 ctx.drawImage(image, 0, 0) } // 设置图片src image.src = 'https://open.weixin.qq.com/zh_CN/htmledition/res/assets/res-design-download/icon64_wx_logo.png' }) },
效果如下:

通过 Canvas.createImage 我们可以创建图片对象并加载图片。当图片加载完成触发 onload 回调之后,使用 ctx.drawImage 即可将图片绘制到 canvas 上。
旧版本
context.drawImage( 'https://open.weixin.qq.com/zh_CN/htmledition/res/assets/res-design-download/icon64_wx_logo.png', 0, 0, 150, 100, ) // // 修改为以下 // const image = canvas.createImage() image.onload = () => { context.drawImage( image, 0, 0, 150, 100, ) } image.src = 'https://open.weixin.qq.com/zh_CN/htmledition/res/assets/res-design-download/icon64_wx_logo.png'
旧版 canvas 接口 CanvasContext.drawImage 直接传入图片 url 进行绘制。
新版 Canvas 2D 接口需要先通过 Canvas.createImage 创建图片对象,onload 图片加载完成回调触发后,再将图片对象传入 context.drawImage 进行绘制。
生成图片
api接口:canvasToTempFilePath导出图片
onLoad(options) { wx.createSelectorQuery() .select('#myCanvas') // 在 WXML 中填入的 id .fields({ node: true, size: true }) .exec((res) => { // Canvas 对象 const canvas = res[0].node // 渲染上下文 const ctx = canvas.getContext('2d') // Canvas 画布的实际绘制宽高 const width = res[0].width const height = res[0].height // 初始化画布大小 const dpr = wx.getWindowInfo().pixelRatio canvas.width = width * dpr canvas.height = height * dpr ctx.scale(dpr, dpr) // 清空画布 ctx.clearRect(0, 0, width, height)// 绘制红色正方形 ctx.fillStyle = 'rgb(200, 0, 0)'; ctx.fillRect(10, 10, 50, 50); // 绘制蓝色半透明正方形 ctx.fillStyle = 'rgba(0, 0, 200, 0.5)'; ctx.fillRect(30, 30, 50, 50); // 生成图片 wx.canvasToTempFilePath({ canvas, success: res => { // 生成的图片临时文件路径 const tempFilePath = res.tempFilePath console.log(tempFilePath) }, }) }) },
控制台打印如下:
![]()
http://tmp/saTFYwBb5R6Fc9a1382c84bf47c9a329a230fd8d1f65.png
通过 wx.canvasToTempFilePath 接口,可以将 canvas 上的内容生成图片临时文件。
旧版本:
wx.canvasToTempFilePath({ canvasId: 'myCanvas', success(res) { // } }) // // 修改为以下 // wx.canvasToTempFilePath({ canvas: canvas, success(res) { // } })
旧版 canvas 接口传入 canvas-id。
新版 Canvas 2D 接口需要直接传入 Canvas 实例
帧动画
function easeOutBounce(x) { const n1 = 7.5625; const d1 = 2.75; if (x < 1 / d1) { return n1 * x * x; } else if (x < 2 / d1) { return n1 * (x -= 1.5 / d1) * x + 0.75; } else if (x < 2.5 / d1) { return n1 * (x -= 2.25 / d1) * x + 0.9375; } else { return n1 * (x -= 2.625 / d1) * x + 0.984375; } } Page({ /** * 页面的初始数据 */ data: { }, /** * 生命周期函数--监听页面加载 */ onLoad(options) { wx.createSelectorQuery() .select('#myCanvas') // 在 WXML 中填入的 id .fields({ node: true, size: true }) .exec((res) => { // Canvas 对象 const canvas = res[0].node // 渲染上下文 const ctx = canvas.getContext('2d') // Canvas 画布的实际绘制宽高 const width = res[0].width const height = res[0].height // 初始化画布大小 const dpr = wx.getWindowInfo().pixelRatio canvas.width = width * dpr canvas.height = height * dpr ctx.scale(dpr, dpr) // 清空画布 ctx.clearRect(0, 0, width, height) const startTime = Date.now() // 帧渲染回调 const draw = () => { const time = Date.now() // 计算经过的时间 const elapsed = time - startTime // 计算动画位置 const n = Math.floor(elapsed / 3000) const m = elapsed % 3000 const dx = (n % 2 ? 0 : 1) + (n % 2 ? 1 : -1) * (m < 2500 ? easeOutBounce(m / 2500) : 1) const x = (width - 50) * dx // 渲染 ctx.clearRect(0, 0, width, height) ctx.fillStyle = 'rgb(200, 0, 0)'; ctx.fillRect(x, height / 2 - 25, 50, 50); // 注册下一帧渲染 canvas.requestAnimationFrame(draw) } draw() }) }, })
通过 Canvas.requestAnimationFrame 可以注册动画帧回调,在回调内进行动画的逐帧绘制。
录制视频
通过 MediaRecorder 可以将 Canvas 内容录制为视频并保存。
ctx.fontSize=10 ctx.fillText('Hello', 20, 20) ctx.fillText('MINA', 100, 100)
五、封装工具类
wxml
<canvas id="myCanvas" type="2d" style="border: 1px solid; width: 300px; height: 150px;" />
js
数据模型
data: { canvasInfo: { //画布的基本信息 id_prefix: '', width: '', height: '', big_circle_R: '', small_circle_R: '', line_color: '', vertical_line_color: '' //竖线颜色 }, },
函数
onLoad(options) { this.setCanval() wx.createSelectorQuery() .select('#myCanvas') // 在 WXML 中填入的 id .fields({node: true, size: true}) .exec((res) => { // Canvas 对象 console.log(res) const canvas = res[0].node // 渲染上下文 const ctx = canvas.getContext('2d') const dpr = wx.getSystemInfoSync().pixelRatio canvas.width = res[0].width * dpr canvas.height = res[0].height * dpr ctx.scale(dpr, dpr) }) }, //设置画布基本信息 setCanval() { // return new Promise((resolve, reject) => { let chain_id = '#myCanvas' wx.createSelectorQuery() .select(chain_id) .fields({node: true, size: true}) .exec(res => { console.log(res) let width = res[0].width this.setData({ canvasInfo: { id_prefix: 'canval_', //画布的基本信息 width: width, height: 70, big_circle_R: 6, small_circle_R: 2, line_color: '#364C6B', vertical_line_color: '#96A1B2' //竖线颜色 } }) console.log(this.data.canvasInfo) // resolve(); }) // }) },
1、封装画竖线的方法
createLine_y(ctx, x, startY, endY) { console.log(this.data.canvasInfo) let color = this.data.canvasInfo.vertical_line_color //竖线 ctx.beginPath() ctx.moveTo(x, startY) ctx.lineTo(x, endY) ctx.strokeStyle = color ctx.lineWidth = 1.5 ctx.stroke() },
调用
onLoad(options) { this.setCanval() wx.createSelectorQuery() .select('#myCanvas') // 在 WXML 中填入的 id .fields({node: true, size: true}) .exec((res) => { // Canvas 对象 console.log(res) const canvas = res[0].node // 渲染上下文 const ctx = canvas.getContext('2d') const dpr = wx.getSystemInfoSync().pixelRatio canvas.width = res[0].width * dpr canvas.height = res[0].height * dpr ctx.scale(dpr, dpr) this.createLine_y(ctx, 100, 50, 100) }) },
效果如下:

2、封装画横线的方法
createLine_x(ctx, startX, endX, y) { let color = this.data.canvasInfo.line_color //横线 ctx.beginPath() ctx.moveTo(startX, y) ctx.lineTo(endX, y) ctx.strokeStyle = color ctx.lineWidth = 1 ctx.stroke() },
调用
onLoad(options) { this.setCanval() wx.createSelectorQuery() .select('#myCanvas') // 在 WXML 中填入的 id .fields({node: true, size: true}) .exec((res) => { // Canvas 对象 console.log(res) const canvas = res[0].node // 渲染上下文 const ctx = canvas.getContext('2d') const dpr = wx.getSystemInfoSync().pixelRatio canvas.width = res[0].width * dpr canvas.height = res[0].height * dpr ctx.scale(dpr, dpr) this.createLine_y(ctx, 100, 50, 100) this.createLine_x(ctx, 100, 200, 50) }) },
效果:

3、封装画圆点的方法
createEndPoint(ctx, x, y) { let color = this.data.canvasInfo.line_color //单实心圆 ctx.beginPath() ctx.arc(x, y, this.data.canvasInfo.small_circle_R, 0, 2 * Math.PI) ctx.fillStyle = color ctx.strokeStyle = color ctx.stroke() ctx.fill() },
调用
onLoad(options) { this.setCanval() wx.createSelectorQuery() .select('#myCanvas') // 在 WXML 中填入的 id .fields({node: true, size: true}) .exec((res) => { // Canvas 对象 console.log(res) const canvas = res[0].node // 渲染上下文 const ctx = canvas.getContext('2d') const dpr = wx.getSystemInfoSync().pixelRatio canvas.width = res[0].width * dpr canvas.height = res[0].height * dpr ctx.scale(dpr, dpr) this.createLine_y(ctx, 100, 50, 100) this.createLine_x(ctx, 100, 200, 50) this.createEndPoint(ctx, 100, 100) }) },
效果如下:

4、封装画同心圆的方法
createStartPoint(ctx, x, y) { let color = this.data.canvasInfo.line_color ctx.beginPath() ctx.arc(x, y, this.data.canvasInfo.big_circle_R, 0, 2 * Math.PI) ctx.fillStyle = '#FFFFFF' ctx.strokeStyle = color ctx.stroke() ctx.fill() ctx.beginPath() ctx.arc(x, y, 4, 0, 2 * Math.PI) ctx.fillStyle = color ctx.strokeStyle = color ctx.stroke() ctx.fill() },
调用
onLoad(options) { this.setCanval() wx.createSelectorQuery() .select('#myCanvas') // 在 WXML 中填入的 id .fields({node: true, size: true}) .exec((res) => { // Canvas 对象 console.log(res) const canvas = res[0].node // 渲染上下文 const ctx = canvas.getContext('2d') const dpr = wx.getSystemInfoSync().pixelRatio canvas.width = res[0].width * dpr canvas.height = res[0].height * dpr ctx.scale(dpr, dpr) this.createLine_y(ctx, 100, 50, 100) this.createLine_x(ctx, 100, 200, 50) this.createEndPoint(ctx, 100, 100) this.createStartPoint(ctx, 50, 50) }) },
效果如下:

5、封装画只有结束端点的竖线的方法
createOnlyHaveEndPointLine_y(ctx, x, startY, endY) { //创建竖线 let line_end_y = endY - (this.data.canvasInfo.small_circle_R * 2) this.createLine_y(ctx, x, startY, line_end_y) //创建结束端点 let end_r = endY - this.data.canvasInfo.small_circle_R * 2 this.createEndPoint(ctx, x, end_r) },
调用:
onLoad(options) { this.setCanval() wx.createSelectorQuery() .select('#myCanvas') // 在 WXML 中填入的 id .fields({node: true, size: true}) .exec((res) => { // Canvas 对象 console.log(res) const canvas = res[0].node // 渲染上下文 const ctx = canvas.getContext('2d') const dpr = wx.getSystemInfoSync().pixelRatio canvas.width = res[0].width * dpr canvas.height = res[0].height * dpr ctx.scale(dpr, dpr) this.createLine_y(ctx, 100, 50, 100) this.createLine_x(ctx, 100, 200, 50) this.createEndPoint(ctx, 100, 100) this.createStartPoint(ctx, 50, 50) this.createOnlyHaveEndPointLine_y(ctx, 110, 50, 100) }) },
效果如下:

6、封装画只有开始端点的竖线的方法
createOnlyHaveStartPointLine_y(ctx, x, startY, endY) { //创建开始的端点 let start_r = startY + this.data.canvasInfo.big_circle_R this.createStartPoint(ctx, x, start_r) //创建竖线 let line_start_y = startY + (this.data.canvasInfo.big_circle_R * 2) this.createLine_y(ctx, x, line_start_y, endY) },
调用:
onLoad(options) { this.setCanval() wx.createSelectorQuery() .select('#myCanvas') // 在 WXML 中填入的 id .fields({node: true, size: true}) .exec((res) => { // Canvas 对象 console.log(res) const canvas = res[0].node // 渲染上下文 const ctx = canvas.getContext('2d') const dpr = wx.getSystemInfoSync().pixelRatio canvas.width = res[0].width * dpr canvas.height = res[0].height * dpr ctx.scale(dpr, dpr) this.createLine_y(ctx, 100, 50, 100) this.createLine_x(ctx, 100, 200, 50) this.createEndPoint(ctx, 100, 100) this.createStartPoint(ctx, 50, 50) this.createOnlyHaveEndPointLine_y(ctx, 110, 50, 100) this.createOnlyHaveStartPointLine_y(ctx, 80, 50, 100) }) },
效果如下:

7、封装画有开始和结束端点的竖线的方法
createHaveStartAndEndPointLine_y(ctx, x, startY, endY) { //创建开始的端点 let start_r = startY + this.data.canvasInfo.big_circle_R this.createStartPoint(ctx, x, start_r) //创建竖线 let line_start_y = startY + (this.data.canvasInfo.big_circle_R * 2) let line_end_y = endY - (this.data.canvasInfo.small_circle_R * 2) this.createLine_y(ctx, x, line_start_y, line_end_y) //创建结束端点 let end_r = endY - this.data.canvasInfo.small_circle_R this.createEndPoint(ctx, x, end_r) },
调用:
onLoad(options) { this.setCanval() wx.createSelectorQuery() .select('#myCanvas') // 在 WXML 中填入的 id .fields({node: true, size: true}) .exec((res) => { // Canvas 对象 console.log(res) const canvas = res[0].node // 渲染上下文 const ctx = canvas.getContext('2d') const dpr = wx.getSystemInfoSync().pixelRatio canvas.width = res[0].width * dpr canvas.height = res[0].height * dpr ctx.scale(dpr, dpr) this.createLine_y(ctx, 100, 50, 100) this.createLine_x(ctx, 100, 200, 50) this.createEndPoint(ctx, 100, 100) this.createStartPoint(ctx, 50, 50) this.createOnlyHaveEndPointLine_y(ctx, 110, 50, 100) this.createOnlyHaveStartPointLine_y(ctx, 80, 50, 100) this.createHaveStartAndEndPointLine_y(ctx, 210, 20, 100) }) },
效果如下所示:

六、使用 Canvas 原生 API 手动绘制折线图
为什么这样画出来的曲线会经过原始数据点?
二次贝塞尔曲线的特性是:它从起点开始,经过控制点,然后结束于终点。由于控制点就是前一个原始数据点,曲线会准确地穿过该点。然后终点是前后两点的中点,所以曲线在穿过前一个点之后会平滑地走向中点。下一个贝塞尔曲线会以这个中点为起点,以其下一个数据点为控制点,再下一个中点为终点……最终,所有原始数据点都变成了各段曲线的控制点,从而被曲线精确穿过。中间中点则是为了控制曲线走向,使整体看起来平滑。
这种技巧在手绘图表中常见,结果是:曲线经过每个原始数据点,且相邻线段之间平滑过渡,没有尖角。
/** * 绘制趋势曲线图,使用 Canvas 原生 API 手动绘制折线图的完整实现。它不依赖任何第三方图表库 */ drawTrendChart() { // 1. 创建 Canvas 上下文,第一个参数是 canvas 组件的 canvas-id // ,第二个参数 this 指定组件/页面实例,用于在自定义组件中正确绑定 const ctx = wx.createCanvasContext('trendChart', this) if (!ctx) { console.error('[deviceDetail] 获取 Canvas 上下文失败') return } const chartData = this.data.chartData console.log(chartData) // 2. 数据校验 if (!chartData || chartData.length === 0) { console.warn('[deviceDetail] 图表数据为空,无法绘制') return } console.log('[deviceDetail] 开始绘制图表,数据:', chartData) // 3. 计算真实数据范围(基于实际最小最大值) const maxValue = Math.max(...chartData) const minValue = Math.min(...chartData) console.log(maxValue, minValue) const range = maxValue - minValue || 1 // 避免除零 console.log('[deviceDetail] 极值:', { maxValue, minValue, range }) // 4. 动态获取屏幕宽度并设置画布尺寸(以 iPhone 6 为基准,可按需调整高度) const systemInfo = wx.getSystemInfoSync() const width = systemInfo.windowWidth // 屏幕逻辑宽度,以适应不同的设备(如 iPhone 12 Pro Max) const height = 110 // 固定高度(可根据实际调整) const padding = { left: 35, right: 20, top: 0, bottom: 10 } const chartWidth = width - padding.left - padding.right // 375 - 5 - 20 = 350 const chartHeight = height - padding.top - padding.bottom // 110 - 0 - 10 = 100 console.log('图表横纵坐标:', { chartWidth, chartHeight }) // ========== Y 轴坐标转换:图表向上为正 → Canvas 向下为正 ========== // chartY = 0(图表底部) → toY(0) = padding.top + chartHeight(图表的顶部,即canvas 中的底部) // chartY = chartHeight(图表顶部) → toY(chartHeight) = padding.top(canvas 中的顶部) // toY坐标转换函数:将“图表坐标系”(Y 向上为正,原点在左下角)转换为 Canvas 坐标系(Y 向下为正,原点在左上角),chartY表示图表的坐标,toY是将图表的坐标chartY转换成canvas的坐标。chartY范围从 0(图表底部)到 chartHeight(图表顶部),toY为函数名 const toY = (chartY) => (padding.top + chartHeight) - chartY // 5. 清空画布 ctx.clearRect(0, 0, width, height) // ========== 6. 先绘制背景渐变(避免覆盖其他元素) ========== // 创建一个垂直渐变(x 不变,y 从 padding.top 到 height - padding.bottom) const gradient = ctx.createLinearGradient(0, padding.top, 0, height - padding.bottom) // 起始色为较浓的绿松石色(#48D1CC,透明度 0.9),结束色为同色但透明度 0.3 gradient.addColorStop(0, 'rgba(72, 209, 204, 0.9)') gradient.addColorStop(1, 'rgba(72, 209, 204, 0.3)') // 用这个渐变填充整个绘图区域矩形 ctx.setFillStyle(gradient) ctx.fillRect(padding.left, padding.top, chartWidth, chartHeight) // ========== 7. 绘制水平辅助线(虚线),使用 toY 转换 ========== // 在 Canvas 上绘制一组水平的虚线辅助线,帮助用户观察数据点的纵坐标值 ctx.setStrokeStyle('rgba(255, 255, 255, 0.5)') // 设置线条的绘制颜色和透明度 // [8, 4] 表示一个重复的线段样式:先画 8 个像素长的实线,再跳过(空隙)4 个像素,然后重复 ctx.setLineDash([8, 4]) // 设置线条为虚线样式 for (let i = 0; i <= 4; i++) { // 计算当前线条对应的数据值(图表坐标系中的纵坐标值),当 i=0 时,chartY = 0;i=4 时,chartY = maxValue const chartY = (maxValue / 4) * i // 图表坐标:0 → maxValue const y = toY(chartY) // 转为 Canvas 坐标 ctx.beginPath() // 开始一个新的路径。避免与之前的路径重叠,保证每次循环只绘制当前这条水平线 ctx.moveTo(padding.left, y) // 移动笔触到起点:(padding.left, y) 是水平线的左端 ctx.lineTo(width - padding.right, y) // 从当前位置(左端点)画一条直线到右端点 ctx.stroke() // 描边路径 } // 清除虚线设置,恢复为实线样式。后续绘制的其他图形(如折线、坐标轴边框)将使用实线,避免受虚线影响 ctx.setLineDash([]) // 恢复实线 // ========== 8. 绘制纵坐标刻度标签(底部为0,顶部为maxValue) ========== ctx.setFontSize(18) // 设置文本的字体大小 ctx.setFillStyle('rgba(255, 255, 255, 0.7)') // 设置文本的填充颜色及透明度 ctx.setTextAlign('right') //设置文本的水平对齐方式'right':右对齐 ctx.setTextBaseline('middle') // 设置文本的垂直基线对齐方式 'middle':文本中部对齐 // 绘制纵坐标的刻度线和对应的数值标签。它会在绘图区域的左侧边缘,从底部到顶部均匀绘制 5 条短横线(刻度线),并在其左侧显示该刻度代表的数据值 // 刻度值从 0 到 maxValue 等分成 5 份(i=0 底部,i=4 顶部) for (let i = 0; i <= 4; i++) { const chartY = (maxValue / 4) * i // 图表坐标 const y = toY(chartY) // 转为 Canvas 坐标 // i=0: chartY=0(底部),i=4: chartY=maxValue(顶部) const value = chartY // 刻度线 ctx.setStrokeStyle('rgba(255, 255, 255, 0.3)') // 设置线条颜色为半透明白色(白色,透明度 0.3)。用于绘制刻度线 ctx.setLineWidth(1) // 设置刻度线宽度为 1 像素 ctx.beginPath() // 开始一个新的路径,避免与之前的路径重叠 ctx.moveTo(padding.left - 5, y) // 将笔触移动到刻度线的左端点 ctx.lineTo(padding.left, y) // 从当前位置(左端点)画一条直线到右端点 ctx.stroke() // 描边路径,实际绘制出这条短横线(刻度线) // 绘制刻度数值文本 ctx.fillText(Math.round(value), padding.left - 8, y) } // ========== 9. 计算数据点坐标(基于 0-maxValue 归一化,与纵坐标刻度一致) ========== // 处理单数据点情况,避免除零 // 计算折线图中每个数据点在 Canvas 上的精确像素坐标,并将结果存储到 points 数组中,供后续绘制折线、填充区域和数据点使用 const step = chartData.length === 1 ? 1 : chartData.length - 1 const points = chartData.map((value, index) => { const x = padding.left + (chartWidth / step) * index // 计算当前点在 Canvas 上的水平位置 // 将数据值 value 转换为 Canvas 上的垂直位置 const y = toY(value / maxValue * chartHeight) // 数据值 → 图表Y → Canvas Y return { x, y, value } }) console.log('[deviceDetail] 坐标点:', points) // ========== 9.1 绘制横坐标垂直刻度线 ========== ctx.setStrokeStyle('rgba(255, 255, 255, 0.4)') // 设置线条颜色为半透明白色(白色,透明度 0.4)。刻度线不会过于突兀,与整体图表风格协调 ctx.setLineWidth(1) // 设置刻度线宽度为 1 像素 for (let i = 0; i < points.length; i++) { if (i % 2 === 0) { // 与时间标签一致,隔一个显示 const x = points[i].x // 获取当前点在 Canvas 上的水平坐标 ctx.beginPath() ctx.moveTo(x, toY(0)) // 将笔触移动到刻度线的起点 ctx.lineTo(x, toY(0) + 5) // 从起点向上画一条直线到 (x, toY(0) + 5) ctx.stroke() } } // ========== 10. 绘制填充区域(折线下方,正确闭合到底部) ========== // 绘制折线图下方的填充区域 ctx.beginPath() // 开始一个新的路径,准备构建多边形 // 将路径起点移动到第一个数据点正下方的横轴位置(图表纵坐标 0 对应的 Canvas 坐标)。toY(0) 是图表底部的 Y 坐标。 ctx.moveTo(points[0].x, toY(0)) // 起始点:左下方(图表Y=0) points.forEach(point => { // 依次连接到每个数据点的曲线位置(point.x, point.y),形成折线的上半部分边界 ctx.lineTo(point.x, point.y) }) ctx.lineTo(points[points.length - 1].x, toY(0)) // 连到右下方 ctx.lineTo(points[0].x, toY(0)) // 闭合到底边,再从右下角底部水平连接到左下角底部,将路径闭合 // 创建一个垂直方向的线性渐变对象,从顶部 padding.top 到底部 height - padding.bottom(即绘图区域的上边缘到下边缘) const fillGradient = ctx.createLinearGradient(0, padding.top, 0, height - padding.bottom) fillGradient.addColorStop(0, 'rgba(255, 255, 255, 0.6)') // 在渐变起点(顶部)添加颜色:白色,透明度 0.6 fillGradient.addColorStop(1, 'rgba(255, 255, 255, 0.1)') // 在渐变终点(底部)添加颜色:白色,透明度 0.1 ctx.setFillStyle(fillGradient) //将填充样式设置为这个渐变 // 填充刚才构建的封闭多边形,实现折线下方区域由白色半透明渐变填充的效果,增强图表的立体感和视觉吸引力 ctx.fill() // ========== 11. 绘制折线(平滑曲线) ========== // 在 Canvas 上绘制一条经过所有数据点的平滑折线,并且线头、连接处采用圆角样式,整体视觉效果更柔和。 ctx.setStrokeStyle('#ffffff') // 设置折线的颜色为纯白色 ctx.setLineWidth(3) // 折线宽度为 3 像素 ctx.setLineCap('round') // 设置线条端点的样式为“圆形”。当线条结束时(例如折线的起点和终点),端点会呈现半圆帽,避免生硬的直角 ctx.setLineJoin('round') // 设置两条线段连接处的样式为“圆形”。在折线拐弯的地方,外角会被处理为圆弧过渡,使曲线看起来更顺滑 ctx.beginPath() // 开始一个新的路径,准备绘制折线 ctx.moveTo(points[0].x, points[0].y) // 将画笔移动到第一个数据点的位置,作为整条折线的起点 // 循环从第二个点(i = 1)开始,依次处理每对相邻的点 (prevPoint, currPoint) for (let i = 1; i < points.length; i++) { const prevPoint = points[i - 1] // 控制点被设置为 前一个点 (prevPoint.x, prevPoint.y) const currPoint = points[i] const cpX = (prevPoint.x + currPoint.x) / 2 // 终点被设置为 前后两点的中点 const cpY = (prevPoint.y + currPoint.y) / 2 // quadraticCurveTo(cpx, cpy, x, y) 是二次贝塞尔曲线方法,它从当前路径的终点(即上一个曲线的终点,或者 moveTo 的点)开始,使用一个控制点 (cpx, cpy),终点被设置为 前后两点的中点 ctx.quadraticCurveTo(prevPoint.x, prevPoint.y, cpX, cpY) } // 循环结束后,最后一段贝塞尔曲线只画到了最后一个中点(即倒数第二个点和最后一个点的中点)。还需要直线连接到最后一个原始数据点,以保证折线完整覆盖所有数据点。 const lastPoint = points[points.length - 1] ctx.lineTo(lastPoint.x, lastPoint.y) ctx.stroke() // ========== 12. 绘制数据点(缩小半径避免密集遮挡) ========== // 根据点间距动态调整半径(间距 = chartWidth / step),但为了简化,固定使用较小半径 // 在 Canvas 图表的每个数据点位置绘制一个三层圆点标记,通过叠加不同颜色、透明度和半径的圆,使数据点具有光晕效果 const pointRadius = { // 定义一个对象,存储三层圆的半径(单位:像素) outerGlow: 5, // 最外层“光晕”的半径(5px) outerWhite: 4, // 中间白色外圈的半径(4px) innerCyan: 2.5 // 最内层青色实心点的半径(2.5px) } points.forEach(point => { // 光晕 ctx.beginPath() // 开始新路径 // 以 (point.x, point.y) 为圆心,半径 5px,绘制一个完整的圆形(弧度 0 到 2π) ctx.arc(point.x, point.y, pointRadius.outerGlow, 0, Math.PI * 2) ctx.setFillStyle('rgba(255, 255, 255, 0.3)') // 填充颜色为白色,透明度 0.3(半透明白色) ctx.fill() // 白色外圈 ctx.beginPath() ctx.arc(point.x, point.y, pointRadius.outerWhite, 0, Math.PI * 2) ctx.setFillStyle('#ffffff') ctx.fill() // 青色内点 ctx.beginPath() ctx.arc(point.x, point.y, pointRadius.innerCyan, 0, Math.PI * 2) ctx.setFillStyle('#1a8fa8') ctx.fill() }) // 13. Canvas 绘制的最终提交与渲染方法,负责将之前所有绘图指令(如 fillRect、stroke、fillText、arc 等)真正绘制到画布上,并在绘制完成后执行回调函数 ctx.draw(false, () => { console.log('[deviceDetail] 图表绘制完成') }) },
效果:

实现点击折线图的数据点弹框的效果:
wxml
<view class="chart-area"> <canvas canvas-id="trendChart" class="trend-chart" bindtap="onChartTap"></canvas> <!-- 数据点弹框(view + z-index 即可覆盖 canvas) --> <view class="data-tooltip" wx:if="{{showTooltip}}" style="left: {{tooltipLeft}}px; top: {{tooltipTop}}px;"> <view class="tooltip-text">负氧离子</view> <view class="tooltip-text">● {{tooltipData.time}} :{{tooltipData.value}}</view> </view> <!-- 时间轴 --> <view class="time-axis"> <text wx:for="{{timeLabels}}" wx:key="*this" wx:if="{{index % 2 === 0}}" class="time-label" style="left: {{(10.67 + index * 84 / (timeLabels.length - 1))}}%;">{{item}}</text> </view> </view>
js
drawTrendChart() { // 1. 创建 Canvas 上下文,第一个参数是 canvas 组件的 canvas-id // ,第二个参数 this 指定组件/页面实例,用于在自定义组件中正确绑定 const ctx = wx.createCanvasContext('trendChart', this) if (!ctx) { console.error('[deviceDetail] 获取 Canvas 上下文失败') return } const chartData = this.data.chartData // console.log(chartData) // 2. 数据校验 if (!chartData || chartData.length === 0) { console.warn('[deviceDetail] 图表数据为空,无法绘制') return } // console.log('[deviceDetail] 开始绘制图表,数据:', chartData) // 3. 计算真实数据范围(基于实际最小最大值) const maxValue = Math.max(...chartData) const minValue = Math.min(...chartData) // console.log(maxValue, minValue) const range = maxValue - minValue || 1 // 避免除零 // console.log('[deviceDetail] 极值:', { maxValue, minValue, range }) // 4. 动态获取屏幕宽度并设置画布尺寸(以 iPhone 6 为基准,可按需调整高度) const systemInfo = wx.getSystemInfoSync() const width = systemInfo.windowWidth // 屏幕逻辑宽度,以适应不同的设备(如 iPhone 12 Pro Max) const height = 110 // 固定高度(可根据实际调整) const padding = { left: 35, right: 20, top: 0, bottom: 10 } const chartWidth = width - padding.left - padding.right // 375 - 5 - 20 = 350 const chartHeight = height - padding.top - padding.bottom // 110 - 0 - 10 = 100 // console.log('图表横纵坐标:', { chartWidth, chartHeight }) // ========== Y 轴坐标转换:图表向上为正 → Canvas 向下为正 ========== // chartY = 0(图表底部) → toY(0) = padding.top + chartHeight(图表的顶部,即canvas 中的底部) // chartY = chartHeight(图表顶部) → toY(chartHeight) = padding.top(canvas 中的顶部) // toY坐标转换函数:将“图表坐标系”(Y 向上为正,原点在左下角)转换为 Canvas 坐标系(Y 向下为正,原点在左上角),chartY表示图表的坐标,toY是将图表的坐标chartY转换成canvas的坐标。chartY范围从 0(图表底部)到 chartHeight(图表顶部),toY为函数名 const toY = (chartY) => (padding.top + chartHeight) - chartY // 5. 清空画布 ctx.clearRect(0, 0, width, height) // ========== 6. 先绘制背景渐变(避免覆盖其他元素) ========== // 创建一个垂直渐变(x 不变,y 从 padding.top 到 height - padding.bottom) const gradient = ctx.createLinearGradient(0, padding.top, 0, height - padding.bottom) // 起始色为较浓的绿松石色(#48D1CC,透明度 0.9),结束色为同色但透明度 0.3 gradient.addColorStop(0, 'rgba(72, 209, 204, 0.9)') gradient.addColorStop(1, 'rgba(72, 209, 204, 0.3)') // 用这个渐变填充整个绘图区域矩形 ctx.setFillStyle(gradient) ctx.fillRect(padding.left, padding.top, chartWidth, chartHeight) // ========== 7. 绘制水平辅助线(虚线),使用 toY 转换 ========== // 在 Canvas 上绘制一组水平的虚线辅助线,帮助用户观察数据点的纵坐标值 ctx.setStrokeStyle('rgba(255, 255, 255, 0.5)') // 设置线条的绘制颜色和透明度 // [8, 4] 表示一个重复的线段样式:先画 8 个像素长的实线,再跳过(空隙)4 个像素,然后重复 ctx.setLineDash([8, 4]) // 设置线条为虚线样式 for (let i = 0; i <= 4; i++) { // 计算当前线条对应的数据值(图表坐标系中的纵坐标值),当 i=0 时,chartY = 0;i=4 时,chartY = maxValue const chartY = (maxValue / 4) * i // 图表坐标:0 → maxValue const y = toY(chartY) // 转为 Canvas 坐标 ctx.beginPath() // 开始一个新的路径。避免与之前的路径重叠,保证每次循环只绘制当前这条水平线 ctx.moveTo(padding.left, y) // 移动笔触到起点:(padding.left, y) 是水平线的左端 ctx.lineTo(width - padding.right, y) // 从当前位置(左端点)画一条直线到右端点 ctx.stroke() // 描边路径 } // 清除虚线设置,恢复为实线样式。后续绘制的其他图形(如折线、坐标轴边框)将使用实线,避免受虚线影响 ctx.setLineDash([]) // 恢复实线 // ========== 8. 绘制纵坐标刻度标签(底部为0,顶部为maxValue) ========== ctx.setFontSize(18) // 设置文本的字体大小 ctx.setFillStyle('rgba(255, 255, 255, 0.7)') // 设置文本的填充颜色及透明度 ctx.setTextAlign('right') //设置文本的水平对齐方式'right':右对齐 ctx.setTextBaseline('middle') // 设置文本的垂直基线对齐方式 'middle':文本中部对齐 // 绘制纵坐标的刻度线和对应的数值标签。它会在绘图区域的左侧边缘,从底部到顶部均匀绘制 5 条短横线(刻度线),并在其左侧显示该刻度代表的数据值 // 刻度值从 0 到 maxValue 等分成 5 份(i=0 底部,i=4 顶部) for (let i = 0; i <= 4; i++) { const chartY = (maxValue / 4) * i // 图表坐标 const y = toY(chartY) // 转为 Canvas 坐标 // i=0: chartY=0(底部),i=4: chartY=maxValue(顶部) const value = chartY // 刻度线 ctx.setStrokeStyle('rgba(255, 255, 255, 0.3)') // 设置线条颜色为半透明白色(白色,透明度 0.3)。用于绘制刻度线 ctx.setLineWidth(1) // 设置刻度线宽度为 1 像素 ctx.beginPath() // 开始一个新的路径,避免与之前的路径重叠 ctx.moveTo(padding.left - 5, y) // 将笔触移动到刻度线的左端点 ctx.lineTo(padding.left, y) // 从当前位置(左端点)画一条直线到右端点 ctx.stroke() // 描边路径,实际绘制出这条短横线(刻度线) // 绘制刻度数值文本 ctx.fillText(Math.round(value), padding.left - 8, y) } // ========== 9. 计算数据点坐标(基于 0-maxValue 归一化,与纵坐标刻度一致) ========== // 处理单数据点情况,避免除零 // 计算折线图中每个数据点在 Canvas 上的精确像素坐标,并将结果存储到 points 数组中,供后续绘制折线、填充区域和数据点使用 const step = chartData.length === 1 ? 1 : chartData.length - 1 const points = chartData.map((value, index) => { const x = padding.left + (chartWidth / step) * index // 计算当前点在 Canvas 上的水平位置 // 将数据值 value 转换为 Canvas 上的垂直位置 const y = toY(value / maxValue * chartHeight) // 数据值 → 图表Y → Canvas Y return { x, y, value } }) console.log('[deviceDetail] 坐标点:', points) // 存储数据点坐标,供点击检测使用 this.setData({ chartPoints: points }, () => { console.log('数据点已存储', this.data.chartPoints) }) // ========== 9.1 绘制横坐标垂直刻度线 ========== ctx.setStrokeStyle('rgba(255, 255, 255, 0.4)') // 设置线条颜色为半透明白色(白色,透明度 0.4)。刻度线不会过于突兀,与整体图表风格协调 ctx.setLineWidth(1) // 设置刻度线宽度为 1 像素 for (let i = 0; i < points.length; i++) { if (i % 2 === 0) { // 与时间标签一致,隔一个显示 const x = points[i].x // 获取当前点在 Canvas 上的水平坐标 ctx.beginPath() ctx.moveTo(x, toY(0)) // 将笔触移动到刻度线的起点 ctx.lineTo(x, toY(0) + 5) // 从起点向上画一条直线到 (x, toY(0) + 5) ctx.stroke() } } // ========== 10. 绘制填充区域(折线下方,正确闭合到底部) ========== // 绘制折线图下方的填充区域 ctx.beginPath() // 开始一个新的路径,准备构建多边形 // 将路径起点移动到第一个数据点正下方的横轴位置(图表纵坐标 0 对应的 Canvas 坐标)。toY(0) 是图表底部的 Y 坐标。 ctx.moveTo(points[0].x, toY(0)) // 起始点:左下方(图表Y=0) points.forEach(point => { // 依次连接到每个数据点的曲线位置(point.x, point.y),形成折线的上半部分边界 ctx.lineTo(point.x, point.y) }) ctx.lineTo(points[points.length - 1].x, toY(0)) // 连到右下方 ctx.lineTo(points[0].x, toY(0)) // 闭合到底边,再从右下角底部水平连接到左下角底部,将路径闭合 // 创建一个垂直方向的线性渐变对象,从顶部 padding.top 到底部 height - padding.bottom(即绘图区域的上边缘到下边缘) const fillGradient = ctx.createLinearGradient(0, padding.top, 0, height - padding.bottom) fillGradient.addColorStop(0, 'rgba(255, 255, 255, 0.6)') // 在渐变起点(顶部)添加颜色:白色,透明度 0.6 fillGradient.addColorStop(1, 'rgba(255, 255, 255, 0.1)') // 在渐变终点(底部)添加颜色:白色,透明度 0.1 ctx.setFillStyle(fillGradient) //将填充样式设置为这个渐变 // 填充刚才构建的封闭多边形,实现折线下方区域由白色半透明渐变填充的效果,增强图表的立体感和视觉吸引力 ctx.fill() // ========== 11. 绘制折线(平滑曲线) ========== // 在 Canvas 上绘制一条经过所有数据点的平滑折线,并且线头、连接处采用圆角样式,整体视觉效果更柔和。 ctx.setStrokeStyle('#ffffff') // 设置折线的颜色为纯白色 ctx.setLineWidth(3) // 折线宽度为 3 像素 ctx.setLineCap('round') // 设置线条端点的样式为“圆形”。当线条结束时(例如折线的起点和终点),端点会呈现半圆帽,避免生硬的直角 ctx.setLineJoin('round') // 设置两条线段连接处的样式为“圆形”。在折线拐弯的地方,外角会被处理为圆弧过渡,使曲线看起来更顺滑 ctx.beginPath() // 开始一个新的路径,准备绘制折线 ctx.moveTo(points[0].x, points[0].y) // 将画笔移动到第一个数据点的位置,作为整条折线的起点 // 循环从第二个点(i = 1)开始,依次处理每对相邻的点 (prevPoint, currPoint) for (let i = 1; i < points.length; i++) { const prevPoint = points[i - 1] // 控制点被设置为 前一个点 (prevPoint.x, prevPoint.y) const currPoint = points[i] const cpX = (prevPoint.x + currPoint.x) / 2 // 终点被设置为 前后两点的中点 const cpY = (prevPoint.y + currPoint.y) / 2 // quadraticCurveTo(cpx, cpy, x, y) 是二次贝塞尔曲线方法,它从当前路径的终点(即上一个曲线的终点,或者 moveTo 的点)开始,使用一个控制点 (cpx, cpy),终点被设置为 前后两点的中点 ctx.quadraticCurveTo(prevPoint.x, prevPoint.y, cpX, cpY) } // 循环结束后,最后一段贝塞尔曲线只画到了最后一个中点(即倒数第二个点和最后一个点的中点)。还需要直线连接到最后一个原始数据点,以保证折线完整覆盖所有数据点。 const lastPoint = points[points.length - 1] ctx.lineTo(lastPoint.x, lastPoint.y) ctx.stroke() // ========== 12. 绘制数据点(缩小半径避免密集遮挡) ========== // 根据点间距动态调整半径(间距 = chartWidth / step),但为了简化,固定使用较小半径 // 在 Canvas 图表的每个数据点位置绘制一个三层圆点标记,通过叠加不同颜色、透明度和半径的圆,使数据点具有光晕效果 const pointRadius = { // 定义一个对象,存储三层圆的半径(单位:像素) outerGlow: 5, // 最外层“光晕”的半径(5px) outerWhite: 4, // 中间白色外圈的半径(4px) innerCyan: 2.5 // 最内层青色实心点的半径(2.5px) } points.forEach(point => { // 光晕 ctx.beginPath() // 开始新路径 // 以 (point.x, point.y) 为圆心,半径 5px,绘制一个完整的圆形(弧度 0 到 2π) ctx.arc(point.x, point.y, pointRadius.outerGlow, 0, Math.PI * 2) ctx.setFillStyle('rgba(255, 255, 255, 0.3)') // 填充颜色为白色,透明度 0.3(半透明白色) ctx.fill() // 白色外圈 ctx.beginPath() ctx.arc(point.x, point.y, pointRadius.outerWhite, 0, Math.PI * 2) ctx.setFillStyle('#ffffff') ctx.fill() // 青色内点 ctx.beginPath() ctx.arc(point.x, point.y, pointRadius.innerCyan, 0, Math.PI * 2) ctx.setFillStyle('#1a8fa8') ctx.fill() }) // 13. Canvas 绘制的最终提交与渲染方法,负责将之前所有绘图指令(如 fillRect、stroke、fillText、arc 等)真正绘制到画布上,并在绘制完成后执行回调函数 ctx.draw(false, () => { // console.log('[deviceDetail] 图表绘制完成') }) }, /** * 点击折线图数据点,弹框显示横纵坐标值 */ onChartTap(e) { console.log('onChartTap triggered', e) const points = this.data.chartPoints if (!points || points.length === 0) { console.log('数据点为空,无法显示弹框') return } const tapX = e.detail.x // 点击位置相对于 canvas 的 CSS 像素坐标 const tapY = e.detail.y console.log('点击坐标', { tapX, tapY }) // 一次性查询 canvas 和 chart-area 的位置信息 wx.createSelectorQuery().in(this) .select('.trend-chart').boundingClientRect() .select('.chart-area').boundingClientRect() .exec((res) => { console.log('查询结果', res) if (!res || !res[0] || !res[1]) { console.log('查询元素信息失败') return } const canvasRect = res[0] const areaRect = res[1] const cssWidth = canvasRect.width const windowWidth = wx.getSystemInfoSync().windowWidth // scale = canvas CSS宽度 / canvas绘图宽度(windowWidth) const scale = cssWidth / windowWidth || 1 console.log('缩放比例', { scale, cssWidth, windowWidth }) // 将点击坐标转换为 canvas 绘图坐标 const drawingX = tapX / scale const drawingY = tapY / scale console.log('绘图坐标', { drawingX, drawingY }) // 查找最近的数据点 let minDist = Infinity let nearestIdx = -1 for (let i = 0; i < points.length; i++) { const dx = drawingX - points[i].x const dy = drawingY - points[i].y const dist = Math.sqrt(dx * dx + dy * dy) console.log(`点${i}距离: ${dist}, 坐标: (${points[i].x}, ${points[i].y})`) if (dist < minDist) { minDist = dist nearestIdx = i } } console.log('最近点索引', { nearestIdx, minDist }) // 点击阈值:绘图坐标 30 像素内 if (minDist > 120 || nearestIdx < 0) { console.log('点击不在数据点附近,隐藏弹框') this.setData({ showTooltip: false }) return } // 计算弹框位置 // tapX和tapY是相对于canvas本身的坐标,需要转换为相对于chart-area的坐标 const left = tapX + (canvasRect.left - areaRect.left) const top = tapY + (canvasRect.top - areaRect.top) - 80 // 向上偏移80px,显示在点击点上方 console.log('弹框位置', { left, top, canvasLeft: canvasRect.left, areaLeft: areaRect.left, tapX, tapY }) const tooltipData = { time: this.data.timeLabels[nearestIdx] || '未知时间', value: points[nearestIdx]?.value || 0 } this.setData({ showTooltip: true, tooltipData: tooltipData, tooltipLeft: left, tooltipTop: top }) console.log('设置弹框数据', this.data.tooltipData) // 3秒后自动隐藏 if (this._tooltipTimer) clearTimeout(this._tooltipTimer) this._tooltipTimer = setTimeout(() => { this.setData({ showTooltip: false }) }, 3000) }) },
wxss
/* ========== 实时曲线图区域 ========== */ .chart-card { /* background: linear-gradient(135deg, rgba(20, 80, 120, 0.95) 0%, rgba(40, 140, 170, 0.9) 100%); */ background-color: #48D1CC; border-radius: 24rpx; padding: 20rpx; margin-bottom: 20rpx; box-shadow: 0 8rpx 30rpx rgba(0, 0, 0, 0.15); } .chart-header { display: flex; justify-content: flex-end; margin-bottom: 20rpx; } .chart-max { font-size: 26rpx; color: rgba(255, 255, 255, 0.9); background: rgba(255, 255, 255, 0.15); padding: 8rpx 20rpx; border-radius: 20rpx; } .chart-area { position: relative; height: 240rpx; overflow: visible; } .trend-chart { width: 100%; height: 200rpx; } .time-axis { position: absolute; bottom: 0; left: 0; right: 0; height: 40rpx; padding-top: 5rpx; } .time-label { position: absolute; font-size: 20rpx; color: rgba(255, 255, 255, 0.8); transform: translateX(-50%); white-space: nowrap; } /* ========== 数据点弹框 ========== */ .data-tooltip { position: absolute; background: rgba(0, 0, 0, 0.78); border-radius: 12rpx; padding: 12rpx 24rpx; z-index: 9999; white-space: nowrap; box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.2); min-width: 120rpx; text-align: center; pointer-events: none; } .data-tooltip::after { content: ''; position: absolute; bottom: -12rpx; left: 50%; transform: translateX(-50%); border: 6rpx solid transparent; border-top-color: rgba(0, 0, 0, 0.78); border-bottom: none; } .tooltip-text { font-size: 24rpx; color: #ffffff; }
效果如下:

七、使用 Canvas 原生 API 手动绘制柱状
代码:
drawBarChart() { const ctx = wx.createCanvasContext('trendChart', this); // 使用 barChart canvas if (!ctx) { console.error('获取canvas上下文失败'); return; } const systemInfo = wx.getSystemInfoSync(); // 左右留白 40px,使图表不贴边 const screenWidth = systemInfo.windowWidth; const canvasWidth = screenWidth - 40; // 固定高度 400px,保证柱状图有足够显示空间 const canvasHeight = 100; const labels = ['1月', '2月', '3月', '4月', '5月', '6月'] // 月份标签 const values = [85, 120, 98, 145, 132, 168] // 数值 this.setData({ timeLabels: labels, chartData: values, maxValue: Math.max(...values) }) if (!this.data.chartData.length || !labels.length) return; // ---------- 1. 边距配置 (逻辑像素) ---------- const padding = { top: 10, // 顶部留白,用于绘制标题和最大值标签 bottom: 10, // 底部留白,用于绘制X轴分类 left: 35, // 左侧留白,用于绘制Y轴刻度文字 right: 20 // 右侧留白,美观 }; const chartWidth = canvasWidth - padding.left - padding.right; const chartHeight = canvasHeight - padding.top - padding.bottom; // ---------- 2. 计算Y轴最大值(对数值数组求最大值,向上取整到10的倍数) ---------- let maxValue = Math.max(...values); // 对数值数组求最大值(不是字符串数组) maxValue = maxValue === 0 ? 100 : maxValue; let yMax = Math.ceil(maxValue * 1.1 / 10) * 10; // 顶部预留10%余量 if (yMax === 0) yMax = 100; // 辅助函数:将数据值转换为 canvas 上的 Y 坐标(原点在左上角,Y向下为正) // value => 图表中的数值, 返回 canvas 中的 y 坐标 const toY = (value) => { const ratio = value / yMax; return padding.top + chartHeight * (1 - ratio); }; // 底部基线 Y 坐标 (值为0时) const baseY = toY(0); // ---------- 3. 清空画布 ---------- ctx.clearRect(0, 0, canvasWidth, canvasHeight); // ---------- 4. 绘制Y轴辅助线(虚线网格)和刻度标签 ---------- ctx.setStrokeStyle('rgba(180, 190, 210, 0.5)'); ctx.setLineWidth(1); ctx.setLineDash([6, 6]); ctx.setFontSize(12); ctx.setFillStyle('#5d6d7e'); ctx.setTextAlign('right'); ctx.setTextBaseline('middle'); const yTickCount = 5; // 包括0和yMax for (let i = 0; i <= yTickCount; i++) { const value = (yMax / yTickCount) * i; const y = toY(value); // 绘制水平辅助线 ctx.beginPath(); ctx.moveTo(padding.left, y); ctx.lineTo(padding.left + chartWidth, y); ctx.stroke(); // 绘制刻度数值 (右侧对齐) ctx.fillText(value.toFixed(0), padding.left - 8, y); } // 恢复实线 ctx.setLineDash([]); // ---------- 5. 绘制X轴分类标签(使用 labels 数组,即月份) ---------- const n = values.length; const groupWidth = chartWidth / n; const barWidth = groupWidth * 0.65; const barSpacing = (groupWidth - barWidth) / 2; ctx.setFontSize(12); ctx.setFillStyle('#5d6d7e'); ctx.setTextAlign('center'); ctx.setTextBaseline('top'); for (let i = 0; i < n; i++) { const x = padding.left + i * groupWidth + groupWidth / 2; const labelY = baseY + 4; let label = labels[i]; // 使用月份标签(不是数值) if (label.length > 4) label = label.slice(0, 3) + '..'; ctx.fillText(label, x, labelY); } // ---------- 6. 绘制X轴基线 ---------- ctx.setStrokeStyle('rgba(180, 190, 210, 0.5)'); ctx.setLineWidth(1); ctx.beginPath(); ctx.moveTo(padding.left, baseY); ctx.lineTo(padding.left + chartWidth, baseY); ctx.stroke(); // ---------- 7. 绘制柱子(渐变色 + 数值标签) ---------- for (let i = 0; i < n; i++) { const value = values[i]; // 使用数值数组(不是字符串数组) const barHeight = (value / yMax) * chartHeight; const barX = padding.left + i * groupWidth + barSpacing; const barY = baseY - barHeight; // 创建垂直渐变 (从上到下,颜色鲜艳) const gradient = ctx.createLinearGradient(barX, barY, barX, barY + barHeight); gradient.addColorStop(0, '#3498db'); gradient.addColorStop(1, '#5dade2'); ctx.setFillStyle(gradient); ctx.fillRect(barX, barY, barWidth, barHeight); // 添加柱子边框,增加立体感 ctx.setStrokeStyle('#2980b9'); ctx.setLineWidth(0.8); ctx.strokeRect(barX, barY, barWidth, barHeight); // 柱顶数值标签(若柱子太矮则显示在柱内上方) ctx.setFontSize(11); ctx.setFillStyle('#2c3e50'); ctx.setTextAlign('center'); ctx.setTextBaseline('bottom'); let textY = barY - 4; if (barHeight < 20) { // 柱子较矮时,数值放到柱子上方避免重叠 ctx.setTextBaseline('bottom'); textY = barY - 4; } else { ctx.setTextBaseline('top'); textY = barY + 4; ctx.setFillStyle('#ffffff'); } ctx.fillText(String(value), barX + barWidth / 2, textY); if (barHeight >= 20) ctx.setFillStyle('#5d6d7e'); } // 最终提交绘制 ctx.draw(false, () => { console.log('柱状图绘制完成'); }); }
效果:

八、使用 Canvas 原生 API 手动绘制饼图
wxml
<canvas canvas-id="pieChart" style="width: {{canvasWidth}}px; height: {{canvasHeight}}px;" width="{{canvasWidth}}" height="{{canvasHeight}}" class="chart-canvas" ></canvas>
js
drawPieChart() { const ctx = wx.createCanvasContext('pieChart', this); if (!ctx) { console.error('获取canvas上下文失败'); return; } const systemInfo = wx.getSystemInfoSync(); const screenWidth = systemInfo.windowWidth; const canvasWidth = screenWidth - 50; // 左右留白 40px const canvasHeight = 100; // 固定高度,保证饼图有足够显示空间 this.setData({ canvasWidth: canvasWidth, canvasHeight: canvasHeight }); const categories = ['电子产品', '服装鞋帽', '食品饮料', '家居生活', '美妆个护'] const values = [35, 28, 42, 19, 31] const colorPalette = [ '#3498db', '#e67e22', '#2ecc71', '#f1c40f', '#9b59b6', '#1abc9c', '#e74c3c', '#34495e', '#16a085', '#d35400' ] this.setData({ timeLabels: categories, chartData: values, colorPalette: colorPalette }) if (!categories.length || !values.length) return; // 1. 计算总和 const total = values.reduce((sum, val) => sum + val, 0); if (total === 0) { console.warn('数据总和为0,无法绘制饼图'); return; } // 2. 饼图绘制区域配置(居中绘制,半径自适应) const centerX = canvasWidth / 2; const centerY = canvasHeight / 2; // 半径取画布宽高较小者的 35%,同时为图例预留右侧空间 const radius = Math.min(canvasWidth, canvasHeight) * 0.38; // 起始弧度(从数学的右侧水平轴开始,逆时针为正,但Canvas弧度0表示右侧,我们设起始角为 -90° 让饼图从顶部开始比较美观) let startAngle = -Math.PI / 2; // 从12点钟方向开始 // 3. 清空画布并设置白色背景 ctx.clearRect(0, 0, canvasWidth, canvasHeight); ctx.setFillStyle('#ffffff'); ctx.fillRect(0, 0, canvasWidth, canvasHeight); // 4. 绘制每一个扇形(同时累计百分比用于标签) const angles = []; // 存储每个扇形的起始和结束弧度,用于后续标签绘制 for (let i = 0; i < values.length; i++) { const value = values[i]; const angle = (value / total) * Math.PI * 2; const endAngle = startAngle + angle; // 记录弧度区间 angles.push({ start: startAngle, end: endAngle, value, category: categories[i] }); // 设置扇形填充颜色(循环使用颜色池) const color = colorPalette[i % colorPalette.length]; ctx.setFillStyle(color); // 开始绘制扇形路径 ctx.beginPath(); ctx.moveTo(centerX, centerY); ctx.arc(centerX, centerY, radius, startAngle, endAngle); ctx.closePath(); // 闭合路径(从弧终点回到圆心) ctx.fill(); // 可选:添加白色分隔线(增强立体感) ctx.setStrokeStyle('#ffffff'); ctx.setLineWidth(1.5); ctx.stroke(); // 更新起始弧度 startAngle = endAngle; } // 5. 在扇形中央添加百分比标签(避开过小的扇形,避免文字重叠) ctx.setFontSize(12); ctx.setFillStyle('#ffffff'); ctx.setTextAlign('center'); ctx.setTextBaseline('middle'); for (let i = 0; i < angles.length; i++) { const seg = angles[i]; const midAngle = seg.start + (seg.end - seg.start) / 2; const percent = (seg.value / total) * 100; // 只显示百分比大于 5% 的标签,避免拥挤 if (percent >= 5) { // 计算标签坐标(距离圆心 radius * 0.7 的位置) const labelRadius = radius * 0.65; const x = centerX + labelRadius * Math.cos(midAngle); const y = centerY + labelRadius * Math.sin(midAngle); ctx.fillText(percent.toFixed(1) + '%', x, y); } } // 6. 绘制图例(右侧或底部,根据画布宽度自适应) // 图例配置 const legendX = canvasWidth - 100; let legendY = 40; const legendItemHeight = 22; ctx.setFontSize(11); ctx.setTextAlign('left'); ctx.setTextBaseline('middle'); for (let i = 0; i < categories.length; i++) { const color = colorPalette[i % colorPalette.length]; const category = categories[i]; const value = values[i]; const percent = ((value / total) * 100).toFixed(1); // 绘制色块 ctx.setFillStyle(color); ctx.fillRect(legendX, legendY + i * legendItemHeight, 12, 12); // 绘制文字 ctx.setFillStyle('#2c3e50'); ctx.fillText(`${category} ${percent}%`, legendX + 18, legendY + i * legendItemHeight + 6); } // 如果图例总高度超过画布底部,则适当调整起始位置(简单处理,确保不越界) const legendTotalHeight = categories.length * legendItemHeight; if (legendY + legendTotalHeight + 20 > canvasHeight) { // 改为从底部向上排列(可选,本示例不做复杂处理,仅提醒) // 实际可根据需要动态计算起始Y,这里简单将图例顶部对齐到画布底部减去总高度 const newLegendY = canvasHeight - legendTotalHeight - 20; if (newLegendY > 20) { // 重绘图例在底部(为了简洁,不再重绘,开发者可优化) } } // 7. 绘制中心标题(可选项:显示总销售额) ctx.setFontSize(14); ctx.setFillStyle('#34495e'); ctx.setTextAlign('center'); ctx.setTextBaseline('middle'); ctx.fillText('销售总额', centerX, centerY - 8); ctx.setFontSize(16); ctx.setFillStyle('#e67e22'); ctx.fillText(total + ' 万元', centerX, centerY + 12); // 8. 绘制外边框(可选,增加精致感) ctx.beginPath(); ctx.arc(centerX, centerY, radius + 1.5, 0, Math.PI * 2); ctx.setStrokeStyle('#dfe6e9'); ctx.setLineWidth(1); ctx.stroke(); // 最终提交绘制 ctx.draw(false, () => { console.log('饼图绘制完成'); }); }
效果

浙公网安备 33010602011771号