前言
在业务中,我们通常需要使用可视化图表来生动得展示业务数据,比如常常用到的折线图、柱形图、和饼图等,这些图表是如何在浏览器上绘制的?本文将简单地介绍绘制这些图形所用到的浏览器技术,使用 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 的典型使用分三步:
- 准备 Canvas API 依赖的 元素
<canvas id="canvas"></canvas>
复制代码
- 获取 Canvas 图形绘制上下文
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
复制代码
- 调用图形绘制命令
ctx.fillStyle = 'green';
cxt.fillRect(10, 10, 150, 100);
复制代码
这里示例是绘制一个绿色矩形,Canvas 提供了各种 API 来绘制各种图形。
下面列举一些常用的 API:
moveTo
:移动位置到指定坐标lineTo
:从当前位置绘制直线到指定位置rect
:绘制矩形arc
:绘制弧形strokeText
、fillText
:绘制文本
掌握了这些图形绘制 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 来简单的会绘制常用的图形了,读者可以尝试绘制如下的图形。
这部分的具体代码可以参照这个 codesandbox。
绘制图表库
图表组成
在了解了 Cavans 图形绘制技术后,开始实现自己的图表库之前,首先先看看一个典型的图形由哪些组成部分。
上面分别是一个折线图和一个柱形图,通常将图形分为以下几个组成部分:
- 图形标记:最主要的组成部分,如折线图中的折线、点,柱形图中的矩形,这些标记是图表背后数据的展示,图形标记的绘制就是将数据绘制成图形标记的过程
- 图形组件:
- X 轴、Y 轴等坐标轴
- 数据标签:用来标记图形标记对应的数据
- 图例:通用用来标记不通颜色、形状对应的数据
那如何从图表背后的数据绘制出这写图形标记呢?这里其实是数据到图形标记的映射:
如对折线图,折线图中每个图形标记点都是一条数据映射到折线图画布中的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();
复制代码
就会完成一个简单折线的绘制了,如下图所示:
柱形图
在实现了折线图,使用类似的思路,这里来实现柱形图的 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();
复制代码
就会绘制一个简单的柱形图和一个条形图了:
饼图
饼图的区别点在于,数据映射到画布的不是 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();
复制代码
就会绘制出简单的饼图:
坐标轴
最后,来看一下,如何给折线图柱形图绘制坐标轴组件。在上面的示例,可以看到,在计算 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" }
);
});
复制代码
最后的效果如下:
这部分的完整实现可以参考 codesanbox。
总结
本文从浏览器提供的基础 2D 图形绘制标准说起,提供了一个简易的 Canvas API 的 OOP 封装,然后使用 Canvas 封装描述了基础的可视化图表的实现基础原理。到这里,已经可以看到一个基础的可视化图表库的雏形了,当然这里仍然有很多问题
在功能层面上看:
- 支持的图形种类只有折柱饼,种类还严重缺失
- 支持的图形组件还缺失:数据标签、图例等等
- 动画缺失
- 事件交互缺失
同时在工程实现上看,这里采用 OOP 实现方式是否也有问题:
- 不同图形将的代码复用多吗?
- 如何解决图形种类增加后类树类型增加问题?
- 如何支持用户自定义图形?
- 如何支持 SVG 等其他图形渲染技术?
这些问题都待读者进一步思考,如何还有进一步的兴趣,可以参照: