g6探索小结
g6简介
G6 是一个简单、易用、完备的图可视化引擎,它在高定制能力的基础上,提供了一系列设计优雅、便于使用的图可视化解决方案。能帮助开发者搭建属于自己的图可视化、图分析、或图编辑器应用。
目标功能
- 节点支持iconfont
- 节点颜色可在外部触发修改
- 节点支持收缩展开隐藏后续部分
实现过程
节点支持iconfont
这个功能g6内置可实现,但仅限于类似阿里的iconfont库的使用方式。
原理为将icon图定制为字体文件,设置对应的字体来展示。g6内置了一类形状为text,可以设置字体以及文字内容,完美贴合了iconfont的使用方式。
首先在iconfont平台选好需要的icon图标,新建一个项目来收纳集合。目前没有看到哪里还提供在线地址,因此需要把文件下载到本地,在本次的需求中需要在物料内部实现,因此无法直接作为打包后的资源放置,需要一起参与打包。打包时可能会遇到woff2字体无法打包的问题,因为loader无法识别,查看了打包工具的配置就可以知道,url-loader的配置不识别该文件类型。
{
test: /\.(png|jpg|svg|woff|eot|ttf)$/,
loaders: 'url-loader',
},
在iconfont的项目设置中可以配置下载后生成的文件类型,配置loader支持的字体类型就可以。
可以打包进物料后,在样式文件内部引用下载后的iconfont.css,使用时需要注册一个新的节点类型来使用。
G6.registerNode('iconfont', {
draw(cfg, group) {
const keyShape = group.addShape('text', {
attrs: {
x: 0,
y: 0,
fontFamily: 'iconfont',
textAlign: 'center',
textBaseline: 'middle',
text: cfg.text,
fontSize: cfg.size,
...style,
},
name: 'text-shape1',
})
return keyShape;
},
update: undefined
}, 'rect')
先看注册节点的使用,G6上挂载的registerNode方法支持注册自定义节点,配置项中的draw方法定义了该节点如何绘制渲染。每个节点由多个形状组成,G6内置了path、rect、circle、text等形状,所有形状包含在一个group中,所以需要将形状添加到节点默认的group上,调用group.addShape时配置项包含形状名(用于后续操作中区分不同形状),形状的属性,例如该形状在group中的位置,以及各形状对应的属性,text形状需要设置字体、内容等,此处为了实现iconfont类型,字体统一设置为iconfont,text则为该形状的文字,需要设置为该图标的content,可以直接在iconfont平台的项目内进行代码复制,但要注意复制下来的代码如

