Canvas

画布是基于矢量图形库实现的一套 JS 绘图接口。

画布使用亚像素精度的抗锯齿算法进行绘图,可用于各种图表和几何图形的绘制。

使用 画布的流程为:

  1. 使用设计器在 UI 中添加 Canvas 控件
  2. 对画布控件使用 pm.createCanvasContext 方法来创建画布上下文
  3. 在画布上下文中添加绘制路径
  4. 导出路径

上述流程均不会产生绘制(矢量路径的光栅化)行为, 画布只有在重绘事件到来时才会绘制已经导出的路径,因此 画布完全是被动绘制的。

canvas 的基类是 widget , widget 属性及接口详细请查看 widget 控件介绍。基础属性中某些属性在canvas 控件中无效(可以设置这类接口属性,但并无效果,显示状态上并无任何变化,因此可以忽略此类属性),不可用属性有:

  • foreground:控件前景色
  • font:字体属性
  • textAlign:文本对齐方式

使用流程

添加 Canvas 控件

只需使用 Persimmon UI Builder 软件将一个 Canvas 控件拖拽到某个 Page 中即可添加一个画布控件。我们唯一要注意的是需要记住该控件的 ID(这里假设为 Canvas1)。

Canvas 控件属性

通过 page.setData 方法可以为 Canvas 控件设置属性,Canvas 控件的继承自 Widget,可以使用所有 Widget 的属性。此外它还支持以下属性:

属性 类型 说明
buffer Boolean 该参数为 true 时,画布控件将使用一个背景缓冲,该缓冲区会保存画布绘制后的图像从而减少渲染次数。当画布不需要频繁更新内容时,使用背景缓冲区可以提高绘图性能¹,但是该缓冲区的内存占用较大²。
使用背景缓冲区时,在 ctx.draw() 调用后的第一次渲染中进行,此后将一直使用缓冲的位图,直到下次调用该方法。

¹ 指需要对画布控件进行拖拽的场景
² 内存占用 ≈ Canvas 宽度 × Canvas 高度 × 4 Bytes

创建画布上下文

在 Page 的 JS 代码中创建画布上下文,原则上可以在任何位置创建画布上下文。以下 API 用于创建画布上下文:

pm.createCanvasContext(id, page);

page 为当前的 Page 对象,一般为 thisid 为目标 Canvas 控件的 ID。该方法的返回值是一个画布上下文对象。

注意:画布上下文必须对画布控件进行创建,否则会引起程序崩溃。

导出路径

通过画布可以实现折线、曲线、填充等绘图效果,这些图形都使用线段、贝塞尔曲线、弧等基本图元的线条和填充来实现,每个图元就构成了一条“路径”。一条路径通常通过一些顶点来定义,例如线段由两个端点来定义;贝塞尔曲线由控制点和端点定义;弧由中心、短轴半径、长轴半径、起始角度、结束角度来定义。

一组路径所构成的图形还受其他某些属性的影响,本节将介绍所有和路径有关的 API。

API 说明

填充样式 API

setFillStyle

该 API 设置填充样式,目前只支持设置填充颜色。颜色使用 HTML 风格的颜色字符串来表示,例如 '#FF0000' 或者 'red'。简化的颜色代码目前不受支持,例如 '#f00' 将无法使用。

var ctx = pm.createCanvasContext('Canvas1', this)
ctx.moveTo(20, 20);             // 移动到 (20,20) 位置
ctx.rectTo(50,50);              // 从(20,20) 绘制矩形到(50,50)位置;一个 30 x 30 大小的矩形
ctx.setFillStyle('red')         // 设置填充样式为红色,与下一行效果一致
ctx.setFillStyle('#FF0000')
// ...

setSourceImage

设置前景图片,该方法会取代 setFillStyle 对前景色样式的设置,该方法有两种调用方法:

ctx.setSourceImage(url)
ctx.setSourceImage()

带参数的版本会将 url 所指向的图片文件作为前景色,url 实际上是相对于 resources 文件夹的文件路径。不带参数的版本会清除上下文中的前景图片,此后将继续使用由 setFillStyle 定义的前景样式。

setLineWidth

设置绘制线条的宽度,在填充模式下线宽属性是无效的。线宽的单位为像素,画布还支持小数线宽且能够正确地绘制。

