从另一个需求说起
继上次用LogicFlow实现了产品提出的分组功能后, 产品又提出了一个新的功能, 要支持画泳道图。 我翻遍了LoficFlow的文档,发现没有对泳道图的支持, 但是bpmn却是有现成的泳道可用的, 我心理一惊, 难道还是得回去用bpmn?我之前基于LogicFlow做的bpmn不支持的功能可咋整?冷静下来想一想, 倒也不必那么惊慌, 之前已经体会过LogicFlow自定义节点的灵活性,想必做个泳道也不难[手动狗头]。
认识泳道
在流程图的绘制过程中,有这样一种场景, 一个整体的流程是由多个职能相互协作完成的, 为了明显区分各个职能负责的部分, 将整张图用线分成不同的区域来表示不同的职能, 这种按照职能分区的流程图因为形状上酷似泳道, 也被称为泳道图。
图: 一个简单流程图和对应的泳道图
实现泳道
实现这个图的过程中,我们可以从代表某个职能的节点入手,代表这个职能的节点称作泳池节点, 职能内部可能还有具体的分工, 划分出的更具体的区域称作泳道节点
图: 泳池/泳道节点示意
关于如何基于LogicFlow实现自定义节点, 这里就不展开了, 又兴趣的可以看下官网文档:LogicFlow-基于继承的自定义节点
泳池和泳道的关系
泳池与泳道是相互独立的节点,这样说的原因是:在画图的过程中,既可以单独选中某个泳道进行操作,也可以直接选中整个泳池进行操作, 因此需要明确一个事情:泳池与泳道都是独立的节点, 只不过泳道只会以泳池的子节点的形式出现。
泳池与泳道又是有联系的, 表现在:
- 删除泳池的同时也要删除泳池内的泳道
- 调整某个泳道的宽高, 影响整个泳池的宽高和泳池内部其他泳道的宽高
- 泳道增加、删除都影响泳池的宽高
- 泳池调整宽高, 会影响泳池内部分泳道的宽高
自定义泳道节点
怎么能够单独选中泳道, 并且对其进行操作?
我的第一反应是, 实现一个泳道节点, 然后在泳池节点的实现过程中嵌套泳道节点, 类似于我们平时写代码时候的嵌套组件。然而LogicFlow并不支持自定义节点的嵌套,如果采用这种方法, 基本上是把logicflow对节点做的事情再重复给泳道来一遍,先不说能不能实现,主要是代价很大且没必要。
我最终的实现方案如下:
泳池节点与泳道节点成组出现, 即一个泳池节点会带着n个泳道节点一起出现,为了防止泳道节点被单独拎出去玩耍,这里要限制泳道的拖拽。另外由于泳池是与泳道密切相关的,大多数情况下, 都是创建已知宽高的泳道,泳道的初始化如下
initNodeData(data) {
super.initNodeData(data)
if (data.width) {
this.width = data.width
}
if (data.height) {
this.height = data.height
}
if (data.properties) {
this.properties = {
...this.properties,
...data.properties
}
}
this.draggable = false
this.resizable = true
this.zIndex = 1
}
泳道与泳道间的事件联系与处理
泳池和泳道间最难处理的是, 一个泳道的形状变化会影响泳池和其他泳道, 为了处理好这种相互联系的变化, 为泳池节点定制了几个特殊的方法。
- resize: 根据泳池内泳道的个数和长宽调整泳池的外形
- resizeChildren:将泳池的宽高分配给泳道, 以及让每个泳道的宽度都正好与泳池的宽度相匹配。
- addChildAbove: 在指定泳道的上方增加一个新的泳道
- addChildBelow:在指定泳道的下方增加一个新的泳道
为泳道定制了一个特殊方法:
- changeAttribute:主动修改泳道节点的attributes
有了上面的这几个方法, 就可以在一个泳池内的一个节点发生变化的时候, 将变化同步给该泳池内的其他节点。 下面这张图展示了各个方法是如何运转起来:
接下来我们一个一个的来看各个方法的具体内容。
泳池resize方法:
遍历所有的泳道节点,找个各个泳道所形成的的最大边界, 然后转换成泳池的宽高和位置, 更新。更新完泳池的model后, 再调用resizeChildren方法, 更新内部的所有泳道。
resize(resizeId) {
if (!this.children.size) {
return
}
let minX = null
let maxX = null
let minY = null
let maxY = null
let hasMaxX = false
// 找到边界
this.children.forEach((elementId) => {
const nodeModel = this.graphModel.getElement(elementId);
const {x,y, width, height, type, id} = nodeModel
if (id === resizeId) {
minX = x - width/2
maxX = x + width/2
hasMaxX = true
}
if (type !== 'lane') {
return
}
if (!hasMaxX && (!minX || (x - width/2 < minX))) {
minX = x - width/2
}
if (!hasMaxX && (!maxX || (x + width/2 > maxX))) {
maxX = x + width/2
}
if (!minY || (y - height/2 < minY)) {
minY = y - height/2
}
if (!maxY || (y + height/2 > maxY)) {
maxY = y + height/2
}
})
if (minX && maxX && minY && maxY) {
this.width = maxX - minX + 30
this.height = maxY - minY
this.x = minX + (maxX - minX) /2 - 15
this.y = minY + (maxY - minY) /2
this.setAttributes()
this.resizeChildren({})
}
}
泳池resizeChildren方法:
遍历所有的泳道节点, 调整泳道的宽高以适应泳池的形状。
如果泳池的高度不等于所有泳道高度之和(resize发生在泳池节点上,且产生了y轴方向的位移), 还需要根据泳池节点的resize方向,决定把y轴上的位移加在哪个泳道上(最上面和最下面泳道二选一)。
resizeChildren({resizeDir='', deltaHeight=0}) {
console.log(resizeDir, deltaHeight)
const {x,y,width} = this
const laneChildren = []
this.children.forEach(elementId => {
const nodeModel = this.graphModel.getElement(elementId)
const {type} = nodeModel
if (type === 'lane') {
laneChildren.push(nodeModel)
}
})
// 按照位置排序
laneChildren.sort((a,b) => {
if (a.y < b.y) {
return -1
} else {
return 1
}
})
// 把泳池resize的高度加进来
switch(resizeDir) {
case 'below':
// 高度加在最下面的泳道上
const lastLane = laneChildren[laneChildren.length - 1]
lastLane.height = lastLane.height + deltaHeight < laneMinSize.height ? laneMinSize.height: lastLane.height + deltaHeight
laneChildren[laneChildren.length - 1] = lastLane
break;
case 'above':
// 高度加在最上面的泳道上
const firstLane = laneChildren[0]
firstLane.height = firstLane.height + deltaHeight < laneMinSize.height ? laneMinSize.height: firstLane.height + deltaHeight
laneChildren[0] = firstLane
break;
default: break;
}
const poolHeight = laneChildren.reduce((a,b) => {
return a + b.height
},0)
let aboveNodeHeights = 0
laneChildren.forEach((nodeModel, index) => {
const {height} = nodeModel
nodeModel.changeAttribute({
width: width - 30,
height,
x: x + 15,
y: y - poolHeight/2 + aboveNodeHeights + height / 2
})
aboveNodeHeights += height
})
}
泳池addChildAbove方法:
这个方法是向指定泳道的上方增加一个新的泳道, 所以先找个这个指定泳道节点, 将该节点上方的节点都向上移动一个新泳道的距离, 然后创建一个新的泳道节点, 加入到当前泳池中来。
addChildAbove({ x, y, width, height}) {
this.children.forEach(elementId => {
const nodeModel = this.graphModel.getElement(elementId)
const {type, y:childY} = nodeModel
if (type !== 'lane') {
return
}
// 在被操作的泳道之上
if (childY < y) {
nodeModel.changeAttribute({y: childY-120})
}
})
const {id:laneId} = this.graphModel.addNode({
type: 'lane',
properties: {
nodeSize: {
width: width,
height: 120
}
},
x,
y: y -height/2 - 60,
})
this.addChild(laneId)
this.height = this.height + 120
this.y = this.y - 60
}
泳池addChildBelow方法:
过程与向上添加泳道类似, 直接上代码:
addChildBelow({ x, y, width, height}) {
this.children.forEach(elementId => {
const nodeModel = this.graphModel.getElement(elementId)
const {type, y:childY} = nodeModel
if (type !== 'lane') {
return
}
// 在被操作的泳道之下
if (childY > y) {
nodeModel.changeAttribute({y: childY+120})
}
})
const {id:laneId} = this.graphModel.addNode({
type: 'lane',
properties: {
nodeSize: {
width: width,
height: 120
}
},
x,
y: y + height/2 + 60,
})
this.addChild(laneId)
this.height = this.height + 120
this.y = this.y + 60
}}
泳道changeAttribute方法:
这个方法比较简单,因为泳池内部经常会因为一些计算调整泳道的宽高和位置, 这个方法就是接受泳池计算好的参数,更新到泳道节点的model上。
changeAttribute({width, height, x, y}) {
if(width) this.width = width
if (height) this.height = height
if (x) this.x = x
if (y) this.y = y
}
结语
这篇文章主要介绍的是怎么实现泳池和泳道节点, 以及怎么处理这种存在关联关系的节点间的事件变化。关于泳道的功能, 这篇文章覆盖的不是百分百全面, 后面如果有时间, 再补充上其他的功能实现思路。
从使用感受上来说, LogicFlow是一个更底层的流程图,缺点是不想bpmn-js那样拿来直接能用,但是足够灵活, 可以根据文档的示例和提供的api去实现自己想要的节点和功能, 例如我之前写的收缩展开和这篇实现的泳道, 都是按照LogicFlow官方文档提供的方法自己开发的。
最差的情况下, LogicFlow现有功能不能支持的, 还可以向他们的github仓库提个issue,等待官方大佬实现。 有能力的也可以直接自己参考源码实现解决方案提个pr给他们。
LogicFlow官网文档地址:logic-flow.org/
LogicFlow仓库地址:github.com/didi/LogicF…
LogicFlow实现收缩展开:juejin.cn/post/708826…