Canvas 简介:如何从零开发图表库

前言

在业务中,我们通常需要使用可视化图表来生动得展示业务数据,比如常常用到的折线图、柱形图、和饼图等,这些图表是如何在浏览器上绘制的?本文将简单地介绍绘制这些图形所用到的浏览器技术,使用 DEMO 的形式来从零开发这些常见的图表。

图形绘制技术

目前主要的浏览器图形绘制方案主要有 SVG、和 Canvas 两种,首先先简单看看 SVG 和 Canvas 的特点和适用范围。

SVG

SVG 是 Scalable Vertical Graphics (可伸缩矢量图形)的缩写,是一种基于 XML 用来描述二维矢量图行的描述行语言标准。它的主要特点有:

  • 基于 XML 描述语言,即 SVG 也是用 XML 的文本来描述的

因此 XML 描述的图形也可是使用 XML 语言来保存成文件,这即是常常可以见到的 svg 图片文件,使用文本编辑器打开 svg 图片,就可以看到它的 XML 描述文本了。

  • 描述的是矢量图形

不同于位图,SVG 描述的是矢量图形,特点是可以在任意尺寸下进行渲染而不丢失图形精度。

  • SVG 图形元素存在于 Document Object Model 中

即当把 SVG 图形渲染在浏览器中时,图形中的各个图形元素都是在 DOM 中可以见到的,可以通过 JavaScript 来获取到这些图形元素,也可以使用 CSS 来设置图形元素的样式。

Canvas

Canvas 是 HTML 5 标准的一部分,主要用来绘制像素级别位图,提供的是过程式的底层 API。浏览器提供 <canvas> 元素来提供 Canvas 图形绘制的上下文。它的主要特点:

  • Cavans 标准提供的是偏底层过程式 API,开发者使用这些 API 来显示得绘制 2D 图形,并需要主要触发画布的更新。

  • Canvas 绘制的是位图,同时 Canvas 绘制的图形不存在于 Document Object Model 中,调用 Canvas API 绘制的图形后,如果需要更新图形,通常需要全部重新绘制整个画布。

总体来说,SVG 和 Canvas 是两种不同的浏览器图形绘制标准,SVG 对 Web 开发者更友好,SVG 中的图形元素和 DOM 中其他元素,可以方便使用 JavaScript 和 CSS 进行交互,SVG 中的图形元素也和其他普通 DOM 元素一样可以响应浏览器事件;如此相反,Canvas 更加底层,Cavans 绘制的图形不存在于 DOM 中,同时 Cavans 绘制的单个图形不响应事件,只有整体的外层 <canvas> 元素来响应事件。针对大量的图形情况下,SVG 相对 Cavans 的性能就会差一些,SVG 图形的更新比较简单,Cavans 的更新更加复杂。

本文不继续讨论 SVG,下文将将继续以 Canvas 为例,讲述如何在浏览器中绘制简单的统计图表,折线图、柱形图、和饼图。

Canvas API 使用

Canvas API 的典型使用分三步:

  1. 准备 Canvas API 依赖的 元素
<canvas id="canvas"></canvas>
复制代码
  1. 获取 Canvas 图形绘制上下文
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
复制代码
  1. 调用图形绘制命令
ctx.fillStyle = 'green';
cxt.fillRect(10, 10, 150, 100);
复制代码

这里示例是绘制一个绿色矩形,Canvas 提供了各种 API 来绘制各种图形。

下面列举一些常用的 API:

  • moveTo:移动位置到指定坐标
  • lineTo:从当前位置绘制直线到指定位置
  • rect:绘制矩形
  • arc:绘制弧形
  • strokeTextfillText:绘制文本

掌握了这些图形绘制 API 后,这里对 Canvas API 进行封装,提供简单的图形绘制函数,对外暴露 Canvas class,提供了文本、点、线、矩形、和弧形的绘制能力。

/**
 * HTML Canvas API 封装
 */
export class Canvas {
  private element: HTMLCanvasElement;
  private context: CanvasRenderingContext2D;

	/** 构造函数 */
  constructor(
    readonly container: HTMLElement,
    readonly width: number,
    readonly height: number
  ) {
    this.element = document.createElement("canvas");
    this.element.width = width * 2;
    this.element.height = height * 2;
    this.element.style.width = `${width}px`;
    this.element.style.height = `${height}px`;
    container.appendChild(this.element);
    this.context = this.element.getContext("2d");
  }

	 /**
   * 绘制文字
   */
  public drawText(text: string, point: Position, style?: Style): void {
		/* ... */
  }

  /**
   * 绘制圆点
   */
  public drawPoint(point: Position, radius: number, style?: Style) {
		/* ... */
  }

	/**
   * 绘制折线
   */
  public drawLine(point1: Position, point2: Position, style?: Style): void {
		/* ... */
  }

	/**
   * 绘制矩形
   */
  public drawRect(point1: Position, point2: Position, style?: Style): void {
    /* ... */
  }

  /**
   * 绘制弧形
   */
  public drawArc(
    center: Position,
    radius: number,
    startAngle: number,
    endAngle: number,
    style?: Style
  ): void {
    /* ... */
  }
}
复制代码

这里略去各个方法的具体实现,读者可以尝试自己实现。在完成了 Canvas Class 的封装后,就可以使用 Canvas Class 来简单的会绘制常用的图形了,读者可以尝试绘制如下的图形。
image.png
这部分的具体代码可以参照这个 codesandbox

绘制图表库

图表组成

在了解了 Cavans 图形绘制技术后,开始实现自己的图表库之前,首先先看看一个典型的图形由哪些组成部分。
image.png
image.png
上面分别是一个折线图和一个柱形图,通常将图形分为以下几个组成部分:

  • 图形标记:最主要的组成部分,如折线图中的折线、点,柱形图中的矩形,这些标记是图表背后数据的展示,图形标记的绘制就是将数据绘制成图形标记的过程
  • 图形组件:
    • X 轴、Y 轴等坐标轴
    • 数据标签:用来标记图形标记对应的数据
    • 图例:通用用来标记不通颜色、形状对应的数据

那如何从图表背后的数据绘制出这写图形标记呢?这里其实是数据到图形标记的映射:
image.png
如对折线图,折线图中每个图形标记点都是一条数据映射到折线图画布中的X/Y位置上,然后使用折线将各个点连接起来,即完成了折线图图形标记的绘制。

折线图

了解了图形的组成,和图形标记实际上是数据的映射后,下面来具体实现折线图,这里通用使用 OOP 模式来设计和显示 Line class。

  • 准备画布

这里是准备 Canvas API 依赖的 <canvas> 元素

// step 1: 准备画布
this.canvas = new Canvas(container, width, height);
复制代码
  • 计算每一条数据到画布位置的映射
// step 2: 数据点位置计算
const xMapping = mapping(data, xField, [MARGIN, width - MARGIN]);
const yMapping = mapping(data, yField, [MARGIN, height - MARGIN]);
复制代码
  • 绘制折线
// step 3: 绘制折线
for (let index = 0; index < data.length - 1; index += 1) {
  this.canvas.drawLine(
    { x: xMapping[index], y: yMapping[index] },
    { x: xMapping[index + 1], y: yMapping[index + 1] },
    {
      strokeStyle: COLOR_PALETTE[0],
      lineWidth: 2
    }
  );
}
复制代码
  • 绘制圆点
// step 4: 绘制数据点
for (let index = 0; index < data.length; index += 1) {
  this.canvas.drawPoint({ x: xMapping[index], y: yMapping[index] }, 4, {
    fillStyle: COLOR_WHITE,
    strokeStyle: COLOR_PALETTE[0]
  });
}
复制代码

这样 Line 的基本绘制逻辑就具备了,测试以下:

const SAMPLE_DATA = [
  { area: "东北", sales: 2681567.469000001 },
  { area: "中南", sales: 4137415.0929999948 },
  { area: "华东", sales: 4684506.442 },
  { area: "华北", sales: 2447301.017000004 },
  { area: "西北", sales: 815039.5959999998 },
  { area: "西南", sales: 1303124.508000002 }
];

// Ex1: draw line chart
const lineDiv = container.appendChild(document.createElement("div"));
const lineChart = new Line(lineDiv, {
  data: SAMPLE_DATA,
  width: 400,
  height: 300,
  xField: "area",
  yField: "sales"
});
lineChart.render();
复制代码