var ctx = pm.createCanvasContext('Canvas1', this)
ctx.setLineWidth(1.5)
ctx.lineTo(20, 20)
// ...

setLineCap

设置线帽形状,线帽形状同样只在线条绘制模式下可用。线帽有三种形状:

  • butt:无线帽,线条的端点是简单的直线(对应下图的 FlatCap),没有任何突出的线帽形状。
  • square:矩形线帽,线条的端点处会伸出一小段矩形(长度为线宽的一半)作为线帽。
  • round:圆形线帽,线条的端点会伸出一个圆形的线帽。

在两条线段的连接处也有类似的处理策略,常用的线条连接样式有:

  • butt:斜连接,对应下图的 MiterJoin,连接处会有一个尖角。
  • square:斜边连接,对应下图的 BevelJoin,连接处使用一条斜线来代替斜连接的尖角。
  • round:圆形连接,对应下图的 RoundJoin,使用一个弧来连接两条线段。

var ctx = pm.createCanvasContext('Canvas1', this)
ctx.setLineCap('round')
ctx.lineTo(20, 20)
// ...

目前只支持 buttsquare 线帽形状,线条链接样式也不可用

setFontSize

设置当前字体的尺寸,单位为像素。目前不支持自定义字体。

var ctx = pm.createCanvasContext('Canvas1', this)
ctx.setFontSize(16)
ctx.text('- 123 abc -')
// ...

setTextAlign

设置文本的水平对齐方式,具体的对其方案如下(区分大小写):

  • 'left': 文本以当前画笔位置为基准左对齐
  • 'center': 文本以当前画笔位置为基准中心对齐
  • 'right': 文本以当前画笔位置为基准右对齐
var ctx = pm.createCanvasContext('Canvas1', this)
ctx.setTextAlign('right')
ctx.text('- 123 abc -')
// ...

setTextBaseline

设置文本的竖向对齐方式,具体的对其方案如下(区分大小写):

  • 'top': 文本以当前画笔位置为基准顶端对齐
  • 'center': 文本以当前画笔位置为基准中心对齐
  • 'bottom': 文本以当前画笔位置为基准底端对齐
  • 'normal': 文本以当前画笔位置为基准进行字体基线对齐。
var ctx = pm.createCanvasContext('Canvas1', this)
ctx.setTextBaseline('normal')
// ...

路径 API

moveTo

移动画笔到当前位置。该方法会闭合先前的路径并设置新的路径起点。任何一条路径都从起点开始,直到下一次调用 moveTo 方法或者被分组时结束。

var ctx = pm.createCanvasContext('Canvas1', this)
ctx.moveTo(10, 10)
// 从坐标(10,10)绘制到(20,20)的一条斜线
ctx.lineTo(20, 20)
// ...

lineTo

绘制一条直线,该直线从上一个顶点开始,到当前顶点结束。lineTo 方法不能在空的路径中调用,通常一组路径都通过调用 moveTo 开始。

var ctx = pm.createCanvasContext('Canvas1', this)
ctx.moveTo(10, 10)  // 直线的起点
ctx.lineTo(100, 40) // (10, 10) --> (100, 40)
ctx.lineTo(40, 60)  // (100, 40) --> (40, 60)
// ...

rectTo

绘制一个矩形,该矩形从上一个顶点开始,到当前顶点结束。可以使用变换 API 来变换该矩形的形状。

var ctx = pm.createCanvasContext('Canvas1', this)
ctx.moveTo(10, 10)  // 直线的起点
ctx.rectTo(100, 40) // (10, 10) --> (100, 40)
// ...

arc

绘制一个弧。该方法的原型为

arc(xc, yc, rx, ry, angle1, angle2)

var ctx = pm.createCanvasContext('Canvas1', this)
// 在(50,50)位置绘制一个直径为 100 的圆,
ctx.arc(50, 50, 50, 50, 0, 360)
  • xcyc 为弧的中心坐标
  • rxry 为弧的横轴和纵轴半径
  • angle1angle2 为弧的起始角度和结束角度(角度制)。角度的计算以最右端为 0 度,顺时针方向为正角度,一周为 360°。如果结束角度小于开始角度,将会逆时针绘制。

如果在绘制弧形之前没有调用 moveTo 设置起点,arc 方法会以弧的起点位置作为路径的起点,否则会在弧之前的顶点和弧的起点之间使用一条线段进行连接。