,需要修改为\ue8cb
使用。需要注意上面示例的节点继承了G6内置的
rect
节点,因此需要覆盖update
方法为undefined
,强制节点更新调用draw
方法,否则节点更新时会产生意料以外的情况。画布第一次绘制iconfont节点时会出现图标消失的情况,拖动等使画布重新绘制的操作会修正这一行为,但需要避免第一次绘制为空的情况,官方推荐的操作为:
// 在 graph.render() 之后调用以下语句: setTimeout(() => { graph.paint(); }, 16);
但这一操作对我的项目并不起作用,修改
paint
方法为refresh
即可。节点颜色可在外部触发修改
G6内置节点的颜色配置为配置项中
style
内的fill
属性,例如:graph.node(node => { return { type: 'iconfont', style: { fill: 'green', stroke: 'red' } } })
示例中调用了
graph.node
方法,该方法可较灵活的配置节点属性,每个节点渲染时会执行该函数,可根据条件配置不同的节点属性,这里设置了节点类型为iconfont
,内部填充色为绿色,外部描边色为红色。
想要实现外部触发修改的功能只需要修改节点的配置信息,并实现它的更新。节点上有个update
方法支持差量更新配置项,外部逻辑触发时查找到对应的节点调用udpate
即可。preItems.current.forEach((item) => { const model = item.getModel() item.update({ style: { ...model._originStyle } }) }) preItems.current = [] graph.getNodes().forEach(node => { const model = node.getModel() const id = model.id if (id.includes(inputValue)) { model._originStyle = model.style node.update({ style: { fill: 'green' } }) preItems.current.push(node) } })
以上逻辑是使用preItem进行上一次被更新的节点缓存,每次执行时首先恢复上一次节点的样式,再查找graph上的节点判断是否符合条件,符合条件的记录下它现有的样式用于恢复,然后更新,并缓存节点。
节点支持收缩展开隐藏后续部分
G6内置的树图实现了收缩展开部分,但在该需求中数据结构不符合树的特性,无法使用树图,但希望某个节点以及后续节点符合树时,可以收缩该节点以及后续部分。
简易版本
收缩实现的功能包括后续节点的移除或隐藏,以及画布节点的布局。
graph上有removeItem/hideItem/showItem方法可用,简易实现中先采用隐藏和展示。
画布的重新布局只需要调用graph.layout。
思路是监听节点的点击事件,在点击时获取节点所对应的所有目标节点,将它们隐藏,并递归隐藏后续所有的节点。完成后调用graph.layout进行重新布局。const handleCollapse = (item, collapsed, cb) => { /** 读取该节点作为源节点的所有目标节点 */ const targets = item.getNeighbors('target') typeof cb === 'function' && cb(item) /** 遍历当前节点的目标节点,根据状态值判断是隐藏还是展示 */ targets.forEach(target => { /** 如果是隐藏,所有后续节点都需要隐藏。如果是展示,隐藏的后续节点不需要展示 */ if (collapsed) { /** 隐藏目标节点 */ graph.hideItem(target) /** 递归后续的节点 */ handleCollapse(target, collapsed) } else { const currentState = target.getModel().collapsed || false graph.showItem(target) if (!currentState) { /** 当前节点是展开状态才递归后续的节点 */ handleCollapse(target, collapsed) } } }) } graph.on('node:click', e => { const item = e.item /** 读取点击节点的数据,修改状态值并开始目标节点的处理 */ const collapsed = item.getModel().collapsed || false item.getModel().collapsed = !collapsed handleCollapse(item, !collapsed) /** 重新布局 */ graph.layout() /** 布局完成后进行点击元素的聚焦,为了处理层级高度或宽度发生大幅度变化的情况 */ setTimeout(() => { graph.focusItem(item, true, { duration: 500 }) }, 0) })
问题:
隐藏和展示的行为比较生硬,不包含过渡和动画效果,这需要在进阶部分里处理;
重新布局时会修改同一层次中的节点顺序,因为节点的增删,导致了布局算法计算出的顺序有变动,这不是期望的效果,翻阅文档后可以配置nodeOrder来固定节点的顺序;
目前来说只有一个节点及其后代节点构成了一棵树才允许收缩,点击行为可以被条件判断进行拦截,但如何制定这个条件?
如何实现第一次绘制某些节点是收缩状态,目前的处理方法:
graph.on('afterrender', () => { /** 读取当前所有节点遍历查找 */ const nodes = graph.getNodes() nodes.forEach(node => { /** 从一个节点出发,每个节点做了一个标记是否被处理过,是的话直接跳过避免冗余操作 */ const model = node.getModel() const isChecked = model._isChecked || false if (isChecked) return const collapsed = model.collapsed /** 处理该节点及其所有后代节点,在函数内传递一个回调用于标记节点 */ handleCollapse(node, collapsed, item => { item.getModel()._isChecked = true }) }) /** 隐藏完节点重新布局,此行为不会触发afterrender */ graph.layout() })
进阶版本
期望实现收缩展开的动画,同时条件判断可收缩的节点。
收缩展开动画
这部分内容可参考G6的树图实现方法,也可以再做优化,因为G6的树图节点移除采用的是删除数据的方式,而前文中收缩展开使用的方式不是修改数据而是隐藏节点。
G6的树图与图有一些差异性,先看官方解释:TreeGraph 是 G6 专门为树图场景打造的图。G6.TreeGraph 与 G6.Graph 最大的区别就是数据结构和内置布局计算。主要考虑:
- 数据结构:树图的数据一般是嵌套结构,边的数据隐含在嵌套结构中,并不会特意指定 edge 。此布局要求数据中一个节点需要有 id 和 children 两个数据项,最精简的数据结构如下所示:
布局特殊性:const data = { id: 'root', children: [ { id: 'subTree1', children: [...] }, { id: 'subTree2', children: [...] } ] };
- 树图的布局算法一般是不改变源数据的,而是重新生成一份数据,将源数据作为新数据的一个属性。如果每次都需要做次遍历转换数据到节点和边的数据增加了用户的实现复杂度。
- 树图的每次新增/删除/展开/收缩节点,都需要重新计算布局。遍历一份结构化数据对应到图上每个节点去做更新操作,也很麻烦。
树类中处理了很多额外逻辑,包括数据结构转化为边-节点、布局计算,甚至包括收缩展开的处理也都包在了里面,且节点收缩展开还都有内置的动画。下面捋一下树类的运行逻辑。
树类的前置使用与普通的图类没有太大差别,主要在赋值data后render中,树类重写了render方法。
render() { const data = this.get('data'); // ... this.layout(this.get('fitView')); // ... }
简化代码后可以看到,render的核心就是调用layout方法。
layout(fitView) { const data = this.get('data'); const layoutMethod = this.get('layoutMethod'); const layoutData = layoutMethod ? layoutMethod(data, this.get('layout')) : data; // ... this.innerUpdateChild(layoutData, undefined); // ... this.layoutAnimate(layoutData); }
代码简化后看逻辑,读取传递进来的data,读取当前的布局算法,对数据进行布局计算,随后调用innerUpdateChild,最后调用layoutAnimate使用布局数据进行收缩展开的动画。核心内容是innerUpdateChild和layoutAnimate。
在树类的布局算法中,会根据数据的collapsed属性来判断后续数据是否需要,如果一个节点是收缩状态的,它的children就是空数组,完全不需要渲染后续的结构。这样布局计算完就是当前展示状态所需的节点信息,接下来需要进行画布上的节点移除和移动,移动需要起点和终点信息,因此需要innerUpdateChild来收集这些信息。innerUpdateChild(data, parent, animate) { const current = this.findById(data.id); // 若子树不存在,整体添加即可 if (!current) { self.innerAddChild(data, parent, animate); return; } // 递归更新新节点下所有子节点 each(data.children || [], (child) => { this.innerUpdateChild(child, current, animate); }); // 用现在节点的children实例来删除移除的子节点 /** 用画布上已有的节点比对调用布局算法计算后的节点 * 若是布局算法计算后的节点中已不存在该节点,表明需要移除这个节点 * 将当前节点的位置信息和节点传递进删除的函数,该函数会递归删除所有后代节点 * 删除的操作只是将节点缓存进一个数组,并标记每个节点需要移动的终点,即最初传递的节点位置信息 * 起点则是该节点此时所在的位置。 */ const children = current.get('children) if (children) { if (children.length > 0) { for (let i = children.length - 1; i >= 0; i--) { const child = children[i].getModel(); if (TreeGraph.indexOfChild(data.children || [], child.id) === -1) { this.innerRemoveChild( child.id, { x: data.x, y: data.y, }, animate, ); // 更新父节点下缓存的子节点 item 实例列表 children.splice(i, 1); } } } } /** 除去要删除的节点,许多节点需要移动位置,记录节点此时的位置作为起点, * 终点存储在布局计算完的节点数据中 */ let oriX; let oriY; if (current.get('originAttrs')) { oriX = current.get('originAttrs').x; oriY = current.get('originAttrs').y; } const model = current.getModel(); if (animate) { // 如果有动画,先缓存节点运动再更新节点 current.set('originAttrs', { x: model.x, y: model.y, }); } current.set('model', data.data); if (oriX !== data.x || oriY !== data.y) { current.updatePosition({ x: data.x, y: data.y }); } }
完成每个需要移动的节点起点和终点位置计算后,需要进行动画。
layoutAnimate( data, onFrame, ) { const self = this; const animateCfg = this.get('animateCfg'); this.get('canvas').animate( (ratio) => { /** 遍历树,每个节点进行动画操作 */ traverseTree(data, child => { /** 每个节点读取originAttrs或自身所在位置,根据当前动画进度计算位移 */ const node = self.findById(child.id); if (node) { let origin = node.get('originAttrs'); const model = node.get('model'); if (!origin) { origin = { x: model.x, y: model.y, }; node.set('originAttrs', origin); } model.x = origin.x + (child.x - origin.x) * ratio; model.y = origin.y + (child.y - origin.y) * ratio; } return true; }); /** 每个要被移除的节点要从from移动到to */ each(self.get('removeList'), node => { const model = node.getModel(); const from = node.get('originAttrs'); const to = node.get('to'); model.x = from.x + (to.x - from.x) * ratio; model.y = from.y + (to.y - from.y) * ratio; }); /** 更新完当前动画进度下每个节点的位置信息后,需要更新所有节点和边的位置 */ self.refreshPositions(); }, { // ... callback: () => { /** 动画完成的回调 */ each(self.getNodes(), node => { node.set('originAttrs', null); }); /** 将完成动画的待删除节点全部删除 */ each(self.get('removeList'), node => { self.removeItem(node); }); self.set('removeList', []); }, // ... }, ); }
最后是节点展开时的添加函数
innerAddChild(treeData, parent, animate) { const model = treeData.data; /** 添加节点 */ const node = this.addItem('node', model, false); if (parent) { /** 挂载父子关系,并读取父节点要移动的起点位置或父节点的位置 */ node.set('parent', parent); const origin = parent.get('originAttrs'); if (origin) { node.set('originAttrs', origin); } else { const parentModel = parent.getModel(); node.set('originAttrs', { x: parentModel.x, y: parentModel.y, }); } const childrenList = parent.get('children'); if (!childrenList) { parent.set('children', [node]); } else { childrenList.push(node); } /** 为父子添加一条边 */ this.addItem( 'edge', { source: parent.get('id'), target: node.get('id'), id: `${parent.get('id')}:${node.get('id')}`, }, false, ); } /** 递归后代继续添加数据 */ // 渲染到视图上应参考布局的children, 避免多绘制了收起的节点 each(treeData.children || [], child => { this.innerAddChild(child, node, animate); }); return node; }
以上是树图更新的一个大体逻辑,总结下来就是重新绘制时使用数据进行布局计算得到新的数据,布局算法中会处理collapsed字段,被收起的节点不会有后代节点。新的数据代表了后续画布需要绘制完成的样子。此时画布中图的结构还未更新,比对新数据和旧数据,找出要删除的节点,并记录下每个节点需要移动的起点和终点,完成后进行动画,实现过渡移动效果,结束后删除所有需要被删除的节点,清理中间过程产生的数据,完成一次布局。
因此,想要实现过渡的移动动画,重点是找到移动的起点和终点。一般的,我们会把节点位置信息交给布局算法来处理,图类中布局算法和渲染是集成在一起的,无法抽离。
G6提供了单独的布局方法,我们可以新建一个类,实现自己的render方法,单独维护一套用以计算终点的布局数据。在收缩时,在布局数据中隐藏不需要的节点,进行布局计算,拿到新的数据,比对画布的数据计算出起点和终点,完成动画,动画结束后更新画布的节点数据。展开类似,因为只是隐藏展示节点,不涉及到新增和删除。
具体实现:
待更新- 数据结构:树图的数据一般是嵌套结构,边的数据隐含在嵌套结构中,并不会特意指定 edge 。此布局要求数据中一个节点需要有 id 和 children 两个数据项,最精简的数据结构如下所示:
条件判断树
完整需求是在一个图中如何找出一些节点,这些节点及其后代节点构成了一棵树。