相关文档
补充更新 – 20230907
第一版上线后,业务方对于布局交互还有一些不太满意,主要集中在以下问题:
1.使用自适应布局后,节点数会根据画布大小自适应调整,当节点过多以后,节点树会默认被缩放得很小而看不清。业务希望固定节点大小,并且垂直方向上可以让节点树上下滚动
2.自动刷新/更新树的时候,由于调用了 graph.render()方法,会导致整个节点树重绘,如果之前已经拖动了画布,重绘后画布又会变成初始状态,无法快速找到刚才操作的位置
然后针对上面的问题,也找到了一些优化方案:
问题1的难点:
1.G6的画布必须要先固定写死宽高,再渲染内容。因此无法被内容撑开就不能达到溢出滚动的效果
2.固定节点大小就意味自适应功能失效,取消以后节点树的位置难控制。尤其是水平方向的居中,因为G6虽然提供了移动画布的功能,但并没有提供获取内容宽度和高度的功能,只能获取画布的宽度和高度,就无法将节点树移到居中的位置
解决方案:
1.设置minZoom和maxZoom相等,这样相当于固定缩放比例,缩放功能会失效,但要保留 fitView: true的设置,这样能保证内容居中,但水平方向和垂直方向都可能会溢出
{
...
fitView: true,
minZoom: 0.6,
maxZoom: 0.6,
}
2.定义一个方法,用来计算节点数的最大层级,然后统计每个层级节点的高度总和,大概就能知道整个树的高度
根据内容的 高度和画布的高度,如果内容超出画布,就将画布往上移动,水平方向不变。
算出内容高度以后,再通过changeSize方法去改变画布高度,就能实现画布的滚动功能了。
const graphPositionInit = () => { const container = document.getElementById('mountNode'); container.style.height = '100%'; const nodes = state.graph.getNodes(); let maxDeep = 0; let height = 0; nodes.forEach((item) => { const model = item.getModel(); const depth = model.depth + 1; if (maxDeep < depth) { height += item.getBBox().height; maxDeep = depth; } }); const graphH = state.graph.getHeight(); const translateY = (height - graphH) / 2.5; if (height > graphH) { state.graph.translate(0, translateY); // 动态增加容器和画布的高度 container.style.height = height + 'px'; } const cWidth = container.offsetWidth; const cHeight = container.offsetHeight; state.graph.changeSize(cWidth, cHeight);};
在每次render以后都去调用这个方法
state.graph.on('afterrender', (evt) => { graphPositionInit(); });
这样问题1就解决了
问题2的解决方法就相对简单一些了,既然render不行,那我们就递归逐个节点去update,这样就不会触发整体的重绘了
这里使用了lodash的isEqual方法来判断对象内容是否相等
// data表示从接口获取的最新数据// 获取并遍历所有节点 const nodes = state.graph.getNodes(); nodes.forEach((item) => { // 当前节点 const model = item.getModel(); // 需要比较的核心对象 const newData = model.data; // 先从新数据中查找匹配对象 const res = searchNewNode(newData.inspectId, [data]); // 结果不同时挨个更新节点 if (!isEqual(res, model.data)) { state.graph.updateItem(item, model, false); } }); const searchNewNode = (inspectId, data) => { // 首先从传入的array中查找,如果找到对应的节点就结束 let res = data.find((item) => item.inspectId === inspectId); if (!res) { data.forEach((item) => { res = searchNewNode(inspectId, item.nextInspectData); }); } return res;};
这样问题就解决了。
------------------------------------------------------------------------------------------------
--------------------------------------------分割线---------------------------------------------
------------------------------------------------------------------------------------------------
突如其来的需求
那是8月11日,一个即将放假的周五,当我刚从厕所回来以后,同事找到我说:“有个估值大屏的需求要你做一下,现在要一起去会议室开会讨论。”
我听完不以为意,业务需求往往都没啥难度,遂跟随他们前去了解需求。
.........此处省略一个小时..........
一个小时会开完以后,发现这次的需求有点意思,是一个流程节点的展示和编辑的功能。头脑中马上就想到了G6。
此前,前老板曾找我几次想让我帮他搞一个流程节点的编辑器,我帮他做过两个版本,感觉这玩意儿能派上用场。
回去以后,就再回顾了G6的文档,可行,G6就是专门做这个的,简直不能再匹配了。
但唯一有疑惑的地方就是节点是否支持如此灵活的自定义样式,此前我做过的节点样式都是普通的标签样式,一些边框+文本+颜色就搞定了。
于是后面的调研和踩坑主要都是围绕这个核心问题:自定义节点的展示和操作。
令人头秃的方案调研
周一,在上午再次开会确认需求以后,我就针对此次的核心问题开展了技术调研,经过大半天的调研,有了一些思路:
围绕自定义节点的难点就是自定义样式和事件的支持,在G6支持的前提下,有三个方案:
1.HTML DOM节点
2.类JSX自定义节点
3.React自定义节点
这三个方案,理论上都可以用来实现节点的自定义,但各有利弊。
除此之外,涉及两个关键方法:
// 监听节点的右键事件
state.graph.on('node:contextmenu', () => {})
// 监听节点的点击事件
state.graph.on('node:click', () => {})
三个方案对这两个方法的匹配性是不一样的:
1.HTML DOM节点:
大概就是十年前的HTML写法,样式定制的灵活度第一(只支持行类样式),无法写script脚本(意味着无法写自定义事件)。
更要命的是,我一开始使用这套方案后,发现 contextmenu 和 click 方法也直接失效了。好家伙,这还怎么操作,连夜删代码。
2.类JSX自定义节点(最终采用的方案):
方案1失败后,我连夜切换到方案2,该方案使用G6内置的一些标签和样式属性,内置标签倒是可以满足,但内置样式的使用比较局限,挺麻烦(后期有一半的时间都花在节点的样式调整上)
3.React自定义节点:
可以像写React一样去使用内置的自定义节点,最开始调研方案的时候,本来是想用这套方案的,差点就新建一个React项目来开发了
由于考虑到团队全都用的VUE,所以并没有优先使用该方案,而是先去尝试了前两个方案
也是幸好没用这个方案,因为我后面写着写着发现关于该功能的官方文档被下架了,写到一半没文档事小,万一是官方发现该方案有问题那才麻烦
一波三折终于解决难题
在确定方案2可行以后,大部分的工作量都在节点的自定义上了。事后回过头来看其实就是
1.用内置标签像写html一样写一个节点,其中的样式要用内置好的样式(不能用css),变量用模板字符串实现
// 自定义节点
const customNode = (cfg) => `
<group>
<rect style={{width: ${cfg.size[0]}, height: ${cfg.size[1]}}} name="rect" >
<rect style={{width: ${cfg.customInfo.borderW}, height: ${cfg.customInfo.borderH},marginLeft: ${
cfg.customInfo.borderML
},lineDash: [1,1], lineWidth: 3, stroke: ${getNodeBorderColor(cfg)}, opacity: ${getNodeOpacity(
cfg
)}, strokeOpacity: ${strokeOpacity.value}}}>
${getNodeImgTag(cfg)}
<image style={{next:inline,width: ${cfg.customInfo.iconImgSize},
height: ${cfg.customInfo.iconImgSize},
marginLeft: ${cfg.customInfo.leftIconML},
opacity: ${getNodeOpacity(cfg)}, img: ${getExpandImg(cfg)},cursor: 'pointer'}} name="expandImage" />
${getOperationImg(cfg)}
</rect>
<text style={{textAlign: 'center',marginLeft: ${cfg.customInfo.textML}, marginTop: ${
cfg.customInfo.textMT
}, fontSize: ${cfg.customInfo.textSize},opacity: ${getNodeOpacity(cfg)},fill: '#fff'}} name="text">${
cfg.data.inspectName
}</text>
</rect>
</group>`;
2.注册节点,并且要根据不同节点的状态来展示不同的结果
// 注册自定义节点
G6.registerNode('custom-node', {
jsx: customNode,
getAnchorPoints() {
return [
[0.5, 1],
[0.5, 0],
];
},
});
其中第一步麻烦的点是:不同节点展示的东西不一样,位置也不一样,甚至大小也要从上往下递减,而可以用于改变位置的样式属性只有两个:marginLeft和marginTop,如何用一段算法来平衡好不同状态,不同类型,不同层级的节点内部一些标签的位置是需要反复衡量的。
这里可以展示一下我自己的方案(可能会有更好的,但该方案目前来看布局显示效果也不错)
state.graph.node((node) => {
// 根据层级生成一个标志
let level = node.depth + 1;
if (level >= 3) {
level = 3;
}
// 动态构建节点高度
let height = node.size[0] - level * 20;
if (height < 120) {
height = 120;
}
// 节点递减大小
let customSize = node.size[0] - level * 20 - 65;
if (customSize < 80) {
customSize = 80;
}
// 虚线边框大小及偏移
const borderW = customSize - 10;
const borderH = height - customSize / 1.8;
const borderML = (node.size[0] - borderW) / 2;
// 主图大小及偏移
const mainImgSize = borderW - 20;
const mainImgML = (node.size[0] - mainImgSize) / 2;
const mainImgMT = (borderH - mainImgSize) / 2;
// 文本偏移及大小
const textML = node.size[0] / 2;
const textMT = (height - borderH) / 2;
const textSize = customSize / 5;
// 操作图标大小及偏移
const iconImgSize = (customSize / node.size[0]) * 55;
const leftIconML = -(mainImgSize - Number(level * 6));
const rightIconML = (customSize / borderW) * ((level + 1.3) * 15);
return {
...node,
size: [240, height],
customInfo: {
customSize,
borderW,
borderH,
borderML,
mainImgSize,
mainImgML,
mainImgMT,
textML,
textMT,
textSize,
leftIconML,
iconImgSize,
rightIconML,
},
};
});
第二步就比较简单了,但当时还是踩了一个坑,就是自定义节点的注册会导致初始化配置的节点锚点失效,于是连线就彻底乱掉了。文档也没有说得很清楚,研究后发现需要在注册自定义节点的时候重新设置锚点。
当节点自定义展示的问题解决以后,剩下的就是节点的点击操作了
// 监听节点的右键事件
state.graph.on('node:contextmenu', (e) => {
// 阻止默认的右键菜单
e.preventDefault();
if (!state.isEdit) return;
const node = e.item;
const model = node.getModel();
// 重复右键时隐藏菜单
if (model.id === state.currentNode.id && state.isShowRightPanel) {
state.isShowRightPanel = false;
state.isShowLeftPanel = false;
state.currentNode = {};
return;
}
state.currentNode = model;
state.panelTop = e.canvasY + 20 + 'px';
state.panelLeft = e.canvasX + 20 + 'px';
state.isShowRightPanel = true;
state.isShowLeftPanel = false;
});
// 监听节点单击事件
state.graph.on('node:click', (e) => {
const node = e.item;
const model = node.getModel();
const shapeName = e.target.cfg.name;
if (shapeName === 'checkImage') {
// 手动确认
state.currentNode = model;
confirmDialog.value.openConfirmDialog(state.currentNode);
} else if (shapeName === 'detailImage') {
// 查看详情
// 重复左键时隐藏菜单
if (model.id === state.currentNode.id && state.isShowLeftPanel) {
state.isShowRightPanel = false;
state.isShowLeftPanel = false;
state.currentNode = {};
return;
}
state.currentNode = model;
// 计算详情弹框位置
// 防止靠右侧时弹框被遮挡
if (e.canvasX >= document.body.clientWidth * 0.8) {
state.panelLeft = e.canvasX - 250 + 'px';
} else {
state.panelLeft = e.canvasX + 20 + 'px';
}
// 防止靠下方时弹框被遮挡
if (e.canvasY >= document.body.clientHeight * 0.6) {
state.panelTop = e.canvasY - 210 + 'px';
} else {
state.panelTop = e.canvasY + 10 + 'px';
}
state.isShowRightPanel = false;
state.isShowLeftPanel = true;
} else if (shapeName === 'expandImage') {
// 展开收起操作
const isCollapsed = model.collapsed;
// 切换节点的展开收起状态
node.update({
collapsed: !isCollapsed,
});
let collapsedList = [];
const localCollapsedList = JSON.parse(localStorage.getItem('collapsedList'));
if (localCollapsedList) {
collapsedList = localCollapsedList;
}
const index = collapsedList.findIndex((item) => item === model.id);
if (index !== -1) {
collapsedList.splice(index, 1);
} else {
collapsedList.push(model.id);
}
localStorage.setItem('collapsedList', JSON.stringify(collapsedList));
state.graph.render();
}
});
左键和右键都可以用内置的基础事件API,但是在实际业务场景下,我们并不是单击一个节点只有一个操作的,可能点A图标是A操作,点B图标是B操作,但是node:click是绑定到整个节点上的,于是我们需要再去根据事件对象来判断具体点击的是节点内部的哪一个标签,再做对应的逻辑处理即可。
处理完这些问题,本以为就没什么阻碍了。毕竟接口的增删改查从来都不是问题,但第一版效果出来的时候,感觉还是怪怪的,仔细一看,发现同一层级的节点没有在同一水平层级上,这样看起来就特别乱,不清晰,像这样:
这样展示,当节点多了以后,水平方向上我们很难看清 数据管理 和 资金管理 是一个层级的,这对于业务来说也是一个很关键的点。
于是我尝试手动设置Y坐标,但并不生效,似乎内置布局的优先级生成的Y坐标比我手动设置的要高。
为此我又陷入了一番苦思,看了一下G6的文档,发现别人的树是可以优先从上往下水平对齐的。问题出在哪儿呢?
研究以后发现了问题所在,只有紧凑树才能满足我们想要的效果,即type: 'compactBox'
layout: {
type: 'compactBox', // 布局类型
preventOverlap: true,
direction: 'TB', // 自上至下布局,可选的有 H / V / LR / RL / TB / BT
nodeSep: 280, // 节点之间间距
rankSep: 280, // 每个层级之间的间距
getFixedAnchorPoints: true, // 启用固定锚点
getVGap: function getVGap() {
return 150;
},
getHGap: function getHGap() {
return 120;
},
},
除了以上的关键问题以外,还有许多细节点的优化也需要考虑,比如:
1.自动刷新的时候要无感知
2.记录用户展开/收起树的习惯,记录用户设置的刷新频率,下次进入或者刷新页面后依旧保留
3.靠近页面右方/下方点击打开弹窗时,弹窗要显示在左侧/上方
................