如果要绘制圆或者椭圆,将起始角度设置为 0,结束角度设置为 360 即可。实际上,只要起止角度差的绝对值超过 360 度即可。绘制椭圆时,只能绘制水平的椭圆而不支持倾斜和旋转。

var ctx = pm.createCanvasContext('Canvas1', this)
ctx.arc(100, 100, 50, 50, 0, 240)
// ...

curve2

绘制二次贝塞尔曲线,该方法的原型为:

ctx.curve2(x1, y1, x2, y2)
  • 曲线的起点为上一个路径的终点
  • x1y1 为控制点的坐标
  • x2y2 为结束点的坐标
  • 如果(x1, y1)与(x2, y2)一致将绘制一条直线

curve3

绘制三次贝塞尔曲线,该方法的原型为:

ctx.curve3(x1, y1, x2, y2, x3, y3)
  • 曲线的起点为上一个路径的终点
  • x1y1 为第一个控制点的坐标
  • x2y2 为第二个控制点的坐标
  • x3y3 为结束点的坐标
  • 如果(x1, y1)、(x2, y2)与(x3, y3)一致将绘制一条直线

text

在当前画笔位置绘制一段文本,如果需要对文本进行变换,需要使用路径变换 API 进行操作。目前只支持 ASCII 码。

var ctx = pm.createCanvasContext('Canvas1', this)
ctx.moveTo(50, 50)
ctx.text('Hello World!')
// ...

arctext

在当前画笔位置绘制一段文本,与 text 方法相比,arctext 方法可以设定文本的弯曲半径和旋转角度。该方法的原型如下,目前只支持 ASCII 码:

ctx.arctext(string)
ctx.arctext(string, radius)
ctx.arctext(string, radius, rotate)

string 为待绘制的文本;radius 为文本的弯曲半径,其值为正时文本两端将向下弯曲,为负时向上弯曲,为 0 时不弯曲;rotate 参数会使文本按照其对齐点(例如居中对齐时的对齐点在文本中心)进行旋转,值为正时顺时针旋转,值为负时逆时针旋转。

只有一个参数时,该方法和 text 方法等效;使用 2 个参数时,文本将不进行旋转。

路径变换 API

某些情况下,仅使用路径 API 很难满足需要,例如 arc 方法不能绘制倾斜的椭圆。为此我们引入了一套路径变换 API 实现平移、旋转、缩放、错切等变换。每一种变换都由一个方法来实现,一个复杂的变换可以由以上基本的变换级联而来。

需要注意的是,变换的级联顺序会影响最终结果。以一个正方形形为例:定义变换 R 为逆时针旋转 30°;变换 S 为对水平分量放大两倍,垂直分量缩小两倍。下图展示了对正方形进行两种变换的不同结果:

上一组图片演示了先进行 R 变换再进行 S 变换的结果,而下一组图片演示先进行 S 变换再进行 R 变换的结果。两组变换的结果明显不同。

translate

对路径进行平移变换,方法原型为

ctx.translate(tx, ty)

ctx.moveTo(20, 20);
ctx.rectTo(50, 50);
// 将矩形向右平移 50 像素
ctx.translate(100,50);

tx 为水平方向上的平移像素数,ty 为垂直方向上的平移像素数。

rotate

对路径进行旋转变换,方法原型为:

ctx.rotate(angle)

// 在坐标(30,30)处绘制一个 20 * 20 的矩形
ctx.moveTo(30, 30)
ctx.rectTo(50, 50)
// 按照矩形的中心点位置移动到原点
ctx.translate(-40, -40)
// 围绕原点进行角度旋转
ctx.rotate(45)
// 将旋转后的矩形移动到原来的位置
ctx.translate(40, 40)

参数 angle 为旋转的角度,单位为角度制,正角度的方向为顺时针。围绕原点进行旋转,即画布(0, 0)位置

scale

对路径进行缩放变换,方法原型为:

ctx.scale(sx, sy)

// 将矩形放大未原来的 1.5 倍
ctx.moveTo(20, 20);
ctx.rectTo(50, 50);
ctx.scale(1.5,1.5);

参数 sx 为水平方向上的缩放比例,sy 为垂直方向上的缩放比例。缩放比例为 1 时即为原大小,小于 1 时缩小,大于 1 时放大,缩放比例为负数则会翻转路径。

