LogicFlow实战二: 实现泳道图

2,124 阅读7分钟

从另一个需求说起

继上次用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…