最近在做一个功能时需要用到类似思维导图的布局方式,为此查了一些资料和开源代码的实现,涨了一波图布局相关的知识。我想实现的功能实际上就是一个有向无环图(DAG,Direct Acyclic Graph),对DAG的布局已有一些较为成熟的算法,如较为常用的Sugiyama, 不过看完这些算法之后,我的状态是这样的:
最后还是根据自己的需求,实现了一个相对比较简单,效率也会好很多的一个布局算法。
一、有向无环图布局算法
在此之前先梳理一下现有的布局算法,查阅相关的资料后,大体上一个有向无环图的布局过程经过如下几个阶段:
1. 除环
先分析当前图是否存在环,存在的话根据相应的算法去掉环线,如深度优先遍历图,如果遍历到的一个节点已经在路径上,说明这个边已经成环,对这条边移除或反向(出入度调换)达到除环的目的
2. 分层
完成除环后,接下来是分层,对图中的节点进行整理,按照一定的算法划分层级,每个节点落入到相应的层级上,相关的算法有:
- 最长路径算法:
longest-path- 紧凑数算法:
tight-tree- 网络单纯型算法:
network-simplex
3. 节点排序
调整同一层级内的节点顺序,减少交叉边,涉及的算法:
- 重心算法:
barycenter- 中心算法:
median
4. 计算坐标
确定了节点的层级和序号后,就可以遍历层级和序号分配坐标了
5. 绘制渲染
这一步也比较简单,有了节点的坐标,丢到Canvas中绘制即可
以上的步骤完成后就实现了DAG的自动布局,其中较为复杂的是第2、3两个步骤,相关的感兴趣的同学可自行科普,文末也会提供一些资料链接,总之这块算法的论文看的一头雾水(= =!)。
这方面的开源项目比较出名的是GitHub - dagrejs/dagre,不过这个库已经不维护了,但是源码还是可供学习的,基本实现了上述的布局算法。由于公司开发使用的Flutter ,也调研了下 使用Flutter 开源的项目graphview,项目主页上介绍说也是参考dagre 实现的Flutter 版本。
二、一个简单的布局算法
上面介绍的算法对我来说还不能一下全掌握,光阅读这些资料就花了一天时间,又花了半天时间实现了其中一些算法,实现的过程中总是感觉把事情想的有点复杂了(男人的第六感?),这个想法像野草一样扎根在思想里,没法专注实现那些Expansive的算法,然后停下当前的工作,重新梳理一下需求,得把这根野草拔掉,同时也避免花了大量时间做了无用功。
在重新复盘的时候我发现,我想实现的DAG图有几个特质:
- 整个图只有唯一的入度为0的节点;
- 图一定是无环的;
- 任意节点的出度节点都是有序的;
这几个特点在构造图数据的时候就已经约束好的,那么根据这几个特质能得出什么结论呢?
- 入度为0的节点可以作为起始节点,从这个节点开始进行布局;
- 图的数据一定是无环的,省去了除环的步骤;
- 图的数据节点已经有了顺序,布局时候不能变动这些顺序,所以如果出现了交叉边,也没有关系,省去了节点排序的步骤,因为分层算法主要是对节点排序服务的,同时也因为入度0的节点只有一个,所有的节点都在这后面,已经又了层级关系,所以也省去了分层 。
实际因为唯一入度0节点的关系,这个图有点像一颗树了,不同地方是子节点中可能会有多个入度的情况。
根据以上的特质,重新设计了一套较为简单的算法,算法基础是使用了深度优先遍历(DFS) ,下面以左->右 方向示例算法布局的过程:
1. 将所有的节点布局在起始坐标上
2. 开始深度优先遍历,在遍历路径过程中,同时横向展开节点
第一次遍历的路径是最顶边,遍历到出度0的节点就是整个布局的右上角节点,此时根节点和右上角节点的坐标就已经确定
3. 开始回溯路径遍历节点,同时纵向展开节点
每个节点除了包含自身的position,还包含一个familyPosition ,定义了该节点所有子节点所占据的区域位置,如:
蓝色虚线展示了节点Node1 的自身position位置,红色虚线展示了节点Node1 的familyPosition的位置。有了familyPosition的概念后,所有的节点都要根据其他节点的familyPosition来定位自身的坐标,否则就会出现重叠的情况。
4. 所有节点的坐标确定好后,连线就可以了
如图所示,如果某个节点有多个入度,该节点的坐标计算根据第一个入度来计算,默认把第一个入度作为父节点。
三、代码实现
1. 深度优先遍历计算坐标
void _dfsSpreadNodes(GraphNodeElement root) {
//遍历路径
var walked = <GraphNodeElement>[root];
//已遍历的节点
var visited = <GraphNodeElement>[];
while (walked.isNotEmpty) {
var currentElement = walked.last;
var canVisit = true;
//初始family尾坐标,在遍历过程中不断更新这个值,最后确定最终的familyPosition
double familyMainEnd = 0, familyCrossEnd = 0;
if (direction == Axis.horizontal) {
familyMainEnd = currentElement.familyPosition.right;
familyCrossEnd = currentElement.familyPosition.bottom;
} else if (direction == Axis.vertical) {
familyMainEnd = currentElement.familyPosition.bottom;
familyCrossEnd = currentElement.familyPosition.right;
}
if (currentElement.node.nextList.isNotEmpty) {
//初始出度节点的尾坐标,更新这个值纵向展开出度节点
var currentCrossEnd = .0;
if (direction == Axis.horizontal) {
currentCrossEnd = currentElement.familyPosition.top;
} else if (direction == Axis.vertical) {
currentCrossEnd = currentElement.familyPosition.left;
}
//遍历出度节点
for (var node in currentElement.node.nextList) {
//if node is in currentNode's family
//判断当前节点是否为该出度节点的"父节点",
if (node.prevList.first == currentElement.node) {
var element = node.element;
if (!visited.contains(element)) {
if (direction == Axis.horizontal) {
//horizontal spread
//横向展开节点
var left = currentElement.familyPosition.right + kMainAxisSpace;
//vertical spread
//纵向展开节点
var top = currentCrossEnd;
var size = element.size;
//更新当前遍历的familyPosition
element.familyPosition = RelativeRect.fromLTRB(
left, top, left + size.width, top + size.height);
} else if (direction == Axis.vertical) {
//horizontal spread
var left = currentCrossEnd;
//vertical spread
var top = currentElement.familyPosition.top + kMainAxisSpace;
var size = element.size;
element.familyPosition = RelativeRect.fromLTRB(
left, top, left + size.width, top + size.height);
}
walked.add(element);
canVisit = false;
break;
} else {
//已遍历过的节点,更新familyPostion 的区域
if (direction == Axis.horizontal) {
familyCrossEnd =
math.max(familyCrossEnd, element.familyPosition.bottom);
familyMainEnd =
math.max(familyMainEnd, element.familyPosition.right);
//update vertical size
currentCrossEnd =
element.familyPosition.bottom + kCrossAxisSpace;
} else if (direction == Axis.vertical) {
familyCrossEnd =
math.max(familyCrossEnd, element.familyPosition.right);
familyMainEnd =
math.max(familyMainEnd, element.familyPosition.bottom);
//update horizontal size
currentCrossEnd =
element.familyPosition.right + kCrossAxisSpace;
}
}
}
}
}
if (canVisit) {
//update current node position & family position
//当前节点的出度节点都遍历完成后,根据更新后的familyPosition,重新计算当前节点的自身position
var nodeSize = currentElement.size;
var familyPosition = currentElement.familyPosition;
if (direction == Axis.horizontal) {
familyPosition = currentElement.familyPosition
.copyWith(right: familyMainEnd, bottom: familyCrossEnd);
} else if (direction == Axis.vertical) {
familyPosition = currentElement.familyPosition
.copyWith(right: familyCrossEnd, bottom: familyMainEnd);
}
currentElement.position = RelativeRect.fromLTRB(
familyPosition.left,
familyPosition.top,
familyPosition.left + nodeSize.width,
familyPosition.top + nodeSize.height);
currentElement.familyPosition = familyPosition;
//visit node
visited.add(currentElement);
walked.removeLast();
}
}
}
进行一次DFS遍历后,图中各节点的坐标已经可以渲染出来:
2.接下来根据节点的坐标和边,绘制连线
void _drawTriArrow(Canvas canvas, Path path, Paint paint) {
double triHeight = triangleArrowHeight;
var lastPathMetric = path.computeMetrics().last;
var t = lastPathMetric.getTangentForOffset(lastPathMetric.length);
//按当前的向量角度位移,
var offset =
Offset(triHeight * math.cos(-t!.angle), triHeight * math.sin(-t.angle));
//计算三角形指向的顶点
var tan = Tangent(t.position + offset, t.vector);
var triPath = Path()..moveTo(tan.position.dx, tan.position.dy - 0.5);
var angle = math.pi - 0.463; //(math.atan2(1,2)的值)
var triHypotenuse = (triHeight / math.cos(0.463)); //边长
//旋转放大
var tipVector = _rotateVector(tan.vector, angle) * triHypotenuse;
var p1 = tan.position + tipVector; //底边顶点
tipVector = _rotateVector(tan.vector, -angle) * triHypotenuse;
var p2 = tan.position + tipVector; //底边顶点
triPath.lineTo(p1.dx, p1.dy);
triPath.lineTo(p2.dx, p2.dy);
triPath.close();
canvas.drawPath(triPath, paint);
}
void render({required Canvas canvas, required Graph graph}) {
graph.nodes.forEach((node) {
var nodeElement = node.element;
node.nextList.forEach((child) {
_linePath.reset();
var childElement = child.element;
if (graph.direction == Axis.horizontal) {
var start = Offset(nodeElement.position.right,
nodeElement.position.top + _connectPointOffset);
var end = Offset(childElement.position.left,
childElement.position.top + _connectPointOffset);
_linePath.moveTo(start.dx, start.dy);
_linePath.cubicTo(
start.dx + kMainAxisSpace / 2,
start.dy,
end.dx - kMainAxisSpace / 2,
end.dy,
end.dx - triangleArrowHeight,
end.dy);
} else if (graph.direction == Axis.vertical) {}
canvas.drawPath(_linePath, _paint);
_drawTriArrow(canvas, _linePath, _trianglePaint);
});
});
}
目前这个布局是顶部对齐,如果想居中对齐也很简单,将上面DFS算法中计算自身节点坐标的部分改成中间节点即可:
Before
currentElement.position = RelativeRect.fromLTRB(
familyPosition.left,
familyPosition.top,
familyPosition.left + nodeSize.width,
familyPosition.top + nodeSize.height);
After
currentElement.position = RelativeRect.fromLTRB(
familyPosition.left,
familyPosition.top +
(familyPosition.bottom - familyPosition.top - nodeSize.height) /
2,
familyPosition.left + nodeSize.width,
familyPosition.top +
(familyPosition.bottom - familyPosition.top + nodeSize.height) /
2);
整个算法过程只使用了一次DFS,并且是有向图,所以时间复杂度为,即节点数+边数的线性时间。
项目源码:flow_graph: 基于Flutter实现的流程图布局
Refrence
[0] Improbable Emancipation: The Sugiyama Layout Algorithm (Hierarchical Algorithm) for dummies )
[1] BarthMutzelJuenger2004.8.2.dvi
[2] Fast and Simple Horizontal Coordinate Assignment
[3] 一种画有向图的技术
[4] 深入解读Dagre布局算法
[5] 浅谈图的层次布局