Grandalf 图布局入门 #
1. 这份文档怎么用 #
先说清楚这份文档的定位、适用的版本,以及一个必须提前打的预防针:网上关于 grandalf 的资料错得相当多,照抄大概率跑不通。
1.1 你会学到什么 #
Grandalf 解决的是一个很具体的问题:给你一堆节点和连线,算出每个节点该放在哪个坐标上,让整张图看起来层次分明、连线尽量不打架。
它只算坐标,不画图。算完之后你想用字符画到终端里、用 matplotlib 存成 PNG、还是用前端库渲染到网页上,都由你自己决定。这个分工听起来有点"半成品",但正因为它不绑定任何绘图库,才能被 LangChain 这样的项目拿来当内部依赖。
这份文档按下面的顺序展开,建议从头读:
- 先补图论和布局算法的基础概念(第 2 章),不然后面的术语会看不懂
- 跑通第一个例子,把两个必踩的坑先踩掉(第 4 章)
- 逐个拆解三个核心对象和布局结果的含义(第 5、6 章)
- 学会控制边的画法和布局参数(第 7、8 章)
- 亲手把布局结果渲染出来,字符画和图片各做一遍(第 9、10 章)
- 回到最实际的场景:LangChain 和 LangGraph 里怎么用(第 12 章)
- 最后是报错速查、选型建议和速查表(第 13 到 15 章)
1.2 环境与版本 #
本文所有代码都在下面这套环境里真实跑过,文中所有输出结果都是实际运行贴回来的,不是手写的示意:
| 组件 | 版本 |
|---|---|
| Python | 3.13.14 |
| grandalf | 0.8 |
| langchain-core | 1.5.3 |
| langgraph | 1.2.10 |
| matplotlib | 3.11.1(只有第 10 章用得到) |
| 操作系统 | Windows |
1.3 一个重要提醒 #
grandalf 的官方文档很少,网上流传的中文教程有相当一部分是照着旧版本或者干脆凭想象写的。常见的错误包括:
- 说有
ForceLayout(力导向布局)、RandLayout(随机布局)——0.8 版里这两个类根本不存在 - 说可以用
layout.vert_space/layout.horiz_space调间距——没有这两个属性,真名叫yspace/xspace - 说可以用
layout.dir = 'TB'换布局方向——没有dir这个属性,而且 grandalf 压根不支持换方向 - 示例代码里不给顶点设置
view属性——这样跑必定报AssertionError - 用
e.view.getpath()取边的路径——没有这个方法
第 8 章和第 13 章会用实际运行结果把这些逐条验证一遍。你现在只要记住:遇到跑不通的示例代码,先怀疑教程,别怀疑自己。
2. 先搞懂几个概念 #
这一章不写代码,只把后面要反复用到的词讲清楚。已经懂图论的可以直接跳到第 3 章。
2.1 图、顶点、边 #
"图"在这里不是"图片"的意思,而是数学上的图(graph):由一堆点和连接这些点的线组成的结构。
- 顶点(Vertex),也叫节点(node):图里的一个点。可以代表一个任务、一个网页、一个人。
- 边(Edge):连接两个顶点的线。可以代表"依赖"、"跳转"、"认识"。
举个例子,一条数据处理流程"读取 → 清洗 → 入库",就是 3 个顶点和 2 条边。
2.2 有向图与 DAG #
边分两种:
- 无向边:A 和 B 互相连着,不分先后。比如"张三和李四是朋友"。
- 有向边:从 A 指向 B,有方向。比如"先读取,再清洗"。
全是有向边的图叫有向图(directed graph)。grandalf 默认建的就是有向图。
有向图里如果顺着箭头走能绕回起点,就说这个图有环(cycle)。比如"校验 → 修复 → 校验"就是一个环。没有环的有向图叫 DAG(Directed Acyclic Graph,有向无环图)。
这个区分很重要,因为分层布局算法只能处理 DAG。遇到有环的图,算法会先临时把某几条边反过来,把环破掉再排版。这个过程第 12 章会实际演示。
2.3 什么叫"布局" #
布局(layout) 就是给每个顶点算一个 (x, y) 坐标。
同一张图可以有无数种布局方式。把 4 个点随便扔在平面上也算一种布局,但那样看起来就是一团乱麻。好的布局要满足几个直觉上的要求:
- 有先后关系的节点,画出来也要有明显的上下或左右顺序
- 连线尽量短、尽量不交叉
- 节点之间不重叠,间距均匀
grandalf 干的就是这件事,而且干得还不错。
2.4 分层布局是怎么回事 #
grandalf 的看家本领是 Sugiyama 分层布局(以提出者杉山公造命名)。它的思路很符合直觉:把图画成一层一层的,箭头统一从上往下走。
具体分四步:
第一步:破环。 前面说了,算法只能处理 DAG。所以先找出造成环的那几条边,把它们的方向临时反过来。这些边会被标记成 feedback = True,排完版之后再改回原方向,画出来就是"往回指"的箭头。
第二步:分层(ranking)。 给每个顶点分配一个层号:没有任何入边的顶点放第 0 层,剩下的顶点必须排在它所有前驱节点的下面。这样一来所有箭头都是从上层指向下层。
第三步:减少交叉(ordering)。 层号定了,但同一层里谁在左谁在右还没定。顺序不同,连线交叉的数量差别很大。算法会反复调整每层的左右顺序,把交叉数往下压。这一步只能求个近似最优解,因为求精确最小交叉是 NP 难问题。
第四步:定坐标(coordinate assignment)。 最后把层号和层内顺序换算成具体的 x、y 数值,同时保证节点不重叠、连线尽量竖直。
还有一个细节:如果一条边跨了好几层(比如从第 0 层直接连到第 3 层),算法会在中间的每一层插入一个虚拟顶点(DummyVertex)当中转站。这样长边就变成了几段短边,画出来是一条有拐点的折线,而不是一条斜穿好几层的直线。第 6 章和第 7 章会看到实际效果。
3. 安装与验证 #
grandalf 是纯 Python 包,没有任何必需的第三方依赖,直接装就行:
pip install grandalf装完跑一下这段,确认能正常导入:
# grandalf 这个包本身没有 __version__ 属性,要用标准库的 importlib.metadata 查版本
from importlib.metadata import version
# 三个最常用的模块,导入不报错就说明装好了
from grandalf.graphs import Edge, Graph, Vertex
from grandalf.layouts import SugiyamaLayout, VertexViewer
from grandalf.routing import route_with_lines
print("grandalf 版本:", version("grandalf"))
print("导入成功,可以开始了")实测输出:
grandalf 版本: 0.8
导入成功,可以开始了第 10 章要把图画成 PNG 图片,那一章额外需要 matplotlib。用不到的话可以先不装:
pip install matplotlib4. 第一个能跑的例子 #
概念讲得再多不如跑一遍。这一章用一个四节点的小图走完整个流程,顺便把两个"不知道就一定会栽"的坑先摆出来。
4.1 完整代码 #
我们要给下面这张图排版。A 分叉到 B 和 C,两条路再汇合到 D:
A
/ \
B C
\ /
D下面这段代码可以直接保存成 hello.py 运行,不依赖任何其他文件:
# 从 grandalf.graphs 导入三个基础对象:顶点、边、图
from grandalf.graphs import Edge, Graph, Vertex
# 从 grandalf.layouts 导入分层布局算法,以及官方自带的"顶点外观"类
from grandalf.layouts import SugiyamaLayout, VertexViewer
# 建四个顶点。括号里的东西会存进 v.data,可以是任意 Python 对象,这里用字符串当名字
A = Vertex("A")
B = Vertex("B")
C = Vertex("C")
D = Vertex("D")
# 建四条有向边,Edge(起点, 终点) 表示一个箭头
edges = [
Edge(A, B), # A 指向 B
Edge(A, C), # A 指向 C
Edge(B, D), # B 指向 D
Edge(C, D), # C 指向 D
]
# 把顶点列表和边列表交给 Graph,图就建好了
g = Graph([A, B, C, D], edges)
# 关键一步:每个顶点必须有 view 属性,且 view 要能提供宽 w 和高 h
# grandalf 只算坐标不管画图,但它需要知道每个方块多大,才能留出不重叠的空间
for v in g.V():
# w 按名字长度估算,h 固定为 30,单位随意(你自己渲染时怎么解释都行)
v.view = VertexViewer(w=len(str(v.data)) * 10 + 20, h=30)
# 第二个关键点:SugiyamaLayout 要的是"连通分量",不是 Graph 本身
# g.C 是连通分量列表,这张图是连通的,所以只有一个分量 g.C[0]
layout = SugiyamaLayout(g.C[0])
# 初始化:分层、给每层的节点排序、给长边插虚拟节点
layout.init_all()
# 真正计算坐标,算完结果写回每个 v.view.xy
layout.draw()
# 打印每个顶点最终的坐标
for v in g.V():
# v.view.xy 是一个 (x, y) 元组
print(f"{v.data}: x={v.view.xy[0]:7.1f}, y={v.view.xy[1]:7.1f}")实测输出:
A: x= 0.0, y= 15.0
B: x= -25.0, y= 65.0
C: x= 25.0, y= 65.0
D: x= 0.0, y= 115.04.2 结果对不对 #
对照一下就知道算得没问题:
- A 在最上面(
y=15),D 在最下面(y=115),中间是 B 和 C,层次正确 - B 和 C 的
y都是 65,说明它们在同一层 - B 的
x是负数、C 的x是正数,一左一右分开了 - A 和 D 的
x都是 0,正好在 B、C 的正中间
注意 y 是越往下越大,这跟你在中学数学里习惯的坐标系是相反的。第 6.3 节会专门讲这件事。
4.3 两个必踩的坑 #
上面代码里我用注释标了"关键一步"和"第二个关键点",这两处是新手 100% 会栽跟头的地方。
坑一:忘了给顶点设置 view。
如果去掉那个设置 view 的循环,程序会直接崩:
AssertionError:而且报错信息是空的,只有一个光秃秃的 AssertionError,什么提示都没有。原因是 grandalf 源码里写的是 assert hasattr(v, "view"),作者没写提示文字。
为什么必须有 view?因为 grandalf 要给每个节点留出不重叠的空间,就得知道这个节点画出来有多大。view 就是用来告诉它宽高的。至于这个"宽高"的单位是像素、是字符格子、还是别的什么,grandalf 完全不关心,由你在渲染时自己解释。
坑二:把 Graph 直接传给 SugiyamaLayout。
网上很多示例写的是 SugiyamaLayout(graph),这样跑会报:
AttributeError: 'Graph' object has no attribute 'sV'正确写法是 SugiyamaLayout(graph.C[0])。原因下一章讲连通分量时会说清楚。
5. 三个核心对象 #
grandalf 的 API 很小,核心就三个类,加一个容易被忽略的 C 属性。
5.1 Vertex:顶点 #
Vertex(data) 建一个顶点,data 可以是任何 Python 对象——字符串、数字、字典、你自己的类实例都行。grandalf 自己完全不解释 data 的内容,只是原样存着,方便你在渲染时取出来用。
常用的两个方法:
v.e_in():返回所有指向这个顶点的边v.e_out():返回所有从这个顶点出发的边
入边数为 0 的顶点就是根节点,分层时会被放在第 0 层。
5.2 Edge:边 #
Edge(起点, 终点) 建一条有向边。完整签名是 Edge(x, y, w=1, data=None, connect=False):
w:权重,默认 1。找最短路之类的算法会用到,分层布局用不上。data:附加信息,跟Vertex.data一样随你放。e.v:一个二元组,e.v[0]是起点,e.v[1]是终点。e.deg:值为 1 表示连接两个不同顶点,值为 0 表示自环(起点终点是同一个)。
注意方向性是 Graph 的参数,不是 Edge 的。想建无向图要写 Graph(V, E, directed=False),而不是给 Edge 传参数。
5.3 Graph:图 #
Graph(顶点列表, 边列表, directed=True) 把顶点和边装配成一张图。
g.V():遍历所有顶点,是方法,要加括号g.E():遍历所有边,同样要加括号
5.4 连通分量 g.C:为什么必须写 C[0] #
这是 grandalf 最容易让人困惑的设计。
连通分量指的是:图里互相能走到的顶点自成一"坨"。如果一张图分成了互不相连的几坨,那它就有几个连通分量。
g.C 是一个列表,装着这张图的所有连通分量。哪怕整张图是连通的,g.C 也是个只有一个元素的列表,所以要写 g.C[0]。
SugiyamaLayout 要求传入的是单个连通分量而不是整张 Graph,因为分层算法的"第 0 层"是从根节点往下推的,两坨互不相连的子图之间没有任何层级关系,没法放进同一套分层里。如果你的图不连通,就得把每个分量分别布局,再手工把它们错开摆放——第 13 章有完整代码。
5.5 完整示例 #
下面这段把上面讲的都跑一遍,可以直接保存运行:
from grandalf.graphs import Edge, Graph, Vertex
# ---------- Vertex:顶点 ----------
# data 可以放任何对象,不只是字符串
v_str = Vertex("登录")
# 这里放一个字典,实际项目里常用来携带业务数据
v_dict = Vertex({"id": 7, "name": "校验"})
# data 属性就是你传进去的原始对象,grandalf 自己完全不解释它
print("字符串顶点的 data:", v_str.data)
print("字典顶点的 data:", v_dict.data)
print("字典顶点取字段:", v_dict.data["name"])
# ---------- Edge:边 ----------
a, b = Vertex("A"), Vertex("B")
# Edge 默认是有向边,第一个参数是起点,第二个是终点
e = Edge(a, b)
# e.v 是一个二元组,保存这条边连接的两个顶点
print("边的两端:", e.v[0].data, "->", e.v[1].data)
# 加 w= 可以给边一个权重,找最短路时会用到;data= 可以挂任意附加信息
e_weighted = Edge(a, b, w=3, data="重要路径")
print("边的权重:", e_weighted.w, "| 边的附加数据:", e_weighted.data)
# deg 为 1 表示这是连接两个不同顶点的普通边,为 0 表示自环(起点和终点是同一个顶点)
print("普通边的 deg:", e.deg, "| 自环的 deg:", Edge(a, a).deg)
# ---------- Graph:图 ----------
# 建一张"两个孤立小图"的大图:A->B 和 C->D 之间没有任何连接
A, B, C, D = Vertex("A"), Vertex("B"), Vertex("C"), Vertex("D")
# 方向性是 Graph 的参数,不是 Edge 的;默认 directed=True 表示有向图
g = Graph([A, B, C, D], [Edge(A, B), Edge(C, D)], directed=True)
# V() 遍历所有顶点,E() 遍历所有边,注意它们是方法,要加括号
print("顶点总数:", len(list(g.V())))
print("边总数:", len(list(g.E())))
# ---------- 连通分量 g.C ----------
# 顺着边能互相走到的顶点算一个"连通分量"。上面这张图有两坨互不相连的部分
print("连通分量个数:", len(g.C))
# 每个分量是一个 graph_core 对象,用 .sV 列出它包含的顶点
for i, comp in enumerate(g.C):
print(f" 分量 {i} 包含:", [v.data for v in comp.sV])
# ---------- 顶点的邻居 ----------
# e_in() 返回所有指向该顶点的边,e_out() 返回从该顶点出发的边
print("A 的出边条数:", len(A.e_out()), "| A 的入边条数:", len(A.e_in()))
print("B 的出边条数:", len(B.e_out()), "| B 的入边条数:", len(B.e_in()))
# 入边为 0 的顶点就是"根节点",布局时经常需要它们
roots = [v for v in g.C[0].sV if len(v.e_in()) == 0]
print("第 0 个分量里的根节点:", [v.data for v in roots])实测输出:
字符串顶点的 data: 登录
字典顶点的 data: {'id': 7, 'name': '校验'}
字典顶点取字段: 校验
边的两端: A -> B
边的权重: 3 | 边的附加数据: 重要路径
普通边的 deg: 1 | 自环的 deg: 0
顶点总数: 4
边总数: 2
连通分量个数: 2
分量 0 包含: ['A', 'B']
分量 1 包含: ['C', 'D']
A 的出边条数: 1 | A 的入边条数: 0
B 的出边条数: 0 | B 的入边条数: 1
第 0 个分量里的根节点: ['A']6. 读懂布局结果 #
布局跑完之后,除了每个顶点的 view.xy,SugiyamaLayout 对象上还挂着一堆有用的中间结果。看懂它们能帮你判断布局为什么长成这样。
6.1 层号 rank 与层列表 layers #
sug.layers:一个列表,每个元素代表一层,层里装着这一层的所有顶点(包括虚拟顶点)sug.grx[v].rank:顶点v被分到了第几层,从 0 开始
层号是按"必须排在所有前驱下面"这个规则推出来的。所以如果 A→B→C,那么 A、B、C 的层号一定是 0、1、2,不可能挤在同一层。
6.2 虚拟顶点 DummyVertex #
前面说过,跨层的长边会被打断,中间插入虚拟顶点。这些虚拟顶点也占位置,所以你会发现 len(sug.layers[i]) 比这一层真实顶点的数量要多。
想区分真假顶点,用 isinstance(v, DummyVertex) 判断即可。渲染时虚拟顶点不该画成方框,它们只是折线的拐点。
6.3 坐标怎么读:y 轴是向下的 #
这是最容易搞错的一点。
grandalf 用的是屏幕坐标系:原点在左上角,x 向右增大,y 向下增大。根节点的 y 最小,越往下层 y 越大。
这跟数学课上的坐标系(y 向上)是反的,也跟 matplotlib 的默认坐标系是反的。所以第 10 章用 matplotlib 画图时,必须把 y 取负号翻过来,否则整张图会上下颠倒。
另外,x 坐标可以是负数。布局是以中轴线为 0 点左右展开的,所以左半边的节点 x 是负的。渲染前一般要先算出最小值,把整体平移到正数区间。
6.4 间距怎么算 #
相邻两层的 y 差 = 顶点高度 h + 层间距 yspace。
比如顶点高 30、yspace 默认 20,那么相邻两层的 y 就差 50。这个规律在下面的输出里能直接验证。
6.5 完整示例 #
下面这段把本章讲的分层、虚拟顶点、y 轴方向、层间距全部实测一遍。图是一条 A→B→C→D 的链,外加一条从 A 直接抄近路到 D 的长边,正好能看到虚拟顶点:
from grandalf.graphs import Edge, Graph, Vertex
# DummyVertex 是算法为"跨层长边"自动插入的占位顶点,导入它是为了把它和真实顶点区分开
from grandalf.layouts import DummyVertex, SugiyamaLayout, VertexViewer
def build(pairs, w=40, h=30):
"""按 [("A","B"), ...] 这样的边列表建图,并给每个顶点配好 view。"""
# 从边列表里收集所有出现过的名字,排序保证每次运行顺序一致
names = sorted({n for pair in pairs for n in pair})
# 名字 -> Vertex 对象的映射,方便后面按名字取顶点
vs = {n: Vertex(n) for n in names}
# 按边列表建 Edge
es = [Edge(vs[a], vs[b]) for a, b in pairs]
# 组装成图
g = Graph(list(vs.values()), es)
# 每个顶点都要有 view,否则布局会直接 AssertionError
for v in vs.values():
v.view = VertexViewer(w=w, h=h)
return g, vs, es
# 一条链 A->B->C->D,外加一条"抄近路"的长边 A->D
pairs = [("A", "B"), ("B", "C"), ("C", "D"), ("A", "D")]
g, vs, es = build(pairs)
# 建布局器并计算
sug = SugiyamaLayout(g.C[0])
sug.init_all()
sug.draw()
# ---------- 看分层结果 ----------
# sug.layers 是一个列表,每个元素代表一层
print("总共分了几层:", len(sug.layers))
# 每层里既有真实顶点,也可能有算法自动插入的虚拟顶点
for i, layer in enumerate(sug.layers):
# 把虚拟顶点过滤掉,只留真实顶点的名字
real = [v.data for v in layer if not isinstance(v, DummyVertex)]
print(f" 第 {i} 层:共 {len(layer)} 个位置,其中真实顶点 = {real}")
# ---------- 看每个顶点的层号和坐标 ----------
# sug.grx[v] 保存 grandalf 给顶点 v 挂的内部属性,rank 就是层号
print("\n顶点 -> 层号与坐标:")
for n in sorted(vs):
v = vs[n]
print(f" {n}: 层号={sug.grx[v].rank} x={v.view.xy[0]:7.1f} y={v.view.xy[1]:7.1f}")
# ---------- 验证 y 轴方向 ----------
# 找出根节点(入边为 0)和叶子节点(出边为 0)
root = [n for n in sorted(vs) if len(vs[n].e_in()) == 0][0]
leaf = [n for n in sorted(vs) if len(vs[n].e_out()) == 0][0]
print(f"\n根节点 {root} 的 y = {vs[root].view.xy[1]:.1f}")
print(f"叶子节点 {leaf} 的 y = {vs[leaf].view.xy[1]:.1f}")
print("结论:根节点 y 更小,说明 y 轴向下增长,画图时 y 越大越靠下")
# ---------- 验证层间距 ----------
# 相邻两层的 y 差 = 顶点高度 h + 层间距 yspace
print(f"\n默认 yspace = {sug.yspace},顶点高 h = 30")
print(f"第 0 层与第 1 层的 y 差 = {vs['B'].view.xy[1] - vs['A'].view.xy[1]:.1f}(= h + yspace)")实测输出:
总共分了几层: 4
第 0 层:共 1 个位置,其中真实顶点 = ['A']
第 1 层:共 2 个位置,其中真实顶点 = ['B']
第 2 层:共 2 个位置,其中真实顶点 = ['C']
第 3 层:共 1 个位置,其中真实顶点 = ['D']
顶点 -> 层号与坐标:
A: 层号=0 x= 0.0 y= 15.0
B: 层号=1 x= -30.0 y= 65.0
C: 层号=2 x= -30.0 y= 115.0
D: 层号=3 x= 0.0 y= 165.0
根节点 A 的 y = 15.0
叶子节点 D 的 y = 165.0
结论:根节点 y 更小,说明 y 轴向下增长,画图时 y 越大越靠下
默认 yspace = 20,顶点高 h = 30
第 0 层与第 1 层的 y 差 = 50.0(= h + yspace)留意第 1 层和第 2 层:都是"共 2 个位置,真实顶点只有 1 个"。多出来的那个位置就是长边 A→D 的虚拟顶点。
7. 边的路径 #
到这里我们只拿到了顶点坐标。要真正画出图来,还得知道每条边该怎么连。
7.1 边的 view 与 setpath #
跟顶点一样,边也可以有 view。区别在于:
- 顶点的
view是必需的,没有就报错 - 边的
view是可选的,不设也能算出顶点坐标,只是拿不到边的路径
布局算完后,grandalf 会对每条有 view 的边调用 e.view.setpath(pts),把折线点列表交给它。pts 是形如 [(x1, y1), (x2, y2), ...] 的列表。
所以你的边外观类只需要实现一个 setpath 方法就够了。
7.2 自带的 EdgeViewer 有个坑 #
grandalf.routing 里提供了现成的 EdgeViewer,但它的实现是这样的:
class EdgeViewer(object):
def setpath(self, pts):
self._pts = pts注意它存进的是 _pts——下划线开头的私有属性。它既没有 pts 属性,也没有网上教程里常说的 getpath() 方法。想取路径只能写 e.view._pts,访问私有属性总归别扭。
推荐做法是自己写一个三行的类,把路径存成公开属性:
class EdgePath:
def __init__(self):
self.pts = []
def setpath(self, pts):
self.pts = ptsLangChain 内部就是这么干的(langchain_core/runnables/graph_ascii.py 里的 _EdgeViewer)。
7.3 route_with_lines:让线停在方框边上 #
默认情况下,边的起点和终点就是两个顶点的中心坐标。直接画出来,线会从方框正中间穿出来、扎进另一个方框正中间。方框如果是不透明的还好,箭头就完全被盖住了。
grandalf.routing.route_with_lines 解决这个问题:它把边的两个端点从"顶点中心"挪到"顶点方框的边界上"。用法是在 draw() 之前给布局器赋值:
sug.route_edge = route_with_lines7.4 长边会变成折线 #
前面说过跨层长边会插虚拟顶点。反映到边的路径上,就是 pts 里会多出中间的拐点。一条跨 3 层的边,pts 会有 4 个点。
7.5 完整示例 #
下面这段用四组对照实验,把"不做路由 / 用 route_with_lines / 用自带的 EdgeViewer / 长边折线"的差别摆在一起看:
from grandalf.graphs import Edge, Graph, Vertex
from grandalf.layouts import SugiyamaLayout, VertexViewer
# route_with_lines 会把边的两端从"顶点中心"挪到"顶点方框的边界"上
from grandalf.routing import EdgeViewer, route_with_lines
class MyEdgeViewer:
"""自己写的边外观类。
grandalf 算完路径后会调用 view.setpath(pts),把折线点列表交给我们。
grandalf 自带的 EdgeViewer 把它存进了私有属性 _pts,用起来别扭,
所以这里自己存成公开的 pts。
"""
def __init__(self):
# 先给个空列表,布局还没跑时访问也不会报错
self.pts = []
def setpath(self, pts):
# grandalf 会主动调用这个方法,pts 是 [(x1,y1), (x2,y2), ...]
self.pts = pts
def build(pairs, viewer_cls):
"""建图并给顶点和边都装上 view。"""
names = sorted({n for pair in pairs for n in pair})
vs = {n: Vertex(n) for n in names}
es = [Edge(vs[a], vs[b]) for a, b in pairs]
g = Graph(list(vs.values()), es)
# 顶点的 view 是必需的
for v in vs.values():
v.view = VertexViewer(w=40, h=30)
# 边的 view 是可选的:不装也能算顶点坐标,但装了才能拿到边的折线
for e in es:
e.view = viewer_cls()
return g, vs, es
pairs = [("A", "B"), ("A", "C"), ("B", "D"), ("C", "D")]
# ---------- 情况一:用自己的 viewer,不做 route_edge ----------
g1, vs1, es1 = build(pairs, MyEdgeViewer)
s1 = SugiyamaLayout(g1.C[0])
s1.init_all()
s1.draw()
print("不设 route_edge:边从顶点中心连到顶点中心")
for e in es1:
pts = [(round(x, 1), round(y, 1)) for x, y in e.view.pts]
print(f" {e.v[0].data} -> {e.v[1].data}: {pts}")
# ---------- 情况二:加上 route_with_lines ----------
g2, vs2, es2 = build(pairs, MyEdgeViewer)
s2 = SugiyamaLayout(g2.C[0])
s2.init_all()
# 把边路由函数换成 route_with_lines,端点会被裁剪到方框边界
s2.route_edge = route_with_lines
s2.draw()
print("\n用 route_with_lines:端点被裁到方框边界上,画出来不会插进方块里")
for e in es2:
pts = [(round(x, 1), round(y, 1)) for x, y in e.view.pts]
print(f" {e.v[0].data} -> {e.v[1].data}: {pts}")
# ---------- 情况三:grandalf 自带的 EdgeViewer ----------
g3, vs3, es3 = build(pairs, EdgeViewer)
s3 = SugiyamaLayout(g3.C[0])
s3.init_all()
s3.draw()
print("\n自带的 EdgeViewer:路径藏在私有属性 _pts 里,没有 pts 也没有 getpath")
e0 = es3[0]
print(" 有 pts 属性吗:", hasattr(e0.view, "pts"))
print(" 有 getpath 方法吗:", hasattr(e0.view, "getpath"))
print(" _pts 里是:", [(round(x, 1), round(y, 1)) for x, y in e0.view._pts])
# ---------- 情况四:跨层长边会变成折线 ----------
long_pairs = [("A", "B"), ("B", "C"), ("C", "D"), ("A", "D")]
g4, vs4, es4 = build(long_pairs, MyEdgeViewer)
s4 = SugiyamaLayout(g4.C[0])
s4.init_all()
s4.draw()
print("\n长边 A->D 跨了 3 层,算法插入虚拟顶点,路径变成多段折线:")
for e in es4:
pts = [(round(x, 1), round(y, 1)) for x, y in e.view.pts]
print(f" {e.v[0].data} -> {e.v[1].data}: {len(pts)} 个点 {pts}")实测输出:
不设 route_edge:边从顶点中心连到顶点中心
A -> B: [(0.0, 15.0), (-30.0, 65.0)]
A -> C: [(0.0, 15.0), (30.0, 65.0)]
B -> D: [(-30.0, 65.0), (0.0, 115.0)]
C -> D: [(30.0, 65.0), (0.0, 115.0)]
用 route_with_lines:端点被裁到方框边界上,画出来不会插进方块里
A -> B: [(-9.0, 30.0), (-21.0, 50.0)]
A -> C: [(9.0, 30.0), (21.0, 50.0)]
B -> D: [(-21.0, 80.0), (-9.0, 100.0)]
C -> D: [(21.0, 80.0), (9.0, 100.0)]
自带的 EdgeViewer:路径藏在私有属性 _pts 里,没有 pts 也没有 getpath
有 pts 属性吗: False
有 getpath 方法吗: False
_pts 里是: [(0.0, 15.0), (-30.0, 65.0)]
长边 A->D 跨了 3 层,算法插入虚拟顶点,路径变成多段折线:
A -> B: 2 个点 [(0.0, 15.0), (-30.0, 65.0)]
B -> C: 2 个点 [(-30.0, 65.0), (-30.0, 115.0)]
C -> D: 2 个点 [(-30.0, 115.0), (0.0, 165.0)]
A -> D: 4 个点 [(0.0, 15.0), (30.0, 65.0), (30.0, 115.0), (0.0, 165.0)]对比前两组数据能清楚看到 route_with_lines 的作用:A→B 的起点从 (0.0, 15.0)(A 的中心)变成了 (-9.0, 30.0)(A 方框的下边界上)。
最后一组里 A→D 有 4 个点,正是因为它跨了 3 层,中间插了 2 个虚拟顶点。
8. 可以调的参数,和不能调的 #
SugiyamaLayout 真正值得调的参数只有四个,其余要么不存在、要么调了没用。这一章把能调的讲清楚,把不能调的用代码逐个证伪。
8.1 xspace 与 yspace:控制间距 #
这是最常用也最有用的两个参数:
sug.xspace:同一层里相邻节点的水平间距,默认 20sug.yspace:相邻两层之间的垂直间距,默认 20
要在 init_all() 之后、draw() 之前设置。
8.2 roots:指定从哪里开始分层 #
init_all(roots=[...]) 可以手工指定哪些顶点算第 0 层。不传的话,grandalf 会自动把所有入边为 0 的顶点当根节点,绝大多数情况下这个默认行为就是对的。
8.3 optimize:优化分层 #
init_all(optimize=True) 会启用基于网络单纯形法的分层优化。
要注意的是,它优化的是分层(每个顶点该放第几层),不是直接减少交叉。但更合理的分层往往会顺带把交叉数降下来。下面的实测里,同一张图开启 optimize 后交叉数从 1 降到了 0。
默认是 False。图比较复杂时值得打开试试,代价只是多花一点计算时间。
8.4 那些根本不存在的参数 #
网上教程里高频出现、但在 0.8 版里压根不存在的属性:
| 传说中的参数 | 真相 |
|---|---|
vert_space |
不存在,正确的是 yspace |
horiz_space |
不存在,正确的是 xspace |
direction / dir |
不存在,grandalf 不支持切换布局方向 |
incremental |
不存在,没有增量布局功能 |
draw(step=20) |
draw() 的参数叫 N,签名是 draw(self, N=1.5) |
下面的实验三会把这些逐个 hasattr 验证一遍。
8.5 dirvh 不是布局方向 #
dirvh 这个属性确实存在,取值 0 到 3,看名字很像"方向",于是常被误当成切换上下左右布局的开关。
它不是。 它是坐标分配算法(Brandes-Köpf)的内部对齐状态:算法会分别按"左上、右上、左下、右下"四种对齐方式各算一遍,然后取折中结果。draw() 内部会把 dirvh 从 0 到 3 全跑一遍再恢复原值,所以你在外面设什么都会被覆盖掉。
想让图横着排怎么办? grandalf 帮不了你,但这件事非常简单:渲染的时候把 x 和 y 对调就行了。布局器算出来的是竖版,你画的时候把 (x, y) 当成 (y, x) 来用,出来的就是横版。
8.6 完整示例 #
这段代码做四个实验:验证 optimize 有效、验证 xspace / yspace 有效、用 hasattr 证明那些传说中的参数不存在、以及证明 dirvh 改了也没用。里面还带了一个自己写的交叉计数函数,用来量化布局好坏:
from grandalf.graphs import Edge, Graph, Vertex
from grandalf.layouts import SugiyamaLayout, VertexViewer
class EV:
"""收集边的折线点,用来数交叉。"""
def __init__(self):
self.pts = []
def setpath(self, pts):
self.pts = pts
def build(pairs):
"""建图,顶点和边都配好 view。"""
# 从边列表里收集所有节点名,排序保证每次运行结果一致
names = sorted({n for pair in pairs for n in pair})
# 名字 -> Vertex 的映射
vs = {n: Vertex(n) for n in names}
# 按边列表建 Edge
es = [Edge(vs[a], vs[b]) for a, b in pairs]
# 组装成图
g = Graph(list(vs.values()), es)
# 顶点的 view 是必需的,统一给 40x30
for v in vs.values():
v.view = VertexViewer(w=40, h=30)
# 边也要配 view,不然拿不到折线,就没法数交叉
for e in es:
e.view = EV()
return g, vs, es
def count_crossings(edges):
"""数一下有多少对线段交叉,数字越小布局越清爽。"""
# 把每条边拆成一段段"相邻两点之间"的直线段
segs = []
for e in edges:
pts = e.view.pts
for i in range(1, len(pts)):
segs.append((pts[i - 1], pts[i]))
n = 0
# 两两比较所有线段
for i in range(len(segs)):
(x1, y1), (x2, y2) = segs[i]
for j in range(i + 1, len(segs)):
(x3, y3), (x4, y4) = segs[j]
# 只比较处在同一层间隙、且互不共享端点的线段
if (y1, y2) != (y3, y4):
continue
if (x1, y1) in ((x3, y3), (x4, y4)) or (x2, y2) in ((x3, y3), (x4, y4)):
continue
# 上端的左右顺序和下端反过来了,就说明这两条线交叉
if (x1 - x3) * (x2 - x4) < 0:
n += 1
return n
# 一张 optimize 能看出效果的图
pairs = [("A", "B"), ("A", "D"), ("A", "E"), ("B", "C"), ("B", "E"), ("C", "D"), ("D", "E")]
print("=" * 60)
print("实验一:optimize 让分层更合理,间接减少交叉")
print("=" * 60)
for opt in (False, True):
g, vs, es = build(pairs)
sug = SugiyamaLayout(g.C[0])
# roots 告诉算法从哪些顶点开始分层,一般就是所有入边为 0 的顶点
roots = [v for v in g.C[0].sV if len(v.e_in()) == 0]
sug.init_all(roots=roots, optimize=opt)
sug.draw()
# 顺便看看每个顶点被分到了第几层
ranks = {n: sug.grx[vs[n]].rank for n in sorted(vs)}
print(f" optimize={str(opt):5s} 交叉数={count_crossings(es)} 层号={ranks}")
print()
print("=" * 60)
print("实验二:xspace 控制左右间距,yspace 控制上下间距")
print("=" * 60)
# 一张最简单的菱形图:A 分叉到 B、C,再汇合到 D
simple = [("A", "B"), ("A", "C"), ("B", "D"), ("C", "D")]
# 三组参数对照:基准、只加大左右间距、只加大上下间距
for xs_val, ys_val in ((20, 20), (60, 20), (20, 80)):
g, vs, es = build(simple)
sug = SugiyamaLayout(g.C[0])
sug.init_all()
# 这两个属性要在 init_all 之后、draw 之前设置
sug.xspace = xs_val
sug.yspace = ys_val
sug.draw()
# B 和 C 在同一层,它们的 x 差反映左右间距
dx = vs["C"].view.xy[0] - vs["B"].view.xy[0]
# A 和 B 在相邻层,它们的 y 差反映上下间距
dy = vs["B"].view.xy[1] - vs["A"].view.xy[1]
print(f" xspace={xs_val:3d} yspace={ys_val:3d} -> 同层间距={dx:5.1f} 层间距={dy:5.1f}")
print()
print("=" * 60)
print("实验三:网上常见的几个参数名,在这个版本里根本不存在")
print("=" * 60)
g, vs, es = build(simple)
sug = SugiyamaLayout(g.C[0])
# 前五个是网上教程里常见但实际不存在的,后三个才是真有的
for name in ("vert_space", "horiz_space", "direction", "dir", "incremental",
"xspace", "yspace", "dirvh"):
# hasattr 直接问对象有没有这个属性,比翻文档可靠
exists = hasattr(sug, name)
mark = "存在" if exists else "不存在"
# getattr 的第三个参数是取不到时的默认值
val = getattr(sug, name, "—")
print(f" {name:14s} {mark} 当前值 = {val}")
print()
print("=" * 60)
print("实验四:dirvh 不是让你调的布局方向")
print("=" * 60)
# dirvh 是算法内部的对齐状态,draw() 里会把 0~3 都跑一遍再取平均,所以你设了也没用
for d in (0, 1, 2, 3):
g, vs, es = build(simple)
sug = SugiyamaLayout(g.C[0])
sug.init_all()
sug.dirvh = d
sug.draw()
xs = {n: round(vs[n].view.xy[0], 1) for n in sorted(vs)}
print(f" dirvh={d} -> {xs}")
print(" 四行结果完全一样,说明 dirvh 影响不了最终布局")
print(" 想让图横着排,请在渲染时把 x 和 y 对调,不要指望布局器")实测输出:
============================================================
实验一:optimize 让分层更合理,间接减少交叉
============================================================
optimize=False 交叉数=1 层号={'A': 0, 'B': 1, 'C': 2, 'D': 3, 'E': 4}
optimize=True 交叉数=0 层号={'A': 0, 'B': 1, 'C': 2, 'D': 3, 'E': 4}
============================================================
实验二:xspace 控制左右间距,yspace 控制上下间距
============================================================
xspace= 20 yspace= 20 -> 同层间距= 60.0 层间距= 50.0
xspace= 60 yspace= 20 -> 同层间距=100.0 层间距= 50.0
xspace= 20 yspace= 80 -> 同层间距= 60.0 层间距=110.0
============================================================
实验三:网上常见的几个参数名,在这个版本里根本不存在
============================================================
vert_space 不存在 当前值 = —
horiz_space 不存在 当前值 = —
direction 不存在 当前值 = —
dir 不存在 当前值 = —
incremental 不存在 当前值 = —
xspace 存在 当前值 = 20
yspace 存在 当前值 = 20
dirvh 存在 当前值 = 0
============================================================
实验四:dirvh 不是让你调的布局方向
============================================================
dirvh=0 -> {'A': 0.0, 'B': -30.0, 'C': 30.0, 'D': 0.0}
dirvh=1 -> {'A': 0.0, 'B': -30.0, 'C': 30.0, 'D': 0.0}
dirvh=2 -> {'A': 0.0, 'B': -30.0, 'C': 30.0, 'D': 0.0}
dirvh=3 -> {'A': 0.0, 'B': -30.0, 'C': 30.0, 'D': 0.0}
四行结果完全一样,说明 dirvh 影响不了最终布局
想让图横着排,请在渲染时把 x 和 y 对调,不要指望布局器实验二里,同层间距 = xspace + 顶点宽度 40,层间距 = yspace + 顶点高度 30,跟 6.4 节讲的规律完全吻合。
9. 自己写一个 ASCII 渲染器 #
前面八章都在算坐标,这一章第一次把坐标变成看得见的东西。全程只用标准库,不装任何图形依赖。
9.1 为什么先做字符画 #
把布局结果渲染成终端里的字符画,是理解 grandalf 输出的最好方式:不需要装任何图形库,一眼就能看出坐标算得对不对。LangChain 的 print_ascii() 干的就是这件事,我们先自己实现一遍,第 11 章再看它是怎么做的。
9.2 思路 #
分五步:
- 建图,给顶点和边都配上
view - 把尺寸单位定成"字符格子":一个方框宽 = 标签宽度 + 2(左右两条竖边),高固定 3 行(上边框、文字、下边框)
- 跑布局,
yspace设成 3 给连线留出 3 行空间,xspace设成最窄的方框宽度 - 平移坐标:
x可能是负数,先整体挪到正数区间,再按格子取整 - 先画线再画框,让方框盖住穿进来的线头
9.3 中文宽度这个坑 #
如果你的节点名是中文,会遇到一个麻烦:一个汉字在终端里占两列宽,但 len() 只算一个字符。直接用 len(name) 算方框宽度,画出来的框会比文字窄一半,整个图都是歪的。
解决办法是用标准库的 unicodedata.east_asian_width() 判断字符宽度:返回 'W'(宽)或 'F'(全角)就算两列,否则算一列。
还有个配套问题:画布是二维字符数组,一个汉字在数组里只占一个格子,但显示时占两列。所以写入汉字后要在下一个格子填一个看不见的占位符,最后渲染时再把占位符丢掉,这样列数才对得上。
9.4 完整代码 #
整个渲染器不到 130 行,分成三部分:算显示宽度的 dispw()、字符画布类 Canvas、以及把两者串起来的 draw_ascii()。直接保存运行即可:
import unicodedata
from grandalf.graphs import Edge, Graph, Vertex
from grandalf.layouts import SugiyamaLayout, VertexViewer
# 画布里用这个看不见的字符占位:一个中文字占两列,第二列就填它
FILLER = "\0"
def dispw(s):
"""算一个字符串在终端里占几列宽。
中文、日文、韩文这类"全角"字符占两列,英文数字占一列。
如果直接用 len() 算宽度,中文标签会把方框撑歪。
"""
# east_asian_width 返回 'W'(宽)或 'F'(全角)时算两列,其余算一列
return sum(2 if unicodedata.east_asian_width(ch) in "WF" else 1 for ch in s)
class EdgePath:
"""接住 grandalf 算出来的边折线。"""
def __init__(self):
self.pts = []
def setpath(self, pts):
self.pts = pts
class Canvas:
"""一块用二维字符数组模拟的画布。"""
def __init__(self, cols, rows):
# 先铺满空格,后面往上面写字符
self.buf = [[" "] * cols for _ in range(rows)]
self.cols = cols
self.rows = rows
def put(self, x, y, ch):
"""在 (x, y) 写一个字符,越界就忽略,省得处理边界报错。"""
if 0 <= x < self.cols and 0 <= y < self.rows:
self.buf[y][x] = ch
def text(self, x, y, s):
"""从 (x, y) 开始横向写一串字,遇到中文自动占两列。"""
for ch in s:
self.put(x, y, ch)
# 宽字符要多吃掉一列,用占位符标记,渲染时会被丢掉
if unicodedata.east_asian_width(ch) in "WF":
self.put(x + 1, y, FILLER)
x += 2
else:
x += 1
def box(self, x, y, w, h, label):
"""画一个左上角在 (x, y)、宽 w 高 h 的方框,中间放 label。"""
# 上下两条横边
for i in range(w):
self.put(x + i, y, "-")
self.put(x + i, y + h - 1, "-")
# 左右两条竖边
for j in range(h):
self.put(x, y + j, "|")
self.put(x + w - 1, y + j, "|")
# 四个角用 + 覆盖,看起来更利落
for cx, cy in ((x, y), (x + w - 1, y), (x, y + h - 1), (x + w - 1, y + h - 1)):
self.put(cx, cy, "+")
# 标签居中放在方框内部,居中要按显示宽度算而不是字符个数
self.text(x + max(1, (w - dispw(label)) // 2), y + h // 2, label)
def line(self, x0, y0, x1, y1, ch="*"):
"""用 Bresenham 算法在两点之间画一条直线。"""
dx = abs(x1 - x0)
dy = abs(y1 - y0)
# 每一步 x 往哪边走,y 往哪边走
sx = 1 if x0 < x1 else -1
sy = 1 if y0 < y1 else -1
# err 用来决定这一步该走 x 还是走 y
err = dx - dy
while True:
self.put(x0, y0, ch)
# 走到终点就停
if x0 == x1 and y0 == y1:
break
e2 = 2 * err
if e2 > -dy:
err -= dy
x0 += sx
if e2 < dx:
err += dx
y0 += sy
def render(self):
"""把二维数组拼成最终的字符串。
占位符要丢掉,否则中文后面会多出一个空格;
每行右边多余的空格也去掉,输出更干净。
"""
rows = ["".join(ch for ch in row if ch != FILLER).rstrip() for row in self.buf]
# 画布高度是按坐标跨度估的,底部往往空出几行,这里一并裁掉
while rows and not rows[-1]:
rows.pop()
return "\n".join(rows)
def draw_ascii(pairs):
"""把一个用边列表描述的有向图画成 ASCII 图。"""
# ---- 第一步:建图 ----
names = sorted({n for pair in pairs for n in pair})
vs = {n: Vertex(n) for n in names}
es = [Edge(vs[a], vs[b]) for a, b in pairs]
g = Graph(list(vs.values()), es)
# ---- 第二步:按字符格子来定义顶点尺寸 ----
# 宽 = 标签显示宽度 + 左右两条竖边;高固定 3 行 = 上边 + 文字 + 下边
for v in vs.values():
v.view = VertexViewer(w=dispw(str(v.data)) + 2, h=3)
for e in es:
e.view = EdgePath()
# ---- 第三步:跑布局 ----
sug = SugiyamaLayout(g.C[0])
sug.init_all()
# 层间距设成 3,正好让上下两个方框之间空出 3 行画连线
sug.yspace = 3
# 同层间距设成最窄的方框宽度,保证左右不会挤在一起
sug.xspace = min(v.view.w for v in vs.values())
sug.draw()
# ---- 第四步:坐标可能是负数,先整体平移到正数区间 ----
xs, ys = [], []
for v in vs.values():
# 方框是以中心点定位的,所以左边界要减去半个宽度
xs += [v.view.xy[0] - v.view.w / 2, v.view.xy[0] + v.view.w / 2]
ys += [v.view.xy[1], v.view.xy[1] + v.view.h]
for e in es:
for x, y in e.view.pts:
xs.append(x)
ys.append(y)
minx, miny = min(xs), min(ys)
# 画布大小按坐标跨度算,多留一格避免写到边上时被截断
canvas = Canvas(int(max(xs) - minx) + 2, int(max(ys) - miny) + 2)
# ---- 第五步:先画线,再画方框,让方框盖住穿进来的线 ----
for e in es:
pts = e.view.pts
for i in range(1, len(pts)):
x0, y0 = pts[i - 1]
x1, y1 = pts[i]
canvas.line(round(x0 - minx), round(y0 - miny),
round(x1 - minx), round(y1 - miny))
for v in vs.values():
x = v.view.xy[0] - v.view.w / 2
y = v.view.xy[1]
canvas.box(round(x - minx), round(y - miny), v.view.w, v.view.h, str(v.data))
return canvas.render()
# 一个带分支和汇合的小流程
print(draw_ascii([("开始", "校验"), ("校验", "成功"), ("校验", "失败"), ("成功", "结束"), ("失败", "结束")]))
print()
# 一条链加一条跨层的长边,看看折线效果
print(draw_ascii([("A", "B"), ("B", "C"), ("C", "D"), ("A", "D")]))实测输出:
+----+
|开始|
+----+
*
*
*
+----+
|校验|
+----+
* *
* *
* *
+----+ +----+
|成功| |失败|
+----+ +----+
* *
* *
* *
+----+
|结束|
+----+
+-+
|A|
+-+
* *
* *
* *
+-+ *
|B| *
+-+ *
* *
* *
* *
+-+ *
|C| *
+-+* *
* *
* *
**
+-+
|D|
+-+第一张图里中文方框对得整整齐齐,说明 dispw() 起作用了。第二张图里 A→D 那条长边顺着右侧一路走下来,正是虚拟顶点的功劳。
10. 用 matplotlib 画成图片 #
字符画适合在终端里快速看结构,要拿去汇报或者存档还是得有张真图。这一章把同一套坐标画成 PNG。
10.1 需要注意的三件事 #
把布局结果画成真正的图片,有三个地方容易出问题:
第一,y 轴方向要翻转。 grandalf 的 y 向下增大,matplotlib 的 y 向上增大。不处理的话整张图会上下颠倒,根节点跑到最底下去。解决办法就一个字:画的时候给 y 加负号。
第二,中文要配字体。 matplotlib 默认字体不含汉字,中文标签会变成一个个小方框(俗称"豆腐块")。要从系统里挑一个中文字体设进 plt.rcParams。另外设了中文字体后负号会显示不出来,需要加一行 plt.rcParams["axes.unicode_minus"] = False。
第三,用 Agg 后端。 matplotlib.use("Agg") 表示只把图存成文件、不弹出窗口。在命令行、服务器、CI 里跑都不会出问题。
10.2 完整代码 #
这段需要先 pip install matplotlib。运行后会在当前目录生成 graph.png:
import matplotlib
# 换成 Agg 后端:只把图存成文件,不弹窗口,服务器和命令行下都能跑
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib import font_manager
from grandalf.graphs import Edge, Graph, Vertex
from grandalf.layouts import SugiyamaLayout, VertexViewer
# 让边停在方框边界而不是扎进方框中心,箭头才看得见
from grandalf.routing import route_with_lines
def pick_cjk_font():
"""挑一个系统里装了的中文字体,挑不到就返回 None。
不设中文字体的话,matplotlib 会把每个汉字画成一个小方框。
"""
# 系统里已经注册的所有字体名
installed = {f.name for f in font_manager.fontManager.ttflist}
# 按优先级挨个试,Windows 一般有前两个
for name in ("Microsoft YaHei", "SimHei", "SimSun", "Noto Sans CJK SC", "PingFang SC"):
if name in installed:
return name
return None
font = pick_cjk_font()
if font:
# 把全局默认字体设成找到的中文字体
plt.rcParams["font.family"] = font
# 用了中文字体后负号会显示不出来,这行是官方推荐的修复
plt.rcParams["axes.unicode_minus"] = False
print("使用字体:", font or "系统默认(可能显示不了中文)")
class EdgePath:
"""接住 grandalf 算好的边折线。"""
def __init__(self):
self.pts = []
def setpath(self, pts):
self.pts = pts
# ---- 建图:一个带分支和回环的小流程 ----
pairs = [
("开始", "取数据"),
("取数据", "校验"),
("校验", "修复"),
("修复", "校验"),
("校验", "入库"),
("入库", "结束"),
]
names = sorted({n for pair in pairs for n in pair})
vs = {n: Vertex(n) for n in names}
es = [Edge(vs[a], vs[b]) for a, b in pairs]
g = Graph(list(vs.values()), es)
# 顶点尺寸按标签长短来定,中文一个字比一个字母宽,所以系数给大一点
for v in vs.values():
v.view = VertexViewer(w=len(str(v.data)) * 22 + 24, h=36)
for e in es:
e.view = EdgePath()
# ---- 跑布局 ----
sug = SugiyamaLayout(g.C[0])
sug.init_all()
sug.xspace = 30
sug.yspace = 40
sug.route_edge = route_with_lines
sug.draw()
# ---- 用 matplotlib 画出来 ----
fig, ax = plt.subplots(figsize=(6, 7))
# 先画边,让方框能盖在线头上
for e in es:
pts = e.view.pts
xs = [p[0] for p in pts]
# 关键:grandalf 的 y 轴向下,matplotlib 的 y 轴向上,所以取负号翻过来
ys = [-p[1] for p in pts]
ax.plot(xs, ys, color="#888888", linewidth=1.2, zorder=1)
# 在边的末端加个箭头,标出方向
ax.annotate(
"",
xy=(xs[-1], ys[-1]),
xytext=(xs[-2], ys[-2]),
arrowprops={"arrowstyle": "-|>", "color": "#888888", "linewidth": 1.2},
zorder=1,
)
# 再画顶点方框
for v in vs.values():
cx, cy = v.view.xy[0], -v.view.xy[1]
w, h = v.view.w, v.view.h
# Rectangle 用左下角定位,所以中心点要减去半宽半高
ax.add_patch(
plt.Rectangle(
(cx - w / 2, cy - h / 2), w, h,
facecolor="#e8f0fe", edgecolor="#3367d6", linewidth=1.5, zorder=2,
)
)
# 标签放在方框正中间
ax.text(cx, cy, str(v.data), ha="center", va="center", fontsize=11, zorder=3)
# 让 x、y 两个方向比例一致,方框才不会被拉变形
ax.set_aspect("equal")
# 自动把所有内容框进视野
ax.autoscale_view()
ax.margins(0.1)
# 关掉坐标轴,只留图
ax.axis("off")
# 存成图片文件,存在当前目录下
fig.savefig("graph.png", dpi=150, bbox_inches="tight")
print("已保存到 graph.png")实测输出:
使用字体: Microsoft YaHei
已保存到 graph.png生成的图片是一张竖排流程图:开始 → 取数据 → 校验,校验之后分成"修复"和"入库"两支,入库再连到结束。其中"修复 → 校验"这条回头边的箭头是朝上的,因为它是被算法反转过的 feedback 边,下一章细说。
11. 有环的图会怎样 #
真实的流程图很少是纯粹的 DAG——重试、回退、循环都会带来环。好消息是 grandalf 会自动处理,你不用做任何额外的事,但最好知道它背后干了什么。
11.1 算法怎么破环 #
第 2.4 节提过,分层算法只能处理 DAG。碰到有环的图,grandalf 会自动做这件事:
- 用 Tarjan 算法找出强连通分量,识别出造成环的边
- 把这些边临时反转,并给它们打上
feedback = True标记 - 在无环的图上正常分层、排序、算坐标
- 算完后把边的方向恢复原状
结果就是:那条回头边的起点层号比终点层号大,画出来箭头朝上。这正是我们想要的效果——重试循环、状态机回退,看起来都很自然。
被反转的边列表存在 sug.alt_e 里,边自己也有 e.feedback 属性可以查。
11.2 完整示例 #
用一个"校验不过就修复,修完再回来校验"的重试环来看,哪条边被标成了 feedback:
from grandalf.graphs import Edge, Graph, Vertex
from grandalf.layouts import SugiyamaLayout, VertexViewer
# 一个带环的流程:校验不过就去修复,修完再回来校验
pairs = [("开始", "校验"), ("校验", "修复"), ("修复", "校验"), ("校验", "结束")]
# 收集所有出现过的节点名,排序是为了每次运行的顺序都一样
names = sorted({n for pair in pairs for n in pair})
# 名字 -> Vertex 的映射,方便按名字取顶点
vs = {n: Vertex(n) for n in names}
# 按边列表建 Edge
es = [Edge(vs[a], vs[b]) for a, b in pairs]
# 组装成图
g = Graph(list(vs.values()), es)
# 每个顶点都要配 view,中文标签宽一点所以 w 给 60
for v in vs.values():
v.view = VertexViewer(w=60, h=30)
# 建布局器,传的是连通分量而不是 Graph
sug = SugiyamaLayout(g.C[0])
# init_all 这一步内部就会破环,把造成环的边标记出来
sug.init_all()
# 算坐标
sug.draw()
# 分层算法只能处理无环图,所以它会先挑几条边"反过来"把环破掉
# 被反转的边会被标记 feedback=True,布局算完后方向依然是你原来定义的
print("哪些边被临时反转了(feedback=True):")
for e in es:
src, dst = e.v[0].data, e.v[1].data
print(f" {src} -> {dst:4s} feedback={e.feedback}")
print("\n各顶点的层号与坐标:")
for n in names:
v = vs[n]
print(f" {n}: 层号={sug.grx[v].rank} x={v.view.xy[0]:6.1f} y={v.view.xy[1]:6.1f}")
print("\n注意「修复」的层号比「校验」大,说明它被排在了下一层。")
print("那条回头边 修复->校验 是从下往上画的,渲染时箭头会朝上。")
# 也可以自己指定要反转哪些边,从而控制布局形状
# alt_e 里存的就是被反转的边,init_all 时可以用 inverted_edges 参数手工指定
print("\n算法实际反转的边共", len(sug.alt_e), "条")实测输出:
哪些边被临时反转了(feedback=True):
开始 -> 校验 feedback=False
校验 -> 修复 feedback=False
修复 -> 校验 feedback=True
校验 -> 结束 feedback=False
各顶点的层号与坐标:
修复: 层号=2 x= -40.0 y= 115.0
开始: 层号=0 x= 0.0 y= 15.0
校验: 层号=1 x= 0.0 y= 65.0
结束: 层号=2 x= 40.0 y= 115.0
注意「修复」的层号比「校验」大,说明它被排在了下一层。
那条回头边 修复->校验 是从下往上画的,渲染时箭头会朝上。
算法实际反转的边共 1 条四条边里只有 修复 -> 校验 被标成了 feedback=True,正是造成环的那一条。
12. 在 LangChain 和 LangGraph 里用 #
这是 grandalf 在实际项目里最常见的用途。前面十一章打的基础,到这里正好用来看懂 LangChain 内部是怎么调它的。
12.1 你多半就是为这个才装的 grandalf #
如果你是在学 LangChain 或 LangGraph 的过程中第一次听说 grandalf,那多半是因为你执行了这行代码:
app.get_graph().print_ascii()然后收到了一句报错:
ImportError: Install grandalf to draw graphs: `pip install grandalf`.LangChain 用 grandalf 来把链和工作流的结构画成终端里的字符画。这是 grandalf 在整个 Python 生态里最广泛的用途。
相关的两个方法:
draw_ascii():返回字符串print_ascii():直接打印,等价于print(draw_ascii())
顺带一提,LangChain 还提供 draw_mermaid()(生成 Mermaid 流程图代码)和 draw_mermaid_png()(生成图片),这两个不需要 grandalf。
12.2 画 LangGraph 工作流 #
下面这个例子建了一个带条件分支和重试环的工作流,不需要任何模型 API,可以直接跑:
import operator
from typing import Annotated, TypedDict
from langgraph.graph import END, START, StateGraph
class State(TypedDict):
"""流程里传递的状态。"""
# 计数器,用来决定走哪个分支
n: int
# Annotated 加 operator.add 表示每个节点返回的列表会被拼接而不是覆盖
log: Annotated[list, operator.add]
def load(state: State) -> dict:
"""取数据:把计数器加一。"""
return {"n": state["n"] + 1, "log": ["load"]}
def check(state: State) -> dict:
"""校验:这里只记一笔日志。"""
return {"log": ["check"]}
def fix(state: State) -> dict:
"""修复:校验没过时走这里。"""
return {"log": ["fix"]}
def done(state: State) -> dict:
"""收尾:校验通过后走这里。"""
return {"log": ["done"]}
def route(state: State) -> str:
"""条件分支:奇数去修复,偶数去收尾。返回值是下一个节点的名字。"""
return "fix" if state["n"] % 2 else "done"
# 建图:先声明状态类型
builder = StateGraph(State)
# 把四个函数注册成四个节点,字符串是节点名
builder.add_node("load", load)
builder.add_node("check", check)
builder.add_node("fix", fix)
builder.add_node("done", done)
# START 是内置的入口,指向第一个真正干活的节点
builder.add_edge(START, "load")
# 普通边:load 跑完必定去 check
builder.add_edge("load", "check")
# 条件边:check 跑完调用 route,按返回值决定去 fix 还是 done
builder.add_conditional_edges("check", route, {"fix": "fix", "done": "done"})
# fix 修完再回 check,形成一个重试环
builder.add_edge("fix", "check")
# done 跑完就结束
builder.add_edge("done", END)
# 编译成可执行的图
app = builder.compile()
# get_graph() 拿到图的结构描述,draw_ascii() 用 grandalf 算布局并画成字符画
print("=== LangGraph 流程图 ===")
print(app.get_graph().draw_ascii())
# print_ascii() 是 draw_ascii() + print() 的快捷写法,效果完全一样
# app.get_graph().print_ascii()
# 图的顶点和边也可以直接拿出来看
graph = app.get_graph()
print("\n=== 节点清单 ===")
for node_id, node in graph.nodes.items():
print(f" {node_id}")
print("=== 边清单(conditional=True 的就是条件边)===")
for edge in graph.edges:
print(f" {edge.source:10s} -> {edge.target:10s} conditional={edge.conditional}")
print("\n注意上面字符画里,条件边画的是点线 '.',普通边画的是星号 '*'")实测输出:
=== LangGraph 流程图 ===
+-----------+
| __start__ |
+-----------+
*
*
*
+------+
| load |
+------+
*
*
*
+-------+
| check |
+-------+
.. ..
. .
. .
+-----+ +------+
| fix | | done |
+-----+ +------+
*
*
*
+---------+
| __end__ |
+---------+
=== 节点清单 ===
__start__
load
check
fix
done
__end__
=== 边清单(conditional=True 的就是条件边)===
__start__ -> load conditional=False
check -> done conditional=True
check -> fix conditional=True
fix -> check conditional=False
load -> check conditional=False
done -> __end__ conditional=False
注意上面字符画里,条件边画的是点线 '.',普通边画的是星号 '*'12.3 点线和星号的区别 #
仔细看上面的字符画:check 往下分出的两条线用的是点 .,其他的线用的是星号 *。
这是 LangChain 故意做的区分:条件边画成点线,普通边画成实线。源码里就一行:
canvas.line(start_x, start_y, end_x, end_y, "." if edge.data else "*")条件边的 data 字段存着分支的标签,所以非空;普通边的 data 是 None。
另外注意 fix -> check 这条回头边在图里没有画出来。原因是 check 已经被排在 fix 上面,这条边要往回连,而 ASCII 画布只画向下的线。这是字符画的固有局限,不是 bug。想看完整的图,用 draw_mermaid()。
12.4 Windows 上重定向输出会多出空行 #
这是个不影响使用但会让人困惑的小问题:在终端里看 print_ascii() 一切正常,一旦把输出重定向到文件,每行之间就凭空多出一个空行。
原因是 LangChain 拼接字符画时用的是 os.linesep,在 Windows 上等于 \r\n。而 Python 的文本模式输出又会把其中的 \n 再翻译成 \r\n,结果变成 \r\r\n,读回来就成了两个换行。
如果你要把结果写进文件,自己处理一下就行:
# 把 \r 全部去掉,还原成终端里看到的样子
text = app.get_graph().draw_ascii().replace("\r", "")
with open("graph.txt", "w", encoding="utf-8") as f:
f.write(text)12.5 画 LCEL 链 #
LangChain 的表达式链(LCEL)也能画。这个例子用一个原样返回的函数代替模型调用,不需要 API key:
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnableLambda
# 用 | 把几个组件串成一条链,这是 LangChain 的 LCEL 写法
chain = (
# 第一步:把 {topic} 填进提示词模板
ChatPromptTemplate.from_template("讲一个关于{topic}的笑话")
# 第二步:本该是模型调用,这里用一个原样返回的函数代替,省得真去请求 API
| RunnableLambda(lambda x: x)
# 第三步:把模型输出转成纯字符串
| StrOutputParser()
)
# get_graph() 把这条链拆成图结构,draw_ascii() 用 grandalf 排版并画成字符画
print(chain.get_graph().draw_ascii())实测输出:
+-------------+
| PromptInput |
+-------------+
*
*
*
+--------------------+
| ChatPromptTemplate |
+--------------------+
*
*
*
+--------+
| Lambda |
+--------+
*
*
*
+-----------------+
| StrOutputParser |
+-----------------+
*
*
*
+-----------------------+
| StrOutputParserOutput |
+-----------------------+除了你写的三个组件,LangChain 还自动加了首尾两个节点表示输入和输出的类型。
12.6 LangChain 内部是怎么调 grandalf 的 #
看一眼 langchain_core/runnables/graph_ascii.py 的核心片段,你会发现跟本文第 4 章教的一模一样:
# 建顶点和边,顶点的 data 前后各加一个空格,方框看起来不会太挤
vertices_ = {id_: Vertex(f" {data} ") for id_, data in vertices.items()}
edges_ = [Edge(vertices_[s], vertices_[e], data=cond) for s, e, _, cond in edges]
vertices_list = vertices_.values()
graph = Graph(vertices_list, edges_)
# 每个顶点都要配 view,VertexViewer 提供 w 和 h
for vertex in vertices_list:
vertex.view = VertexViewer(vertex.data)
# 找出最窄的方框宽度,待会儿当 xspace 用
minw = min(v.view.w for v in vertices_list)
# 每条边也配一个 view,用来接收 setpath 传回的折线
for edge in edges_:
edge.view = _EdgeViewer()
# 注意这里传的是 graph.C[0],不是 graph
sug = SugiyamaLayout(graph.C[0])
graph = graph.C[0]
# 手工挑出所有入边为 0 的顶点当根节点
roots = list(filter(lambda x: len(x.e_in()) == 0, graph.sV))
sug.init_all(roots=roots, optimize=True)
# 层间距用方框高度,同层间距用最窄的方框宽度
sug.yspace = VertexViewer.HEIGHT
sug.xspace = minw
sug.route_edge = route_with_lines
sug.draw()对照一下就能确认本文讲的都是对的:
- 顶点必须配
view(VertexViewer) - 边配
view是为了拿折线,而且用的是自定义的_EdgeViewer而不是自带的 - 传的是
graph.C[0] - 调的是
yspace/xspace,不是vert_space/horiz_space - 用了
optimize=True和route_with_lines
13. 常见报错速查 #
这一章把新手最容易撞上的五个问题集中起来,每个都给出真实的报错原文和修复办法。
13.1 五个典型错误 #
| 症状 | 原因 | 怎么改 |
|---|---|---|
AssertionError(没有任何提示文字) |
顶点没有 view 属性 |
给每个顶点赋 v.view = VertexViewer(w=..., h=...) |
AttributeError: 'Graph' object has no attribute 'sV' |
把 Graph 传给了 SugiyamaLayout |
改成传 g.C[0] |
AttributeError: 'SugiyamaLayout' object has no attribute 'alt_e' |
跳过了 init_all() |
draw() 之前必须先 init_all() |
AttributeError: 'Xxx' object has no attribute 'w' |
view 对象缺 w 或 h |
用 VertexViewer(w=..., h=...),或自己的类里补上这两个属性 |
部分顶点的 view.xy 是 None,不报错 |
图不连通,只布局了 C[0] 一个分量 |
遍历 g.C 把每个分量分别布局 |
最后一条最阴险:它不会抛异常,只是有些顶点的坐标是 None,等你渲染时才莫名其妙地崩掉。图有可能不连通的话,一定要检查 len(g.C)。
13.2 完整示例 #
这段把五个错误都实际触发一遍,并演示不连通图的正确处理方式:
import traceback
from grandalf.graphs import Edge, Graph, Vertex
from grandalf.layouts import SugiyamaLayout, VertexViewer
def show(title, fn):
"""跑一段会出错的代码,把报错原样打印出来。"""
print("=" * 62)
print(title)
print("=" * 62)
try:
fn()
print(" 没有报错")
except Exception as exc:
# 报错类型加上报错信息。注意要先转成字符串再判断空,
# 因为异常对象本身永远是"真"的,写成 exc or ... 是拿不到兜底文案的
print(f" {type(exc).__name__}: {str(exc) or '(无提示文字)'}")
# 只打印出错的那一行源码,看得更清楚
last = traceback.extract_tb(exc.__traceback__)[-1]
print(f" 出错位置: {last.filename.split(chr(92))[-1]} 第 {last.lineno} 行")
print(f" 出错代码: {last.line}")
print()
def make_graph():
"""建一张最普通的四点图,顶点先不配 view。"""
# 四个顶点 A B C D
vs = {n: Vertex(n) for n in "ABCD"}
# 菱形结构:A 分叉到 B、C,再汇合到 D
es = [Edge(vs["A"], vs["B"]), Edge(vs["A"], vs["C"]),
Edge(vs["B"], vs["D"]), Edge(vs["C"], vs["D"])]
# 同时把图和名字映射返回,方便后面各个函数按需要加工
return Graph(list(vs.values()), es), vs
def err_no_view():
"""最常见的错误:忘了给顶点配 view。"""
g, vs = make_graph()
# 顶点一个 view 都没配,构造布局器时就会断言失败
SugiyamaLayout(g.C[0])
def err_pass_graph():
"""第二常见:把 Graph 直接传进去,而不是传连通分量。"""
g, vs = make_graph()
for v in vs.values():
v.view = VertexViewer(w=40, h=30)
# 这里传 g 而不是 g.C[0],就会出错
sug = SugiyamaLayout(g)
sug.init_all()
def err_forgot_init():
"""第三常见:跳过 init_all 直接 draw。"""
g, vs = make_graph()
for v in vs.values():
v.view = VertexViewer(w=40, h=30)
sug = SugiyamaLayout(g.C[0])
# 少了 sug.init_all() 这一步
sug.draw()
def err_no_wh():
"""view 有了,但没有 w 和 h 两个属性。"""
g, vs = make_graph()
# 一个什么都没有的空类,能通过 hasattr(v, "view") 那道断言
class Empty:
pass
for v in vs.values():
v.view = Empty()
# 断言过了,但后面算中位数宽高时会去取 view.w,这才暴露问题
SugiyamaLayout(g.C[0])
def err_only_one_component():
"""图不连通时只画了第一个分量,另一半顶点根本没算坐标。"""
A, B, C, D = Vertex("A"), Vertex("B"), Vertex("C"), Vertex("D")
# A->B 和 C->D 之间没有任何连接,所以这张图有两个连通分量
g = Graph([A, B, C, D], [Edge(A, B), Edge(C, D)])
for v in (A, B, C, D):
v.view = VertexViewer(w=40, h=30)
# 只对 C[0] 做布局,C[1] 里的顶点被漏掉了
sug = SugiyamaLayout(g.C[0])
sug.init_all()
sug.draw()
# C 和 D 在第二个分量里,没参与布局,访问 xy 就会炸
print(" A 的坐标:", A.view.xy)
print(" C 的坐标:", C.view.xy)
show("错误一:顶点没有 view", err_no_view)
show("错误二:传了 Graph 而不是 Graph.C[0]", err_pass_graph)
show("错误三:忘了先调用 init_all()", err_forgot_init)
show("错误四:view 里缺 w / h 属性", err_no_wh)
show("错误五:图不连通,只布局了一个分量", err_only_one_component)
print("=" * 62)
print("错误五的正确做法:把每个连通分量分别布局,再手工错开位置")
print("=" * 62)
A, B, C, D = Vertex("A"), Vertex("B"), Vertex("C"), Vertex("D")
g = Graph([A, B, C, D], [Edge(A, B), Edge(C, D)])
for v in (A, B, C, D):
v.view = VertexViewer(w=40, h=30)
# 记录当前已经用掉的横向宽度,让每个分量往右接着排
offset = 0.0
for comp in g.C:
sug = SugiyamaLayout(comp)
sug.init_all()
sug.draw()
# 这个分量里最左和最右的 x
xs = [v.view.xy[0] for v in comp.sV]
# 把整个分量平移,使它紧挨着前一个分量的右边
shift = offset - min(xs)
for v in comp.sV:
v.view.xy = (v.view.xy[0] + shift, v.view.xy[1])
# 下一个分量再往右让开一段,100 是随手留的间隙
offset += (max(xs) - min(xs)) + 100
for v in (A, B, C, D):
print(f" {v.data}: x={v.view.xy[0]:7.1f}, y={v.view.xy[1]:7.1f}")实测输出:
==============================================================
错误一:顶点没有 view
==============================================================
AssertionError: (无提示文字)
出错位置: layouts.py 第 395 行
出错代码: assert hasattr(v, "view")
==============================================================
错误二:传了 Graph 而不是 Graph.C[0]
==============================================================
AttributeError: 'Graph' object has no attribute 'sV'
出错位置: layouts.py 第 415 行
出错代码: roots = [v for v in self.g.sV if len(v.e_in()) == 0]
==============================================================
错误三:忘了先调用 init_all()
==============================================================
AttributeError: 'SugiyamaLayout' object has no attribute 'alt_e'
出错位置: layouts.py 第 445 行
出错代码: for e in self.alt_e:
==============================================================
错误四:view 里缺 w / h 属性
==============================================================
AttributeError: 'Empty' object has no attribute 'w'
出错位置: geometry.py 第 84 行
出错代码: mw = [v.w for v in views]
==============================================================
错误五:图不连通,只布局了一个分量
==============================================================
A 的坐标: (0.0, 15.0)
C 的坐标: None
没有报错
==============================================================
错误五的正确做法:把每个连通分量分别布局,再手工错开位置
==============================================================
A: x= 0.0, y= 15.0
B: x= 0.0, y= 65.0
C: x= 100.0, y= 15.0
D: x= 100.0, y= 65.014. 和 NetworkX、Graphviz 怎么选 #
这三个库经常被放在一起提,但它们其实不太算竞品。搞清楚各自解决什么问题,选型就不纠结了。
14.1 三者的定位完全不同 #
新手常把这三个库放在一起比较,其实它们解决的是不同的问题:
| Grandalf | NetworkX | Graphviz | |
|---|---|---|---|
| 它是什么 | 只算布局坐标 | 图论算法库 | 完整的绘图工具 |
| 安装 | pip install,纯 Python |
pip install,纯 Python |
要额外装 C 语言写的系统程序 |
| 输出 | 一堆坐标数字 | 一堆坐标数字(也能直接画) | 直接出 PNG / SVG / PDF |
| 分层布局质量 | 好 | 一般 | 最好 |
| 图论算法 | 基本没有 | 极其丰富 | 没有 |
| 适合的规模 | 中小 | 大 | 中小 |
14.2 该用哪个 #
用 Grandalf:你只想要分层布局的坐标,不想让用户额外安装系统级软件,或者你要自己控制渲染(比如画到终端、画到 Canvas、导出给前端)。LangChain 选它就是因为这一点——用户 pip install 一下就能用,不用折腾 Graphviz 的系统安装。
用 NetworkX:你的重点是分析图而不是画图。求最短路、找社区、算中心性、判断连通性,这些 Grandalf 都不提供。NetworkX 也能画图,但布局质量一般,通常还是配合 matplotlib 用。
用 Graphviz:你要的是好看的成品图,并且能接受在部署环境里装一个系统程序。它的布局质量和渲染效果都是最好的,几十年打磨出来的。
顺便一提,Grandalf 的 grandalf.utils 里带了 convert_grandalf_graph_to_networkx_graph 和 convert_nextworkx_graph_to_grandalf(后者的函数名拼错了,是库里的历史遗留问题),可以在两个库之间转换图对象。
15. 小结与速查 #
最后把整份文档压缩成一段模板代码、九条避坑要点和一张 API 表,方便以后回来查。
15.1 标准流程 #
用 grandalf 的完整套路就是下面七步,记住这个模板基本就够用了:
from grandalf.graphs import Edge, Graph, Vertex
from grandalf.layouts import SugiyamaLayout, VertexViewer
from grandalf.routing import route_with_lines
class EdgePath:
"""边的外观类,只需要一个 setpath 方法接住折线点。"""
def __init__(self):
self.pts = []
def setpath(self, pts):
self.pts = pts
# 第一步:建顶点和边
A, B, C = Vertex("A"), Vertex("B"), Vertex("C")
edges = [Edge(A, B), Edge(A, C)]
# 第二步:组装成图
g = Graph([A, B, C], edges)
# 第三步:给每个顶点配 view,必须有 w 和 h(漏了会报空的 AssertionError)
for v in g.V():
v.view = VertexViewer(w=len(str(v.data)) * 10 + 20, h=30)
# 第四步:给每条边配 view,想拿到边的折线就得配
for e in g.E():
e.view = EdgePath()
# 第五步:建布局器,注意传的是 g.C[0] 不是 g
sug = SugiyamaLayout(g.C[0])
# 第六步:先 init_all 再 draw,顺序不能反;参数在这两步之间设
sug.init_all(optimize=True)
sug.xspace = 30
sug.yspace = 30
sug.route_edge = route_with_lines
sug.draw()
# 第七步:取结果。顶点坐标在 view.xy,边的折线在 view.pts
# 注意 g.E() 的遍历顺序不一定跟你建边的顺序一样
for v in g.V():
print(f"顶点 {v.data}: {v.view.xy}")
for e in g.E():
print(f"边 {e.v[0].data}->{e.v[1].data}: {e.view.pts}")实测输出:
顶点 A: (0.0, 15.0)
顶点 B: (-30.0, 75.0)
顶点 C: (30.0, 75.0)
边 A->C: [(7.5, 30.0), (22.5, 60.0)]
边 A->B: [(-7.5, 30.0), (-22.5, 60.0)]15.2 记住这几条就不会踩坑 #
- 顶点必须有
view,且view必须有w和h,否则报一个没有任何提示文字的AssertionError - 传
g.C[0],不是g - 先
init_all()再draw(),参数在这两步之间设 - y 轴向下,用 matplotlib 画图时记得给 y 加负号
- x 可以是负数,渲染前要整体平移到正数区间
- 间距参数叫
xspace/yspace,不叫vert_space/horiz_space - 没法切换布局方向,想横排就在渲染时把 x 和 y 对调
- 边的路径通过
setpath回调拿,自己写个三行的类比用自带的EdgeViewer顺手 - 图不连通时要遍历
g.C,否则有些顶点的坐标是None且不报错
15.3 API 一览 #
| 名称 | 说明 |
|---|---|
Vertex(data) |
顶点,data 随便放什么对象 |
Edge(x, y, w=1, data=None) |
有向边,e.v 是 (起点, 终点) |
Graph(V, E, directed=True) |
图,g.V() / g.E() 遍历,g.C 是连通分量列表 |
VertexViewer(w, h) |
现成的顶点外观类,提供宽高 |
SugiyamaLayout(g.C[0]) |
分层布局器 |
.init_all(roots=None, inverted_edges=None, optimize=False) |
初始化:破环、分层、插虚拟顶点 |
.draw(N=1.5) |
算坐标并路由边 |
.xspace / .yspace |
同层间距 / 层间距,默认都是 20 |
.route_edge |
边路由函数,一般设成 route_with_lines |
.layers |
分层结果,每层含真实顶点和虚拟顶点 |
.grx[v].rank |
顶点 v 的层号 |
.alt_e |
为破环而被反转的边 |
v.view.xy |
布局算出的顶点坐标 (x, y) |
e.view.pts |
边的折线点(前提是你的 view 类把 setpath 存成了 pts) |
e.feedback |
这条边是否为破环而被反转过 |
route_with_lines |
把边端点裁到方框边界上 |
DummyVertex |
长边的虚拟中转顶点,渲染时不要画成方框 |