ZRender学习笔记

如何调试程序

1
npm run watch:bundle

注意,HTML 引入 dist 下的文件;如果遇到 TS 报错,设置@ts-ignore 即可。

ZRender 是什么

这个文章描述得很清晰,这里就不再赘述了:

https://www.runoob.com/w3cnote/html5-canvas-zrender.html

ZRender 应用了很多计算机图形学的内容(详见其 core 目录)

因为 ZR 比较早,那时候很多 ES 特性还没有,因此 ZR 都手动实现了

另外为了减少依赖,ZR 也实现了很多算法

所以其源码是绝对值得一读的,可以让我们受益颇多。

为什么叫 ZRender?

因为这是基于层级(z-index)实现的渲染器,因此叫做 ZRender

ECharts 与 ZRender 的关系

这两者就是一个数据驱动的具体体现。
ECharts 只处理数据(storage),相当于只做数据的预处理。

ZRender 就是一个渲染引擎。

数据驱动:我们应该大部分代码都只是操作数据;绘制完全由渲染引擎搞定。

重点学什么

参考二八原则,我只需要先了解 20%最重要的概念,能够应对平时 80%的需求即可。后续的等有具体需求再不断学习。

通过_zr 实例快速了解 ZRender 的数据结构。

Painter 的分治法设计,调度器设计

事件的抽象和封装,比如小程序、canvas、dom 都是不一样的

名词

shape

ZRender 内置了很多图形(圆形、椭圆、圆环、扇形、矩形、多边形、直线、曲线、心形、水滴、路径、文字、图片等),能够满足我们平时的绝大部分需求。

这些 shape 的配置,都是在 option 下的 shape 这个属性上。

这里只列出一些非常规的图形。

类型 说明
Arc 圆弧
BezierCurve 贝塞尔曲线
CompoundPath 复合路径
Droplet 水滴
Ellipse 椭圆
Heart 心形
Image 图形
Isogon 正多边形
Polyline 填充的多边形
Rose 玫瑰线
Sector 扇形
Star 星型
Trochoid 内外旋轮曲线
Path.extend(props) 扩展自定义图形

Element

元素,用来修改属性

Eventful

支持事件的元素,其 API 都是和事件处理相关的。

Group

Group 是一个容器,可以插入子节点,Group 的变换也会被应用到子节点上

这个很适合对包含多个元素的内容进行批量操作,比如一个图形的 legend 有 N 个,那么可以通过 group 对齐进行统一的动画处理和事件处理。

Group 继承自 zrender.Element

Transformable

可以进行变换的对象,变换包括:位移、旋转、缩放。

util

静态工具类

数据结构

图形的抽象类zrender.Displayable一定要弄明白,这是所有图形的属性基础。

这里的配置很多都是和 CSS 相关的,因此需要先熟悉下 CSS。

了解这个的意义,就是你知道哪些视觉样式效果可以实现。

拾取

可视化中的拾取一般有 2 种:像素拾取和包围盒拾取,ZRender 用的是包围盒拾取。但是并不是简单的矩形包围盒拾取,而是在此之上做了更细致的处理,保证不规则图形也能准确拾取。

代码

可以重点看下Handler.ts

会用上一个元素和当前元素的对比,然后通过 dispatch 的方式触发事件:

1
dispatchToElement(lastHovered, 'mouseout', event);

这个设计很赞!这些事件分发机制是可视化库中很重要的核心机制,也是很好的抽象解耦设计,可以整理整个项目中所有的事件机制,拿出来整体分析讲一下。

(TODO)不规则图形的拾取

可以看下path.ts,针对每个图形,应该有个数据结构来描述和存储其线条信息(数据驱动)。

非零环绕原则

如何判断某个点是否在某个图形内部:非零环绕原则

“非零环绕原则”(Non-Zero Winding Number Rule)是一个在计算机图形学中用于确定点是否在路径内部的规则。这个原则通常用于 Canvas 绘图或图形处理中,尤其是在处理自相交的多边形或路径时。

根据搜索结果,非零环绕原则的工作原理如下:

  • 从区域内随机选取一个点。

  • 从这个点拉一条直线到图形外部。

  • 观察穿过这条直线的路径段,如果是顺时针方向穿过则记为 +1,逆时针方向穿过则记为 -1。

  • 将所有穿过直线的路径段的记数相加,如果总和为非零,则填充该区域;如果总和为零,则不填充。

这个原则适用于复杂图形,包括自相交的多边形,以确定哪些区域应该被填充。

(TODO)性能问题

这部分待确认。

模糊搜索

这个可以用于扩大交互热区,可以重点看下findHoverpointerSize相关的代码。
可惜这是 ECharts5.4 新增的特性,我们目前还不能使用,得升级下基础组件库版本才行。

元素选择

图形元素有 name 属性,这个是很有用的,比如可以给元素设置 name 属性,能很方便的通过 childOfName 寻找元素:

1
2
circle.name = 'abc';
group.childOfName('abc');

事件

事件操作和普通的 DOM 事件操作类似,非常方便:

1
2
3
rect.on('click', () => {
console.log('click on the rect');
});

Canvas 就一个画布,那事件是怎么实现的呢?

其原理是给 canvas 绑定相关事件,然后通过记录元素在 canvas 中的坐标范围,判断事件作用于哪些元素,然后选择这些元素中层级最高的一个,执行该元素的对应事件。

可以看下这个文章,讲得很详细:https://www.cnblogs.com/suyuanli/p/9212994.html

分层

shape 的层级,是根据添加的顺序来定的,先添加的层级较低,后添加的层级较高,会显示在最上面,覆盖之前的图形。

这样刷新也是局部刷新,可以带来性能上的提升。

动画

张宇航的文章-同顺圈

张宇航的文章-掘金

执行流程

flow

数据结构

一帧动画的定义:

Frame = Clip(Tracks)
Clip:剪辑,确定时间
Track:轨道,确定属性和值

concept

Animator

功能:定义动画(类比 Model)。

动画对象

Animator 的构造函数的 animatorObj 参数:
animator-object

Track

Track = Attribute(startValue + endValue)

单属性关键帧(属性变化的起止值)。每个属性会单独生成一个 Track 对象(比如给 rotate 的变更生成一个 Track),在该对象的 keyframes 数组中存放了该属性的关键帧(一般为起始帧+截止帧,共 2 帧)。

这是个数据概念

这个类是定义在 Animator 中的,由此可见二者的关系。

这是一个 Track 的数据结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
{
"keyframes": [
{
"time": 0,
"value": 0,
"rawValue": 0,
"percent": 0
},
{
"time": 1000,
"value": 2,
"rawValue": 2,
"percent": 1
}
],
"discrete": false,
"_invalid": false,
"_needsSort": true,
"_lastFr": 0,
"_lastFrP": 1,
"propName": "rotation",
"valType": 0,
"_finished": true
}

这是放大效果(enlarge)的 tracks:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
[
{
keyframes: [
{
time: 0,
value: -50,
rawValue: -50,
percent: 0,
},
{
time: 1000,
value: -100,
rawValue: -100,
percent: 1,
},
],
discrete: false,
_invalid: false,
_needsSort: true,
_lastFr: 0,
_lastFrP: 1,
propName: 'x',
valType: 0,
_finished: true,
},
{
keyframes: [
{
time: 0,
value: -50,
rawValue: -50,
percent: 0,
},
{
time: 1000,
value: -100,
rawValue: -100,
percent: 1,
},
],
discrete: false,
_invalid: false,
_needsSort: true,
_lastFr: 0,
_lastFrP: 1,
propName: 'y',
valType: 0,
_finished: true,
},
{
keyframes: [
{
time: 0,
value: 100,
rawValue: 100,
percent: 0,
},
{
time: 1000,
value: 200,
rawValue: 200,
percent: 1,
},
],
discrete: false,
_invalid: false,
_needsSort: true,
_lastFr: 0,
_lastFrP: 1,
propName: 'width',
valType: 0,
_finished: true,
},
{
keyframes: [
{
time: 0,
value: 100,
rawValue: 100,
percent: 0,
},
{
time: 1000,
value: 200,
rawValue: 200,
percent: 1,
},
],
discrete: false,
_invalid: false,
_needsSort: true,
_lastFr: 0,
_lastFrP: 1,
propName: 'height',
valType: 0,
_finished: true,
},
];

Clip(动画片段/动画主控制器/剪辑)

这是个操作概念。可以理解为是个代理,代理到Trackstep()

功能:剪辑 AnimatorTracks(所以 Clip 中没有动画属性,只有时间属性。这也是为什么 onframe 的实现是放在 Animator 中定义的,因为只有 Animator 存放了动画相关的属性):

1
2
3
4
5
6
7
8
9
10
export interface ClipProps {
life?: number;
delay?: number;
loop?: boolean;
easing?: AnimationEasing;

onframe?: OnframeCallback;
ondestroy?: ondestroyCallback;
onrestart?: onrestartCallback;
}

相关动画名词可以参考 Three.js 的动画文档

单词 翻译
clip 动画片段(也有翻译为剪辑的,但是感觉没有片段好,ECharts 注释中叫做动画主控制器)
tracks 一个由关键帧轨道(KeyframeTracks)组成的数组。

这是最小粒度的动画,每一个属性的变动,都会生成一个 clip,比如这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
circle.animateTo(
{
shape: {
r: 10,
},
style: {
fill: 'green',
},
position: [300, 600],
},
{
duration: 1000,
delay: 500,
done: function () {
console.log('动画结束了');
},
}
);

总共改变了 circle 的三个属性:半径 r、填充色 fill、位置 position,因此这里会生成 3 个 clip。

Clip 类的成员变量如下:

1
2
3
4
5
6
7
8
9
* @config target 动画对象,可以是数组,如果是数组的话会批量分发onframe等事件
* @config life(1000) 动画时长
* @config delay(0) 动画延迟时间
* @config loop(true)
* @config gap(0) 循环的间隔时间
* @config onframe
* @config easing(optional)
* @config ondestroy(optional)
* @config onrestart(optional)
Clip 的step流程:
  • 通过 elapsedTime、lifeTime 算出动画百分比 percent
  • 通过 easingFunc + percent 算出百分比对应的值 schedule
  • 调用 onframe(schedule)更新动画对象的属性(如果没自定义,一般就是调用的 Animatorstart() 方法中定义的 onframe()方法)

Clip 对象是个链表结构。

由于 Clip 是 ZRender 的底层动画机制,粒度太小了,按理说我们不应该操作它。

GPT 的解释

这个文件定义了一个名为 Clip 的类,它在 ZRender 中扮演着动画剪辑或片段的角色。Clip 类是动画系统的一个基础组件,用于管理和控制单个动画的生命周期和行为。以下是该类的关键点:

动画生命周期管理:Clip 类管理动画的开始、进行、暂停、恢复和结束。

属性定义:

_life:动画的持续时间。
_delay:动画开始前的延迟时间。
_inited:标记动画是否已初始化。
_startTime:动画开始的时间。
_pausedTime 和 _paused:用于控制动画的暂停状态。
回调函数:

onframe:每个动画帧调用的回调函数,用于更新动画状态。
ondestroy:动画结束时调用的回调函数。
onrestart:动画循环重新开始时调用的回调函数。
动画循环控制:

loop:控制动画是否循环播放。
缓动函数:

easing 和 easingFunc:用于指定动画速度变化的缓动函数。
动画控制方法:

step:根据全局时间和时间差来更新动画状态,是动画的主驱动方法。
pause 和 resume:用于控制动画的暂停和恢复。
设置缓动函数:

setEasing:允许动态设置动画的缓动函数。
链表结构:Clip 对象可以通过 next 和 prev 属性连接成链表,这有助于动画管理系统高效地遍历和处理动画队列。

构造函数:接受一个配置对象 opts,用于初始化 Clip 实例的各种属性。

这个类可能是 ZRender 动画系统的一部分,用于处理动画的逻辑。在实际使用中,你可能会创建 Clip 实例,配置其属性,并将其添加到动画控制器中,以实现复杂的动画效果。通过 onframe 回调,你可以定义动画过程中的具体行为,例如更新对象的位置、形状或其他视觉属性。

Animation

这才是动画总控制器(类比 Controller)。

这里的 update()方法是执行动画逻辑的地方。

动画的RAF 帧循环也是在这里实现的(有做优化,仅会在动画中运行):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
_startLoop() {
const self = this;

this._running = true;

function step() {
if (self._running) {
requestAnimationFrame(step);
!self._paused && self.update();
}
}

requestAnimationFrame(step);
}

我们可以通过修改 animation 的属性,然后调用 animation.update(),跳转到任何一个指定的动画关键帧。只需要设置一个 point 比例尺,反向根据数值计算动画的百分比即可。

折线图的动画,要确认下是否拆分为了多个 clip,因为是有多段的;也可能是一个动画,通过裁剪实现的?

常用方法

常用的有 animateTo()和 animate()这 2 个方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
rect.animateTo(
{
position: [10, 100],
},
500,
0,
'linear'
);

line
.animate('shape', false)
.when(500, {
percent: 1,
})
.start();

建议使用 animate(),这个更完善,能实现的功能更多。

顺序播放动画的实现

  • 可以通过设置不同的 delay 来实现,适合多个元素不同动画同时执行的情况
  • 可以通过.done()执行动画的链式调用来实现,适合一个元素多个动画顺序执行

这两种方式有不同的应用场景,可以结合起来用。

如何跳到某一帧?

手动计算

  • 通过动画的起止值,设置一个属性比例尺 attrScale
  • 根据动画的播放时长,设置一个时间比例尺 timeScale
  • 利用 timeScale,根据传入的目标时间参数(now),得到归一化后的时间值 t
  • 根据该归一化的时间值 t,通过属性比例尺 attrScale 得到该时间点的属性值 attrObject
  • 调用图形的 attr(attrObject)方法绘制该帧数的图形
1
2
3
4
5
update(): void {
const val = this.scaler.normalize(this.now);
const sttr: any = this.getAttr(this.formatters, Player.doEase(val,this.option.easing));
this.target.attr(sttr);
}

clip.onframe(percent)

1
clip.onframe(1);

比如 Animator.ts 的 stop()方法就是用这个实现跳转到动画最后一帧的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* Stop animation
* @param {boolean} forwardToLast If move to last frame before stop
*/
stop(forwardToLast?: boolean) {
if (!this._clip) {
return;
}
const clip = this._clip;
if (forwardToLast) {
// Move to last frame before stop
clip.onframe(1);
}

this._abortedCallback();
}

缓动类型

可以查看这个页面:

https://echarts.apache.org/examples/zh/editor.html?c=line-easing

禁用动画

禁用入场动画

禁用播放动画

ZRender.Element.stopAnimation()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* 停止动画
* @param {boolean} forwardToLast If move to last frame before stop
*/
stopAnimation(scope?: string, forwardToLast?: boolean) {
const animators = this.animators;
const len = animators.length;
const leftAnimators: Animator<any>[] = [];
for (let i = 0; i < len; i++) {
const animator = animators[i];
if (!scope || scope === animator.scope) {
animator.stop(forwardToLast);
} else {
leftAnimators.push(animator);
}
}
this.animators = leftAnimators;

return this;
}

渲染器

ZRender 从 4.0 版本就实现了 Canvas、SVG、VML 三种渲染器,通过配置项的 renderer 即可配置。

不过据说 SVG 渲染器在处理渐变色上有 BUG,官方尚未修正(似乎是尚未实现)。

注册渲染器

如果想要引入 SVG 渲染器,需要注册下。类似这样:

1
2
3
4
export * from './src/zrender';
export * from './src/export';

import './src/svg/svg';

在这个 src/svg/svg.ts 中,进行了渲染器的注册:

1
2
3
4
import { registerPainter } from '../zrender';
import Painter from './Painter';

registerPainter('svg', Painter);

这样你就可以使用 SVG 渲染器了。

如果你项目中是通过 npm 包的形式引入 ZRender 的,那么在 node_modules/zrender/zrender.all.js 中,也可以看到渲染器的引入:

1
2
3
4
5
export * from './src/zrender';
export * from './src/export';
// 引入SVG渲染器
import './src/svg/svg';
import './src/vml/vml';

SVG 渲染器的实现原理

SVGPathRebuilder 这个类,用 SVG 实现了 Canvas 的各种 API,因此 ZRender 就可以像操控 Canvas 一样操控 SVG 了。

SVGPathRebuilder

(待确认)具体是调用 Canvas 还是 SVG,是在 src/canvas/Painter.ts 和 src/svg/Painter.ts 的 paintOne()方法中进行差异化的:

1
2
3
4
5
6
7
8
9
10
11
// SVG的
paintOne(el: Displayable): SVGElement {
const svgProxy = getSvgProxy(el);
svgProxy && (svgProxy as SVGProxy<Displayable>).brush(el);
return getSvgElement(el);
}

// Canvas的
paintOne(ctx: CanvasRenderingContext2D, el: Displayable) {
brushSingle(ctx, el);
}

绘图流程

数据驱动

数据驱动的理念,在 ZRender 中和 D3 中,都是一样的,核心都是将数据分为三个类别:enter、update、exit,针对不同类型的数据,实现渲染/动画函数,并将数据处理和渲染分离开。

为什么要有数据驱动,而不是删除重建:动画和图形的连续性。

下面以一个柱状图动画为例进行讲解。

扩展

分而治之的图形定义策略允许你扩展自己独有的图形元素,你既可以完整实现三个接口方法(brush、drift、isCover), 也可以通过 base 派生后仅实现你所关心的图形细节。

常见功能的实现原理

坐标轴

ZRender 画坐标轴,是通过画一条长线(Line)+N个刻度线(Line)+N个坐标轴数值文本(Text)来实现的,那么在页面缩放的时候,这些数值是如何自动调整的呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
var xline = new zrender.Line({
shape: {
x1: disLeft,
y1: h - disTop,
x2: disLeft + wRadio * (4 * dis),
y2: h - disTop,
},
style: {
stroke: stroke,
},
});
var yline = new zrender.Line({
shape: {
x1: disLeft,
y1: disTop,
x2: disLeft,
y2: h - disTop,
},
style: {
stroke: stroke,
},
});
zr.add(xline);
zr.add(yline);
for (i = 0; i < 5; i++) {
var smline = new zrender.Line({
shape: {
x1: 0,
y1: 0,
x2: 0,
y2: 0.02 * h,
},
style: {
stroke: stroke,
},
position: [disLeft + wRadio * (i * dis), h - disBottom],
});
var smText = new zrender.Text({
style: {
stroke: '#434348',
text: this.beginSec + i * dis,
fontSize: '11',
textAlign: 'center',
},
position: [disLeft + wRadio * (i * dis), h - disBottom + 0.03 * h],
});
zr.add(smline);
zr.add(smText);
}

渐变

渐变是通过将 shape 的 style.fill 属性设置为一个渐变元素来实现的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
this.originLinearColor = new zrender.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: color[0],
},
{
offset: 1,
color: color[1],
},
]);
var zrEle = new zrender.Rect({
shape: {
cx: 0,
cy: 0,
width: wRadio * (ed - bg),
height: 0,
},
style: {
// 渐变
fill: this.originLinearColor,
},
position: [disLeft + wRadio * (bg - this.beginSec), h - disBottom],
});

扩展新的图形

分而治之的图形定义策略允许你扩展自己独有的图形元素,你既可以完整实现三个接口方法(brush、drift、isCover), 也可以通过 base 派生后仅实现你所关心的图形细节。

可以参考 graphic/helper/roundRect.ts 这个类,对于圆角矩形的实现方案。

参考这个案例:https://github.com/ecomfe/zrender/blob/master/test/pin.html

通过 Path 构建自定义图形

业务需求中经常会出现不规则图形,这种情况,使用 ZRender 默认提供的几何图形可能无法实现,就需要我们自己去扩展了,此时用 Path 就很合适。

ZRender 支持不同类型的渲染器(Painter),如果想要用 Path 绘图,那么我们需要获取到渲染器对象 SVGPathRebuilder 的引用,才能调用偏底层的绘图函数,比如 arc、moveTo、lineTo 这种。

但是 ZRender 并没有对外提供获取 SVGPathRebuilder 的方法,那我们怎么才能获取 SVGPathRebuilder 的引用呢?

答案就是通过 zrender.Path.extend()扩展新的 Path 类图形,这样在渲染该图形的时候,扩展类的 buildPath(ctx, shape)方法中,ZRender 会默认将 SVGPathRebuilder 引用作为第一个参数传入进去,我们就可以用这个引用绘图了。

绘制自定义图形的过程

svg/Painter.ts 中:

1
2
3
4
5
paintOne(el: Displayable): SVGElement {
const svgProxy = getSvgProxy(el);
svgProxy && (svgProxy as SVGProxy<Displayable>).brush(el);
return getSvgElement(el);
}

svg/graphic.ts 中:

1
2
3
4
5
6
7
8
9
10
11
12
const svgPath: SVGProxy<Path> = {
brush(el: Path) {
// 次数省略部分代码
if (el.shapeChanged()) {
path.beginPath();
// 将SVGPathRebuilder的引用(即path变量)传递给了自定义图形的buildPath方法
el.buildPath(path, el.shape);
el.pathUpdated();
}
// 次数省略部分代码
},
};

一个示例

比如我要扩展一个带圆角的矩形,就可以这样编写代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
let div = document.getElementById('main');
let zr = zrender.init(div, {
renderer: 'svg',
});

const RadiusRect = zrender.Path.extend({
type: 'RadiusRect',
shape: {},
buildPath: (path, shape) => {
path.beginPath();
// 注意,必须要有moveTo这一步,否则针对SVG渲染器,会因为缺少起点M属性,而无法绘图
path.moveTo(100, 0);
path.lineTo(900, 0);
path.arc(900, 20, 20, -Math.PI / 2, 0);
path.lineTo(920, 100);
path.lineTo(100, 100);
path.lineTo(100, 0);
},
});

const db = new RadiusRect({
shape: {},
style: {
fill: '#009000',
},
});

zr.add(db);

变形动画(morphPath)

可以通过这个功能,实现很炫酷的形变动画效果!

对应的 API 是 zrender.morphPath:

1
2
3
4
5
6
zrender.morphPath(currentShape, nextShape, {
duration: 1000,
done() {
setTimeout(morphShape, 100);
},
});

可以参考 zrender 代码库下的 test/morphPath.html 这个示例,源码在 zrender/src/tool/morphPath.ts 中。

从源码来看,使用了贝赛尔曲线,会通过新老图形的 path 信息,寻找一个合适的点,将老图形往这个点集合,然后再从这个点扩展到新图形。

ZRender4 VS. ZRender5

内容 ZRender4 ZRender5 备注
语言 JavaScript TypeScript
继承方式 原型链 class

几个代码层面需要注意的地方:

1、zrender4 的 Canvas 模式不支持 path.beginPath(),如果加上这一行,会导致第一次图形绘制不出来。

注意事项

初始页面元素必须设置高宽,否则图形会因为 height=0 无法显示,因为 zrender.init(domElement)是根据传入的 DOM 元素的属性来设置高宽的。

怎么在网页中调试 ZRender 呢?比如我想知道页面上的图形,具体是由哪些元素(shape)构成的、每个元素的层级是什么?

元素初始化之后,需要通过 zrender.Element.attr 修改属性

官方文档明确说明了这一点。初始化之后,直接修改 style 是不生效的。

感触

因为之前看过 D3.js,感觉 ZRender 就是 Canvas 版本的 D3.js 的作图部分,学起来非常快,因为很多概念都是想通的。

可以通过 ZRender+D3 结合的方式来作图,D3 负责做数据计算,ZRender 负责做渲染。