到上一篇,可以说,所有的工作已经完成了,那为什么还会有第六篇呢?因为,客户改需求了,UI设计图改了,我也就不得不改代码,画一个新的版本出来。我在大功告成,开始写第一篇的时候突然改的,我也就准备加一篇来讲讲我的修改和注意事项。这一篇很长,但也很重要,希望有兴趣的小伙伴来看看!
修改前是这样:
修改后的效果是这样的——
没错!肉眼可见,有这些变化了——原价也出现在了折线图里、使用了虚线、里面绘制出了价格特别大的点、拼团人数不再是“x人团”的字样了、特殊点的价格被迫变小。
现在就先来讲讲我的修改。
这个改动不太大,就是里面有了“原价”的加入,我之前显示的时候都是“x人团”,突然变成了“原价人团”,肯定很不对劲。那要怎么区分呢?我是在写字之前,判断当前要写的东西的类型是否为数字,是数字则拼接成“x人团”再来写,否则不拼接,直接写。
/**
* 绘制普通拼团人数
* @param {*} context 上下文对象
* @param {*} center 拐点信息
* @param {*} isSpecial 判断前一个是不是特殊情况
*/
function drawNormalTeamNumber(context, center, isSpecial) {
context.beginPath();
context.setFontSize(12);
context.fillStyle = "#999999";
var dx = getTextWidth(context, '(' + center.teammateNumber + '人团)') / 2;
var dy = 70 * _this.data.toPx
if (isSpecial) {
dx = getTextWidth(context, '(' + center.teammateNumber + '人团)') /2 + 74 * _this.data.toPx;
dy = 14 * _this.data.toPx
}
// 如果有写几人团,那么就写几人团,否则不显示“人团”,直接显示信息)
if (typeof center.teammateNumber === 'number') {
context.fillText('(' + center.teammateNumber + '人团)', center.x - dx, center.y - dy);
}
else {
context.fillText('(' + center.teammateNumber + ')', center.x - getTextWidth(context, '(' + center.teammateNumber + ')') / 2, center.y - dy);
}
context.draw(true);
}
修改后的图的第一条折线和最后一条折线变成了虚线,绘制虚线的代码并不是我自己写的,而是拿了别人的代码,来自https://www.jianshu.com/p/49dfb86d0681,非常感谢这篇文章的作者,他没有使用canvas提供的画虚线的API,而是自己写了一个函数。思路是,根据起点和终点坐标,通过勾股定理来算出两点之间的直线距离,直线距离除以每段虚线的长度就得出可以分为几段,然后开始循环绘制每一段,注意每画出一段,下一次就要跳过一段,再接着画。
代码如下:
/**
* 绘制虚线
* @param {*} context 上下文对象
* @param {*} x1 起点x坐标
* @param {*} y1 起点y坐标
* @param {*} x2 终点x坐标
* @param {*} y2 终点y坐标
* @param {*} dashLength 每一段虚线的长度
*/
function drawDashLine(context, x1, y1, x2, y2, dashLength){
context.beginPath();
// 每段虚线的长度默认为5px
let dashLen = dashLength === undefined ? 5 : dashLength;
const xpos = x2 - x1; //得到横向的宽度;
const ypos = y2 - y1; //得到纵向的高度;
// 求出起点和终点的直线距离,除以每段的长度,得出共有多少段
const numDashes = Math.floor(Math.sqrt(xpos * xpos + ypos * ypos) / dashLen);
for(var i=0; i
那么什么时候来调用这个函数呢?
由于虚线是折线图的一部分,所以绘制虚线的子函数会在绘制折线图的子函数里调用。绘制虚线需要提供起点和终点,而且虚线分别是第一条和最后一条线,所以我在发现是要绘制第一条和最后一条的时候调用了绘制虚线的函数,它们与其他线实际上并不是相连的,所以我是在算出了所有拐点坐标之后,先绘制了这两条虚线,然后再把中间的实线画出来。
绘制折线图的子函数修改了这两个地方,一是判断是否是在画第一条或者最后一条,二是后来再来连接中间的几个点。
// 如果是画第一条线和最后一条线,用虚线来画,其他的线在循环外面连接
if (i == 1 || i == priceArr.length - 1) {
drawDashLine(context, pointArray[i-1].x, pointArray[i-1].y, pointArray[i].x, pointArray[i].y, 2)
}
// 得到了拐点坐标后,连接中间的几个拐点
context.moveTo(pointArray[1].x, pointArray[1].y)
for (let i = 1; i < pointArray.length - 1; i++) {
// 连接这个拐点坐标
context.lineTo(pointArray[i].x, pointArray[i].y);
}
这个点这是难点,老实说这个点让我纠结了好久。我一开始不确定要不要改,以及怎么改。
为什么是难点?
①假如我老老实实地按照数值来绘制,那么最大值是199,最小值是0.99,相差将近200了,如果说最大值是接近于坐标轴最高点,那么最低点岂不是低到了尘埃里?
②除了199,其他数字都是几十,可以说比较接近,那么我为什么要以一个特殊值来作为标准(我这个绘制折线图的函数就是把最大值设为图像最高点,然后根据有最大高度除以最大值来得到刻度的,比如最大高度为500px,最大值为100元,那么图上的5px就表示1元),而不是让几个极为接近的值为标准呢?
可以展示一下,如果不处理价格特别大的点,那么绘制出来的效果会相当糟糕,因为最高点显得太鹤立鸡群了。
看到了吗?比起199,从9.9到59的变化太微弱了,几乎成了水平线,没有办法让人看清楚9.9到59这一段的价格曲线,显然体验是相当的不好。
所以要怎么改呢?在UI同事跟我保证过,价格很大的数字只会有一个,如只有199,没有190、180之类的,我终于可以放心地改了。
我的方案是:
① 找到最大值以后,判断是否存在与最大值相差很大的点,如果有,说明最大值太大了,需要修正;
② 修正是要适当缩小最大值,让它的值仍然的最大的,但是与其他值的差距要缩小很多;
③ 修正的方案可以是把最大值除以2或者除以3或者减去多少值,我这里暂定的方案是除以2,因为除以3得到的69与59差距不是太大,会让人觉得59与199相差不大;
④ 绘制路径之前,把它原本的值与修正后的最大值比较,如果发现原本的值大于修正后的最大值,那么就用修正后的最大值来计算位置;
⑤ 最后,在得到拐点数据的时候,value属性使用的必须是数组里的原值,不能使用修正后的值,因为等一下要把原价绘制到图上,使用修正后的值会露馅;
⑥ 相应的,0.99与9.9的差距不大,然而这里价格最低,是特殊点,需要突出,我们也可以修正一下,把它的y坐标加大,让它位置更低,与其他点距离更大。
经过以上的处理,以及前面讲的,加上对虚线的绘制,我的绘制折线图的函数被改成了这样——
/**
* 绘制折线图,返回值是拐点数组
* @param {*} context 上下文对象
* @param {*} arr 绘图用的数据
* @param {*} color 颜色
* @param {*} width 画布宽度
* @param {*} height 画布高度
* @param {*} borderInfo 图像边界与画布边界的距离
* @param {*} specialIndex 特殊点的下标
*/
function drawFoldLine(context, arr, color, width, height, borderInfo, specialIndex = -1) {
// 数组里的是对象,包括价格和拼团人数,找最大值的时候把价格拿出来组成新数组
const priceArr = arr.map(function (item) {
return item.price
});
// 获取图像的边界
const { xLeft, xRight, yBottom, yTop } = borderInfo;
// 获取价格的最大值
let maxV = priceArr.max()
console.log(maxV);
// 如果有与最大值差距过大(大于最大值一半)的值,则绘图时修正最大值
if (priceArr.some(item => Math.abs(item - maxV) > maxV/2)) {
// 修正方案暂时定为最大值除以2
maxV = maxV / 2;
}
//计算y轴增量
const yStep = (height - yBottom - yTop) / maxV;
// 设置x偏移量
const x = xLeft;
context.moveTo(x, 0);//开始画图的位置
context.beginPath();
context.lineWidth = 4 * _this.data.toPx;
context.strokeStyle = color;
// 画点的x坐标当前的位置,一开始为x的偏移量
let xLen = x;
console.log(xLen)
// x轴的刻度要分为几份来计算(如果有特殊点,要少算一份)
let xParts = priceArr.length - 1;
if ( specialIndex > -1 ) {
xParts --
}
// x轴刻度的大小(等于有效宽度除以x轴的份数)
const x_space = (width - x - xRight) / xParts;//水平点的间隙像素
console.log(x_space)
// 收集拐点坐标的数组
const pointArray = [];
// 遍历数据,找出拐点,组成路径
for (let i = 0; i < priceArr.length; i++) {
let yValue = priceArr[i];//纵坐标值
// 如果y值异常的大,那么让它的值为修正后的最大值
if (yValue > maxV) {
yValue = maxV
}
xLen += x_space;
let yPont = height - yBottom - yValue * yStep;
// x从偏移量开始
if (i == 0) {
xLen = x;
}
// 特殊情况的坐标和下一个点的坐标的x相对窄一些
if (i == specialIndex || i == specialIndex + 1) {
xLen = xLen - (x_space / 2);
}
// 突出特殊点,让特殊点下移一些
if ( i == specialIndex ) {
yPont = yPont + 16 * _this.data.toPx;
}
// 把拐点信息加到拐点数组中
pointArray.push({
// 拐点坐标
x: xLen,
y: yPont,
// 价格(yValue被修正过了,这里注意把原来的值放进去)
value: priceArr[i],
// 人数
teammateNumber: arr[i].number,
// 日期
date: arr[i].date
})
// 如果是画第一条线和最后一条线,用虚线来画,其他的线在循环外面连接
if (i == 1 || i == priceArr.length - 1) {
drawDashLine(context, pointArray[i-1].x, pointArray[i-1].y, pointArray[i].x, pointArray[i].y, 2)
}
}
// 得到了拐点坐标后,连接中间的几个拐点
context.moveTo(pointArray[1].x, pointArray[1].y)
for (let i = 1; i < pointArray.length - 1; i++) {
// 连接这个拐点坐标
context.lineTo(pointArray[i].x, pointArray[i].y);
}
// 画出路径
context.stroke();
context.draw(true);
// 画完以后,把拐点数据返回给主函数使用
return pointArray;
}
这也是一个很重要的修改,如你所见,由于点的个数变多,横轴需要分成更多份,平均每份的宽度会变小,所以原来的胶囊太大,宽度上占了太多空间了。也就是需要减小胶囊的宽度。
我们之前计算胶囊宽度是根据文字宽度来计算的,把文字宽度加上某个值,作为胶囊宽度。后来我才意识到我算得不够精准——我之前说的“胶囊宽度”,说的是胶囊中间的矩形的宽度,胶囊的实际宽度其实是中间矩形的宽度再加上两边的半圆的宽度!所以我算出来的宽度比实际需要的大多了。我们现在需要的胶囊宽度(不包括半圆的宽度)反而是文字宽度减去一个值。据我的测试,这个值还不小,为40rpx才对。
修改后的代码如下——
/**
* 绘制特殊情况的拼团价格
* @param {*} context 上下文对象
* @param {*} center 拐点信息
*/
function drawSpecialPrice(context, center) {
context.beginPath();
context.setFontSize(28 * _this.data.toPx);
// 计算白字的宽度
const textWidth = getTextWidth(context, '¥' + center.value);
// 先画胶囊形状
// 根据白字的宽度,来计算胶囊的宽度,才能确定胶囊的位置和宽度
const capsultWidth = textWidth - 40 * _this.data.toPx;
console.log(capsultWidth)
context.moveTo(center.x - capsultWidth / 2, center.y + 42 * _this.data.toPx);
context.lineTo(center.x + capsultWidth / 2, center.y + 42 * _this.data.toPx);
context.arc(center.x + capsultWidth / 2, center.y + 70 * _this.data.toPx, 28 * _this.data.toPx, 1.5 * Math.PI, 2.5 * Math.PI, false);
context.lineTo(center.x - capsultWidth / 2, center.y + 98 * _this.data.toPx);
context.arc(center.x - capsultWidth / 2, center.y + 70 * _this.data.toPx, 28 * _this.data.toPx, 0.5 * Math.PI, 1.5 * Math.PI, false);
context.closePath();
// 准备一个渐变
const grd = context.createLinearGradient(center.x - (capsultWidth / 2 - 28 * _this.data.toPx), 0, center.x + (capsultWidth / 2 + 28 * _this.data.toPx), 0)
grd.addColorStop(0, '#FE7301');
grd.addColorStop(1, '#FF4800');
context.fillStyle = grd;
// 胶囊填充的是渐变色
context.fill();
context.draw(true);
// 写白色的字
context.beginPath();
context.fillStyle = "#ffffff";
context.fillText('¥' + center.value, center.x - textWidth / 2, center.y + 82 * _this.data.toPx);
context.draw(true);
}
当然,图上还有一个细微的修改我没有讲到,就是发现如果前一个点的值与当前点的值差距过大,那么就把文字写到左边,主要是怕差距过大的话线条可能会过于陡峭,线条可以会与拐点上方的文字重叠。修改后的代码如下,这里多了一个if来判断——
/**
* 绘制普通价格
* @param {*} context 上下文对象
* @param {*} center 拐点信息
* @param {*} isSpecial 判断前一个是不是特殊情况
*/
function drawNormalPrice(context, center, isSpecial) {
// 写价格
context.beginPath();
context.setFontSize(12);
context.fillStyle = "#333333";
if (isSpecial) {
// 如果前一个价格比当前的价格大很多,那么把这个字往左平移
context.fillText('¥' + center.value, center.x - getTextWidth(context, '¥' + center.value)/2 - 74 * _this.data.toPx, center.y + 25 * _this.data.toPx);
} else {
// 写出价格,注意x的偏移量是文字宽度的一半
context.fillText('¥' + center.value, center.x - getTextWidth(context, '¥' + center.value) / 2, center.y - 34 * _this.data.toPx);
}
context.draw(true);
}
当然,如果是把折线图画出来,并且按照这个图来修改,那么以上工作都做完,就大功告成了。可是,这个东西确实是真实项目中的一部分,既然有使用,那么后续就会出现问题,需要进行修改,目前我遇到了两个问题,整理如下——
因为canvas里面需要的单位是px,而我们为了适配不同宽度的设备,更喜欢使用rpx的单位,所以非常需要转换这两个单位。我之前整理的那个公式完全没有问题,可以把rpx转成px,也可以设为data里的一个数据,可是实际使用的时候还是“翻车”了。
这里放一下“车祸现场”的截图,来自真机上的测试效果。
可以看到,不仅拐点的圆圈变得硕大无比,而且下面的刻度线比坐标背景图低了太多(就在下面黑色的日期附近)。这是为什么呢?这些值都是我传入rpx的值后,通过toPx算出来的,所以它们问题的根源,肯定是toPx算出来不对。
会不会是来不及算出正确的值,就已经开始使用了?为了验证我的猜想,我修改了对toPx的赋值,之前是直接写到data里去的,现在改成data里的toPx默认为0,在onLoad里面再去计算这个值,并且用this.setData来赋值,之后才调用主函数开始绘画,就像这样——
data: {
// 其他代码
// 把rpx换成px
toPx: 0,
},
//其他代码...
/**
* 生命周期函数--监听页面加载
*/
onLoad: function (options) {
this.setData({
toPx: 1 / 750 * wx.getSystemInfoSync().windowWidth
})
this.draw()
},
果然解决了该问题。
第二个问题是因为canvas是原生组件,而且这不是一个同层渲染的组件,在真机上,canvas的层级比固定在底部的那一条层级更高,盖住了底部。幸好在微信小程序端,可以通过
注意,在小程序端的
到此,这个小功能终于告一段落,我的相关博客也写完了,今年也快要过去了。本来我很怕canvas,但是这次客户只给出想要的价格走势图的样子,而不愿意自己画好上传过来,我真的只能自己来画了,很感谢那几天拼命找资料和思考的自己,我记录下这些过程和自己的心路历程,一是想鼓励自己,二也是希望能帮到有类似需求的朋友们。感谢大家的阅读!提前祝大家新年快乐!