最近比较忙,所以趁着今年的最后一个周末,来把最后两篇完结掉。这是倒数第二篇了,这一篇的内容很简单,讲的是细节的绘制,也就是底部的刻度线和日期的绘制,以及一个小小的总结。
目前我们完成了canvas的准备、折线图的绘制、价格圆点的绘制、价格和拼团人数的标注,可以说是几乎已经完成了,目前效果是这样的——
也就是,下方的横轴上是光秃秃的,我们现在来加上合适的内容。
一、刻度线和日期的绘制
如你所见,图上有灰色的坐标轴。因为它带有箭头,用canvas画出来比较困难,而且又不重要,只是个背景板,不如直接用图片做吧,所以我是放了个图在canvas的下面一层。这个横轴之前还是有自带一些刻度线的,我只好用PS把这样线给P掉,把底部还原成光滑直线才进行这一步。
绘制刻度线要解决一个问题,那就是绘制的位置。
为什么会有刻度线,肯定是因为这里有拐点吧,所以刻度线的中心部位与拐点的x坐标相同,那么在知道拐点坐标后,就知道了刻度线的x位置。另外,特殊点下方这里没有刻度线,所以我只会在非特殊点的时候来绘制刻度。还要注意一个细节,那就是刻度线的粗细。所以刻度线最左边的x坐标并不等于拐点的x坐标,而是等于拐点x坐标再减去刻度线的粗细的一半,它最右边的坐标等于拐点x坐标再加上刻度线粗细的一半。
刻度线的y坐标在哪里呢?显然底部位于横轴上,顶部的y等于横轴y减去刻度线的高度,并且所有的刻度线y值相同。所以底部的y是多少呢?canvas的高度吗?不不不,由于刻度线下面还要写日期,所以canvas下方还留了一定的空间,所以底部的y就是canvas的高度减去底部空间的高度。
刻度线虽然很细,但我仍然把它当成一个实心矩形,所以我是找好了这4个点(左上,右上,右下,左下)的坐标,围成一个矩形,然后填充颜色的。
因为我定义的刻度线宽度为4rpx,高度为16rpx,颜色为#eee,所以我绘制刻度线的函数是这样的——
/**
* 画出坐标轴的刻度
* @param {*} context 上下文对象
* @param {*} center 拐点信息
* @param {*} height 画布高度
*/
function drawMark(context, center, height) {
context.beginPath();
console.log(height)
// 底部显示文字区域的高度
const bottomPos = 72 * _this.data.toPx;
context.moveTo(center.x - 2 * _this.data.toPx, height - (bottomPos + 16 * _this.data.toPx));
context.lineTo(center.x + 2 * _this.data.toPx, height - (bottomPos + 16 * _this.data.toPx));
context.lineTo(center.x + 2 * _this.data.toPx, height - bottomPos);
context.lineTo(center.x - 2 * _this.data.toPx, height - bottomPos);
context.closePath()
context.fillStyle = "#eaeaea"
context.fill();
context.draw(true);
}
日期的绘制比较简单,只要注意日期与刻度线水平居中对齐就好了。也就只有一个要点,日期的中间位置与拐点坐标x相同。所以只需要在设置好日期的字体样式后量取文字的宽度,把字体起点的x坐标设为拐点x坐标减去文字宽度的一半即可。
代码是这样的——
/**
* 画出刻度线下的日期
* @param {*} context 上下文对象
* @param {*} center 拐点信息
* @param {*} height 画布高度
*/
function drawDate(context, center, height) {
// 写出刻度对应的日期
context.beginPath();
context.setFontSize(28 * _this.data.toPx);
context.fillStyle = '#333333';
// 计算文字的宽度
var textWidth = getTextWidth(context, center.date)
// 确保文字显示在刻度的正中间
context.fillText(center.date, center.x - (textWidth / 2), height - 30 * _this.data.toPx);
context.draw(true);
}
二、原价和箭头的绘制
可以说这个是非常微不足道的细节了,仅仅只是在左上角画几个字和一个图而已,需要用到的方法是context的fillText和drawImage这两个。
需要注意的地方是原价的对齐问题和drawImage的参数。
原价的左上角位置很好找,在图上两个差不多的地方就可以了。注意一下,原价文字的水平方向的中点在哪里,因为箭头与文字要水平居中对齐。显然,应该是原价绘制起点的x坐标加上原价文字宽度的一半。
drawImage的第一个参数是要绘制的图片,如果是本地图片只需要写路径即可,如果是在线图片需要将图片下载下来才可以绘制,我这里用的就是本地图片。第二和第三个参数是要绘制图片左上角的地方,第四个和第五个参数是图片要画多大。我就使用了前五个参数。绘制图片左上角的y显然比绘制原价文字的y要大一些,那么x坐标呢?显然并不是等于之前的文字中心点,而是在文字中心点往左平移一半图片宽度的地方。
解决完这两个问题,答案就呼之欲出了。
/**
* 写出原价,返回原价的宽度
* @param {*} context 上下文对象
* @param {*} originalPrice 原价
* @param {*} x 原价的横坐标,单位是px
* @param {*} y 原价的纵坐标,单位是px
*/
function drawOriginPrice(context, originalPrice, x, y) {
context.beginPath();
context.fillStyle = '#333333';
context.font = 'normal bold 16px sans-serif';
context.fillText('原价' + originalPrice, x, y);
context.draw(true);
// 计算并返回原价的宽度
return getTextWidth(context, '原价' + originalPrice)
}
/**
* 绘制图片,如这里的红色箭头
* @param {*} context 上下文对象
* @param {*} imgPath 要绘制的图片路径
* @param {*} x 图片最左边的坐标,单位是px
* @param {*} y 图片最上边的坐标,单位是px
* @param {*} imgWidth 图片宽度,单位是rpx
* @param {*} imgHeight 图片高度,单位是rpx
*/
function drawImage(context, imgPath, x, y, imgWidth, imgHeight) {
context.drawImage(imgPath, x, y, imgWidth *_this.data.toPx, imgHeight*_this.data.toPx);
context.draw(true);
}
三、主函数
其实我是在绘制完所有东西后,才拆分成主函数和子函数的。这样拆分的好处有几个:
1.让我们调用函数的时候只需要跟主函数的打交道,所以调用的时候只需要考虑主函数的参数即可,也就是只需要传入我们的数据、canvas边界相对于绘制折线图区域的边界四个方向上的距离(如果不留,那么会紧贴这canvas的边缘开始绘图)和特殊点的下标即可。这些数据都是比较直观的,很容易理解。
2.让流程更清晰。在主函数里面,我们就按照流程的先后顺序,去调用执行相关功能的子函数了,在主函数里面,用一行代码就可以代表一个流程。我把流程分为4大步(绘制折线图、绘制拐点、绘制原价和绘制箭头),第二步又分成几个小步,每一小步都是调用某个子函数。
3.让调试更方便。现在不再是一大坨代码放在一个函数里了,而是让这些执行功能的子函数获取主函数传入的参数,并把在其他子函数会使用的东西返回给主函数。哪个流程如果出问题,看结果就知道是哪个子函数的问题了。
拆分的过程我也说一下:
1.在主函数中留下我们传入的参数,与子函数需要的数据。还要留下主流程,比如绘制所有的拐点,以及判断当前是否为特殊点这些属于主流程,而不是单独的子功能。然后观察一下,判断要拆分出几个子函数。
2.分析哪些代码属于某个子功能,为它创建对应的子函数,并且把这一段代码直接拷到子函数中。
3.判断一下,这个子函数使用到了哪些数据,这些数据怎么来的,要么是来自页面的data,要么是来自主函数的传参。因为子函数都是在绘图,必须使用context,所以我规定第一个参数都为context,之后的参数就根据需要,让主函数传入。所以我定义了相应的形参,并写到了函数的文档注释中。
4.如果子函数得出了在其他子函数里也需要的数据,那么要把这个数据作为子函数的返回值返回给主函数。比如绘制折线图的函数得到了拐点数据,这是几乎每个子函数都要使用的哦东西,必须返回给主函数才可以正常绘图。
5.对每个独立的功能都这么做,然后在主函数这边以合理的顺序调用这些子函数即可。
最后,只需要准备好相应的数据,在onLoad的时候调用主函数即可完成绘制!
最后贴一下,主函数的代码。
/**
* 这个是绘图的主函数
* @param {*} arr 每个值组成的数组
* @param {*} xLeft 折线图最左边与画布边界的距离,单位是px,默认为0
* @param {*} xRight 折线图最右边与画布边界的距离,单位是px,默认为0
* @param {*} yBottom 折线图最下边与画布边界的距离,单位是px,默认为0
* @param {*} yTop 折线图最上边与画布边界的距离,单位是px,默认为0
* @param {*} specialIndex 特殊点的下标,默认为-1(默认没有特殊点)
*/
function draw(arr, xLeft = 0, xRight = 0, yBottom = 0, yTop = 0, specialIndex = -1) {
// 获取上下文对象
const context = wx.createCanvasContext('canvas');
// 获取画布的宽高
const width = _this.data.canvasWidth;
const height = _this.data.canvasHeight;
// 记录图形的边界
const borderInfo = { xLeft, xRight, yBottom, yTop }
// 1.画折线图,传入数据,主色调,画布宽高,图形边界和特殊点位置
// 获得返回的拐点数组
const pointArray = drawFoldLine(context, arr, mainColor, width, height, borderInfo, specialIndex)
// 2.遍历拐点数组
for (let i = 0; i < pointArray.length; i++) {
// 把拐点作为圆心
let center = pointArray[i];
// 2.1画出小圆
drawInflectionPoint(context, mainColor, center, 14, i === specialIndex);
if (i !== specialIndex) {// 不是特殊点的情况
// 2.2 写出价格
drawNormalPrice(context, center);
// 2.3写拼团人数
drawNormalTeamNumber(context, center);
// 2.4画出下面的刻度
drawMark(context, center, height);
// 2.5 画出刻度线下面的日期
drawDate(context, center, height);
}
else {//是特殊点的情况
// 2.6写出特殊情况的价格
drawSpecialPrice(context, center);
// 2.7写出特殊情况的拼团人数
drawSpecialTeamNumber(context, center, mainColor);
}
}
// 3.在图上写出原价
// 得到原价的宽度
const priceTextWidth = drawOriginPrice(context, originalPrice, 60 * _this.data.toPx, 40 * _this.data.toPx);
console.log(priceTextWidth)
// 4.画出下降的箭头,传入箭头路径,位置和大小(位置的单位是px,大小的单位是rpx)
// 箭头的x位置是:从原价的最左边右移原价宽度的一半,再左移箭头宽度的一半
const imgX = 60 * _this.data.toPx + priceTextWidth / 2 - 78 * _this.data.toPx / 2;
drawImage(context, arrowPic, imgX, 56 * _this.data.toPx, 78, 112)
}
数据的示例:
const arr = [
{ price: 9.9, number: 2, date: '12.10' },
{ price: 0.99, number: 5, date: '' },
{ price: 19.9, number: 2, date: '12.17' },
{ price: 29.9, number: 2, date: '1.02-1.19' }];