Spaces:
Sleeping
Sleeping
Delete edgebundling.py
Browse files- edgebundling.py +0 -498
edgebundling.py
DELETED
@@ -1,498 +0,0 @@
|
|
1 |
-
import numpy as np
|
2 |
-
import matplotlib.pyplot as plt
|
3 |
-
import networkx as nx
|
4 |
-
from matplotlib.collections import LineCollection
|
5 |
-
from itertools import count
|
6 |
-
from heapq import heappush, heappop
|
7 |
-
from collections import defaultdict
|
8 |
-
import time
|
9 |
-
import pandas as pd
|
10 |
-
from datashader.bundling import hammer_bundle # New import for hammer bundling
|
11 |
-
|
12 |
-
###############################################################################
|
13 |
-
# Minimal AbstractBundling base class (refactored from .abstractBundling import)
|
14 |
-
###############################################################################
|
15 |
-
class AbstractBundling:
|
16 |
-
def __init__(self, G: nx.Graph):
|
17 |
-
self.G = G
|
18 |
-
|
19 |
-
def bundle(self):
|
20 |
-
raise NotImplementedError("Subclasses should implement 'bundle'.")
|
21 |
-
|
22 |
-
###############################################################################
|
23 |
-
# Simple SplineC placeholder (refactoring out the nx2ipe dependency)
|
24 |
-
###############################################################################
|
25 |
-
class SplineC:
|
26 |
-
def __init__(self, points):
|
27 |
-
self.points = points
|
28 |
-
|
29 |
-
###############################################################################
|
30 |
-
# A base SpannerBundling class that SpannerBundlingNoSP depends on
|
31 |
-
###############################################################################
|
32 |
-
class SpannerBundling(AbstractBundling):
|
33 |
-
"""
|
34 |
-
S-EPB. Implementation
|
35 |
-
|
36 |
-
weightFactor: kappa value that sets the bundling strength
|
37 |
-
distortion: t value that sets the maximum allowed stretch/distortion
|
38 |
-
numWorkers: number of workers that process biconnected components
|
39 |
-
"""
|
40 |
-
def __init__(self, G: nx.Graph, weightFactor=2, distortion=2, numWorkers=1):
|
41 |
-
super().__init__(G)
|
42 |
-
self.distortion = distortion
|
43 |
-
self.weightFactor = weightFactor
|
44 |
-
self.mode = "greedy"
|
45 |
-
self.name = None
|
46 |
-
self.numWorkers = numWorkers
|
47 |
-
|
48 |
-
@property
|
49 |
-
def name(self):
|
50 |
-
return f"SEPB_d_{self.distortion}_w_{self.weightFactor}_{self.mode}"
|
51 |
-
|
52 |
-
@name.setter
|
53 |
-
def name(self, value):
|
54 |
-
self._name = value
|
55 |
-
|
56 |
-
def bundle(self):
|
57 |
-
# Default does nothing
|
58 |
-
return 0.0
|
59 |
-
|
60 |
-
def process(self, component):
|
61 |
-
# Default does nothing
|
62 |
-
pass
|
63 |
-
|
64 |
-
def spanner(self, g, k):
|
65 |
-
# Default does nothing
|
66 |
-
return None
|
67 |
-
|
68 |
-
###############################################################################
|
69 |
-
# The requested SpannerBundlingNoSP class
|
70 |
-
###############################################################################
|
71 |
-
class SpannerBundlingNoSP(SpannerBundling):
|
72 |
-
"""
|
73 |
-
S-EPB where instead of computing single source shortest paths we reuse
|
74 |
-
shortest paths during the spanner construction.
|
75 |
-
"""
|
76 |
-
def __init__(self, G: nx.Graph, weightFactor=2, distortion=2):
|
77 |
-
super().__init__(G)
|
78 |
-
self.distortion = distortion
|
79 |
-
self.weightFactor = weightFactor
|
80 |
-
self.mode = "reuse"
|
81 |
-
|
82 |
-
def bundle(self):
|
83 |
-
"""
|
84 |
-
Executes the bundling process on all biconnected components.
|
85 |
-
Returns the total time for bundling.
|
86 |
-
"""
|
87 |
-
t_start = time.process_time()
|
88 |
-
|
89 |
-
if nx.is_directed(self.G):
|
90 |
-
# Convert to undirected for the biconnected components
|
91 |
-
GG = self.G.to_undirected(as_view=True)
|
92 |
-
components = nx.biconnected_components(GG)
|
93 |
-
else:
|
94 |
-
components = nx.biconnected_components(self.G)
|
95 |
-
|
96 |
-
to_process = []
|
97 |
-
for nodes in components:
|
98 |
-
if len(nodes) > 2:
|
99 |
-
subg = self.G.subgraph(nodes).copy()
|
100 |
-
to_process.append(subg)
|
101 |
-
|
102 |
-
# Sort the components from largest to smallest
|
103 |
-
to_process = sorted(to_process, key=lambda x: len(x.nodes()), reverse=True)
|
104 |
-
|
105 |
-
# Process each component
|
106 |
-
for comp in to_process:
|
107 |
-
self.process(comp)
|
108 |
-
|
109 |
-
return time.process_time() - t_start
|
110 |
-
|
111 |
-
def process(self, component):
|
112 |
-
"""
|
113 |
-
Process a component: build a spanner, then for each edge not in
|
114 |
-
the spanner, store a 'path' and create a Spline if possible.
|
115 |
-
"""
|
116 |
-
T = self.spanner(component, self.distortion)
|
117 |
-
|
118 |
-
# Mark edges in T as 'Spanning'
|
119 |
-
for u, v, data in T.edges(data=True):
|
120 |
-
data["weight"] = np.power(data["dist"], self.weightFactor)
|
121 |
-
|
122 |
-
for u, v in T.edges():
|
123 |
-
self.G[u][v]["Layer"] = "Spanning"
|
124 |
-
self.G[u][v]["Stroke"] = "blue"
|
125 |
-
|
126 |
-
# For edges not in T, build a spline from the stored path
|
127 |
-
for u, v, data in component.edges(data=True):
|
128 |
-
if T.has_edge(u, v):
|
129 |
-
continue
|
130 |
-
|
131 |
-
path = data.get("path", [])
|
132 |
-
if len(path) < 1:
|
133 |
-
continue
|
134 |
-
|
135 |
-
spline_points = []
|
136 |
-
current = path[0]
|
137 |
-
for nxt in path[1:-1]:
|
138 |
-
x = component.nodes[nxt].get("X", component.nodes[nxt].get("x", 0))
|
139 |
-
y = component.nodes[nxt].get("Y", component.nodes[nxt].get("y", 0))
|
140 |
-
spline_points.append((x, y))
|
141 |
-
current = nxt
|
142 |
-
|
143 |
-
self.G[u][v]["Spline"] = SplineC(spline_points)
|
144 |
-
self.G[u][v]["Layer"] = "Bundled"
|
145 |
-
self.G[u][v]["Stroke"] = "purple"
|
146 |
-
|
147 |
-
return
|
148 |
-
|
149 |
-
def spanner(self, g, k):
|
150 |
-
"""
|
151 |
-
Create a spanner and store the shortest path in edge['path'] when the
|
152 |
-
edge is not added to the spanner.
|
153 |
-
"""
|
154 |
-
if nx.is_directed(g):
|
155 |
-
spanner = nx.DiGraph()
|
156 |
-
else:
|
157 |
-
spanner = nx.Graph()
|
158 |
-
|
159 |
-
edges = sorted(g.edges(data=True), key=lambda t: t[2].get("dist", 1))
|
160 |
-
|
161 |
-
for u, v, data in edges:
|
162 |
-
if u not in spanner.nodes:
|
163 |
-
spanner.add_edge(u, v, dist=data["dist"])
|
164 |
-
continue
|
165 |
-
if v not in spanner.nodes:
|
166 |
-
spanner.add_edge(u, v, dist=data["dist"])
|
167 |
-
continue
|
168 |
-
|
169 |
-
pred, pathLength = nx.dijkstra_predecessor_and_distance(
|
170 |
-
spanner, u, weight="dist", cutoff=k * data["dist"]
|
171 |
-
)
|
172 |
-
|
173 |
-
# If v is in pathLength, we store the path in data['path']
|
174 |
-
if v in pathLength:
|
175 |
-
# reconstruct path from v back to u
|
176 |
-
path = []
|
177 |
-
nxt = v
|
178 |
-
while nxt != u:
|
179 |
-
path.append(nxt)
|
180 |
-
nxt = pred[nxt][0]
|
181 |
-
# remove the first node (==v) because we typically want just intermediate
|
182 |
-
path = path[1:]
|
183 |
-
path.reverse()
|
184 |
-
|
185 |
-
data["path"] = path
|
186 |
-
else:
|
187 |
-
spanner.add_edge(u, v, dist=data["dist"])
|
188 |
-
|
189 |
-
return spanner
|
190 |
-
|
191 |
-
###############################################################################
|
192 |
-
# Function to plot only the bundled edges (with optional color gradient)
|
193 |
-
###############################################################################
|
194 |
-
def plot_bundled_edges_only(G, edge_gradient=False, node_colors=None, ax=None, **plot_kwargs):
|
195 |
-
"""
|
196 |
-
Plots only the edges whose 'Layer' is 'Bundled' (or user-defined).
|
197 |
-
Nodes are plotted for reference in black.
|
198 |
-
|
199 |
-
Parameters:
|
200 |
-
G: NetworkX graph
|
201 |
-
title: Plot title
|
202 |
-
edge_gradient: If True, color edges with gradient
|
203 |
-
node_colors: Dictionary of node colors
|
204 |
-
ax: Optional matplotlib axis to plot on. If None, creates new figure.
|
205 |
-
**plot_kwargs: Additional keyword arguments passed to LineCollection
|
206 |
-
"""
|
207 |
-
# Use provided axis or create new one
|
208 |
-
if ax is None:
|
209 |
-
plt.figure(figsize=(8, 8))
|
210 |
-
ax = plt.gca()
|
211 |
-
|
212 |
-
# 1. Extract positions
|
213 |
-
pos = {}
|
214 |
-
for node, data in G.nodes(data=True):
|
215 |
-
x = data.get('X', data.get('x', 0))
|
216 |
-
y = data.get('Y', data.get('y', 0))
|
217 |
-
pos[node] = (x, y)
|
218 |
-
|
219 |
-
# 2. Assign or retrieve node colors. If your graph doesn't already have
|
220 |
-
# some color-coded attribute, you can define them here.
|
221 |
-
# For example, let's just fix them to green for demonstration:
|
222 |
-
# node_colors = {}
|
223 |
-
# for node in G.nodes():
|
224 |
-
# node_colors[node] = (0.0, 0.5, 0.0, 1.0) # RGBA
|
225 |
-
|
226 |
-
# 3. Build up segments (and possibly per-segment colors) for the edges
|
227 |
-
def binomial(n, k):
|
228 |
-
"""Compute the binomial coefficient (n choose k)."""
|
229 |
-
coeff = 1
|
230 |
-
for i in range(1, k + 1):
|
231 |
-
coeff *= (n - i + 1) / i
|
232 |
-
return coeff
|
233 |
-
|
234 |
-
def approxBezier(points, n=50):
|
235 |
-
"""
|
236 |
-
Compute and return n points along a Bezier curve defined by control points.
|
237 |
-
"""
|
238 |
-
X, Y = [], []
|
239 |
-
m = len(points) - 1
|
240 |
-
binom_vals = [binomial(m, i) for i in range(m + 1)]
|
241 |
-
t_values = np.linspace(0, 1, n)
|
242 |
-
for t in t_values:
|
243 |
-
pX, pY = 0.0, 0.0
|
244 |
-
for i, p in enumerate(points):
|
245 |
-
coeff = binom_vals[i] * ((1 - t) ** (m - i)) * (t ** i)
|
246 |
-
pX += coeff * p[0]
|
247 |
-
pY += coeff * p[1]
|
248 |
-
X.append(pX)
|
249 |
-
Y.append(pY)
|
250 |
-
return np.column_stack([X, Y])
|
251 |
-
|
252 |
-
edge_segments = []
|
253 |
-
edge_colors = []
|
254 |
-
|
255 |
-
for u, v, data in G.edges(data=True):
|
256 |
-
if data.get("Layer", None) != "Bundled":
|
257 |
-
# Skip edges not marked as bundled
|
258 |
-
continue
|
259 |
-
|
260 |
-
# (a) Gather the control points
|
261 |
-
if "Spline" in data and data["Spline"] is not None:
|
262 |
-
spline_obj = data["Spline"]
|
263 |
-
control_points = list(spline_obj.points)
|
264 |
-
# Add the start/end for completeness
|
265 |
-
control_points = [pos[u]] + control_points + [pos[v]]
|
266 |
-
else:
|
267 |
-
# fallback to a straight line
|
268 |
-
control_points = [pos[u], pos[v]]
|
269 |
-
|
270 |
-
# (b) Approximate a curve from these control points
|
271 |
-
# We always subdivide if edge_gradient is True.
|
272 |
-
# If not gradient-based, only subdivide for an actual curve.
|
273 |
-
do_subdivide = edge_gradient or (len(control_points) > 2)
|
274 |
-
if do_subdivide:
|
275 |
-
curve_points = approxBezier(control_points, n=50)
|
276 |
-
else:
|
277 |
-
curve_points = np.array(control_points)
|
278 |
-
|
279 |
-
# (c) If we're using gradient, we break it into small segments, each with a color
|
280 |
-
if edge_gradient:
|
281 |
-
c_u = np.array(node_colors[u]) # RGBA for source node
|
282 |
-
c_v = np.array(node_colors[v]) # RGBA for target node
|
283 |
-
num_pts = len(curve_points)
|
284 |
-
for i in range(num_pts - 1):
|
285 |
-
p0 = curve_points[i]
|
286 |
-
p1 = curve_points[i + 1]
|
287 |
-
# fraction along the curve
|
288 |
-
t = i / max(1, (num_pts - 2))
|
289 |
-
seg_color = (1 - t) * c_u + t * c_v # linear interpolation in RGBA
|
290 |
-
edge_segments.append([p0, p1])
|
291 |
-
edge_colors.append(seg_color)
|
292 |
-
else:
|
293 |
-
# Single color for the entire edge
|
294 |
-
if len(curve_points) > 1:
|
295 |
-
edge_segments.append([curve_points[0], curve_points[-1]])
|
296 |
-
edge_colors.append((0.5, 0.0, 0.5, 0.9)) # purple RGBA
|
297 |
-
|
298 |
-
# 4. Plot
|
299 |
-
# Remove the plt.figure() call since we're using the provided axis
|
300 |
-
|
301 |
-
# Set default values for LineCollection
|
302 |
-
lc_kwargs = {
|
303 |
-
'linewidths': 1,
|
304 |
-
'alpha': 0.9
|
305 |
-
}
|
306 |
-
|
307 |
-
# If colors weren't explicitly passed and we calculated edge_colors, use them
|
308 |
-
if 'colors' not in plot_kwargs and edge_colors:
|
309 |
-
lc_kwargs['colors'] = edge_colors
|
310 |
-
|
311 |
-
# Update with user-provided kwargs
|
312 |
-
lc_kwargs.update(plot_kwargs)
|
313 |
-
|
314 |
-
# Create the LineCollection with all parameters
|
315 |
-
lc = LineCollection(edge_segments, **lc_kwargs)
|
316 |
-
ax.add_collection(lc)
|
317 |
-
|
318 |
-
# The nodes in black
|
319 |
-
# node_positions = np.array([pos[n] for n in G.nodes()])
|
320 |
-
# ax.scatter(node_positions[:, 0], node_positions[:, 1], color="black", s=20, alpha=0.8)
|
321 |
-
|
322 |
-
# ax.set_aspect('equal')
|
323 |
-
# Remove plt.show() since we want to allow further additions to the plot
|
324 |
-
|
325 |
-
###############################################################################
|
326 |
-
# Convenience function to run SpannerBundlingNoSP on a graph and plot results
|
327 |
-
###############################################################################
|
328 |
-
def run_and_plot_spanner_bundling_no_sp(G, weightFactor=2, distortion=2, edge_gradient=False, node_colors=None, ax=None, **plot_kwargs):
|
329 |
-
"""
|
330 |
-
Create an instance of SpannerBundlingNoSP, run .bundle(), and
|
331 |
-
plot only the bundled edges. Pass edge_gradient=True to see
|
332 |
-
color-gradient edges.
|
333 |
-
|
334 |
-
Additional keyword arguments are passed to the LineCollection for edge styling.
|
335 |
-
"""
|
336 |
-
bundler = SpannerBundlingNoSP(G, weightFactor=weightFactor, distortion=distortion)
|
337 |
-
bundler.bundle()
|
338 |
-
plot_bundled_edges_only(G,
|
339 |
-
edge_gradient=edge_gradient,
|
340 |
-
node_colors=node_colors,
|
341 |
-
ax=ax,
|
342 |
-
**plot_kwargs)
|
343 |
-
|
344 |
-
def run_hammer_bundling(G, accuracy=500, advect_iterations=50, batch_size=20000,
|
345 |
-
decay=0.01, initial_bandwidth=1.1, iterations=4,
|
346 |
-
max_segment_length=0.016, min_segment_length=0.008,
|
347 |
-
tension=1.2):
|
348 |
-
"""
|
349 |
-
Run hammer bundling on a NetworkX graph and return the bundled paths.
|
350 |
-
"""
|
351 |
-
# Create nodes DataFrame
|
352 |
-
nodes = []
|
353 |
-
node_to_index = {}
|
354 |
-
for i, (node, attr) in enumerate(G.nodes(data=True)):
|
355 |
-
x = attr.get('X', attr.get('x', 0))
|
356 |
-
y = attr.get('Y', attr.get('y', 0))
|
357 |
-
nodes.append({'node': node, 'x': x, 'y': y})
|
358 |
-
node_to_index[node] = i
|
359 |
-
nodes_df = pd.DataFrame(nodes)
|
360 |
-
|
361 |
-
# Create edges DataFrame
|
362 |
-
edges = []
|
363 |
-
for u, v in G.edges():
|
364 |
-
edges.append({'source': node_to_index[u], 'target': node_to_index[v]})
|
365 |
-
edges_df = pd.DataFrame(edges)
|
366 |
-
|
367 |
-
# Apply hammer bundling
|
368 |
-
bundled_paths = hammer_bundle(nodes_df, edges_df,
|
369 |
-
accuracy=accuracy,
|
370 |
-
advect_iterations=advect_iterations,
|
371 |
-
batch_size=batch_size,
|
372 |
-
decay=decay,
|
373 |
-
initial_bandwidth=initial_bandwidth,
|
374 |
-
iterations=iterations,
|
375 |
-
max_segment_length=max_segment_length,
|
376 |
-
min_segment_length=min_segment_length,
|
377 |
-
tension=tension)
|
378 |
-
|
379 |
-
# Convert bundled paths to a format compatible with our plotting function
|
380 |
-
paths = []
|
381 |
-
current_path = []
|
382 |
-
edge_index = 0
|
383 |
-
|
384 |
-
for _, row in bundled_paths.iterrows():
|
385 |
-
if pd.isna(row['x']) or pd.isna(row['y']):
|
386 |
-
if current_path:
|
387 |
-
# Get source and target nodes for this edge
|
388 |
-
source_idx = edges_df.iloc[edge_index]['source']
|
389 |
-
target_idx = edges_df.iloc[edge_index]['target']
|
390 |
-
source_node = nodes_df.iloc[source_idx]['node']
|
391 |
-
target_node = nodes_df.iloc[target_idx]['node']
|
392 |
-
|
393 |
-
paths.append((source_node, target_node, current_path))
|
394 |
-
current_path = []
|
395 |
-
edge_index += 1
|
396 |
-
else:
|
397 |
-
current_path.append((row['x'], row['y']))
|
398 |
-
|
399 |
-
if current_path: # Handle the last path
|
400 |
-
source_idx = edges_df.iloc[edge_index]['source']
|
401 |
-
target_idx = edges_df.iloc[edge_index]['target']
|
402 |
-
source_node = nodes_df.iloc[source_idx]['node']
|
403 |
-
target_node = nodes_df.iloc[target_idx]['node']
|
404 |
-
paths.append((source_node, target_node, current_path))
|
405 |
-
|
406 |
-
return paths
|
407 |
-
|
408 |
-
def plot_bundled_edges(G, bundled_paths, edge_gradient=False, node_colors=None, ax=None, **plot_kwargs):
|
409 |
-
"""
|
410 |
-
Generic plotting function that works with both bundling methods.
|
411 |
-
|
412 |
-
Parameters:
|
413 |
-
G: NetworkX graph
|
414 |
-
bundled_paths: List of (source, target, path_points) tuples
|
415 |
-
edge_gradient: If True, color edges with gradient
|
416 |
-
node_colors: Dictionary of node colors
|
417 |
-
ax: Optional matplotlib axis
|
418 |
-
**plot_kwargs: Additional styling arguments
|
419 |
-
"""
|
420 |
-
if ax is None:
|
421 |
-
plt.figure(figsize=(8, 8))
|
422 |
-
ax = plt.gca()
|
423 |
-
|
424 |
-
def approxBezier(points, n=50):
|
425 |
-
"""Compute points along a Bezier curve."""
|
426 |
-
points = np.array(points)
|
427 |
-
t = np.linspace(0, 1, n)
|
428 |
-
return np.array([(1-t)*points[:-1] + t*points[1:] for t in t]).reshape(-1, 2)
|
429 |
-
|
430 |
-
edge_segments = []
|
431 |
-
edge_colors = []
|
432 |
-
|
433 |
-
for source, target, path_points in bundled_paths:
|
434 |
-
points = np.array(path_points)
|
435 |
-
|
436 |
-
if edge_gradient:
|
437 |
-
# Create segments with gradient colors
|
438 |
-
c_u = np.array(node_colors[source])
|
439 |
-
c_v = np.array(node_colors[target])
|
440 |
-
num_pts = len(points)
|
441 |
-
|
442 |
-
for i in range(num_pts - 1):
|
443 |
-
p0, p1 = points[i], points[i + 1]
|
444 |
-
t = i / max(1, (num_pts - 2))
|
445 |
-
seg_color = (1 - t) * c_u + t * c_v
|
446 |
-
edge_segments.append([p0, p1])
|
447 |
-
edge_colors.append(seg_color)
|
448 |
-
else:
|
449 |
-
# Single color for the entire path
|
450 |
-
for i in range(len(points) - 1):
|
451 |
-
edge_segments.append([points[i], points[i + 1]])
|
452 |
-
edge_colors.append((0.5, 0.0, 0.5, 0.9))
|
453 |
-
|
454 |
-
# Plot edges
|
455 |
-
lc_kwargs = {'linewidths': 1, 'alpha': 0.9}
|
456 |
-
if edge_colors:
|
457 |
-
lc_kwargs['colors'] = edge_colors
|
458 |
-
lc_kwargs.update(plot_kwargs)
|
459 |
-
|
460 |
-
lc = LineCollection(edge_segments, **lc_kwargs)
|
461 |
-
ax.add_collection(lc)
|
462 |
-
ax.autoscale()
|
463 |
-
|
464 |
-
def run_and_plot_bundling(G, method='hammer', edge_gradient=False, node_colors=None, ax=None,
|
465 |
-
bundling_params=None, **plot_kwargs):
|
466 |
-
"""
|
467 |
-
Unified function to run and plot different bundling methods.
|
468 |
-
|
469 |
-
Parameters:
|
470 |
-
G: NetworkX graph
|
471 |
-
method: 'spanner' or 'hammer'
|
472 |
-
bundling_params: dict of parameters specific to the bundling method
|
473 |
-
Other parameters same as plot_bundled_edges
|
474 |
-
"""
|
475 |
-
bundling_params = bundling_params or {}
|
476 |
-
|
477 |
-
if method == 'spanner':
|
478 |
-
bundler = SpannerBundlingNoSP(G, **bundling_params)
|
479 |
-
bundler.bundle()
|
480 |
-
|
481 |
-
# Extract bundled paths from SpannerBundling format
|
482 |
-
bundled_paths = []
|
483 |
-
for u, v, data in G.edges(data=True):
|
484 |
-
if data.get("Layer") == "Bundled" and "Spline" in data:
|
485 |
-
spline_points = data["Spline"].points
|
486 |
-
pos_u = (G.nodes[u].get('X', G.nodes[u].get('x', 0)),
|
487 |
-
G.nodes[u].get('Y', G.nodes[u].get('y', 0)))
|
488 |
-
pos_v = (G.nodes[v].get('X', G.nodes[v].get('x', 0)),
|
489 |
-
G.nodes[v].get('Y', G.nodes[v].get('y', 0)))
|
490 |
-
path = [pos_u] + list(spline_points) + [pos_v]
|
491 |
-
bundled_paths.append((u, v, path))
|
492 |
-
|
493 |
-
elif method == 'hammer':
|
494 |
-
bundled_paths = run_hammer_bundling(G, **bundling_params)
|
495 |
-
else:
|
496 |
-
raise ValueError(f"Unknown bundling method: {method}")
|
497 |
-
|
498 |
-
plot_bundled_edges(G, bundled_paths, edge_gradient, node_colors, ax, **plot_kwargs)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|