shear

对路径进行错切变换,方法原型为:

ctx.shear(sh, sv)

sh 为水平方向上的错切量,当 sh 为正时将向右错切,否则向左错切;sv 为垂直方向上的错切量。其值为正时向下错切,否则向上错切。可以理解为矩形变成平行四边形的意思。

transformReset

复位所有的路径变换操作,该方法无参数。

ctx.transformReset();

路径分组 API

路径分组操作会将当前未分组的所有路径合并为一组,一组路径具有相同的颜色、线形、线宽、变换等属性。如果要对两个路径使用不同的属性,必须将它们分别分组。在使用路径分组命令的同时也确定了对这组路径使用的绘制方案:画线、填充或是闭合线条等。

stroke

最基础的路径分组命令,将当前的所有路径分组并保存。stroke 分组的路径将使用线条的方式来绘制。

var ctx = pm.createCanvasContext('Canvas1', this)
ctx.setFillStyle('red')
ctx.setLineCap('round')
ctx.setLineWidth(8)
ctx.arc(100, 80, 80, 60, -90, 230)
ctx.stroke() // 路径分组
ctx.draw()

fill

将当前所有的路径分组并保存,与 stroke 方法不同的是,fill 会使用填充的方式来绘制,且该方法会将所有的路径闭合起来(所有被绘填充的路径必须是闭合的)。

var ctx = pm.createCanvasContext('Canvas1', this)
ctx.setFillStyle('red')
ctx.moveTo(100, 80)
ctx.arc(100, 80, 80, 60, -90, 230)
ctx.fill() // 路径分组
ctx.draw()

closePath

stroke 方法相似,只不过它会将所有的路径闭合起来。

var ctx = pm.createCanvasContext('Canvas1', this)
ctx.setFillStyle('#800080')
ctx.setLineWidth(5)
ctx.moveTo(100, 80)
ctx.arc(100, 80, 80, 60, -90, 230)
ctx.moveTo(140, 100)
ctx.arc(120, 100, 20, 20, 0, 360)
ctx.closePath() // 路径分组
ctx.draw()

路径导出 API

draw

导出所有路径,调用该方法后会保存当前画布上下文的所有路径并在画布控件重绘时绘制。在调用该方法时,还未被分组的路径将进行 stroke 分组。因此调用该方法后可以销毁画布上下文(不需要手动销毁,通过 JS 的作用于机制维护即可)。

注意事项

在一组路径中会使用相同的线宽、线帽和填充样式属性,如果需要对像个路径使用不同的属性,需要用 strokefill 或者 closePath 方法来进行分组。但是为了提高绘图性能,应该尽量减少分组操作,只需要将路径切断的情况下应该使用 moveTo 方法来移动画笔位置(而不是调用 stroke 一类的方法)。

画布支持亚像素的绘图精度,因此所有的坐标、长度和角度值都可以使用小数。

所有的绘图操作结束后一定要调用 draw 方法来导出路径,否则上下文中的路径不会绘制。在调用 draw 之前未分组的路径会自动使用 stroke 进行分组。

建议在调用 draw 方法之后销毁画布上下文,这意味着在通常情况下将画布上下文作为一个局部变量进行创建即可。

例子

曲线绘制

三次贝塞尔曲线

var ctx = pm.createCanvasContext('Canvas1', this)
// 绘制控制点之间的直线
ctx.setFillStyle('black')
ctx.setLineWidth(1)
ctx.moveTo(30, 100)
ctx.lineTo(50, 60)
ctx.moveTo(140, 30)
ctx.lineTo(150, 90)
ctx.stroke()
// 绘制贝塞尔曲线
ctx.setFillStyle('#A0C000C0')
ctx.setLineWidth(12)
ctx.moveTo(30, 100)
ctx.curve3(50, 60, 140, 30, 150, 90)
ctx.stroke()
// 绘制控制点
ctx.setFillStyle('#0040FF')
ctx.arc(30, 100, 2, 2, 0, 360)
ctx.fill()
ctx.arc(50, 60, 2, 2, 0, 360)
ctx.fill()
ctx.arc(140, 30, 2, 2, 0, 360)
ctx.fill()
ctx.arc(150, 90, 2, 2, 0, 360)
ctx.fill()
// 导出路径
ctx.draw()

