官网地址: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(110, 60):增加一个新点,然后创建一条从上次指定点到目标点的线
ctx.fill():填充
ctx.stroke():描边
(1)、路径描边stroke
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] 图表绘制完成')
    })
  },

效果:

image

实现点击折线图的数据点弹框的效果:

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;
}

效果如下:

image

 

七、使用 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('柱状图绘制完成');
    });
  }

效果:

image

八、使用 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('饼图绘制完成');
    });
  }

效果

image

 

posted on 2026-06-13 07:25  周文豪  阅读(98)  评论(0)    收藏  举报