用 Bokeh 构建可选择节点和关联边的网络图
原文:Bokeh 用户指南:Network graphs。作者归属 Bokeh Contributors。本文依据 2026-10-05 读取的 Bokeh 3.10.0 文档翻译整理,示例保留原文结构,并明确标出编辑调整。未安装 Bokeh 或 NetworkX,也未执行示例或验证浏览器交互。
网络图不只是把点连起来。数据定义了谁与谁相连,布局决定这些点放在哪里,交互策略则决定点击或悬停一个元素时哪些相关元素响应。Bokeh 用 GraphRenderer 把这三件事连接起来。

节点、边与布局分别放在哪里
GraphRenderer.node_renderer 管理节点数据及图元,edge_renderer 管理边,layout_provider 提供节点坐标。布局与连接关系分开,同一份图数据便可以换一种布局展示,而不必改动其拓扑结构。
节点数据源必须有名为 index 的列,而且节点索引要唯一。边数据源必须有 start 和 end 两列,分别指向起点与终点的节点索引。其他元数据也可以作为列加入,以供颜色映射、回调或悬停提示使用。每个数据源内部各列应具有一致的行数。
原文说明节点可以改用 Ellipse、Rect 或 Scatter 等 XYGlyph;边通常使用 MultiLine 一类的多折线图元。下列完整基础示例以八个椭圆节点演示结构和坐标:
import math
from bokeh.models import Ellipse, GraphRenderer, StaticLayoutProvider
from bokeh.palettes import Spectral8
from bokeh.plotting import figure, show
N = 8
node_indices = list(range(N))
plot = figure(
title="Graph layout demonstration",
x_range=(-1.1, 1.1), y_range=(-1.1, 1.1),
tools="", toolbar_location=None,
)
graph = GraphRenderer()
graph.node_renderer.glyph = Ellipse(
height=0.1, width=0.2, fill_color="fill_color"
)
graph.node_renderer.data_source.data = dict(
index=node_indices, fill_color=Spectral8
)
graph.edge_renderer.data_source.data = dict(
start=[0] * N, end=node_indices
)
angles = [i * 2 * math.pi / N for i in node_indices]
x = [math.cos(a) for a in angles]
y = [math.sin(a) for a in angles]
graph_layout = dict(zip(node_indices, zip(x, y)))
graph.layout_provider = StaticLayoutProvider(graph_layout=graph_layout)
plot.renderers.append(graph)
show(plot)
StaticLayoutProvider 接收“节点索引 → 二维坐标”的字典。这段代码把节点按圆周排列,所有边的起点都是节点 0。注意原例的 end 包含 0,因此也包含一条 0 → 0 的自环;它不是仅有七条边的星形图。直线路径的自环会退化为同一个坐标点。
上面将原文固定除数 8 写为 N,在本例 N = 8 时语义相同。没有把输出图声明为已实际生成。
用显式路径绘制曲线
默认情况下,静态布局提供者在节点坐标之间画直线。要自定义边的路径,可以在边数据源中提供 xs 和 ys:每一行分别是一条边的 x 坐标序列和 y 坐标序列。路径行顺序必须与同一数据源的 start、end 相同。
官方特别提醒:Bokeh 不会自动验证显式路径是否与节点位置吻合。即使图能显示,曲线也可能连接错点。以下代码应插入上一个例子的 plot.renderers.append(graph) 之前:
def bezier(start, end, control, steps):
return [
(1 - s) ** 2 * start
+ 2 * (1 - s) * s * control
+ s ** 2 * end
for s in steps
]
xs, ys = [], []
sx, sy = graph_layout[0]
# 编辑修正:包含 s=1,确保采样包含终点。
steps = [i / 100.0 for i in range(101)]
for node_index in node_indices:
ex, ey = graph_layout[node_index]
xs.append(bezier(sx, ex, 0, steps))
ys.append(bezier(sy, ey, 0, steps))
graph.edge_renderer.data_source.data["xs"] = xs
graph.edge_renderer.data_source.data["ys"] = ys
它分别计算 x 与 y 两个方向的二次贝塞尔曲线,控制点都是 0,因此二维控制点为原点。与原文差异:官方示例使用 range(100),参数只到 0.99;这里改成 range(101),使最后一个样本达到 1。这是根据公式做的静态修正,不是实测结论。0 → 0 的那条路径仍从同一点出发再返回,不会因为补齐采样而变成另一条拓扑边。
从 NetworkX 生成图渲染器
bokeh.plotting.from_networkx 接收 NetworkX 图对象及布局方法,返回配置好的 GraphRenderer。官方这一段的介绍文字称示范“Zachary 空手道俱乐部图”,但实际代码调用的是 nx.desargues_graph(),即具有 20 个节点的 Desargues 图。本文按代码说明该例;后面的交互和属性示例才使用空手道俱乐部图。
import networkx as nx
from bokeh.models import Scatter
from bokeh.palettes import Category20_20
from bokeh.plotting import figure, from_networkx, show
G = nx.desargues_graph()
p = figure(
x_range=(-2, 2), y_range=(-2, 2),
x_axis_location=None, y_axis_location=None,
tools="hover", tooltips="index: @index",
)
p.grid.grid_line_color = None
graph = from_networkx(G, nx.spring_layout, scale=1.8, center=(0, 0))
graph.node_renderer.data_source.data["colors"] = Category20_20
graph.node_renderer.glyph = Scatter(size=20, fill_color="colors")
p.renderers.append(graph)
show(p)
编辑调整:这里显式设置 Scatter,避免把 size 写到不一定支持它的默认节点图元;同时保留 from_networkx 生成的索引,没有照抄原例重新用 range(len(G)) 覆盖索引的步骤。对这个内置图,标签是整数;推广到具有字符串或其他标签的图时,擅自重编号会破坏节点与边的对应关系。spring_layout 的画面位置不等于图指标结论;未固定随机种子时,也不应期待每次坐标完全一致。
把选择行为与悬停行为分开配置
selection_policy 控制选择,例如单击或框选;inspection_policy 控制检查,例如悬停。两者都接收 GraphHitTestPolicy 的模型实例,可以独立设置。
| 策略 | 关联关系 |
|---|---|
NodesAndLinkedEdges() |
命中节点,并联动该节点相接的边。 |
EdgesAndLinkedNodes() |
命中边,并联动该边的起点和终点节点。 |
NodesAndAdjacentNodes() |
命中节点,并联动通过图边与它相邻的节点。 |
节点和边各自的 selection_glyph、nonselection_glyph、hover_glyph 则规定这些状态如何显示。改变策略决定“哪些元素响应”,改变图元决定“它们如何变色或改变线宽”。
原文分别列出了三个结构相同的完整例子。下面合并它们的公共部分,通过 mode 选择策略;数据仍来自 NetworkX 内置的空手道俱乐部图,不需要下载外部样本。
import networkx as nx
from bokeh.models import (
BoxSelectTool, EdgesAndLinkedNodes, HoverTool, MultiLine,
NodesAndAdjacentNodes, NodesAndLinkedEdges, Plot, Range1d,
Scatter, TapTool,
)
from bokeh.palettes import Spectral4
from bokeh.plotting import from_networkx, show
G = nx.karate_club_graph()
plot = Plot(
width=400, height=400,
x_range=Range1d(-1.1, 1.1), y_range=Range1d(-1.1, 1.1),
)
plot.title.text = "Graph Interaction Demonstration"
plot.add_tools(HoverTool(tooltips=None), TapTool(), BoxSelectTool())
renderer = from_networkx(G, nx.circular_layout, scale=1, center=(0, 0))
node = Scatter(size=15, fill_color=Spectral4[0])
renderer.node_renderer.glyph = node
renderer.node_renderer.selection_glyph = node.clone(fill_color=Spectral4[2])
renderer.node_renderer.hover_glyph = node.clone(fill_color=Spectral4[1])
edge = MultiLine(line_color="#CCCCCC", line_alpha=0.8, line_width=5)
renderer.edge_renderer.glyph = edge
renderer.edge_renderer.selection_glyph = edge.clone(
line_color=Spectral4[2], line_alpha=1
)
renderer.edge_renderer.hover_glyph = edge.clone(
line_color=Spectral4[1], line_width=1
)
# 编辑合并:三种 mode 对应原文的三个示例。
mode = "nodes_edges"
policies = {
"nodes_edges": NodesAndLinkedEdges,
"edges_nodes": EdgesAndLinkedNodes,
"nodes_neighbors": NodesAndAdjacentNodes,
}
policy = policies[mode]
renderer.selection_policy = policy()
renderer.inspection_policy = policy()
plot.renderers.append(renderer)
show(plot)
也可以把两行策略设为不同类型,例如点击节点时选中相接边,悬停边时检查两端节点。合并代码没有引入 eval 或动态执行输入;mode 只从固定字典选择类。
让节点属性进入提示,让边属性决定颜色
from_networkx 还会把 NetworkX 的节点和边属性转换进相应的数据源。空手道俱乐部图的节点带有 club 属性,可以在提示中用 @club 访问;边的颜色也可以根据两端节点是否属于同一俱乐部计算。
import networkx as nx
from bokeh.models import MultiLine, Scatter
from bokeh.plotting import figure, from_networkx, show
G = nx.karate_club_graph()
SAME_CLUB_COLOR, DIFFERENT_CLUB_COLOR = "darkgrey", "red"
edge_attrs = {}
for start_node, end_node, _ in G.edges(data=True):
same_club = G.nodes[start_node]["club"] == G.nodes[end_node]["club"]
edge_attrs[(start_node, end_node)] = (
SAME_CLUB_COLOR if same_club else DIFFERENT_CLUB_COLOR
)
nx.set_edge_attributes(G, edge_attrs, "edge_color")
plot = figure(
width=400, height=400,
x_range=(-1.2, 1.2), y_range=(-1.2, 1.2),
x_axis_location=None, y_axis_location=None, toolbar_location=None,
title="Graph Interaction Demo", background_fill_color="#efefef",
tooltips="index: @index, club: @club",
)
plot.grid.grid_line_color = None
renderer = from_networkx(G, nx.spring_layout, scale=1, center=(0, 0))
renderer.node_renderer.glyph = Scatter(size=15, fill_color="lightblue")
renderer.edge_renderer.glyph = MultiLine(
line_color="edge_color", line_alpha=1, line_width=2
)
plot.renderers.append(renderer)
show(plot)
这个映射只表达两端节点的已有分类是否一致,不构成社群发现、因果解释或分类验证。扩展到真实业务数据时,应先检查节点索引唯一、边端点存在、数据列长度一致,并为显式路径核对顺序和端点。
代码审查、版本与许可
这些例子没有硬编码凭证、命令执行或外部输入拼接;本次静态阅读未发现明确的注入路径。本文没有加入自定义 JavaScript。实际运行 show 可能生成 HTML 并打开浏览器,发布交互图时应另行核对静态资源和数据范围,不能把未脱敏数据随输出公开。静态审核不是漏洞扫描或运行测试。











暂无评论内容