前言
最近在写项目遇到一个需求,根据树形数据渲染出流程图。最终选定用X6来实现,如果是正常流程图,根据数据直接渲染即可,但是实际流程图有横向有纵向,所以最终决定手算坐标并渲染。计算涉及到树的层级算法、递归的运用。
正常情况下的流程图: 最终计算得到的流程图: 🖥demo代码地址:👉X6渲染复杂路程图
此处的难点在于数据结构是个树形结构,也就是说要么都是横向的要么都是纵向的,要想把节点下的子项强行'掰弯',就有点难处理了,只能是手动计算坐标。
那么,手动计算坐标需要思考这几个问题:
- 1.如何入手,画布该怎么渲染,怎么分布?
- 2.节点该如何定位,也就是坐标该怎么计算?
- 3.怎么把横向的子节点变成纵向的,如何有横向有纵向?
- 4.纵向的节点如何定位(也就是node3-2节点如何得知node3-1节点的区域大小)?
- 5.纵向的分组该怎么划分?
- 6.横向节点太多导致节点堆积在一起如何解决?
实现
首先思考问题1,起初的设想是一层一层渲染,然后定位,那么问题4就变得无解。过多次的思考和尝试,确定出一个整体方案。如图:
将整体划分为四型:类型一是父(根)节点,类型二是父(根)节点下的子节点,类型三是要'掰弯'的节点,类型四是要'掰弯'的节点的剩余子节点。整体分为网格状渲染,每个节点占用一个格子。从上往下,从左往右渲染,一个类型一个类型进行渲染。 所以得先计算出横向有多少个格子,也就是说除去类型二的子节点类型三不做统计(横向的可以视作他们占用一个格子),统计类型二和类型四的节点层级数量。在渲染之前,先做一些准备工作:
将一些配置(类型配置和节点的信息)做统一的管理
js
// config.js
export const configSttrData ={
UBSpan: 40, // 上下节点间距
LRSpan: 30, // 左右节点间距
type1: {
height: 130,
width: 200
}
...其他类型配置...
}
export const configData = {
type1: {
position: {
y: 30
},
attrs: {},
shape: 'custom-vue-node',
},
type2: {
position: {},
attrs: {},
shape: 'custom-vue-node',
},
...其他类型配置...
}
将渲染函数封装成一个类,通过新建实例调用
html
// index.vue
<template>
<div ref="base" style="height: 100%;width: 100%;">
<div id="container"></div>
</div>
</template>
<script>
import { register } from '@antv/x6-vue-shape'
import { mockData} from './mock' // mock数据
import { configSttrData, configData } from './config' // 配置
import CreateGraph from './Graph' // 渲染类函数
import Info from './info'
register({
shape: 'custom-vue-node',
component: Info,
})
export default {
mounted(){
const clientWidth = this.$refs.base.clientWidth
new CreateGraph({
elementId: 'container',
graphData: mockData,
typeConfigData: configSttrData,
typeTemplateData: configData,
clientWidth
})
}
}
</script>
实例化类时,利用树的层级遍历算法先统计出横向格子的数量
js
// Graph.js
import { Graph } from '@antv/x6'
import { cloneDeep } from 'lodash'
class CreateGraph {
constructor(options) {
// 初始化Graph类
this.graph = new Graph({
container: document.getElementById(options.elementId),
...其他配置...
})
Object.keys(options).forEach(item => {
this[item] = options[item]
})
this.init()
}
init() {
// 储存所有列
this.allCols = []
this.graphData[0].children.forEach(item => {
this.allCols.push(this.getCols(item))
})
// 视图的列(横向格子)的数量
const maxPieceNum = this.allCols.flat(1).length
// 整个屏幕平分后每块区域大小
this.pieceSize = Math.floor( this.clientWidth / maxPieceNum)
}
getCols(nodes){
const stack = [[nodes, 0]];
const colNum = [];
while (stack.length) {
const [node, col] = stack.pop()
if(colNum[col] && node.infoType !== 3) {
colNum[col]++
} else if(node.infoType !== 3) {
colNum[col] = 1
}
node.children.forEach(item => stack.push([item, col + 1]));
}
return colNum.filter(item => item)
}
}
export default CreateGraph
准备工作完毕后,开始挨个类型渲染
渲染类型一
类型一的定位是最容易的,因为只有一个根节点,所以它的X轴坐标是视图的中间点减去节点元素宽度的一半,y轴上可以给定一个值,这里给30,类型一渲染完毕后开始渲染它的子节点类型二
js
// 处理1类型
dealLevelOneData(node) {
let nodeObj = cloneDeep(node)
delete nodeObj.children
nodeObj = {...this.typeTemplateData[`type${node.infoType}`],id: nodeObj.id, data: nodeObj,size: { height: this.typeConfigData.type1.height,width: this.typeConfigData.type1.width}}
nodeObj.position.x = this.midWidth - this.typeConfigData.type1.width / 2
let newNode = this.graph.addNode(nodeObj)
const data = newNode.getData()
newNode.setData(data)
node.children.forEach((item,index) => this.dealLevelTwoData(item, nodeObj, index))
}
渲染类型二
处理类型二的时候,x轴方向之前在统计横向有多少个格子的时候做了处理,也就是allCols
数组,在渲染时候是从左往右,所以渲染第二个类型二节点时,第一个类型节点下的所有子节点已经全部渲染完毕,也就是可以知道第一个类型二以及子节点占用了多少个横向格子,也就能就能知道第二个类型二的节点需要放在哪个格子。y轴方向则是类型一的y轴坐标 + 类型一的节点元素高度 + 上下节点间距
js
// 处理2类型
dealLevelTwoData(node, parentNode, index){
let nodeObj = cloneDeep(node)
delete nodeObj.children
nodeObj = {...this.typeTemplateData[`type${node.infoType}`],id: nodeObj.id, data: nodeObj,size: { height: this.typeConfigData.type2.height,width: this.typeConfigData.type2.width}}
let posX = this.pieceSize / 2 - this.typeConfigData.type2.width / 2
let posY = parentNode.position.y + this.typeConfigData[`type${parentNode.data.infoType}`].height + this.typeConfigData.UBSpan
let len = this.allCols[index - 1]?this.allCols[index - 1].length:0
posX = this.pieceSize * len + this.pieceSize / 2 - this.typeConfigData.type2.width / 2
nodeObj.position.x = posX
nodeObj.position.y = posY
let newNode = this.graph.addNode(nodeObj)
if(parentNode) this.graph.addEdge({
source: parentNode.id,
target: nodeObj.id,
router: {
name: 'er',
args: {direction: 'V',offset:'center'},
},
attrs: {
line: {
targetMarker: 'classic',
stroke: '#f5222d',
},
},
})
const data = newNode.getData()
newNode.setData(data)
node.children.reduce((pre, cur, index) => {
return this.dealLevelThreeData(cur, pre, index, node.children.length - 1)
},{...nodeObj, maxRowTotalHeight: 0})
}
渲染类型三
因为类型三是需要强行'掰弯',此时就得一个子节点一个子节点渲染,因为只有这样,才能在渲染第二个子节点时候根据第一个子节点坐标去确定第二个子节点坐标,那么,在类型二处理完渲染子节点类型三的时候使用reduce
方法,这样就能确保渲染第二个子节点时,第一个子节点已经渲染完毕。还有一个问题就是群组,群组的开始坐标可以根据第一个类型三的节点坐标确定,结束坐标根据最后一个类型三的节点坐标确定。 还有就是如何渲染第二个类型三节点时知道该在第一个类型三的节点坐标上加多少呢,所以在渲染第一个类型三节点时,得把类型三节点下的所有类型四节点渲染完毕,这样就能知道它占用多少空间,得出第二个类型三的坐标。
js
// 处理3类型
dealLevelThreeData(node, parentNode,index,parentChildNum, col = 0) {
let nodeObj = cloneDeep(node)
delete nodeObj.children
nodeObj = {...this.typeTemplateData[`type${node.infoType}`],id: nodeObj.id, data: nodeObj, size: { height: this.typeConfigData.type3.height,width: this.typeConfigData.type3.width}}
let maxRowTotalHeight = 0
let row = this.getMaxRow(node)
let rowHeight = 0
if(node.children.length){
maxRowTotalHeight = row * this.typeConfigData.type3.height + row * this.typeConfigData.UBSpan
rowHeight = this.typeConfigData.type3.height + this.typeConfigData.UBSpan
}
nodeObj.maxRowTotalHeight = maxRowTotalHeight
nodeObj.rowHeight = rowHeight
let rowNum = node.children.length
let posX = parentNode.position.x
let posY = parentNode.position.y + this.typeConfigData[`type${parentNode.data.infoType}`].height + this.typeConfigData.UBSpan + maxRowTotalHeight/2 + parentNode.maxRowTotalHeight / 2
nodeObj.position.x = posX
nodeObj.position.y = posY
nodeObj.originalPosY = parentNode.position.y + this.typeConfigData.UBSpan + this.typeConfigData[`type${parentNode.data.infoType}`].height
nodeObj.originalPosX = parentNode.position.x
// 如果子节点创建完毕则创建群组
if(parentChildNum == index) {
let height = nodeObj.position.y - this.OriginPosition.y + this.typeConfigData.type3.height + 40
this.graph.addNode({
x: this.OriginPosition.x - 10,
y: this.OriginPosition.y - 10,
width: this.typeConfigData.type3.width + 20,
height,
zIndex: 1,
attrs: {
label: {
refY: 120,
fontSize: 12,
},
body: {
fill: '#1890ff1a',
stroke: '#1890ff',
},
},
})
this.OriginPosition = {}
}
let newNode = this.graph.addNode(nodeObj)
if(parentNode && index == 0) {
this.OriginPosition.x = nodeObj.position.x
this.OriginPosition.y = nodeObj.position.y
this.graph.addEdge({
source: parentNode.id,
target: nodeObj.id,
router: {
name: 'er',
args: {direction: 'V',offset:'center'},
}
})
}
const data = newNode.getData()
newNode.setData(data)
col++
node.children.forEach( item => {
this.dealEndNode(item, nodeObj, col, rowNum)
})
return nodeObj
}
渲染类型四
类型四节点的渲染思路也是差不多,先将子节点渲染完成,在渲染下一个,这里会有一个坐标重叠校验,当第一个节点渲染完毕后,就证明这个格子已经有了节点,所以下一个节点就不能放在这,得向下移动
js
// 处理4类型
dealEndNode(node, parentNode, col = 0, rowNum) {
let nodeObj = cloneDeep(node)
delete nodeObj.children
let rowHeight = parentNode.maxRowTotalHeight / rowNum
nodeObj = {...this.typeTemplateData[`type${node.infoType}`],id: nodeObj.id, data: nodeObj, size: { height: this.typeConfigData.type4.height,width: this.typeConfigData.type4.width}}
let posX = parentNode.originalPosX + this.pieceSize * col + this.pieceSize / 2 - this.typeConfigData.type4.width / 2
let posY = parentNode.originalPosY + rowHeight / 2 - this.typeConfigData.type4.height / 2
// 子项坐标重叠校验
let check = `${posX}${posY}`
while(this.posSet.has(check)) {
posY += rowHeight
check = `${posX}${posY}`
}
this.posSet.add(check)
nodeObj.position.x = posX
nodeObj.position.y = posY
nodeObj.maxRowTotalHeight = rowHeight
nodeObj.originalPosY = parentNode.originalPosY
nodeObj.originalPosX = parentNode.originalPosX
let newNode = this.graph.addNode(nodeObj)
if(parentNode) this.graph.addEdge({
source: parentNode.id,
target: nodeObj.id,
router: {
name: 'er',
args: {direction: 'L',offset:'center'},
},
attrs: {
line: {
stroke: '#faad14',
targetMarker: 'classic',
},
},
})
const data = newNode.getData()
newNode.setData(data)
col++
node.children.forEach( item => this.dealEndNode(item, nodeObj, col, node.children.length))
}
总结
整个过程我花了四天时间,不是说代码有多难写,而是不断的尝试和修正,达到心理预期。很多时候实现并不是最难的,难的是思路,在实现一个复杂功能时,有一个清晰的思路必将事半功倍。