Canvas折线图入门
发布于 5 年前 作者 mailzwj 3663 次浏览 来自 分享

一年一度电商人的节日刚过,双十一的余热还未散去。看着大屏上各种数据图表,折线图一会儿风平浪静,骤然间波涛汹涌,作为一个前端开发工程师,技术热情也被再一次燃起。现在我们就来聊一聊如何使用Canvas实现一个简单的折线图报表,本文的内容适合有一定网页绘图基础的开发工程师,不讲解基础的HTML概念。

开始之前

我们知道,<canvas>是一个HTML标签,是强大的网页绘图工具,它提供了非常多的方便我们绘制图形的API,比如:

  • moveTo(x, y)
  • lineTo(x, y)
  • fillRect(x, y, width, height)
  • bezierCurbeTo(cpx1, cpy1, cpx2, cpy2, x, y)
  • 等等,非常多。

所以,我们可以用这些API在画布上绘制任何我们想要的图形,比如常见的折线图图表。

线段 VS 曲线段

就像我们平常看到的,折线图有的呈现为直线段的组合,拐点锋利,有的呈现为平滑的曲线段组合,拐点圆润。要绘制这两类线段,我们可以使用lineTobezierCurbeTo两个方法:

const cvs = document.querySelector('#MyCanvas');
const ctx = cvs.getContext('2d');
ctx.fillStyle = '#333';
ctx.fontSize = 12;
ctx.fillText('0', 5, 195);

ctx.beginPath();
ctx.moveTo(50, 100);
ctx.lineTo(100, 100);

ctx.moveTo(150, 100);
ctx.lineTo(200, 50);

ctx.moveTo(250, 100);
ctx.bezierCurveTo(300, 100, 300, 70, 350, 70);

ctx.stroke();

以上代码绘制三条线段,前两条为水平和倾斜的直线段,第三条为曲线段,显示效果如图: cvs_1.png

集合

通常我们在图表中显示的都是一组数据的集合,并不是单纯的两个点的连线。因此,上述的逐行驻点的绘制方法,我们需要进行加工,封装以支持数据集合的绘制。在一个2D坐标系中,一个点的位置由x轴的横坐标和y轴的纵坐标组合来表示,我们常见的图表中横坐标默认根据画布宽度和数据个数平均分配而来,而纵坐标则表示该横坐标位置对应的具体数据。

因此,为了简化演示我们假定横坐标固定以50px间隔,同时固定画布高度为200px,且构造一组数值不超过200的假数据:

const drawOnContext = (data, ctx) => {
    const xStep = 50; // 横坐标固定间隔50px
    data.forEach((d, i) => {
        ctx.lineTo(i * xStep, d);
    });
    ctx.stroke();
};

const cvs = document.querySelector('#MyCanvas');
const ctx = cvs.getContext('2d');
const data = [10, 80, 33, 43, 68, 22, 82, 54];

ctx.fillStyle = '#333';
ctx.fontSize = 12;
ctx.fillText('0', 5, 195);
drawOnContext(data, ctx);

现在我们已经基本有了一个折线图的形状: cvs_2.png

但是,我们仔细一看发现不对,我们通常所见的图表坐标原点在左下角,如上图标注【0】的位置,但是canvas默认坐标原点在左上角,所以上图展示的图表数据是不对的,越大的数看起来越小了,因此我们还需要转换一下坐标。方法也比较简单,只需要将图表高度减去Y轴对应的值,这样图形就倒过来了:

const drawOnContext = (data, ctx) => {
    const xStep = 50; // 横坐标固定间隔50px
    data.forEach((d, i) => {
        ctx.lineTo(i * xStep, 200 - d);
    });
    ctx.stroke();
};

现在图表看起来就正常了: cvs_3.png

个性化

上面我们已经做到了简单的展示一条曲线,但是,实质上这完全不够,比如:

  • 能一次绘制条曲线吗?
  • 能配置曲线的颜色吗?
  • 拐角好尖锐,我可以让它平滑一点吗?
  • 线太细了,看不清楚,我能设粗一点吗?

No! 这些现在都还做不到!那么问题来了,这样的图表能用吗?

所以,革命尚未成功,我们仍需努力。我们先来进行一个简单的抽象,假设一条曲线就是一个对象,根据上面描述的问题,这个对象需要包含颜色、线宽、是否平滑以及最重要的数据等属性,抽象出JS对象:

// 一条曲线
{
    color: '#39f', // 任意合法的颜色值
    lineWidth: 2, // 线宽数值
    smooth: true, // 是否平滑显示,布尔型
    data: [10, 80, 33, 43, 68, 22, 82, 54] // 最重要的数据
}

进而,多条曲线就可以表示为由以上对象组成的数组序列,但是同一个图表中的曲线是可以包含一些相同参数的,比如:相同的横坐标间隔,相同的数据点标记等。因此我们可以再抽象出一个对象:

// 一个图表
{
    xStep: 50,
    symbolSize: 6,
    series: [lineObj1, lineObj2, ...]
}

简化演示,就不设计太多参数了。基于此,我们再对绘制函数drawOnContext进一步改造:

const drawOnContext = (opts, ctx) => {
    ctx.save();
    const axiasStep = opts.axiasStep || 75;
    const symbleSize = opts.symbleSize || 6;
    opts.color = opts.color || [];
    opts.series.forEach((s, i) => {
        if (s.data.length) {
            const color = s.color || opts.color[i] || '#f00';
            const smooth = !!s.smooth;
            ctx.strokeStyle = color;
            ctx.fillStyle = color;
            ctx.lineWidth = s.lineWidth || 1;
            ctx.beginPath();
            ctx.moveTo(0, 200 - s.data[0]);
            s.data.forEach((d, i) => {
                if (i > 0) {
                    if (smooth) {
                        ctx.bezierCurveTo((i - 1) * axiasStep + axiasStep / 2, 200 - s.data[i - 1], i * axiasStep - axiasStep / 2, 200 - d, i * axiasStep, 200 - d);
                    } else {
                        ctx.lineTo(i * axiasStep, 200 - d);
                    }
                }
            });
            ctx.stroke();
        }
    });
    ctx.restore();
};

现在我们调用绘制折线图方法的时候,传入的数据也需要修改:

drawOnContext({
    axiasStep: 80,
    symbolSize: 4,
    series: [
        {
            lineWidth: 2,
            data: [10, 80, 33, 43, 68, 22, 82, 54]
        },
        {
            lineWidth: 1,
            color: '#39f',
            smooth: true,
            data: [10, 80, 33, 43, 68, 22, 82, 54].reverse()
        }
    ]
}, ctx);

通过如此调用,我们可以得到一个拥有两条折线的图表,并且两条折线拥有各自个性化的配置: cvs_4.png

进阶

到上面为止,我们已经能够创建出漂亮的图表了,但是还有一些不尽人意的地方:平滑曲线中数据点在哪个位置?数据值超出canvas高度怎么办?

所以,我们继续扩展我们图表的能力,继续修改绘制函数:

const drawOnContext = (opts, ctx) => {
    ctx.save();
    const cvsHeight = 200; // 可通过dom计算获得 *********新增的东西
    const dataMax = 400; // 可根据数据集合比较计算出最大值 ********新增的东西
    const axiasStep = opts.axiasStep || 75;
    const symbleSize = opts.symbleSize || 6;
    const ratio = cvsHeight / dataMax; // 这里其实就是缩放比
    opts.color = opts.color || [];
    opts.series.forEach((s, i) => {
        if (s.data.length) {
            const color = s.color || opts.color[i] || '#f00';
            const smooth = !!s.smooth;
            ctx.strokeStyle = color;
            ctx.fillStyle = color;
            ctx.lineWidth = s.lineWidth || 1;
            ctx.beginPath();
            ctx.moveTo(0, cvsHeight - s.data[0] * ratio);
            // 绘制折线起始点坐标
            ctx.fillRect(-symbleSize / 2, cvsHeight - s.data[0] * ratio - symbleSize / 2, symbleSize, symbleSize);
            s.data.forEach((d, i) => {
                if (i > 0) {
                    // 绘制数据点坐标,方便识别
                    ctx.fillRect(i * axiasStep - symbleSize / 2, cvsHeight - s.data[i] * ratio - symbleSize / 2, symbleSize, symbleSize);
                    if (smooth) {
                        ctx.bezierCurveTo((i - 1) * axiasStep + axiasStep / 2, cvsHeight - s.data[i - 1] * ratio, i * axiasStep - axiasStep / 2, cvsHeight - d * ratio, i * axiasStep, cvsHeight - d * ratio);
                    } else {
                        ctx.lineTo(i * axiasStep, cvsHeight - d * ratio);
                    }
                }
            });
            ctx.stroke();
        }
    });
    ctx.restore();
};

现在,我们修改一下假数据,构造一些超出200的数据,然后再进行绘制:

drawOnContext({
    axiasStep: 80,
    symbolSize: 4,
    series: [
        {
            lineWidth: 2,
            data: [100, 60, 380, 135, 73, 300, 256, 370]
        },
        {
            lineWidth: 1,
            color: '#39f',
            smooth: true,
            data: [60, 340, 35, 173, 110, 216, 350, 262]
        }
    ]
}, ctx);

显示效果如下: cvs_5.png

好了,至此已经可以做出一个非常漂亮的折线图图表了,但是里面还缺少一些交互。篇幅问题,本文就暂不继续扩展了,有时间再通过新的文章和大家一起讨论。 欢迎关注我的订阅号: bmsz-f.png

回到顶部