实现流程图和类流程图的工具主要需要解决数据 -> 图形 和交互 两方面问题。在实现图形元素时也有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
方法,分发mouseout
,mouseover
,mousemove
给刚才得到的元素实例
dispatchToElement
,分发事件到对应实例,将事件对象封装,trigger实例的对应事件handler,并通过el.parent
向上冒泡
findHover
,指定x, y坐标寻找该坐标位置的元素。从storage中拿到所有的displayable的list。挨个调用isHover
判断displayable和[x, y]坐标的关系
isHover
函数,根据displayable的rectHover属性,即是否使用包围盒检测鼠标进入。调用displayable的rectContain
或contain
检测是否在其中。
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 (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的绘制又额外包装了一层PathProxy
,getBoundingRect
也是使用的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 ( ) { var rect = this ._rect ; var style = this .style ; var needsUpdateRect = !rect; if (needsUpdateRect) { var path = this .path ; if (!path) { 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 rectWithStroke; } return rect; },
Displayable
继承自Element
,Element
通过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
下对应的contain
或containStroke
方法。前者实际上是后者stroke为0时的特殊情况。除了path外,可以判断点是否在元素图形内的所有元素在contain
下都有对应文件。基本所有的包含都可以转化为指定闭合路径是否包含指定点的问题。
zrender利用PIP (point-in-polygon)问题winding number 的解法判断点是否在path中;canvas提供的API中也有isPointInPath
和isPointInStroke
,不过只能针对当前的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 ); const shape = canvas.getShape (canvasPoint.x , canvasPoint.y ); const item = graph.getItemByShape (shape); }
p.s. 另说一点,frontCanvas的作用是绘制拖拽状态中的元素和辅助线等信息。
最关键的方法getPointByClient
和getShape
来自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 ) { 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); 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的优势体现在交互更容易实现。