返回 登录
0

微信小程序不支持图表工具,通过实例带你了解绘制方案

阅读11143

作者:musiq1989,前端开发工程师,专注于前端技术研究和内容分享,Github地址:https://github.com/xiaolin3303
责编:陈秋歌,关注微信开发等领域,寻求报道或者投稿请发邮件至chenqg#csdn.net。
欢迎加入“CSDN微信开发”群,与作者及更多专家、技术同行进行热点、难点技术交流。请加群主微信「Rachel_qg」,申请入群,务必注明「姓名+公司+职位」。

由于微信小程序本身框架的限制,很难集成目前已有的图表工具,显示图表目前有两种方案:

  1. 服务器端渲染图表,输出图片,微信小程序中直接显示渲染好的图片,比如Highcharts提供了服务端渲染的能力hightcharts server render,这种方式需要后台有一套渲染服务,并且有一定的网络开销。
  2. 微信小程序API中提供了canvas的支持,利用canvas自行绘制图表。

下面我们来看下怎么在微信小程序中绘制图表。

API

查看微信小程序详细 Canvas API 文档。

在模板文件中使用<canvas></canvas>声明一个canvas组件。

使用wx.createContext获取绘图上下文 context。

调用wx.drawCanvas进行绘制。

wx.drawCanvas({
    canvasId: 'firstCanvas',
    actions: context.getActions() // 获取绘图动作数组
});

开始图表的绘制

绘制折线图

// 获取绘图上下文 context
var context = wx.createContext();
// 设置描边颜色
context.setStrokeStyle("#7cb5ec");
// 设置线宽
context.setLineWidth(4);

context.moveTo(50, 70);
context.lineTo(150, 150);
context.lineTo(250, 30);
context.lineTo(350, 120);
context.lineTo(450, 150);
context.lineTo(550, 95);
// 对当前路径进行描边
context.stroke();
wx.drawCanvas({
    canvasId: 'testCanvas',
    actions: context.getActions()
});

说明:moveTo方法不记录到路径中

效果图:
图片描述

好像没有想象中难,看上去效果还不错。

绘制每个数据点的标识图案

...

context.beginPath();
// 设置描边颜色
context.setStrokeStyle("#ffffff");
// 设置填充颜色
context.setFillStyle("#7cb5ec");
context.moveTo(50 + 7, 70);
// 绘制圆形区域
context.arc(50, 70, 8, 0, 2 * Math.PI, false);

context.moveTo(150 + 7, 150);
context.arc(150, 150, 8, 0, 2 * Math.PI, false);

...

context.closePath();
// 填充路径
context.fill();
context.stroke();

效果图:
图片描述

说明:避免之前绘制的折线路径影响到标识图案的路径,这里包裹在了beginPathclosePath中。

绘制横坐标

规定我们的参数格式是这样的。

opts = {
    width: 640,    // 画布区域宽度
    height: 400,   // 画布区域高度
    categories: ['2016-08', '2016-09', '2016-10', '2016-11', '2016-12', '2017']
}

我们根据参数中的categories来绘制横坐标。

稍微整理下思路:

  1. 根据categories数均分画布宽度;
  2. 计算出横坐标中每个分类的起始点;
  3. 绘制文案(这儿会多一些代码,后面会具体提到)。
var eachSpacing = Math.floor(opts.width / opts.categories.length);
var points = [];
// 起始点x坐标
var startX = 0;
// 起始点y坐标
var startY = opts.height - 30;
// 终点x坐标
var endX = opts.width;
// 终点y坐标
var endY = opts.height;

// 计算每个分类的起始点x坐标
opts.categories.forEach(function(item, index) {
    points.push(startX + index * eachSpacing);
});
points.push(endX);

// 绘制横坐标
context.beginPath();
context.setStrokeStyle("#cccccc");
context.setLineWidth(1);

// 绘制坐标轴横线
context.moveTo(startX, startY);
context.lineTo(endX, startY);
// 绘制坐标轴各区块竖线
points.forEach(function(item, index) {
    context.moveTo(item, startY);
    context.lineTo(item, endY);
});
context.closePath();
context.stroke();

context.beginPath();
// 设置字体大小
context.setFontSize(20);
// 设置字体填充颜色
context.setFillStyle('#666666');
opts.categories.forEach(function(item, index) {
    context.fillText(item, points[index], startY + 28);
});
context.closePath();
context.stroke();

效果图:
图片描述

效果不错,除了文字没有居中……

查看微信小程序官方提供的文档并没有提供HTML5 Canvas中的mesureText(获取文案宽度)方法,下面我们自己简单的实现,并不是绝对精确,但是误差基本可以忽略。

function mesureText (text) {
    var text = text.split('');
    var width = 0;
    text.forEach(function(item) {
        if (/[a-zA-Z]/.test(item)) {
            width += 14;
        } else if (/[0-9]/.test(item)) {
            width += 11;
        } else if (/\./.test(item)) {
            width += 5.4;
        } else if (/-/.test(item)) {
            width += 6.5;
        } else if (/[\u4e00-\u9fa5]/.test(item)) {
            width += 20;
        }
    });
    return width;
}

这里分别处理了字母数字.-汉字这几个常用字符。

上面的代码稍微修改下:

opts.categories.forEach(function(item, index) {
    var offset = eachSpacing / 2 - mesureText(item) / 2;
    context.fillText(item, points[index] + offset, startY + 28);
});

图片描述

大功告成!

如何在折线上绘制出每个数据点的数值文案大家可以动手自己实现下。

确定纵坐标的范围并绘制

为了避免纵坐标的刻度出现小数的情况,我们把纵坐标分为5个区块,我们取最小单位刻度为例如10(能够被5整除),当然真实情况会比这复杂,待会儿我们再讨论。

所以我们的处理输入输出应该是下面的结果。

(5, 34.1)  => (10, 40)
(10, 34)   => (10, 40)
(-5.1, 40) => (-10, 40)
// 确定Y轴取值范围
function findRange (num, type, limit) {
    limit = limit || 10;

    // upper向上查找,lower向下查找
    type = type ? type : 'upper';

    // 进行取整操作,避免while时进入死循环
    if (type === 'upper') {
        num = Math.ceil(num);
    } else {
        num = Math.floor(num);
    }
    while (num % limit !== 0) {
        if (type === 'upper') {
            num++;
        } else {
            num--;
        }
    }

    return num;
}

好了,初步的确定范围已经完成了,但是细想一下这个范围还是不是很理想,比如用户传入的数据都是小数级别的,比如 (0.2, 0.8),我们输出的范围是(0, 5)这个范围偏大,图表展现的效果则会是上面有大部分的留白,同样用户输入的数据很大,比如(10000, 18000),我们得到的范围是(10000, 18010),这个范围则没什么意义,所以我们需要根据传入的数据的范围来分别确定我们的最小单位刻度。

规定我们的参数格式是这样的:

opts = {
    ...
    series: [{
            ...
            data: [15, 20, 45, 37, 4, 80]
        }, {
            ...
            data: [70, 40, 65, 100, 34, 18]
        }
    ]
}

让我们继续进行优化。

// 合并数据,将series中的每项data整合到一个数组当中
function dataCombine(series) {
    return series.reduce(function(a, b) {
        return (a.data ? a.data : a).concat(b.data);
    }, []);
}

// 根据数据范围确定最小单位刻度
function getLimit (maxData, minData)
    var limit = 0;
    var range = maxData - minData;
    if (range >= 10000) {
        limit = 1000;
    } else if (range >= 1000) {
        limit = 100;
    } else if (range >= 100) {
        limit = 10;
    } else if (range >= 10) {
        limit = 5;
    } else if (range >= 1) {
        limit = 1;
    } else if (range >= 0.1) {
        limit = 0.1;
    } else {
        limit = 0.01;
    }
}

var dataList = dataCombine(opts.series);
// 获取传入数据的最小值
var minData = Math.min.apply(this, dataList);
// 获取传入数据的最大值
var maxData = Math.max.apply(this, dataList);

var limit = getLimit(maxData, minData);

var minRange = findRange(minData, 'lower', limit);
var maxRange = findRange(maxData, 'upper', limit);

现在我们动态的确定除了合适的最小刻度范围,接下来我们接着优化一下上面的findRange方法,主要是增加对小数的支持。

function findRange (num, type, limit) {
    limit = limit || 10;
    type = type ? type : 'upper';
    var multiple = 1;
    while (limit < 1) {
        limit *= 10;
        multiple *= 10;
    }
    if (type === 'upper') {
        num = Math.ceil(num * multiple);
    } else {
        num = Math.floor(num * multiple);
    }
    while (num % limit !== 0) {
        if (type === 'upper') {
            num++;
        } else {
            num--;
        }
    }

    return num / multiple;
}