这个例子的显示效果如下:

二次贝塞尔曲线

var ctx = pm.createCanvasContext('Canvas1', this);
ctx.setFillStyle('black')
ctx.setLineWidth(1)
!function(x, y) {
    // 所有的控制点
    x = [x, x + 80, x + 160, x + 240, x + 320]
    y = [y, y - 150, y, y + 150, y]
    // 连接第一个贝塞尔曲线的控制点
    ctx.setFillStyle('red')
    ctx.moveTo(x[0], y[0])
    ctx.lineTo(x[1], y[1])
    ctx.lineTo(x[2], y[2])
    ctx.stroke()
    // 连接第二个贝塞尔曲线的控制点
    ctx.setFillStyle('blue')
    ctx.moveTo(x[2], y[2])
    ctx.lineTo(x[3], y[3])
    ctx.lineTo(x[4], y[4])
    ctx.stroke()
    // 绘制两条贝塞尔曲线(直接相连)
    ctx.setFillStyle('black')
    ctx.setLineWidth(2)
    ctx.moveTo(x[0], y[0])
    ctx.curve2(x[1], y[1], x[2], y[2])
    ctx.curve2(x[3], y[3], x[4], y[4])
    ctx.stroke()
    // 绘制所有控制点(绘制为小圆点)
    for (var i = 0; i < 5; ++i) {
        ctx.setFillStyle(i == 3 ? 'blue' : 'red')
        ctx.arc(x[i], y[i], 3, 3, 0, 360)
        ctx.fill()
    }
} (195 - 160, 195) // 图案的坐标
// 导出路径
ctx.draw();

显示效果如下:

技巧

具有弧形边的三角形

填充一个底边是弧形的等边三角形时可以先将画笔移动到三角形的顶点,再绘制底边的弧,最后执行填充操作即可:

var ctx = pm.createCanvasContext('Canvas1', this);
ctx.setFillStyle("#C0800080");
ctx.moveTo(40, 30);
ctx.arc(-30, 30, 50, 50, -15, 15);
ctx.fill();
ctx.draw();

这样我们就可以得到一个底边是弧的三角形:

倾斜的椭圆弧

arc 方法不支持设置倾角参数,但是我们可以使用路径变换来实现这个需求:

var ctx = pm.createCanvasContext('Canvas1', this);
ctx.setFillStyle("#A000A0");
ctx.setLineWidth(2);
// 定义路径
ctx.moveTo(-90, 0);                 // 路径起点
ctx.arc(-40, 0, 20, 20, 180, 0);    // 较小的半圆弧
ctx.arc(30, 0, 30, 30, 180, 0);     // 较大的半圆弧
ctx.lineTo(90, 0);                  // 路径终点
// 对路径进行变换
ctx.scale(0.8, 1.2);                // 通过对 x, y 方向的不同缩放比例产生椭圆
ctx.rotate(-30);                    // 旋转图案
ctx.translate(80, 50);              // 移动到合适的位置
ctx.stroke();
ctx.draw();

将输出以下图案:

在这个例子中,我们先围绕原点 (0, 0) 定义路径,该路径包含两个半圆弧和几段直线。以原点为中心的路径可以很方便的旋转(旋转之后位置不会偏移)。我们先通过 scale 方法来将这个图案压扁,也就是产生椭圆,然后再进行旋转,最后将其平移到适合显示的位置。

宽度较大的弧

直接使用画线模式绘制大线宽的弧可能无法得到很好的效果,例如使用以下代码绘制圆弧

ctx.setLineWidth(40);
ctx.arc(60, 10, 30, 30, 0, 180);
ctx.stroke();

将得到这样的效果:

很明显,半圆弧的起始和终点处的边应该是水平的,但是这里却倾斜了。在绘制环状饼图的时候要求所有扇形的边都要紧紧相连,普通的 arc 方法使用形式将无法满足这类需求。更好的做法是使用两个同心圆弧线的填充来绘制圆弧:

ctx.setFillStyle("#A000A0");
ctx.arc(60, 10, 10, 10, 0, 180); // 内圈的轮廓
ctx.arc(60, 10, 50, 50, 180, 0); // 外圈的轮廓
ctx.fill();

注意内外轮廓弧线的绕行方向要相反才能按照正确的路径进行填充。现在我们将得到下面的弧: