流程图JS实现方案对比

实现流程图和类流程图的工具主要需要解决数据 -> 图形交互两方面问题。在实现图形元素时也有canvas,SVG,canvas with DOM,SVG with DOM,DOM with canvas一些实现方式。

canvas和SVG的实现方式区别比较明显:

  • 大规模元素、频繁重绘上,canvas完胜
  • 强调光影效果上,canvas小胜
  • 强调导出图片上,canvas小胜
  • 强调元素可交互上,SVG完胜
  • 强调画图元素可缩放上,SVG完胜

使用SVG实现时,元素规模大以及频繁重绘时会出现卡顿现象,在大规模元素场景下交互也会有卡顿。使用canvas实现时,保证流程图元素的可交互性将成为头疼的难题,开发者需要自己模拟浏览器的一部分行为。

下面是一些流程图实现基础的对比。

d3

d3着眼在数据可视化,重点在使用不同layout组织数据,完成可视化。

d3最初是天然支持SVG的,这点从类jQuery的API也能看出来。d3和canvas的结合上,绘制需要额外的data binding操作,周期性地将虚拟的DOM节点映射到canvas上,重绘得到下一帧画面。要实现canvas可交互的话也需要一些hack的手段。基于d3实现流程图并不划算。

zrender

zrender是一个canvas画图的基础库。它并不负责数据的组织和渲染,需要自己完成这一部分工作。但是zrender提供了让canvas可交互的重要功能。

zrender下,mixin了Eventful特性的元素上可以监听交互事件。Eventful只是为元素提供了类似EventEmitter的功能。真正实现元素可交互的handler。

handler内会拦截发生在canvas内的click/mousedown/mouseup/mousewheel/dblclick/contextmenu事件,交予prototype内对应的处理方法处理,handler内有下面几个关键方法:

  • mousemove,监听canvas内mousemove事件,调用findHover得到当前位置对应的元素,根据情况调用dispatchToElement方法,分发mouseoutmouseovermousemove给刚才得到的元素实例
  • dispatchToElement,分发事件到对应实例,将事件对象封装,trigger实例的对应事件handler,并通过el.parent向上冒泡
  • findHover,指定x, y坐标寻找该坐标位置的元素。从storage中拿到所有的displayable的list。挨个调用isHover判断displayable和[x, y]坐标的关系
  • isHover函数,根据displayable的rectHover属性,即是否使用包围盒检测鼠标进入。调用displayable的rectContaincontain检测是否在其中。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function isHover(displayable, x, y) {
if (displayable[displayable.rectHover ? 'rectContain' : 'contain'](x, y)) {
var el = displayable;
var isSilent;
while (el) {
// If clipped by ancestor.
// FIXME: If clipPath has neither stroke nor fill,
// el.clipPath.contain(x, y) will always return false.
if (el.clipPath && !el.clipPath.contain(x, y)) {
return false;
}
if (el.silent) {
isSilent = true;
}
el = el.parent;
}
return isSilent ? SILENT : true;
}

return false;
}

先简单看下storage,因为zrender里绘制的元素之间没有逻辑关联,因此需要有一个全局存储storage去统一管理加入的Group或Shape。storage的getDisplayList方法返回所有图形的绘制队列。

1
2
3
4
5
6
7
getDisplayList: function (update, includeIgnore) {
includeIgnore = includeIgnore || false;
if (update) {
this.updateDisplayList(includeIgnore);
}
return this._displayList;
},

注:方法中提到的updateDisplayList用于更新图形的绘制队列,在每次绘制前调用,它会深度优先遍历整个树,更新所有的变换后,根据优先级排序得到新的绘制队列。

在displayable的基类中,contain方法只是单纯调用了rectContain(子类都有区别于rectContain的自己的实现)。在rectContain中,获取到坐标相对于图形的坐标(transformCoordToLocal)和图形的包围盒(getBoundingRect)。这里先说简单的RectContain

1
2
3
4
5
rectContain: function (x, y) {
var coord = this.transformCoordToLocal(x, y);
var rect = this.getBoundingRect();
return rect.contain(coord[0], coord[1]);
}

其中getBoundingRect是各自类自己实现的。除了个别情况,如Text,形状都基于Path类。在Path的getBoundingRect中可以看到,path的绘制又额外包装了一层PathProxygetBoundingRect也是使用的PathProxy的方法。在实现上,PathProxy把绘制路径的操作命令拆分成了命令数组。通过记录每一段子路径上x、y的最大最小值,再将所有这些极值比较得到最后的最值。在PathProxy返回结果后,根据描边粗细得到最终结果。

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
getBoundingRect: function () {
// _rect变量做缓存用,计算完成后只在重绘时置空,避免重复计算
var rect = this._rect;
var style = this.style;
var needsUpdateRect = !rect;
if (needsUpdateRect) {
var path = this.path;
if (!path) {
// Create path on demand.
path = this.path = new PathProxy();
}
if (this.__dirtyPath) {
path.beginPath();
this.buildPath(path, this.shape, false);
}
rect = path.getBoundingRect();
}
this._rect = rect;

if (style.hasStroke()) {
// ...

// Return rect with stroke
return rectWithStroke;
}

return rect;
},

Displayable继承自ElementElement通过mixin得到来自Transformable中的transformCoordToLocal方法。这里要说到,zrender中元素和Group都有一个构造时的初始位置,而后的所有变化都是作为transform叠加在元素上的。例如拖拽元素对应的是“原始位置 + transform”而不是一个“新位置”。

在每次变换后,Transformable中的updateTransform方法都会调用,设置自身invTransform属性为这次变化的逆矩阵。在transformCoordToLocal中对向量[x, y]应用这个逆矩阵即可得到点相对于当前形状的位置(可以理解成将点逆变换到形状变换前的位置)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
transformableProto.updateTransform = function () {
//...

m = m || matrix.create();

// ...

// 保存这个变换矩阵
this.transform = m;

// ...

this.invTransform = this.invTransform || matrix.create();
matrix.invert(this.invTransform, m);
};
// ...
transformableProto.transformCoordToLocal = function (x, y) {
var v2 = [x, y];
var invTransform = this.invTransform;
if (invTransform) {
vector.applyTransform(v2, v2, invTransform);
}
return v2;
};

综合这两个方法即可判断点是否在某元素的包围盒中。

判断contain时,首先需要满足rectContain的关系。之后根据描边和填充情况,执行contain/path下对应的containcontainStroke方法。前者实际上是后者stroke为0时的特殊情况。除了path外,可以判断点是否在元素图形内的所有元素在contain下都有对应文件。基本所有的包含都可以转化为指定闭合路径是否包含指定点的问题。

zrender利用PIP(point-in-polygon)问题winding number的解法判断点是否在path中;canvas提供的API中也有isPointInPathisPointInStroke,不过只能针对当前的path。

综上,zrender可以实现canvas内的元素和交互。

g6

g6是antv的一部分,是一个canvas实现的展示关系型数据的JS可视化库。使用canvas的原因应该也在展示大量数据和重绘上更流畅。

使用canvas实现时,g6一样会遇到zrender遇到的实现元素可交互的难题。从处理event的event.js中能看到,关联事件和元素的实现在_getEventObj处完成,剩下的步骤只是额外的封装操作:

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
Util.each(MouseEventTypes, item => {
_events.push(Util.addEventListener(el, item, ev => {
const oldEventObj = this._currentEventObj;
this._oldEventObj = oldEventObj;
this._processEventObj(ev);
const currentEventObj = this._currentEventObj;

// ...
}
}));
});

_processEventObj(ev) {
const graph = this.graph;
const canvas = graph.get('_canvas');
const frontCanvas = graph.get('_frontCanvas');
const evObj = this._getEventObj(ev, canvas);
const frontEvObj = this._getEventObj(ev, frontCanvas);

// ...
}

_getEventObj(ev, canvas) {
const graph = this.graph;
const clientX = ev.clientX;
const clientY = ev.clientY;
const canvasPoint = canvas.getPointByClient(clientX, clientY);
const point = this._parsePoint(canvasPoint.x, canvasPoint.y); // 根据pixel ratio做一个转换
const shape = canvas.getShape(canvasPoint.x, canvasPoint.y);
const item = graph.getItemByShape(shape);

// ...
// ...
}

p.s. 另说一点,frontCanvas的作用是绘制拖拽状态中的元素和辅助线等信息。

最关键的方法getPointByClientgetShape来自Graph的canvas属性,这个属性通过‘@antv/g’(G2)的canvas构造得来。

1
2
3
const G = require('@antv/g');
// ...
const Canvas = G.Canvas;

在G2中,Canvas继承自Group,可以认为Canvas本身已经扮演了根节点的角色。Canvas判断坐标对应元素的方法getShape(x,y)也来自Group。此方法遍历Group下所有元素(包括单个元素或Group),判断点[x, y]是否在范围内:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function find(children, x, y) {
let rst;
for (let i = children.length - 1; i >= 0; i--) {
const child = children[i];
if (child.__cfg.visible && child.__cfg.capture) {
// 是Group就继续向下寻找
if (child.isGroup) {
rst = child.getShape(x, y);
} else if (child.isHit(x, y)) {
rst = child;
}
}
if (rst) {
break;
}
}
return rst;
}

关键的child.isHit方法类似zrender里的contain方法。区别使用包围盒还是自身范围判断。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
isHit(x, y) {
const self = this;
const v = [ x, y, 1 ];
self.invert(v); // canvas

if (self.isHitBox()) {
const box = self.getBBox();
if (box && !Inside.box(box.minX, box.maxX, box.minY, box.maxY, v[0], v[1])) {
return false;
}
}
const clip = self.__attrs.clip;
if (clip) {
if (clip.inside(x, y)) {
return self.isPointInPath(v[0], v[1]);
}
} else {
return self.isPointInPath(v[0], v[1]);
}
return false;
}

使用包围盒时用getBBox()判断,类似zrender;否则使用isPointInPath。这点上g2不同,它只对特殊的闭合曲线如圆、矩形、贝塞尔曲线等等进行自己的实现。对一般性的path,直接使用上面提到的canvas的API来判断。

processOn

processOn严格意义上是一个产品,类似于在线的visio,编辑很流畅。使用DOM + canvas实现。具体来说:

  • DOM绘制每个元素占位,响应交互
  • canvas绘制每个DOM内的图形本身

这么做的好处在有二:1. 天然解决了元素交互的问题;2. 更平滑的元素拖拽效果。

类似的还有jsPlumb这样的使用SVG的方案,使用SVG的优势体现在交互更容易实现。