现在我们已经确定好了Y轴的取值范围,关于如何画出Y轴可以参看上文中X轴的绘制方法,此处不再累赘。

Y轴效果图:

opts = {
    ...
    series: [{
            ...
            data: [15, 20, 45, 37, 4, 80]
        }, {
            ...
            data: [70, 40, 65, 100, 34, 18]
        }
    ]
}

图片描述

opts = {
    ...
    series: [{
            ...
            data: [0.15, 0.2, 0.45, 0.37, 0.4, 0.8]
        }, {
            ...
            data: [0.30, 0.37, 0.65, 0.78, 0.69, 0.94]
        }
    ]
}

图片描述

效果还不错,我们接着往下。

根据真实数据绘制折线

问题的关键在于确定每个数据点的(x, y)坐标,x坐标比较好确定,我们根据画布的宽度以及opts.categories即可确定。

规定我们的配置为:

config = {
    xAxisHeight: 30, // X轴高度
    yAxisWdith: 30   // Y轴宽度
}
var data = [15, 20, 45, 37, 4, 80];
var xPoints = [];
var validWidth = opts.width - config.yAxisWidth;
var eachSpace = validWidth / opts.categories.length;
var start = config.yAxisWidth;

data.forEach(function (item, index) {
    xPoints.push(start + (index + 0.5) * eachSpace);
});

y坐标稍微会复杂一点,需要根据Y轴的范围已经本身的数值进行计算得出。

图片描述

所以我们计算出的y应该为:

y = validHeight * (data - min) / (max - min);
// 由于canvas画布是左上角为原点坐标,故我们变化一下
// 得到最终的y绘制点
y = valideHeight - y;

代码如下:

var data = [15, 20, 45, 37, 4, 80];
var yPoints = [];
var validHeight = opts.height - config.xAxisHeight;
data.forEach(function(item) {
    var y = validHeight * (item - min) / (max - min);
    y = validHeight - y;

    yPoints.push(y);
}

现在我们已经确定了数据点在画布上的绘制坐标,关于如何绘制折现请查看 part1 中相关内容,此处不再累赘。

最终效果图如下:

图片描述

饼图绘制

先看一下API。

图片描述

下面开始(使用ES6语法编写,后面我们可以使用rollup编译成ES5的语法)

假设我们有这样的数据

const series = [
    {data: 15, color: '#7cb5ec'},
    {data: 35, color: '#f7a35c'},
    {data: 78, color: '#434348'},
    {data: 63, color: '#90ed7d'}
];

计算出各项所占的比例和开始的弧度。

calPieData.js

export function calPieAngle (series) {
    // 计算数据总和
    let count = 0;
    series.forEach((item) => {
        count += item.data;
    });

    // 计算出开始的弧度和所占比例
    let startAngle = 0;
    return series.map((item) => {
        item.proportion = item.data / count;
        item.startAngle = startAngle;
        startAngle += 2 * Math.PI * item.proportion;
        return item;
    });
}

数据已经计算出来了,下面让我开始绘制吧。

drawPieChart.js

import { calPieAngle } from 'calPieData'

export default function drawPieChart (series) {
    ...

    let pieSeries = calPieAngle(series);
    pieSeries.forEach((item) => {
        context.beginPath();
        // 设置填充颜色
        context.setFillStyle(item.color);
        // 移动到原点
        context.moveTo(100, 100);    
        // 绘制弧度
        context.arc(100, 100, 80, item.startAngle, item.startAngle + 2 * Math.PI * item.proportion);
        context.closePath();
        context.fill();
    });

    ...

}

调用drawPieChart(series)就可以得到下面的结果:

图片描述

很简单是不是,下面我们给各区块加上一个白色的分割线。

因为arc实际上是绘制了一条路径,所以我们简单的stroke描边一下就可以了。

...

context.setLineWidth(2);
context.setStrokeStyle('#ffffff');
pieSeries.forEach((item) => {
    context.beginPath();
    context.setFillStyle(item.color);
    context.moveTo(100, 100);    
    context.arc(100, 100, 80, item.startAngle, item.startAngle + 2 * Math.PI * item.proportion);
    context.closePath();
    context.fill();
    context.stroke();
})

...

图片描述

添加动画效果

首先让我们创建一个动画工具,这个动画工具能够传入一些自定义的参数,比如动画时间,能够有动画每一步的回调以及动画结束的回调。

animation.js

export default function Animation (opts) {
    // 处理用户传入的动画时间,默认为1000ms
    // 因为用户有可能传入duration为0,所以不能用opts.duration = opts.duration || 1000 来做默认值处理
    // 否则用户传入0也会处理成默认值1000
    opts.duration = typeof opts.duration === 'undefined' ? 1000 : opts.duration;

    let startTimeStamp = null;

    function step (timestamp) {
        if (startTimeStamp === null) {
            startTimeStamp = timestamp;
        } 
        if (timestamp - startTimeStamp < opts.duration) {
            // 计算出动画的进度
            let process = (timestamp - startTimeStamp) / opts.duration;
            // 触发动画每一步的回调,传入进度process
            opts.onProcess && opts.onProcess(process);
            // 动画进行中,执行下一次动画
            requestAnimationFrame(step);
        } else {
            // 动画结束
            opts.onProcess && opts.onProcess(1);
            // 触发动画结束回调
            opts.onAnimationFinish && opts.onAnimationFinish();
        }
    }

    requestAnimationFrame(step);
}

动画使用了requestAnimationFrame,并且已经满足了我们上面定义的需求
在实战中,此处的动画都是线性的,一般我们还会加入缓动选项,比如缓入,缓出,还有一点,在微信小程序真机中IOS设备是不支持requestAnimationFrame的,所以要做降级处理,使用setTimeout查看完整的代码

下面我们调用animation来完成动画效果。

app.js

import Animation from 'animation'
import drawPieChart from 'drawPieChart'

Animation({
    duration: 1000,
    onProcess: (process) => {
        drawPieDataChart(series, process);
    }
});

修改一下drawPieDataChart function,能够接受process参数。

...

export default function drawPieChart (series, process = 1) {
    ...
    // 将process传入给calPieAngle,计算出对应进度下的图表角度数据
    let pieSeries = calPieAngle(series, process);

...

同样,修改一下calPieAngle function,能够接受process参数。

export function calPieAngle (series, process = 1) {
    ...

    // 计算出开始的弧度和所占比例
    let startAngle = 0;
    return series.map((item) => {
        // 计算出当前动画进度的比例
        item.proportion = item.data / count * process;
        item.startAngle = startAngle;
        startAngle += 2 * Math.PI * item.proportion;
        return item;
    });
}

好了,现在我们的动画就可以动起来了,类似这样。

图片描述

使用rollup构建项目

Rollup is a next-generation JavaScript module bundler. Author your app or library using ES2015 modules, then efficiently bundle them up into a single file for use in browsers and Node.js.

也就是说rollup是一个前端构建工具,能够将我们的整个项目合并输出成一个最终的编译结果,上面我们编写代码的时候都是按照不同的功能放到不同的文件中,这样有利于后期的可持续性开发和维护,rollup正好能帮助我们构建出最后的编译结果。

先安装rollup

npm install -g rollup

添加对ES6的支持。

npm install --save-dev rollup-plugin-babel
npm install --save-dev babel-preset-es2015-rollup

创建.babelrc文件在项目根目录,告诉babel转义时使用哪个presets

{
  "presets": ["es2015-rollup"],
}

好了剩下最后一步,定义我们的rollup.config.js配置文件:

import babel from 'rollup-plugin-babel';

export default {
  // 入口文件
  entry: 'app.js',
  // 输出格式,这里使用commonJS
  format: 'cjs',
  // 输出文件
  dest: 'dist/charts.js',
  // 使用babel进行ES6转ES5
  plugins: [
      babel({
          exclude: 'node_modules/**',
      })
  ]
};

rollup会从入口文件开始,查找我们的依赖(import),逐级往下深入,把依赖的文件全部收集起来并合并到一起,最后输出到我们定义的dest文件中

执行:

rollup -c

好了,我们就得到了我们最后的项目编译文件charts.js


CSDN技术公开课诚邀对小程序有实践经验的技术专家、资深开发者,担任微信小程序技术公开课讲师,带来有关小程序更深入、更全面的技术分享。邀请流程请见:http://geek.csdn.net/news/detail/134790

也欢迎各位专家老师扫描以下二维码,加秋歌为好友,前来咨询,相互交流。加好友时,请注明:讲师+小程序。

图片描述

评论