1"""
2******
3Layout
4******
5
6Node positioning algorithms for graph drawing.
7
8For `random_layout()` the possible resulting shape
9is a square of side [0, scale] (default: [0, 1])
10Changing `center` shifts the layout by that amount.
11
12For the other layout routines, the extent is
13[center - scale, center + scale] (default: [-1, 1]).
14
15Warning: Most layout routines have only been tested in 2-dimensions.
16
17"""
18
19import networkx as nx
20from networkx.utils import np_random_state
21
22__all__ = [
23 "bipartite_layout",
24 "circular_layout",
25 "forceatlas2_layout",
26 "kamada_kawai_layout",
27 "random_layout",
28 "rescale_layout",
29 "rescale_layout_dict",
30 "shell_layout",
31 "spring_layout",
32 "spectral_layout",
33 "planar_layout",
34 "fruchterman_reingold_layout",
35 "spiral_layout",
36 "multipartite_layout",
37 "bfs_layout",
38 "arf_layout",
39]
40
41
42def _process_params(G, center, dim):
43 # Some boilerplate code.
44 import numpy as np
45
46 if not isinstance(G, nx.Graph):
47 empty_graph = nx.Graph()
48 empty_graph.add_nodes_from(G)
49 G = empty_graph
50
51 if center is None:
52 center = np.zeros(dim)
53 else:
54 center = np.asarray(center)
55
56 if len(center) != dim:
57 msg = "length of center coordinates must match dimension of layout"
58 raise ValueError(msg)
59
60 return G, center
61
62
63@np_random_state(3)
64def random_layout(G, center=None, dim=2, seed=None, store_pos_as=None):
65 """Position nodes uniformly at random in the unit square.
66
67 For every node, a position is generated by choosing each of dim
68 coordinates uniformly at random on the interval [0.0, 1.0).
69
70 NumPy (http://scipy.org) is required for this function.
71
72 Parameters
73 ----------
74 G : NetworkX graph or list of nodes
75 A position will be assigned to every node in G.
76
77 center : array-like or None
78 Coordinate pair around which to center the layout.
79
80 dim : int
81 Dimension of layout.
82
83 seed : int, RandomState instance or None optional (default=None)
84 Set the random state for deterministic node layouts.
85 If int, `seed` is the seed used by the random number generator,
86 if numpy.random.RandomState instance, `seed` is the random
87 number generator,
88 if None, the random number generator is the RandomState instance used
89 by numpy.random.
90
91 store_pos_as : str, default None
92 If non-None, the position of each node will be stored on the graph as
93 an attribute with this string as its name, which can be accessed with
94 ``G.nodes[...][store_pos_as]``. The function still returns the dictionary.
95
96 Returns
97 -------
98 pos : dict
99 A dictionary of positions keyed by node
100
101 Examples
102 --------
103 >>> from pprint import pprint
104 >>> G = nx.lollipop_graph(4, 3)
105 >>> pos = nx.random_layout(G)
106 >>> # suppress the returned dict and store on the graph directly
107 >>> _ = nx.random_layout(G, seed=42, store_pos_as="pos")
108 >>> pprint(nx.get_node_attributes(G, "pos"))
109 {0: array([0.37454012, 0.9507143 ], dtype=float32),
110 1: array([0.7319939, 0.5986585], dtype=float32),
111 2: array([0.15601864, 0.15599452], dtype=float32),
112 3: array([0.05808361, 0.8661761 ], dtype=float32),
113 4: array([0.601115 , 0.7080726], dtype=float32),
114 5: array([0.02058449, 0.96990985], dtype=float32),
115 6: array([0.83244264, 0.21233912], dtype=float32)}
116 """
117 import numpy as np
118
119 G, center = _process_params(G, center, dim)
120 pos = seed.rand(len(G), dim) + center
121 pos = pos.astype(np.float32)
122 pos = dict(zip(G, pos))
123
124 if store_pos_as is not None:
125 nx.set_node_attributes(G, pos, store_pos_as)
126 return pos
127
128
129def circular_layout(G, scale=1, center=None, dim=2, store_pos_as=None):
130 # dim=2 only
131 """Position nodes on a circle.
132
133 Parameters
134 ----------
135 G : NetworkX graph or list of nodes
136 A position will be assigned to every node in G.
137
138 scale : number (default: 1)
139 Scale factor for positions.
140
141 center : array-like or None
142 Coordinate pair around which to center the layout.
143
144 dim : int
145 Dimension of layout.
146 If dim>2, the remaining dimensions are set to zero
147 in the returned positions.
148 If dim<2, a ValueError is raised.
149
150 store_pos_as : str, default None
151 If non-None, the position of each node will be stored on the graph as
152 an attribute with this string as its name, which can be accessed with
153 ``G.nodes[...][store_pos_as]``. The function still returns the dictionary.
154
155 Returns
156 -------
157 pos : dict
158 A dictionary of positions keyed by node
159
160 Raises
161 ------
162 ValueError
163 If dim < 2
164
165 Examples
166 --------
167 >>> from pprint import pprint
168 >>> G = nx.path_graph(4)
169 >>> pos = nx.circular_layout(G)
170 >>> # suppress the returned dict and store on the graph directly
171 >>> _ = nx.circular_layout(G, store_pos_as="pos")
172 >>> pprint(nx.get_node_attributes(G, "pos"))
173 {0: array([9.99999986e-01, 2.18556937e-08]),
174 1: array([-3.57647606e-08, 1.00000000e+00]),
175 2: array([-9.9999997e-01, -6.5567081e-08]),
176 3: array([ 1.98715071e-08, -9.99999956e-01])}
177
178
179 Notes
180 -----
181 This algorithm currently only works in two dimensions and does not
182 try to minimize edge crossings.
183
184 """
185 import numpy as np
186
187 if dim < 2:
188 raise ValueError("cannot handle dimensions < 2")
189
190 G, center = _process_params(G, center, dim)
191
192 paddims = max(0, (dim - 2))
193
194 if len(G) == 0:
195 pos = {}
196 elif len(G) == 1:
197 pos = {nx.utils.arbitrary_element(G): center}
198 else:
199 # Discard the extra angle since it matches 0 radians.
200 theta = np.linspace(0, 1, len(G) + 1)[:-1] * 2 * np.pi
201 theta = theta.astype(np.float32)
202 pos = np.column_stack(
203 [np.cos(theta), np.sin(theta), np.zeros((len(G), paddims))]
204 )
205 pos = rescale_layout(pos, scale=scale) + center
206 pos = dict(zip(G, pos))
207
208 if store_pos_as is not None:
209 nx.set_node_attributes(G, pos, store_pos_as)
210
211 return pos
212
213
214def shell_layout(
215 G, nlist=None, rotate=None, scale=1, center=None, dim=2, store_pos_as=None
216):
217 """Position nodes in concentric circles.
218
219 Parameters
220 ----------
221 G : NetworkX graph or list of nodes
222 A position will be assigned to every node in G.
223
224 nlist : list of lists
225 List of node lists for each shell.
226
227 rotate : angle in radians (default=pi/len(nlist))
228 Angle by which to rotate the starting position of each shell
229 relative to the starting position of the previous shell.
230 To recreate behavior before v2.5 use rotate=0.
231
232 scale : number (default: 1)
233 Scale factor for positions.
234
235 center : array-like or None
236 Coordinate pair around which to center the layout.
237
238 dim : int
239 Dimension of layout, currently only dim=2 is supported.
240 Other dimension values result in a ValueError.
241
242 store_pos_as : str, default None
243 If non-None, the position of each node will be stored on the graph as
244 an attribute with this string as its name, which can be accessed with
245 ``G.nodes[...][store_pos_as]``. The function still returns the dictionary.
246
247 Returns
248 -------
249 pos : dict
250 A dictionary of positions keyed by node
251
252 Raises
253 ------
254 ValueError
255 If dim != 2
256
257 Examples
258 --------
259 >>> from pprint import pprint
260 >>> G = nx.path_graph(4)
261 >>> shells = [[0], [1, 2, 3]]
262 >>> pos = nx.shell_layout(G, shells)
263 >>> # suppress the returned dict and store on the graph directly
264 >>> _ = nx.shell_layout(G, shells, store_pos_as="pos")
265 >>> pprint(nx.get_node_attributes(G, "pos"))
266 {0: array([0., 0.]),
267 1: array([-5.00000000e-01, -4.37113883e-08]),
268 2: array([ 0.24999996, -0.43301272]),
269 3: array([0.24999981, 0.43301281])}
270
271 Notes
272 -----
273 This algorithm currently only works in two dimensions and does not
274 try to minimize edge crossings.
275
276 """
277 import numpy as np
278
279 if dim != 2:
280 raise ValueError("can only handle 2 dimensions")
281
282 G, center = _process_params(G, center, dim)
283
284 if len(G) == 0:
285 return {}
286 if len(G) == 1:
287 return {nx.utils.arbitrary_element(G): center}
288
289 if nlist is None:
290 # draw the whole graph in one shell
291 nlist = [list(G)]
292
293 radius_bump = scale / len(nlist)
294
295 if len(nlist[0]) == 1:
296 # single node at center
297 radius = 0.0
298 else:
299 # else start at r=1
300 radius = radius_bump
301
302 if rotate is None:
303 rotate = np.pi / len(nlist)
304 first_theta = rotate
305 npos = {}
306 for nodes in nlist:
307 # Discard the last angle (endpoint=False) since 2*pi matches 0 radians
308 theta = (
309 np.linspace(0, 2 * np.pi, len(nodes), endpoint=False, dtype=np.float32)
310 + first_theta
311 )
312 pos = radius * np.column_stack([np.cos(theta), np.sin(theta)]) + center
313 npos.update(zip(nodes, pos))
314 radius += radius_bump
315 first_theta += rotate
316
317 if store_pos_as is not None:
318 nx.set_node_attributes(G, npos, store_pos_as)
319 return npos
320
321
322def bipartite_layout(
323 G,
324 nodes=None,
325 align="vertical",
326 scale=1,
327 center=None,
328 aspect_ratio=4 / 3,
329 store_pos_as=None,
330):
331 """Position nodes in two straight lines.
332
333 Parameters
334 ----------
335 G : NetworkX graph or list of nodes
336 A position will be assigned to every node in G.
337
338 nodes : collection of nodes
339 Nodes in one node set of the graph. This set will be placed on
340 left or top. If `None` (the default), a node set is chosen arbitrarily
341 if the graph if bipartite.
342
343 align : string (default='vertical')
344 The alignment of nodes. Vertical or horizontal.
345
346 scale : number (default: 1)
347 Scale factor for positions.
348
349 center : array-like or None
350 Coordinate pair around which to center the layout.
351
352 aspect_ratio : number (default=4/3):
353 The ratio of the width to the height of the layout.
354
355 store_pos_as : str, default None
356 If non-None, the position of each node will be stored on the graph as
357 an attribute with this string as its name, which can be accessed with
358 ``G.nodes[...][store_pos_as]``. The function still returns the dictionary.
359
360 Returns
361 -------
362 pos : dict
363 A dictionary of positions keyed by node.
364
365 Raises
366 ------
367 NetworkXError
368 If ``nodes=None`` and `G` is not bipartite.
369
370 Examples
371 --------
372 >>> G = nx.complete_bipartite_graph(3, 3)
373 >>> pos = nx.bipartite_layout(G)
374
375 The ordering of the layout (i.e. which nodes appear on the left/top) can
376 be specified with the `nodes` parameter:
377
378 >>> top, bottom = nx.bipartite.sets(G)
379 >>> pos = nx.bipartite_layout(G, nodes=bottom) # "bottom" set appears on the left
380
381 `store_pos_as` can be used to store the node positions for the computed layout
382 directly on the nodes:
383
384 >>> _ = nx.bipartite_layout(G, nodes=bottom, store_pos_as="pos")
385 >>> from pprint import pprint
386 >>> pprint(nx.get_node_attributes(G, "pos"))
387 {0: array([ 1. , -0.75]),
388 1: array([1., 0.]),
389 2: array([1. , 0.75]),
390 3: array([-1. , -0.75]),
391 4: array([-1., 0.]),
392 5: array([-1. , 0.75])}
393
394
395 The ``bipartite_layout`` function can be used with non-bipartite graphs
396 by explicitly specifying how the layout should be partitioned with `nodes`:
397
398 >>> G = nx.complete_graph(5) # Non-bipartite
399 >>> pos = nx.bipartite_layout(G, nodes={0, 1, 2})
400
401 Notes
402 -----
403 This algorithm currently only works in two dimensions and does not
404 try to minimize edge crossings.
405
406 """
407
408 import numpy as np
409
410 if align not in ("vertical", "horizontal"):
411 msg = "align must be either vertical or horizontal."
412 raise ValueError(msg)
413
414 G, center = _process_params(G, center=center, dim=2)
415 if len(G) == 0:
416 return {}
417
418 height = 1
419 width = aspect_ratio * height
420 offset = (width / 2, height / 2)
421
422 if nodes is None:
423 top, bottom = nx.bipartite.sets(G)
424 nodes = list(G)
425 else:
426 top = set(nodes)
427 bottom = set(G) - top
428 # Preserves backward-compatible node ordering in returned pos dict
429 nodes = list(top) + list(bottom)
430
431 left_xs = np.repeat(0, len(top))
432 right_xs = np.repeat(width, len(bottom))
433 left_ys = np.linspace(0, height, len(top))
434 right_ys = np.linspace(0, height, len(bottom))
435
436 top_pos = np.column_stack([left_xs, left_ys]) - offset
437 bottom_pos = np.column_stack([right_xs, right_ys]) - offset
438
439 pos = np.concatenate([top_pos, bottom_pos])
440 pos = rescale_layout(pos, scale=scale) + center
441 if align == "horizontal":
442 pos = pos[:, ::-1] # swap x and y coords
443 pos = dict(zip(nodes, pos))
444
445 if store_pos_as is not None:
446 nx.set_node_attributes(G, pos, store_pos_as)
447
448 return pos
449
450
451@np_random_state("seed")
452def spring_layout(
453 G,
454 k=None,
455 pos=None,
456 fixed=None,
457 iterations=50,
458 threshold=1e-4,
459 weight="weight",
460 scale=1,
461 center=None,
462 dim=2,
463 seed=None,
464 store_pos_as=None,
465 *,
466 method="auto",
467 gravity=1.0,
468):
469 """Position nodes using Fruchterman-Reingold force-directed algorithm.
470
471 The algorithm simulates a force-directed representation of the network
472 treating edges as springs holding nodes close, while treating nodes
473 as repelling objects, sometimes called an anti-gravity force.
474 Simulation continues until the positions are close to an equilibrium.
475
476 There are some hard-coded values: minimal distance between
477 nodes (0.01) and "temperature" of 0.1 to ensure nodes don't fly away.
478 During the simulation, `k` helps determine the distance between nodes,
479 though `scale` and `center` determine the size and place after
480 rescaling occurs at the end of the simulation.
481
482 Fixing some nodes doesn't allow them to move in the simulation.
483 It also turns off the rescaling feature at the simulation's end.
484 In addition, setting `scale` to `None` turns off rescaling.
485
486 Parameters
487 ----------
488 G : NetworkX graph or list of nodes
489 A position will be assigned to every node in G.
490
491 k : float (default=None)
492 Optimal distance between nodes. If None the distance is set to
493 1/sqrt(n) where n is the number of nodes. Increase this value
494 to move nodes farther apart.
495
496 pos : dict or None optional (default=None)
497 Initial positions for nodes as a dictionary with node as keys
498 and values as a coordinate list or tuple. If None, then use
499 random initial positions.
500
501 fixed : list or None optional (default=None)
502 Nodes to keep fixed at initial position.
503 Nodes not in ``G.nodes`` are ignored.
504 ValueError raised if `fixed` specified and `pos` not.
505
506 iterations : int optional (default=50)
507 Maximum number of iterations taken
508
509 threshold: float optional (default = 1e-4)
510 Threshold for relative error in node position changes.
511 The iteration stops if the error is below this threshold.
512
513 weight : string or None optional (default='weight')
514 The edge attribute that holds the numerical value used for
515 the edge weight. Larger means a stronger attractive force.
516 If None, then all edge weights are 1.
517
518 scale : number or None (default: 1)
519 Scale factor for positions. Not used unless `fixed is None`.
520 If scale is None, no rescaling is performed.
521
522 center : array-like or None
523 Coordinate pair around which to center the layout.
524 Not used unless `fixed is None`.
525
526 dim : int
527 Dimension of layout.
528
529 seed : int, RandomState instance or None optional (default=None)
530 Used only for the initial positions in the algorithm.
531 Set the random state for deterministic node layouts.
532 If int, `seed` is the seed used by the random number generator,
533 if numpy.random.RandomState instance, `seed` is the random
534 number generator,
535 if None, the random number generator is the RandomState instance used
536 by numpy.random.
537
538 store_pos_as : str, default None
539 If non-None, the position of each node will be stored on the graph as
540 an attribute with this string as its name, which can be accessed with
541 ``G.nodes[...][store_pos_as]``. The function still returns the dictionary.
542
543 method : str optional (default='auto')
544 The method to compute the layout.
545 If 'force', the force-directed Fruchterman-Reingold algorithm [1]_ is used.
546 If 'energy', the energy-based optimization algorithm [2]_ is used with absolute
547 values of edge weights and gravitational forces acting on each connected component.
548 If 'auto', we use 'force' if ``len(G) < 500`` and 'energy' otherwise.
549
550 gravity: float optional (default=1.0)
551 Used only for the method='energy'.
552 The positive coefficient of gravitational forces per connected component.
553
554 Returns
555 -------
556 pos : dict
557 A dictionary of positions keyed by node
558
559 Examples
560 --------
561 >>> from pprint import pprint
562 >>> G = nx.path_graph(4)
563 >>> pos = nx.spring_layout(G)
564 >>> # suppress the returned dict and store on the graph directly
565 >>> _ = nx.spring_layout(G, seed=123, store_pos_as="pos")
566 >>> pprint(nx.get_node_attributes(G, "pos"))
567 {0: array([-0.61495802, -1. ]),
568 1: array([-0.21789544, -0.35432583]),
569 2: array([0.21847843, 0.35527369]),
570 3: array([0.61437502, 0.99905215])}
571
572
573 # The same using longer but equivalent function name
574 >>> pos = nx.fruchterman_reingold_layout(G)
575
576 References
577 ----------
578 .. [1] Fruchterman, Thomas MJ, and Edward M. Reingold.
579 "Graph drawing by force-directed placement."
580 Software: Practice and experience 21, no. 11 (1991): 1129-1164.
581 http://dx.doi.org/10.1002/spe.4380211102
582 .. [2] Hamaguchi, Hiroki, Naoki Marumo, and Akiko Takeda.
583 "Initial Placement for Fruchterman--Reingold Force Model With Coordinate Newton Direction."
584 arXiv preprint arXiv:2412.20317 (2024).
585 https://arxiv.org/abs/2412.20317
586 """
587 import numpy as np
588
589 if method not in ("auto", "force", "energy"):
590 raise ValueError("the method must be either auto, force, or energy.")
591 if method == "auto":
592 method = "force" if len(G) < 500 else "energy"
593
594 G, center = _process_params(G, center, dim)
595
596 if fixed is not None:
597 if pos is None:
598 raise ValueError("nodes are fixed without positions given")
599 for node in fixed:
600 if node not in pos:
601 raise ValueError("nodes are fixed without positions given")
602 nfixed = {node: i for i, node in enumerate(G)}
603 fixed = np.asarray(
604 [nfixed[node] for node in fixed if node in nfixed], dtype=int
605 )
606
607 if pos is not None:
608 # Determine size of existing domain to adjust initial positions
609 dom_size = max(coord for pos_tup in pos.values() for coord in pos_tup)
610 if dom_size == 0:
611 dom_size = 1
612 pos_arr = seed.rand(len(G), dim) * dom_size + center
613
614 for i, n in enumerate(G):
615 if n in pos:
616 pos_arr[i] = np.asarray(pos[n])
617 else:
618 pos_arr = None
619 dom_size = 1
620
621 if len(G) == 0:
622 return {}
623 if len(G) == 1:
624 pos = {nx.utils.arbitrary_element(G.nodes()): center}
625 if store_pos_as is not None:
626 nx.set_node_attributes(G, pos, store_pos_as)
627 return pos
628
629 # Sparse matrix
630 if len(G) >= 500 or method == "energy":
631 A = nx.to_scipy_sparse_array(G, weight=weight, dtype="f")
632 if k is None and fixed is not None:
633 # We must adjust k by domain size for layouts not near 1x1
634 nnodes, _ = A.shape
635 k = dom_size / np.sqrt(nnodes)
636 pos = _sparse_fruchterman_reingold(
637 A, k, pos_arr, fixed, iterations, threshold, dim, seed, method, gravity
638 )
639 else:
640 A = nx.to_numpy_array(G, weight=weight)
641 if k is None and fixed is not None:
642 # We must adjust k by domain size for layouts not near 1x1
643 nnodes, _ = A.shape
644 k = dom_size / np.sqrt(nnodes)
645 pos = _fruchterman_reingold(
646 A, k, pos_arr, fixed, iterations, threshold, dim, seed
647 )
648 if fixed is None and scale is not None:
649 pos = rescale_layout(pos, scale=scale) + center
650 pos = dict(zip(G, pos))
651
652 if store_pos_as is not None:
653 nx.set_node_attributes(G, pos, store_pos_as)
654
655 return pos
656
657
658fruchterman_reingold_layout = spring_layout
659
660
661@np_random_state(7)
662def _fruchterman_reingold(
663 A, k=None, pos=None, fixed=None, iterations=50, threshold=1e-4, dim=2, seed=None
664):
665 # Position nodes in adjacency matrix A using Fruchterman-Reingold
666 # Entry point for NetworkX graph is fruchterman_reingold_layout()
667 import numpy as np
668
669 try:
670 nnodes, _ = A.shape
671 except AttributeError as err:
672 msg = "fruchterman_reingold() takes an adjacency matrix as input"
673 raise nx.NetworkXError(msg) from err
674
675 if pos is None:
676 # random initial positions
677 pos = np.asarray(seed.rand(nnodes, dim), dtype=A.dtype)
678 else:
679 # make sure positions are of same type as matrix
680 pos = pos.astype(A.dtype)
681
682 # optimal distance between nodes
683 if k is None:
684 k = np.sqrt(1.0 / nnodes)
685 # the initial "temperature" is about .1 of domain area (=1x1)
686 # this is the largest step allowed in the dynamics.
687 # We need to calculate this in case our fixed positions force our domain
688 # to be much bigger than 1x1
689 t = max(max(pos.T[0]) - min(pos.T[0]), max(pos.T[1]) - min(pos.T[1])) * 0.1
690 # simple cooling scheme.
691 # linearly step down by dt on each iteration so last iteration is size dt.
692 dt = t / (iterations + 1)
693 delta = np.zeros((pos.shape[0], pos.shape[0], pos.shape[1]), dtype=A.dtype)
694 # the inscrutable (but fast) version
695 # this is still O(V^2)
696 # could use multilevel methods to speed this up significantly
697 for iteration in range(iterations):
698 # matrix of difference between points
699 delta = pos[:, np.newaxis, :] - pos[np.newaxis, :, :]
700 # distance between points
701 distance = np.linalg.norm(delta, axis=-1)
702 # enforce minimum distance of 0.01
703 np.clip(distance, 0.01, None, out=distance)
704 # displacement "force"
705 displacement = np.einsum(
706 "ijk,ij->ik", delta, (k * k / distance**2 - A * distance / k)
707 )
708 # update positions
709 length = np.linalg.norm(displacement, axis=-1)
710 # Threshold the minimum length prior to position scaling
711 # See gh-8113 for detailed discussion of the threshold
712 length = np.clip(length, a_min=0.01, a_max=None)
713 delta_pos = np.einsum("ij,i->ij", displacement, t / length)
714 if fixed is not None:
715 # don't change positions of fixed nodes
716 delta_pos[fixed] = 0.0
717 pos += delta_pos
718 # cool temperature
719 t -= dt
720 if (np.linalg.norm(delta_pos) / nnodes) < threshold:
721 break
722 return pos
723
724
725@np_random_state(7)
726def _sparse_fruchterman_reingold(
727 A,
728 k=None,
729 pos=None,
730 fixed=None,
731 iterations=50,
732 threshold=1e-4,
733 dim=2,
734 seed=None,
735 method="energy",
736 gravity=1.0,
737):
738 # Position nodes in adjacency matrix A using Fruchterman-Reingold
739 # Entry point for NetworkX graph is fruchterman_reingold_layout()
740 # Sparse version
741 import numpy as np
742 import scipy as sp
743
744 try:
745 nnodes, _ = A.shape
746 except AttributeError as err:
747 msg = "fruchterman_reingold() takes an adjacency matrix as input"
748 raise nx.NetworkXError(msg) from err
749
750 if pos is None:
751 # random initial positions
752 pos = np.asarray(seed.rand(nnodes, dim), dtype=A.dtype)
753 else:
754 # make sure positions are of same type as matrix
755 pos = pos.astype(A.dtype)
756
757 # no fixed nodes
758 if fixed is None:
759 fixed = []
760
761 # optimal distance between nodes
762 if k is None:
763 k = np.sqrt(1.0 / nnodes)
764
765 if method == "energy":
766 return _energy_fruchterman_reingold(
767 A, nnodes, k, pos, fixed, iterations, threshold, dim, gravity
768 )
769
770 # make sure we have a LIst of Lists representation
771 try:
772 A = A.tolil()
773 except AttributeError:
774 A = (sp.sparse.coo_array(A)).tolil()
775
776 # the initial "temperature" is about .1 of domain area (=1x1)
777 # this is the largest step allowed in the dynamics.
778 t = max(max(pos.T[0]) - min(pos.T[0]), max(pos.T[1]) - min(pos.T[1])) * 0.1
779 # simple cooling scheme.
780 # linearly step down by dt on each iteration so last iteration is size dt.
781 dt = t / (iterations + 1)
782
783 displacement = np.zeros((dim, nnodes))
784 for iteration in range(iterations):
785 displacement *= 0
786 # loop over rows
787 for i in range(A.shape[0]):
788 if i in fixed:
789 continue
790 # difference between this row's node position and all others
791 delta = (pos[i] - pos).T
792 # distance between points
793 distance = np.sqrt((delta**2).sum(axis=0))
794 # enforce minimum distance of 0.01
795 distance = np.clip(distance, a_min=0.01, a_max=None)
796 # the adjacency matrix row
797 Ai = A.getrowview(i).toarray() # TODO: revisit w/ sparse 1D container
798 # displacement "force"
799 displacement[:, i] += (
800 delta * (k * k / distance**2 - Ai * distance / k)
801 ).sum(axis=1)
802 # update positions
803 length = np.sqrt((displacement**2).sum(axis=0))
804 # Threshold the minimum length prior to position scaling
805 # See gh-8113 for detailed discussion of the threshold
806 length = np.clip(length, a_min=0.01, a_max=None)
807 delta_pos = (displacement * t / length).T
808 pos += delta_pos
809 # cool temperature
810 t -= dt
811 if (np.linalg.norm(delta_pos) / nnodes) < threshold:
812 break
813 return pos
814
815
816def _energy_fruchterman_reingold(
817 A, nnodes, k, pos, fixed, iterations, threshold, dim, gravity
818):
819 # Entry point for NetworkX graph is fruchterman_reingold_layout()
820 # energy-based version
821 import numpy as np
822 import scipy as sp
823
824 if gravity <= 0:
825 raise ValueError(f"the gravity must be positive.")
826
827 # make sure we have a Compressed Sparse Row format
828 try:
829 A = A.tocsr()
830 except AttributeError:
831 A = sp.sparse.csr_array(A)
832
833 # Take absolute values of edge weights and symmetrize it
834 A = np.abs(A)
835 A = (A + A.T) / 2
836
837 n_components, labels = sp.sparse.csgraph.connected_components(A, directed=False)
838 bincount = np.bincount(labels)
839 batchsize = 500
840
841 def _cost_FR(x):
842 pos = x.reshape((nnodes, dim))
843 grad = np.zeros((nnodes, dim))
844 cost = 0.0
845 for l in range(0, nnodes, batchsize):
846 r = min(l + batchsize, nnodes)
847 # difference between selected node positions and all others
848 delta = pos[l:r, np.newaxis, :] - pos[np.newaxis, :, :]
849 # distance between points with a minimum distance of 1e-5
850 distance2 = np.sum(delta * delta, axis=2)
851 distance2 = np.maximum(distance2, 1e-10)
852 distance = np.sqrt(distance2)
853 # temporary variable for calculation
854 Ad = A[l:r] * distance
855 # attractive forces and repulsive forces
856 grad[l:r] = 2 * np.einsum("ij,ijk->ik", Ad / k - k**2 / distance2, delta)
857 # integrated attractive forces
858 cost += np.sum(Ad * distance2) / (3 * k)
859 # integrated repulsive forces
860 cost -= k**2 * np.sum(np.log(distance))
861 # gravitational force from the centroids of connected components to (0.5, ..., 0.5)^T
862 centers = np.zeros((n_components, dim))
863 np.add.at(centers, labels, pos)
864 delta0 = centers / bincount[:, np.newaxis] - 0.5
865 grad += gravity * delta0[labels]
866 cost += gravity * 0.5 * np.sum(bincount * np.linalg.norm(delta0, axis=1) ** 2)
867 # fix positions of fixed nodes
868 grad[fixed] = 0.0
869 return cost, grad.ravel()
870
871 # Optimization of the energy function by L-BFGS algorithm
872 options = {"maxiter": iterations, "gtol": threshold}
873 return sp.optimize.minimize(
874 _cost_FR, pos.ravel(), method="L-BFGS-B", jac=True, options=options
875 ).x.reshape((nnodes, dim))
876
877
878def kamada_kawai_layout(
879 G,
880 dist=None,
881 pos=None,
882 weight="weight",
883 scale=1,
884 center=None,
885 dim=2,
886 store_pos_as=None,
887):
888 """Position nodes using Kamada-Kawai path-length cost-function.
889
890 Parameters
891 ----------
892 G : NetworkX graph or list of nodes
893 A position will be assigned to every node in G.
894
895 dist : dict (default=None)
896 A two-level dictionary of optimal distances between nodes,
897 indexed by source and destination node.
898 If None, the distance is computed using shortest_path_length().
899
900 pos : dict or None optional (default=None)
901 Initial positions for nodes as a dictionary with node as keys
902 and values as a coordinate list or tuple. If None, then use
903 circular_layout() for dim >= 2 and a linear layout for dim == 1.
904
905 weight : string or None optional (default='weight')
906 The edge attribute that holds the numerical value used for
907 the edge weight. If None, then all edge weights are 1.
908
909 scale : number (default: 1)
910 Scale factor for positions.
911
912 center : array-like or None
913 Coordinate pair around which to center the layout.
914
915 dim : int
916 Dimension of layout.
917
918 store_pos_as : str, default None
919 If non-None, the position of each node will be stored on the graph as
920 an attribute with this string as its name, which can be accessed with
921 ``G.nodes[...][store_pos_as]``. The function still returns the dictionary.
922
923 Returns
924 -------
925 pos : dict
926 A dictionary of positions keyed by node
927
928 Examples
929 --------
930 >>> from pprint import pprint
931 >>> G = nx.path_graph(4)
932 >>> pos = nx.kamada_kawai_layout(G)
933 >>> # suppress the returned dict and store on the graph directly
934 >>> _ = nx.kamada_kawai_layout(G, store_pos_as="pos")
935 >>> pprint(nx.get_node_attributes(G, "pos"))
936 {0: array([0.99996577, 0.99366857]),
937 1: array([0.32913544, 0.33543827]),
938 2: array([-0.33544334, -0.32910684]),
939 3: array([-0.99365787, -1. ])}
940 """
941 import numpy as np
942
943 G, center = _process_params(G, center, dim)
944 nNodes = len(G)
945 if nNodes == 0:
946 return {}
947
948 if dist is None:
949 dist = dict(nx.shortest_path_length(G, weight=weight))
950 dist_mtx = 1e6 * np.ones((nNodes, nNodes))
951 for row, nr in enumerate(G):
952 if nr not in dist:
953 continue
954 rdist = dist[nr]
955 for col, nc in enumerate(G):
956 if nc not in rdist:
957 continue
958 dist_mtx[row][col] = rdist[nc]
959
960 if pos is None:
961 if dim >= 3:
962 pos = random_layout(G, dim=dim)
963 elif dim == 2:
964 pos = circular_layout(G, dim=dim)
965 else:
966 pos = dict(zip(G, np.linspace(0, 1, len(G))))
967 pos_arr = np.array([pos[n] for n in G])
968
969 pos = _kamada_kawai_solve(dist_mtx, pos_arr, dim)
970
971 pos = rescale_layout(pos, scale=scale) + center
972 pos = dict(zip(G, pos))
973
974 if store_pos_as is not None:
975 nx.set_node_attributes(G, pos, store_pos_as)
976
977 return pos
978
979
980def _kamada_kawai_solve(dist_mtx, pos_arr, dim):
981 # Anneal node locations based on the Kamada-Kawai cost-function,
982 # using the supplied matrix of preferred inter-node distances,
983 # and starting locations.
984
985 import numpy as np
986 import scipy as sp
987
988 meanwt = 1e-3
989 costargs = (np, 1 / (dist_mtx + np.eye(dist_mtx.shape[0]) * 1e-3), meanwt, dim)
990
991 optresult = sp.optimize.minimize(
992 _kamada_kawai_costfn,
993 pos_arr.ravel(),
994 method="L-BFGS-B",
995 args=costargs,
996 jac=True,
997 )
998
999 return optresult.x.reshape((-1, dim))
1000
1001
1002def _kamada_kawai_costfn(pos_vec, np, invdist, meanweight, dim):
1003 # Cost-function and gradient for Kamada-Kawai layout algorithm
1004 nNodes = invdist.shape[0]
1005 pos_arr = pos_vec.reshape((nNodes, dim))
1006
1007 delta = pos_arr[:, np.newaxis, :] - pos_arr[np.newaxis, :, :]
1008 nodesep = np.linalg.norm(delta, axis=-1)
1009 direction = np.einsum("ijk,ij->ijk", delta, 1 / (nodesep + np.eye(nNodes) * 1e-3))
1010
1011 offset = nodesep * invdist - 1.0
1012 offset[np.diag_indices(nNodes)] = 0
1013
1014 cost = 0.5 * np.sum(offset**2)
1015 grad = np.einsum("ij,ij,ijk->ik", invdist, offset, direction) - np.einsum(
1016 "ij,ij,ijk->jk", invdist, offset, direction
1017 )
1018
1019 # Additional parabolic term to encourage mean position to be near origin:
1020 sumpos = np.sum(pos_arr, axis=0)
1021 cost += 0.5 * meanweight * np.sum(sumpos**2)
1022 grad += meanweight * sumpos
1023
1024 return (cost, grad.ravel())
1025
1026
1027def spectral_layout(G, weight="weight", scale=1, center=None, dim=2, store_pos_as=None):
1028 """Position nodes using the eigenvectors of the graph Laplacian.
1029
1030 Using the unnormalized Laplacian, the layout shows possible clusters of
1031 nodes which are an approximation of the ratio cut. If dim is the number of
1032 dimensions then the positions are the entries of the dim eigenvectors
1033 corresponding to the ascending eigenvalues starting from the second one.
1034
1035 Parameters
1036 ----------
1037 G : NetworkX graph or list of nodes
1038 A position will be assigned to every node in G.
1039
1040 weight : string or None optional (default='weight')
1041 The edge attribute that holds the numerical value used for
1042 the edge weight. If None, then all edge weights are 1.
1043
1044 scale : number (default: 1)
1045 Scale factor for positions.
1046
1047 center : array-like or None
1048 Coordinate pair around which to center the layout.
1049
1050 dim : int
1051 Dimension of layout.
1052
1053 store_pos_as : str, default None
1054 If non-None, the position of each node will be stored on the graph as
1055 an attribute with this string as its name, which can be accessed with
1056 ``G.nodes[...][store_pos_as]``. The function still returns the dictionary.
1057
1058 Returns
1059 -------
1060 pos : dict
1061 A dictionary of positions keyed by node
1062
1063 Examples
1064 --------
1065 >>> from pprint import pprint
1066 >>> G = nx.path_graph(4)
1067 >>> pos = nx.spectral_layout(G)
1068 >>> # suppress the returned dict and store on the graph directly
1069 >>> _ = nx.spectral_layout(G, store_pos_as="pos")
1070 >>> pprint(nx.get_node_attributes(G, "pos"))
1071 {0: array([-1. , 0.76536686]),
1072 1: array([-0.41421356, -0.76536686]),
1073 2: array([ 0.41421356, -0.76536686]),
1074 3: array([1. , 0.76536686])}
1075
1076
1077 Notes
1078 -----
1079 Directed graphs will be considered as undirected graphs when
1080 positioning the nodes.
1081
1082 For larger graphs (>500 nodes) this will use the SciPy sparse
1083 eigenvalue solver (ARPACK).
1084 """
1085 # handle some special cases that break the eigensolvers
1086 import numpy as np
1087
1088 G, center = _process_params(G, center, dim)
1089
1090 if len(G) <= 2:
1091 if len(G) == 0:
1092 pos = np.array([])
1093 elif len(G) == 1:
1094 pos = np.array([center])
1095 else:
1096 pos = np.array([np.zeros(dim), np.array(center) * 2.0])
1097 return dict(zip(G, pos))
1098 try:
1099 # Sparse matrix
1100 if len(G) < 500: # dense solver is faster for small graphs
1101 raise ValueError
1102 A = nx.to_scipy_sparse_array(G, weight=weight, dtype="d")
1103 # Symmetrize directed graphs
1104 if G.is_directed():
1105 A = A + np.transpose(A)
1106 pos = _sparse_spectral(A, dim)
1107 except (ImportError, ValueError):
1108 # Dense matrix
1109 A = nx.to_numpy_array(G, weight=weight)
1110 # Symmetrize directed graphs
1111 if G.is_directed():
1112 A += A.T
1113 pos = _spectral(A, dim)
1114
1115 pos = rescale_layout(pos, scale=scale) + center
1116 pos = dict(zip(G, pos))
1117
1118 if store_pos_as is not None:
1119 nx.set_node_attributes(G, pos, store_pos_as)
1120
1121 return pos
1122
1123
1124def _spectral(A, dim=2):
1125 # Input adjacency matrix A
1126 # Uses dense eigenvalue solver from numpy
1127 import numpy as np
1128
1129 try:
1130 nnodes, _ = A.shape
1131 except AttributeError as err:
1132 msg = "spectral() takes an adjacency matrix as input"
1133 raise nx.NetworkXError(msg) from err
1134
1135 # form Laplacian matrix where D is diagonal of degrees
1136 D = np.identity(nnodes, dtype=A.dtype) * np.sum(A, axis=1)
1137 L = D - A
1138
1139 eigenvalues, eigenvectors = np.linalg.eig(L)
1140 # sort and keep smallest nonzero
1141 index = np.argsort(eigenvalues)[1 : dim + 1] # 0 index is zero eigenvalue
1142 return np.real(eigenvectors[:, index])
1143
1144
1145def _sparse_spectral(A, dim=2):
1146 # Input adjacency matrix A
1147 # Uses sparse eigenvalue solver from scipy
1148 # Could use multilevel methods here, see Koren "On spectral graph drawing"
1149 import numpy as np
1150 import scipy as sp
1151
1152 try:
1153 nnodes, _ = A.shape
1154 except AttributeError as err:
1155 msg = "sparse_spectral() takes an adjacency matrix as input"
1156 raise nx.NetworkXError(msg) from err
1157
1158 # form Laplacian matrix
1159 D = sp.sparse.dia_array((A.sum(axis=1), 0), shape=(nnodes, nnodes)).tocsr()
1160 L = D - A
1161
1162 k = dim + 1
1163 # number of Lanczos vectors for ARPACK solver.What is the right scaling?
1164 ncv = max(2 * k + 1, int(np.sqrt(nnodes)))
1165 # return smallest k eigenvalues and eigenvectors
1166 eigenvalues, eigenvectors = sp.sparse.linalg.eigsh(L, k, which="SM", ncv=ncv)
1167 index = np.argsort(eigenvalues)[1:k] # 0 index is zero eigenvalue
1168 return np.real(eigenvectors[:, index])
1169
1170
1171def planar_layout(G, scale=1, center=None, dim=2, store_pos_as=None):
1172 """Position nodes without edge intersections.
1173
1174 Parameters
1175 ----------
1176 G : NetworkX graph or list of nodes
1177 A position will be assigned to every node in G. If G is of type
1178 nx.PlanarEmbedding, the positions are selected accordingly.
1179
1180 scale : number (default: 1)
1181 Scale factor for positions.
1182
1183 center : array-like or None
1184 Coordinate pair around which to center the layout.
1185
1186 dim : int
1187 Dimension of layout.
1188
1189 store_pos_as : str, default None
1190 If non-None, the position of each node will be stored on the graph as
1191 an attribute with this string as its name, which can be accessed with
1192 ``G.nodes[...][store_pos_as]``. The function still returns the dictionary.
1193
1194 Returns
1195 -------
1196 pos : dict
1197 A dictionary of positions keyed by node
1198
1199 Raises
1200 ------
1201 NetworkXException
1202 If G is not planar
1203
1204 Examples
1205 --------
1206 >>> from pprint import pprint
1207 >>> G = nx.path_graph(4)
1208 >>> pos = nx.planar_layout(G)
1209 >>> # suppress the returned dict and store on the graph directly
1210 >>> _ = nx.planar_layout(G, store_pos_as="pos")
1211 >>> pprint(nx.get_node_attributes(G, "pos"))
1212 {0: array([-0.77777778, -0.33333333]),
1213 1: array([ 1. , -0.33333333]),
1214 2: array([0.11111111, 0.55555556]),
1215 3: array([-0.33333333, 0.11111111])}
1216 """
1217 import numpy as np
1218
1219 if dim != 2:
1220 raise ValueError("can only handle 2 dimensions")
1221
1222 G, center = _process_params(G, center, dim)
1223
1224 if len(G) == 0:
1225 return {}
1226
1227 if isinstance(G, nx.PlanarEmbedding):
1228 embedding = G
1229 else:
1230 is_planar, embedding = nx.check_planarity(G)
1231 if not is_planar:
1232 raise nx.NetworkXException("G is not planar.")
1233 pos = nx.combinatorial_embedding_to_pos(embedding)
1234 node_list = list(embedding)
1235 pos = np.vstack([pos[x] for x in node_list])
1236 pos = pos.astype(np.float64)
1237 pos = rescale_layout(pos, scale=scale) + center
1238 pos = dict(zip(node_list, pos))
1239 if store_pos_as is not None:
1240 nx.set_node_attributes(G, pos, store_pos_as)
1241 return pos
1242
1243
1244def spiral_layout(
1245 G,
1246 scale=1,
1247 center=None,
1248 dim=2,
1249 resolution=0.35,
1250 equidistant=False,
1251 store_pos_as=None,
1252):
1253 """Position nodes in a spiral layout.
1254
1255 Parameters
1256 ----------
1257 G : NetworkX graph or list of nodes
1258 A position will be assigned to every node in G.
1259
1260 scale : number (default: 1)
1261 Scale factor for positions.
1262
1263 center : array-like or None
1264 Coordinate pair around which to center the layout.
1265
1266 dim : int, default=2
1267 Dimension of layout, currently only dim=2 is supported.
1268 Other dimension values result in a ValueError.
1269
1270 resolution : float, default=0.35
1271 The compactness of the spiral layout returned.
1272 Lower values result in more compressed spiral layouts.
1273
1274 equidistant : bool, default=False
1275 If True, nodes will be positioned equidistant from each other
1276 by decreasing angle further from center.
1277 If False, nodes will be positioned at equal angles
1278 from each other by increasing separation further from center.
1279
1280 store_pos_as : str, default None
1281 If non-None, the position of each node will be stored on the graph as
1282 an attribute with this string as its name, which can be accessed with
1283 ``G.nodes[...][store_pos_as]``. The function still returns the dictionary.
1284
1285 Returns
1286 -------
1287 pos : dict
1288 A dictionary of positions keyed by node
1289
1290 Raises
1291 ------
1292 ValueError
1293 If dim != 2
1294
1295 Examples
1296 --------
1297 >>> from pprint import pprint
1298 >>> G = nx.path_graph(4)
1299 >>> pos = nx.spiral_layout(G)
1300 >>> nx.draw(G, pos=pos)
1301 >>> # suppress the returned dict and store on the graph directly
1302 >>> _ = nx.spiral_layout(G, store_pos_as="pos")
1303 >>> pprint(nx.get_node_attributes(G, "pos"))
1304 {0: array([-0.64153279, -0.68555087]),
1305 1: array([-0.03307913, -0.46344795]),
1306 2: array([0.34927952, 0.14899882]),
1307 3: array([0.32533239, 1. ])}
1308
1309 Notes
1310 -----
1311 This algorithm currently only works in two dimensions.
1312
1313 """
1314 import numpy as np
1315
1316 if dim != 2:
1317 raise ValueError("can only handle 2 dimensions")
1318
1319 G, center = _process_params(G, center, dim)
1320
1321 if len(G) == 0:
1322 return {}
1323 if len(G) == 1:
1324 pos = {nx.utils.arbitrary_element(G): center}
1325 if store_pos_as is not None:
1326 nx.set_node_attributes(G, pos, store_pos_as)
1327 return pos
1328
1329 pos = []
1330 if equidistant:
1331 chord = 1
1332 step = 0.5
1333 theta = resolution
1334 theta += chord / (step * theta)
1335 for _ in range(len(G)):
1336 r = step * theta
1337 theta += chord / r
1338 pos.append([np.cos(theta) * r, np.sin(theta) * r])
1339
1340 else:
1341 dist = np.arange(len(G), dtype=float)
1342 angle = resolution * dist
1343 pos = np.transpose(dist * np.array([np.cos(angle), np.sin(angle)]))
1344
1345 pos = rescale_layout(np.array(pos), scale=scale) + center
1346
1347 pos = dict(zip(G, pos))
1348
1349 if store_pos_as is not None:
1350 nx.set_node_attributes(G, pos, store_pos_as)
1351
1352 return pos
1353
1354
1355def multipartite_layout(
1356 G, subset_key="subset", align="vertical", scale=1, center=None, store_pos_as=None
1357):
1358 """Position nodes in layers of straight lines.
1359
1360 Parameters
1361 ----------
1362 G : NetworkX graph or list of nodes
1363 A position will be assigned to every node in G.
1364
1365 subset_key : string or dict (default='subset')
1366 If a string, the key of node data in G that holds the node subset.
1367 If a dict, keyed by layer number to the nodes in that layer/subset.
1368
1369 align : string (default='vertical')
1370 The alignment of nodes. Vertical or horizontal.
1371
1372 scale : number (default: 1)
1373 Scale factor for positions.
1374
1375 center : array-like or None
1376 Coordinate pair around which to center the layout.
1377
1378 store_pos_as : str, default None
1379 If non-None, the position of each node will be stored on the graph as
1380 an attribute with this string as its name, which can be accessed with
1381 ``G.nodes[...][store_pos_as]``. The function still returns the dictionary.
1382
1383 Returns
1384 -------
1385 pos : dict
1386 A dictionary of positions keyed by node.
1387
1388 Examples
1389 --------
1390 >>> G = nx.complete_multipartite_graph(28, 16, 10)
1391 >>> pos = nx.multipartite_layout(G)
1392 >>> # suppress the returned dict and store on the graph directly
1393 >>> G = nx.complete_multipartite_graph(28, 16, 10)
1394 >>> _ = nx.multipartite_layout(G, store_pos_as="pos")
1395
1396 or use a dict to provide the layers of the layout
1397
1398 >>> G = nx.Graph([(0, 1), (1, 2), (1, 3), (3, 4)])
1399 >>> layers = {"a": [0], "b": [1], "c": [2, 3], "d": [4]}
1400 >>> pos = nx.multipartite_layout(G, subset_key=layers)
1401
1402 Notes
1403 -----
1404 This algorithm currently only works in two dimensions and does not
1405 try to minimize edge crossings.
1406
1407 Network does not need to be a complete multipartite graph. As long as nodes
1408 have subset_key data, they will be placed in the corresponding layers.
1409
1410 """
1411 import numpy as np
1412
1413 if align not in ("vertical", "horizontal"):
1414 msg = "align must be either vertical or horizontal."
1415 raise ValueError(msg)
1416
1417 G, center = _process_params(G, center=center, dim=2)
1418 if len(G) == 0:
1419 return {}
1420
1421 try:
1422 # check if subset_key is dict-like
1423 if len(G) != sum(len(nodes) for nodes in subset_key.values()):
1424 raise nx.NetworkXError(
1425 "all nodes must be in one subset of `subset_key` dict"
1426 )
1427 except AttributeError:
1428 # subset_key is not a dict, hence a string
1429 node_to_subset = nx.get_node_attributes(G, subset_key)
1430 if len(node_to_subset) != len(G):
1431 raise nx.NetworkXError(
1432 f"all nodes need a subset_key attribute: {subset_key}"
1433 )
1434 subset_key = nx.utils.groups(node_to_subset)
1435
1436 # Sort by layer, if possible
1437 try:
1438 layers = dict(sorted(subset_key.items()))
1439 except TypeError:
1440 layers = subset_key
1441
1442 pos = None
1443 nodes = []
1444 width = len(layers)
1445 for i, layer in enumerate(layers.values()):
1446 height = len(layer)
1447 xs = np.repeat(i, height)
1448 ys = np.arange(0, height, dtype=float)
1449 offset = ((width - 1) / 2, (height - 1) / 2)
1450 layer_pos = np.column_stack([xs, ys]) - offset
1451 if pos is None:
1452 pos = layer_pos
1453 else:
1454 pos = np.concatenate([pos, layer_pos])
1455 nodes.extend(layer)
1456 pos = rescale_layout(pos, scale=scale) + center
1457 if align == "horizontal":
1458 pos = pos[:, ::-1] # swap x and y coords
1459 pos = dict(zip(nodes, pos))
1460
1461 if store_pos_as is not None:
1462 nx.set_node_attributes(G, pos, store_pos_as)
1463
1464 return pos
1465
1466
1467@np_random_state("seed")
1468def arf_layout(
1469 G,
1470 pos=None,
1471 scaling=1,
1472 a=1.1,
1473 etol=1e-6,
1474 dt=1e-3,
1475 max_iter=1000,
1476 *,
1477 seed=None,
1478 store_pos_as=None,
1479):
1480 """Arf layout for networkx
1481
1482 The attractive and repulsive forces (arf) layout [1] improves the spring
1483 layout in three ways. First, it prevents congestion of highly connected nodes
1484 due to strong forcing between nodes. Second, it utilizes the layout space
1485 more effectively by preventing large gaps that spring layout tends to create.
1486 Lastly, the arf layout represents symmetries in the layout better than the
1487 default spring layout.
1488
1489 Parameters
1490 ----------
1491 G : nx.Graph or nx.DiGraph
1492 Networkx graph.
1493 pos : dict
1494 Initial position of the nodes. If set to None a
1495 random layout will be used.
1496 scaling : float
1497 Scales the radius of the circular layout space.
1498 a : float
1499 Strength of springs between connected nodes. Should be larger than 1.
1500 The greater a, the clearer the separation of unconnected sub clusters.
1501 etol : float
1502 Gradient sum of spring forces must be larger than `etol` before successful
1503 termination.
1504 dt : float
1505 Time step for force differential equation simulations.
1506 max_iter : int
1507 Max iterations before termination of the algorithm.
1508 seed : int, RandomState instance or None optional (default=None)
1509 Set the random state for deterministic node layouts.
1510 If int, `seed` is the seed used by the random number generator,
1511 if numpy.random.RandomState instance, `seed` is the random
1512 number generator,
1513 if None, the random number generator is the RandomState instance used
1514 by numpy.random.
1515 store_pos_as : str, default None
1516 If non-None, the position of each node will be stored on the graph as
1517 an attribute with this string as its name, which can be accessed with
1518 ``G.nodes[...][store_pos_as]``. The function still returns the dictionary.
1519
1520 Returns
1521 -------
1522 pos : dict
1523 A dictionary of positions keyed by node.
1524
1525 Examples
1526 --------
1527 >>> G = nx.grid_graph((5, 5))
1528 >>> pos = nx.arf_layout(G)
1529 >>> # suppress the returned dict and store on the graph directly
1530 >>> G = nx.grid_graph((5, 5))
1531 >>> _ = nx.arf_layout(G, store_pos_as="pos")
1532
1533 References
1534 ----------
1535 .. [1] "Self-Organization Applied to Dynamic Network Layout", M. Geipel,
1536 International Journal of Modern Physics C, 2007, Vol 18, No 10,
1537 pp. 1537-1549.
1538 https://doi.org/10.1142/S0129183107011558 https://arxiv.org/abs/0704.1748
1539 """
1540 import warnings
1541
1542 import numpy as np
1543
1544 if a <= 1:
1545 msg = "The parameter a should be larger than 1"
1546 raise ValueError(msg)
1547
1548 pos_tmp = nx.random_layout(G, seed=seed)
1549 if pos is None:
1550 pos = pos_tmp
1551 else:
1552 for node in G.nodes():
1553 if node not in pos:
1554 pos[node] = pos_tmp[node].copy()
1555
1556 # Initialize spring constant matrix
1557 N = len(G)
1558 # No nodes no computation
1559 if N == 0:
1560 return pos
1561
1562 # init force of springs
1563 K = np.ones((N, N)) - np.eye(N)
1564 node_order = {node: i for i, node in enumerate(G)}
1565 for x, y in G.edges():
1566 if x != y:
1567 idx, jdx = (node_order[i] for i in (x, y))
1568 K[idx, jdx] = a
1569
1570 # vectorize values
1571 p = np.asarray(list(pos.values()))
1572
1573 # equation 10 in [1]
1574 rho = scaling * np.sqrt(N)
1575
1576 # looping variables
1577 error = etol + 1
1578 n_iter = 0
1579 while error > etol:
1580 diff = p[:, np.newaxis] - p[np.newaxis]
1581 A = np.linalg.norm(diff, axis=-1)[..., np.newaxis]
1582 # attraction_force - repulsions force
1583 # suppress nans due to division; caused by diagonal set to zero.
1584 # Does not affect the computation due to nansum
1585 with warnings.catch_warnings():
1586 warnings.simplefilter("ignore")
1587 change = K[..., np.newaxis] * diff - rho / A * diff
1588 change = np.nansum(change, axis=0)
1589 p += change * dt
1590
1591 error = np.linalg.norm(change, axis=-1).sum()
1592 if n_iter > max_iter:
1593 break
1594 n_iter += 1
1595
1596 pos = dict(zip(G.nodes(), p))
1597
1598 if store_pos_as is not None:
1599 nx.set_node_attributes(G, pos, store_pos_as)
1600
1601 return pos
1602
1603
1604@np_random_state("seed")
1605@nx._dispatchable(edge_attrs="weight", mutates_input={"store_pos_as": 15})
1606def forceatlas2_layout(
1607 G,
1608 pos=None,
1609 *,
1610 max_iter=100,
1611 jitter_tolerance=1.0,
1612 scaling_ratio=2.0,
1613 gravity=1.0,
1614 distributed_action=False,
1615 strong_gravity=False,
1616 node_mass=None,
1617 node_size=None,
1618 weight=None,
1619 linlog=False,
1620 seed=None,
1621 dim=2,
1622 store_pos_as=None,
1623):
1624 """Position nodes using the ForceAtlas2 force-directed layout algorithm.
1625
1626 This function applies the ForceAtlas2 layout algorithm [1]_ to a NetworkX graph,
1627 positioning the nodes in a way that visually represents the structure of the graph.
1628 The algorithm uses physical simulation to minimize the energy of the system,
1629 resulting in a more readable layout.
1630
1631 Parameters
1632 ----------
1633 G : nx.Graph
1634 A NetworkX graph to be laid out.
1635 pos : dict or None, optional
1636 Initial positions of the nodes. If None, random initial positions are used.
1637 max_iter : int (default: 100)
1638 Number of iterations for the layout optimization.
1639 jitter_tolerance : float (default: 1.0)
1640 Controls the tolerance for adjusting the speed of layout generation.
1641 scaling_ratio : float (default: 2.0)
1642 Determines the scaling of attraction and repulsion forces.
1643 gravity : float (default: 1.0)
1644 Determines the amount of attraction on nodes to the center. Prevents islands
1645 (i.e. weakly connected or disconnected parts of the graph)
1646 from drifting away.
1647 distributed_action : bool (default: False)
1648 Distributes the attraction force evenly among nodes.
1649 strong_gravity : bool (default: False)
1650 Applies a strong gravitational pull towards the center.
1651 node_mass : dict or None, optional
1652 Maps nodes to their masses, influencing the attraction to other nodes.
1653 node_size : dict or None, optional
1654 Maps nodes to their sizes, preventing crowding by creating a halo effect.
1655 weight : string or None, optional (default: None)
1656 The edge attribute that holds the numerical value used for
1657 the edge weight. If None, then all edge weights are 1.
1658 linlog : bool (default: False)
1659 Uses logarithmic attraction instead of linear.
1660 seed : int, RandomState instance or None optional (default=None)
1661 Used only for the initial positions in the algorithm.
1662 Set the random state for deterministic node layouts.
1663 If int, `seed` is the seed used by the random number generator,
1664 if numpy.random.RandomState instance, `seed` is the random
1665 number generator,
1666 if None, the random number generator is the RandomState instance used
1667 by numpy.random.
1668 dim : int (default: 2)
1669 Sets the dimensions for the layout. Ignored if `pos` is provided.
1670 store_pos_as : str, default None
1671 If non-None, the position of each node will be stored on the graph as
1672 an attribute with this string as its name, which can be accessed with
1673 ``G.nodes[...][store_pos_as]``. The function still returns the dictionary.
1674
1675 Examples
1676 --------
1677 >>> import networkx as nx
1678 >>> G = nx.florentine_families_graph()
1679 >>> pos = nx.forceatlas2_layout(G)
1680 >>> nx.draw(G, pos=pos)
1681 >>> # suppress the returned dict and store on the graph directly
1682 >>> pos = nx.forceatlas2_layout(G, store_pos_as="pos")
1683 >>> _ = nx.forceatlas2_layout(G, store_pos_as="pos")
1684
1685 References
1686 ----------
1687 .. [1] Jacomy, M., Venturini, T., Heymann, S., & Bastian, M. (2014).
1688 ForceAtlas2, a continuous graph layout algorithm for handy network
1689 visualization designed for the Gephi software. PloS one, 9(6), e98679.
1690 https://doi.org/10.1371/journal.pone.0098679
1691 """
1692 import numpy as np
1693
1694 if len(G) == 0:
1695 return {}
1696 # parse optional pos positions
1697 if pos is None:
1698 pos = nx.random_layout(G, dim=dim, seed=seed)
1699 pos_arr = np.array(list(pos.values()))
1700 elif len(pos) == len(G):
1701 pos_arr = np.array([pos[node] for node in G])
1702 else:
1703 # set random node pos within the initial pos values
1704 pos_init = np.array(list(pos.values()))
1705 max_pos = pos_init.max(axis=0)
1706 min_pos = pos_init.min(axis=0)
1707 dim = max_pos.size
1708 pos_arr = min_pos + seed.rand(len(G), dim) * (max_pos - min_pos)
1709 for idx, node in enumerate(G):
1710 if node in pos:
1711 pos_arr[idx] = pos[node].copy()
1712
1713 mass = np.zeros(len(G))
1714 size = np.zeros(len(G))
1715
1716 # Only adjust for size when the users specifies size other than default (1)
1717 adjust_sizes = False
1718 if node_size is None:
1719 node_size = {}
1720 else:
1721 adjust_sizes = True
1722
1723 if node_mass is None:
1724 node_mass = {}
1725
1726 for idx, node in enumerate(G):
1727 mass[idx] = node_mass.get(node, G.degree(node) + 1)
1728 size[idx] = node_size.get(node, 1)
1729
1730 n = len(G)
1731 gravities = np.zeros((n, dim))
1732 attraction = np.zeros((n, dim))
1733 repulsion = np.zeros((n, dim))
1734 A = nx.to_numpy_array(G, weight=weight)
1735
1736 def estimate_factor(n, swing, traction, speed, speed_efficiency, jitter_tolerance):
1737 """Computes the scaling factor for the force in the ForceAtlas2 layout algorithm.
1738
1739 This helper function adjusts the speed and
1740 efficiency of the layout generation based on the
1741 current state of the system, such as the number of
1742 nodes, current swing, and traction forces.
1743
1744 Parameters
1745 ----------
1746 n : int
1747 Number of nodes in the graph.
1748 swing : float
1749 The current swing, representing the oscillation of the nodes.
1750 traction : float
1751 The current traction force, representing the attraction between nodes.
1752 speed : float
1753 The current speed of the layout generation.
1754 speed_efficiency : float
1755 The efficiency of the current speed, influencing how fast the layout converges.
1756 jitter_tolerance : float
1757 The tolerance for jitter, affecting how much speed adjustment is allowed.
1758
1759 Returns
1760 -------
1761 tuple
1762 A tuple containing the updated speed and speed efficiency.
1763
1764 Notes
1765 -----
1766 This function is a part of the ForceAtlas2 layout algorithm and is used to dynamically adjust the
1767 layout parameters to achieve an optimal and stable visualization.
1768
1769 """
1770 import numpy as np
1771
1772 # estimate jitter
1773 opt_jitter = 0.05 * np.sqrt(n)
1774 min_jitter = np.sqrt(opt_jitter)
1775 max_jitter = 10
1776 min_speed_efficiency = 0.05
1777
1778 other = min(max_jitter, opt_jitter * traction / n**2)
1779 jitter = jitter_tolerance * max(min_jitter, other)
1780
1781 if swing / traction > 2.0:
1782 if speed_efficiency > min_speed_efficiency:
1783 speed_efficiency *= 0.5
1784 jitter = max(jitter, jitter_tolerance)
1785 if swing == 0:
1786 target_speed = np.inf
1787 else:
1788 target_speed = jitter * speed_efficiency * traction / swing
1789
1790 if swing > jitter * traction:
1791 if speed_efficiency > min_speed_efficiency:
1792 speed_efficiency *= 0.7
1793 elif speed < 1000:
1794 speed_efficiency *= 1.3
1795
1796 max_rise = 0.5
1797 speed = speed + min(target_speed - speed, max_rise * speed)
1798 return speed, speed_efficiency
1799
1800 speed = 1
1801 speed_efficiency = 1
1802 swing = 1
1803 traction = 1
1804 for _ in range(max_iter):
1805 # compute pairwise difference
1806 diff = pos_arr[:, None] - pos_arr[None]
1807 # compute pairwise distance
1808 distance = np.linalg.norm(diff, axis=-1)
1809
1810 # linear attraction
1811 if linlog:
1812 attraction = -np.log(1 + distance) / distance
1813 np.fill_diagonal(attraction, 0)
1814 attraction = np.einsum("ij, ij -> ij", attraction, A)
1815 attraction = np.einsum("ijk, ij -> ik", diff, attraction)
1816
1817 else:
1818 attraction = -np.einsum("ijk, ij -> ik", diff, A)
1819
1820 if distributed_action:
1821 attraction /= mass[:, None]
1822
1823 # repulsion
1824 tmp = mass[:, None] @ mass[None]
1825 if adjust_sizes:
1826 distance += -size[:, None] - size[None]
1827
1828 d2 = distance**2
1829 # remove self-interaction
1830 np.fill_diagonal(tmp, 0)
1831 np.fill_diagonal(d2, 1)
1832 factor = (tmp / d2) * scaling_ratio
1833 repulsion = np.einsum("ijk, ij -> ik", diff, factor)
1834
1835 # gravity
1836 pos_centered = pos_arr - np.mean(pos_arr, axis=0)
1837 if strong_gravity:
1838 gravities = -gravity * mass[:, None] * pos_centered
1839 else:
1840 # hide warnings for divide by zero. Then change nan to 0
1841 with np.errstate(divide="ignore", invalid="ignore"):
1842 unit_vec = pos_centered / np.linalg.norm(pos_centered, axis=-1)[:, None]
1843 unit_vec = np.nan_to_num(unit_vec, nan=0)
1844 gravities = -gravity * mass[:, None] * unit_vec
1845
1846 # total forces
1847 update = attraction + repulsion + gravities
1848
1849 # compute total swing and traction
1850 swing += (mass * np.linalg.norm(pos_arr - update, axis=-1)).sum()
1851 traction += (0.5 * mass * np.linalg.norm(pos_arr + update, axis=-1)).sum()
1852
1853 speed, speed_efficiency = estimate_factor(
1854 n,
1855 swing,
1856 traction,
1857 speed,
1858 speed_efficiency,
1859 jitter_tolerance,
1860 )
1861
1862 # update pos
1863 if adjust_sizes:
1864 df = np.linalg.norm(update, axis=-1)
1865 swinging = mass * df
1866 factor = 0.1 * speed / (1 + np.sqrt(speed * swinging))
1867 factor = np.minimum(factor * df, 10.0 * np.ones(df.shape)) / df
1868 else:
1869 swinging = mass * np.linalg.norm(update, axis=-1)
1870 factor = speed / (1 + np.sqrt(speed * swinging))
1871
1872 factored_update = update * factor[:, None]
1873 pos_arr += factored_update
1874 if abs(factored_update).sum() < 1e-10:
1875 break
1876
1877 pos = dict(zip(G, pos_arr))
1878 if store_pos_as is not None:
1879 nx.set_node_attributes(G, pos, store_pos_as)
1880
1881 return pos
1882
1883
1884def rescale_layout(pos, scale=1):
1885 """Returns scaled position array to (-scale, scale) in all axes.
1886
1887 The function acts on NumPy arrays which hold position information.
1888 Each position is one row of the array. The dimension of the space
1889 equals the number of columns. Each coordinate in one column.
1890
1891 To rescale, the mean (center) is subtracted from each axis separately.
1892 Then all values are scaled so that the largest magnitude value
1893 from all axes equals `scale` (thus, the aspect ratio is preserved).
1894 The resulting NumPy Array is returned (order of rows unchanged).
1895
1896 Parameters
1897 ----------
1898 pos : numpy array
1899 positions to be scaled. Each row is a position.
1900
1901 scale : number (default: 1)
1902 The size of the resulting extent in all directions.
1903
1904 attribute : str, default None
1905 If non-None, the position of each node will be stored on the graph as
1906 an attribute named `attribute` which can be accessed with
1907 `G.nodes[...][attribute]`. The function still returns the dictionary.
1908
1909 Returns
1910 -------
1911 pos : numpy array
1912 scaled positions. Each row is a position.
1913
1914 See Also
1915 --------
1916 rescale_layout_dict
1917 """
1918 import numpy as np
1919
1920 # Find max length over all dimensions
1921 pos -= pos.mean(axis=0)
1922 lim = np.abs(pos).max() # max coordinate for all axes
1923 # rescale to (-scale, scale) in all directions, preserves aspect
1924 if lim > 0:
1925 pos *= scale / lim
1926 return pos
1927
1928
1929def rescale_layout_dict(pos, scale=1):
1930 """Return a dictionary of scaled positions keyed by node
1931
1932 Parameters
1933 ----------
1934 pos : A dictionary of positions keyed by node
1935
1936 scale : number (default: 1)
1937 The size of the resulting extent in all directions.
1938
1939 Returns
1940 -------
1941 pos : A dictionary of positions keyed by node
1942
1943 Examples
1944 --------
1945 >>> import numpy as np
1946 >>> pos = {0: np.array((0, 0)), 1: np.array((1, 1)), 2: np.array((0.5, 0.5))}
1947 >>> nx.rescale_layout_dict(pos)
1948 {0: array([-1., -1.]), 1: array([1., 1.]), 2: array([0., 0.])}
1949
1950 >>> pos = {0: np.array((0, 0)), 1: np.array((-1, 1)), 2: np.array((-0.5, 0.5))}
1951 >>> nx.rescale_layout_dict(pos, scale=2)
1952 {0: array([ 2., -2.]), 1: array([-2., 2.]), 2: array([0., 0.])}
1953
1954 See Also
1955 --------
1956 rescale_layout
1957 """
1958 import numpy as np
1959
1960 if not pos: # empty_graph
1961 return {}
1962 pos_v = np.array(list(pos.values()))
1963 pos_v = rescale_layout(pos_v, scale=scale)
1964 return dict(zip(pos, pos_v))
1965
1966
1967def bfs_layout(G, start, *, align="vertical", scale=1, center=None, store_pos_as=None):
1968 """Position nodes according to breadth-first search algorithm.
1969
1970 Parameters
1971 ----------
1972 G : NetworkX graph
1973 A position will be assigned to every node in G.
1974
1975 start : node in `G`
1976 Starting node for bfs
1977
1978 align : string (default='vertical')
1979 The alignment of nodes within a layer, either `"vertical"` or
1980 `"horizontal"`.
1981
1982 scale : number (default: 1)
1983 Scale factor for positions.
1984
1985 center : array-like or None
1986 Coordinate pair around which to center the layout.
1987
1988 store_pos_as : str, default None
1989 If non-None, the position of each node will be stored on the graph as
1990 an attribute with this string as its name, which can be accessed with
1991 ``G.nodes[...][store_pos_as]``. The function still returns the dictionary.
1992
1993 Returns
1994 -------
1995 pos : dict
1996 A dictionary of positions keyed by node.
1997
1998 Examples
1999 --------
2000 >>> from pprint import pprint
2001 >>> G = nx.path_graph(4)
2002 >>> pos = nx.bfs_layout(G, 0)
2003 >>> # suppress the returned dict and store on the graph directly
2004 >>> _ = nx.bfs_layout(G, 0, store_pos_as="pos")
2005 >>> pprint(nx.get_node_attributes(G, "pos"))
2006 {0: array([-1., 0.]),
2007 1: array([-0.33333333, 0. ]),
2008 2: array([0.33333333, 0. ]),
2009 3: array([1., 0.])}
2010
2011
2012
2013 Notes
2014 -----
2015 This algorithm currently only works in two dimensions and does not
2016 try to minimize edge crossings.
2017
2018 """
2019 G, center = _process_params(G, center, 2)
2020
2021 # Compute layers with BFS
2022 layers = dict(enumerate(nx.bfs_layers(G, start)))
2023
2024 if len(G) != sum(len(nodes) for nodes in layers.values()):
2025 raise nx.NetworkXError(
2026 "bfs_layout didn't include all nodes. Perhaps use input graph:\n"
2027 " G.subgraph(nx.node_connected_component(G, start))"
2028 )
2029
2030 # Compute node positions with multipartite_layout
2031 pos = multipartite_layout(
2032 G, subset_key=layers, align=align, scale=scale, center=center
2033 )
2034
2035 if store_pos_as is not None:
2036 nx.set_node_attributes(G, pos, store_pos_as)
2037
2038 return pos