就会完成一个简单折线的绘制了,如下图所示:
image.png

柱形图

在实现了折线图,使用类似的思路,这里来实现柱形图的 Column class、和条形图 Bar class。
这里第一步、和第二步和 Line 一致,均是准备画布、和计算数据到画布X/Y坐标的映射计算。第三步则按照 Column、和 Bar 来分别来绘制柱子和水平柱子。

对柱形图 Column 来说,第三步为根据 X/Y 坐标会绘制垂直柱子:

// step 3: 绘制柱子

// 计算柱子宽度
const columnWidth =
      xMapping.length > 1
? (xMapping[1] - xMapping[0]) * 0.5
: (width - MARGIN * 2) * 0.5;

// 循环绘制矩形
for (let index = 0; index < data.length; index += 1) {
  this.canvas.drawRect(
    { x: xMapping[index] - columnWidth / 2, y: MARGIN },
    { x: xMapping[index] + columnWidth / 2, y: yMapping[index] },
    { fillStyle: COLOR_PALETTE[0] }
  );
}
复制代码

对条形图 Bar 来说,第三步为根据 X/Y 坐标来绘制水平柱子:

// step 3: 绘制水平柱子

// 计算柱子宽度
const columnWidth =
      xMapping.length > 1
? (xMapping[1] - xMapping[0]) * 0.5
: (width - MARGIN * 2) * 0.5;

// 循环绘制水平矩形
for (let index = 0; index < data.length; index += 1) {
  this.canvas.drawRect(
    {
      x: MARGIN,
      y: xMapping[index] - columnWidth / 2
    },
    {
      x: yMapping[index],
      y: xMapping[index] + columnWidth / 2
    },
    { fillStyle: COLOR_PALETTE[0] }
  );
}
复制代码

完成 Column 和 Bar 的实现后,同样来测试一下:

const SAMPLE_DATA = [
  { area: "东北", sales: 2681567.469000001 },
  { area: "中南", sales: 4137415.0929999948 },
  { area: "华东", sales: 4684506.442 },
  { area: "华北", sales: 2447301.017000004 },
  { area: "西北", sales: 815039.5959999998 },
  { area: "西南", sales: 1303124.508000002 }
];

// Ex1: draw line chart
const lineDiv = container.appendChild(document.createElement("div"));
const lineChart = new Line(lineDiv, {
  data: SAMPLE_DATA,
  width: 400,
  height: 300,
  xField: "area",
  yField: "sales"
});
lineChart.render();

// Ex2: draw column chart
const columnDiv = container.appendChild(document.createElement("div"));
const columnChart = new Column(columnDiv, {
  data: SAMPLE_DATA,
  width: 400,
  height: 300,
  xField: "area",
  yField: "sales"
});
columnChart.render();

// Ex3: draw bar chart
const barDiv = container.appendChild(document.createElement("div"));
const barChart = new Bar(barDiv, {
  data: SAMPLE_DATA,
  width: 400,
  height: 300,
  xField: "area",
  yField: "sales"
});
barChart.render();
复制代码

就会绘制一个简单的柱形图和一个条形图了:
image.png

饼图

饼图的区别点在于,数据映射到画布的不是 X/Y 坐标,因为折线图、柱形图都是笛卡尔坐标系,在图形绘制的时候需要确定的是 X/Y 坐标;而饼图是极坐标系,在图形绘制需要确定的是半径和角度,另外,饼图中各个切片的半径是相同的,因此这里绘制时半径使用固定值。在通用情况下,饼图会使用不同的颜色来区分不同的切片。所以在饼图绘制时候,第二步为计算数据到角度和颜色的映射,第三步根据角度和颜色绘制扇区:

// 半径:固定值
const r = Math.min(width, height) / 2 - MARGIN;
// 中心点
const center = {
  x: width / 2,
  y: height / 2
};

// step 2: 角度/颜色计算
const angleMapping = sumMapping(data, angleField, [0, Math.PI * 2]);
const colorMapping = mapping(data, colorField, [0, data.length]);

// step 3: 绘制弧形
for (let index = 0; index < data.length; index += 1) {
  this.canvas.drawArc(
    center,
    r,
    angleMapping[index - 1] || 0,
    angleMapping[index],
    {
      fillStyle: COLOR_PALETTE[Math.floor(colorMapping[index])]
    }
  );
}
复制代码

通用,测试代码如下

// Ex4: draw pie chart
const pieDiv = container.appendChild(document.createElement("div"));
const pieChart = new Pie(pieDiv, {
  data: SAMPLE_DATA,
  width: 400,
  height: 300,
  colorField: "area",
  angleField: "sales"
});
pieChart.render();
复制代码

就会绘制出简单的饼图:
image.png

坐标轴

最后,来看一下,如何给折线图柱形图绘制坐标轴组件。在上面的示例,可以看到,在计算 X/Y 坐标映射的时候,已经在图形画布四周预留了空白区域(MARGIN 常量决定的区域),这即是预留给坐标轴的空间。

首先看坐标轴的组成:

  • 坐标轴标记线:即基础的轴线,对 Y 轴,通常还有网格线
  • 坐标轴文本标签:标记坐标轴上的刻度文本
  • 坐标轴标题:对坐标轴可选绘制一个标题说明

对于 X 轴来说,X 轴对于的数据通常是分类数据,对 X 轴的绘制就是对数据中的分类字段按顺序绘制即可:

// X 轴
const xTicks = mappingTicks(data, xField, [MARGIN, width - MARGIN]);
this.canvas.drawLine(
  { x: MARGIN, y: MARGIN },
  { x: width - MARGIN, y: MARGIN },
  { strokeStyle: COLOR_BLACK }
);
_.each(xTicks, (xm, index) => {
  this.canvas.drawLine(
    { x: xm.value, y: MARGIN },
    { x: xm.value, y: MARGIN - 4 },
    { strokeStyle: COLOR_BLACK }
  );
  this.canvas.drawText(
    xm.label,
    { x: xm.value, y: MARGIN - 10 },
    { textAlign: "center", textBaseline: "middle", font: "18px Sans" }
  );
});
复制代码

对 Y 轴来说,通用 Y 轴是数值类型,需要计算数据中数值的范围,然后计算每个刻度的文本:

// Y 轴
const yTicks = mappingTicks(data, yField, [MARGIN, height - MARGIN]);
_.each(yTicks, (ym, index) => {
  if (index > 0) {
    this.canvas.drawLine(
      { x: MARGIN, y: ym.value },
      { x: width - MARGIN, y: ym.value },
      { strokeStyle: COLOR_BLACK2 }
    );
  }
  this.canvas.drawText(
    ym.label,
    { x: MARGIN - 5, y: ym.value },
    { textAlign: "right", textBaseline: "middle", font: "18px Sans" }
  );
});
复制代码

最后的效果如下:
image.png
这部分的完整实现可以参考 codesanbox

总结

本文从浏览器提供的基础 2D 图形绘制标准说起,提供了一个简易的 Canvas API 的 OOP 封装,然后使用 Canvas 封装描述了基础的可视化图表的实现基础原理。到这里,已经可以看到一个基础的可视化图表库的雏形了,当然这里仍然有很多问题

在功能层面上看:

  • 支持的图形种类只有折柱饼,种类还严重缺失
  • 支持的图形组件还缺失:数据标签、图例等等
  • 动画缺失
  • 事件交互缺失

同时在工程实现上看,这里采用 OOP 实现方式是否也有问题:

  • 不同图形将的代码复用多吗?
  • 如何解决图形种类增加后类树类型增加问题?
  • 如何支持用户自定义图形?
  • 如何支持 SVG 等其他图形渲染技术?

这些问题都待读者进一步思考,如何还有进一步的兴趣,可以参照:

  • ggplot2 :ggplot2 也是 R 语言社区中最著名的绘图包,是图形语法实现最全面的软件,提供一种全新的视角来理解可视化图形
  • d3-scale: 本文提到的数据到X/Y/颜色等的映射,即是 scale 的基本功能
  • AntV G2:G2 是蚂蚁 AntV 小组对图形语法的实现
© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享