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([nfixed[node] for node in fixed if node in nfixed])
604
605 if pos is not None:
606 # Determine size of existing domain to adjust initial positions
607 dom_size = max(coord for pos_tup in pos.values() for coord in pos_tup)
608 if dom_size == 0:
609 dom_size = 1
610 pos_arr = seed.rand(len(G), dim) * dom_size + center
611
612 for i, n in enumerate(G):
613 if n in pos:
614 pos_arr[i] = np.asarray(pos[n])
615 else:
616 pos_arr = None
617 dom_size = 1
618
619 if len(G) == 0:
620 return {}
621 if len(G) == 1:
622 pos = {nx.utils.arbitrary_element(G.nodes()): center}
623 if store_pos_as is not None:
624 nx.set_node_attributes(G, pos, store_pos_as)
625 return pos
626
627 # Sparse matrix
628 if len(G) >= 500 or method == "energy":
629 A = nx.to_scipy_sparse_array(G, weight=weight, dtype="f")
630 if k is None and fixed is not None:
631 # We must adjust k by domain size for layouts not near 1x1
632 nnodes, _ = A.shape
633 k = dom_size / np.sqrt(nnodes)
634 pos = _sparse_fruchterman_reingold(
635 A, k, pos_arr, fixed, iterations, threshold, dim, seed, method, gravity
636 )
637 else:
638 A = nx.to_numpy_array(G, weight=weight)
639 if k is None and fixed is not None:
640 # We must adjust k by domain size for layouts not near 1x1
641 nnodes, _ = A.shape
642 k = dom_size / np.sqrt(nnodes)
643 pos = _fruchterman_reingold(
644 A, k, pos_arr, fixed, iterations, threshold, dim, seed
645 )
646 if fixed is None and scale is not None:
647 pos = rescale_layout(pos, scale=scale) + center
648 pos = dict(zip(G, pos))
649
650 if store_pos_as is not None:
651 nx.set_node_attributes(G, pos, store_pos_as)
652
653 return pos
654
655
656fruchterman_reingold_layout = spring_layout
657
658
659@np_random_state(7)
660def _fruchterman_reingold(
661 A, k=None, pos=None, fixed=None, iterations=50, threshold=1e-4, dim=2, seed=None
662):
663 # Position nodes in adjacency matrix A using Fruchterman-Reingold
664 # Entry point for NetworkX graph is fruchterman_reingold_layout()
665 import numpy as np
666
667 try:
668 nnodes, _ = A.shape
669 except AttributeError as err:
670 msg = "fruchterman_reingold() takes an adjacency matrix as input"
671 raise nx.NetworkXError(msg) from err
672
673 if pos is None:
674 # random initial positions
675 pos = np.asarray(seed.rand(nnodes, dim), dtype=A.dtype)
676 else:
677 # make sure positions are of same type as matrix
678 pos = pos.astype(A.dtype)
679
680 # optimal distance between nodes
681 if k is None:
682 k = np.sqrt(1.0 / nnodes)
683 # the initial "temperature" is about .1 of domain area (=1x1)
684 # this is the largest step allowed in the dynamics.
685 # We need to calculate this in case our fixed positions force our domain
686 # to be much bigger than 1x1
687 t = max(max(pos.T[0]) - min(pos.T[0]), max(pos.T[1]) - min(pos.T[1])) * 0.1
688 # simple cooling scheme.
689 # linearly step down by dt on each iteration so last iteration is size dt.
690 dt = t / (iterations + 1)
691 delta = np.zeros((pos.shape[0], pos.shape[0], pos.shape[1]), dtype=A.dtype)
692 # the inscrutable (but fast) version
693 # this is still O(V^2)
694 # could use multilevel methods to speed this up significantly
695 for iteration in range(iterations):
696 # matrix of difference between points
697 delta = pos[:, np.newaxis, :] - pos[np.newaxis, :, :]
698 # distance between points
699 distance = np.linalg.norm(delta, axis=-1)
700 # enforce minimum distance of 0.01
701 np.clip(distance, 0.01, None, out=distance)
702 # displacement "force"
703 displacement = np.einsum(
704 "ijk,ij->ik", delta, (k * k / distance**2 - A * distance / k)
705 )
706 # update positions
707 length = np.linalg.norm(displacement, axis=-1)
708 # Threshold the minimum length prior to position scaling
709 # See gh-8113 for detailed discussion of the threshold
710 length = np.clip(length, a_min=0.01, a_max=None)
711 delta_pos = np.einsum("ij,i->ij", displacement, t / length)
712 if fixed is not None:
713 # don't change positions of fixed nodes
714 delta_pos[fixed] = 0.0
715 pos += delta_pos
716 # cool temperature
717 t -= dt
718 if (np.linalg.norm(delta_pos) / nnodes) < threshold:
719 break
720 return pos
721
722
723@np_random_state(7)
724def _sparse_fruchterman_reingold(
725 A,
726 k=None,
727 pos=None,
728 fixed=None,
729 iterations=50,
730 threshold=1e-4,
731 dim=2,
732 seed=None,
733 method="energy",
734 gravity=1.0,
735):
736 # Position nodes in adjacency matrix A using Fruchterman-Reingold
737 # Entry point for NetworkX graph is fruchterman_reingold_layout()
738 # Sparse version
739 import numpy as np
740 import scipy as sp
741
742 try:
743 nnodes, _ = A.shape
744 except AttributeError as err:
745 msg = "fruchterman_reingold() takes an adjacency matrix as input"
746 raise nx.NetworkXError(msg) from err
747
748 if pos is None:
749 # random initial positions
750 pos = np.asarray(seed.rand(nnodes, dim), dtype=A.dtype)
751 else:
752 # make sure positions are of same type as matrix
753 pos = pos.astype(A.dtype)
754
755 # no fixed nodes
756 if fixed is None:
757 fixed = []
758
759 # optimal distance between nodes
760 if k is None:
761 k = np.sqrt(1.0 / nnodes)
762
763 if method == "energy":
764 return _energy_fruchterman_reingold(
765 A, nnodes, k, pos, fixed, iterations, threshold, dim, gravity
766 )
767
768 # make sure we have a LIst of Lists representation
769 try:
770 A = A.tolil()
771 except AttributeError:
772 A = (sp.sparse.coo_array(A)).tolil()
773
774 # the initial "temperature" is about .1 of domain area (=1x1)
775 # this is the largest step allowed in the dynamics.
776 t = max(max(pos.T[0]) - min(pos.T[0]), max(pos.T[1]) - min(pos.T[1])) * 0.1
777 # simple cooling scheme.
778 # linearly step down by dt on each iteration so last iteration is size dt.
779 dt = t / (iterations + 1)
780
781 displacement = np.zeros((dim, nnodes))
782 for iteration in range(iterations):
783 displacement *= 0
784 # loop over rows
785 for i in range(A.shape[0]):
786 if i in fixed:
787 continue
788 # difference between this row's node position and all others
789 delta = (pos[i] - pos).T
790 # distance between points
791 distance = np.sqrt((delta**2).sum(axis=0))
792 # enforce minimum distance of 0.01
793 distance = np.clip(distance, a_min=0.01, a_max=None)
794 # the adjacency matrix row
795 Ai = A.getrowview(i).toarray() # TODO: revisit w/ sparse 1D container
796 # displacement "force"
797 displacement[:, i] += (
798 delta * (k * k / distance**2 - Ai * distance / k)
799 ).sum(axis=1)
800 # update positions
801 length = np.sqrt((displacement**2).sum(axis=0))
802 # Threshold the minimum length prior to position scaling
803 # See gh-8113 for detailed discussion of the threshold
804 length = np.clip(length, a_min=0.01, a_max=None)
805 delta_pos = (displacement * t / length).T
806 pos += delta_pos
807 # cool temperature
808 t -= dt
809 if (np.linalg.norm(delta_pos) / nnodes) < threshold:
810 break
811 return pos
812
813
814def _energy_fruchterman_reingold(
815 A, nnodes, k, pos, fixed, iterations, threshold, dim, gravity
816):
817 # Entry point for NetworkX graph is fruchterman_reingold_layout()
818 # energy-based version
819 import numpy as np
820 import scipy as sp
821
822 if gravity <= 0:
823 raise ValueError(f"the gravity must be positive.")
824
825 # make sure we have a Compressed Sparse Row format
826 try:
827 A = A.tocsr()
828 except AttributeError:
829 A = sp.sparse.csr_array(A)
830
831 # Take absolute values of edge weights and symmetrize it
832 A = np.abs(A)
833 A = (A + A.T) / 2
834
835 n_components, labels = sp.sparse.csgraph.connected_components(A, directed=False)
836 bincount = np.bincount(labels)
837 batchsize = 500
838
839 def _cost_FR(x):
840 pos = x.reshape((nnodes, dim))
841 grad = np.zeros((nnodes, dim))
842 cost = 0.0
843 for l in range(0, nnodes, batchsize):
844 r = min(l + batchsize, nnodes)
845 # difference between selected node positions and all others
846 delta = pos[l:r, np.newaxis, :] - pos[np.newaxis, :, :]
847 # distance between points with a minimum distance of 1e-5
848 distance2 = np.sum(delta * delta, axis=2)
849 distance2 = np.maximum(distance2, 1e-10)
850 distance = np.sqrt(distance2)
851 # temporary variable for calculation
852 Ad = A[l:r] * distance
853 # attractive forces and repulsive forces
854 grad[l:r] = 2 * np.einsum("ij,ijk->ik", Ad / k - k**2 / distance2, delta)
855 # integrated attractive forces
856 cost += np.sum(Ad * distance2) / (3 * k)
857 # integrated repulsive forces
858 cost -= k**2 * np.sum(np.log(distance))
859 # gravitational force from the centroids of connected components to (0.5, ..., 0.5)^T
860 centers = np.zeros((n_components, dim))
861 np.add.at(centers, labels, pos)
862 delta0 = centers / bincount[:, np.newaxis] - 0.5
863 grad += gravity * delta0[labels]
864 cost += gravity * 0.5 * np.sum(bincount * np.linalg.norm(delta0, axis=1) ** 2)
865 # fix positions of fixed nodes
866 grad[fixed] = 0.0
867 return cost, grad.ravel()
868
869 # Optimization of the energy function by L-BFGS algorithm
870 options = {"maxiter": iterations, "gtol": threshold}
871 return sp.optimize.minimize(
872 _cost_FR, pos.ravel(), method="L-BFGS-B", jac=True, options=options
873 ).x.reshape((nnodes, dim))
874
875
876def kamada_kawai_layout(
877 G,
878 dist=None,
879 pos=None,
880 weight="weight",
881 scale=1,
882 center=None,
883 dim=2,
884 store_pos_as=None,
885):
886 """Position nodes using Kamada-Kawai path-length cost-function.
887
888 Parameters
889 ----------
890 G : NetworkX graph or list of nodes
891 A position will be assigned to every node in G.
892
893 dist : dict (default=None)
894 A two-level dictionary of optimal distances between nodes,
895 indexed by source and destination node.
896 If None, the distance is computed using shortest_path_length().
897
898 pos : dict or None optional (default=None)
899 Initial positions for nodes as a dictionary with node as keys
900 and values as a coordinate list or tuple. If None, then use
901 circular_layout() for dim >= 2 and a linear layout for dim == 1.
902
903 weight : string or None optional (default='weight')
904 The edge attribute that holds the numerical value used for
905 the edge weight. If None, then all edge weights are 1.
906
907 scale : number (default: 1)
908 Scale factor for positions.
909
910 center : array-like or None
911 Coordinate pair around which to center the layout.
912
913 dim : int
914 Dimension of layout.
915
916 store_pos_as : str, default None
917 If non-None, the position of each node will be stored on the graph as
918 an attribute with this string as its name, which can be accessed with
919 ``G.nodes[...][store_pos_as]``. The function still returns the dictionary.
920
921 Returns
922 -------
923 pos : dict
924 A dictionary of positions keyed by node
925
926 Examples
927 --------
928 >>> from pprint import pprint
929 >>> G = nx.path_graph(4)
930 >>> pos = nx.kamada_kawai_layout(G)
931 >>> # suppress the returned dict and store on the graph directly
932 >>> _ = nx.kamada_kawai_layout(G, store_pos_as="pos")
933 >>> pprint(nx.get_node_attributes(G, "pos"))
934 {0: array([0.99996577, 0.99366857]),
935 1: array([0.32913544, 0.33543827]),
936 2: array([-0.33544334, -0.32910684]),
937 3: array([-0.99365787, -1. ])}
938 """
939 import numpy as np
940
941 G, center = _process_params(G, center, dim)
942 nNodes = len(G)
943 if nNodes == 0:
944 return {}
945
946 if dist is None:
947 dist = dict(nx.shortest_path_length(G, weight=weight))
948 dist_mtx = 1e6 * np.ones((nNodes, nNodes))
949 for row, nr in enumerate(G):
950 if nr not in dist:
951 continue
952 rdist = dist[nr]
953 for col, nc in enumerate(G):
954 if nc not in rdist:
955 continue
956 dist_mtx[row][col] = rdist[nc]
957
958 if pos is None:
959 if dim >= 3:
960 pos = random_layout(G, dim=dim)
961 elif dim == 2:
962 pos = circular_layout(G, dim=dim)
963 else:
964 pos = dict(zip(G, np.linspace(0, 1, len(G))))
965 pos_arr = np.array([pos[n] for n in G])
966
967 pos = _kamada_kawai_solve(dist_mtx, pos_arr, dim)
968
969 pos = rescale_layout(pos, scale=scale) + center
970 pos = dict(zip(G, pos))
971
972 if store_pos_as is not None:
973 nx.set_node_attributes(G, pos, store_pos_as)
974
975 return pos
976
977
978def _kamada_kawai_solve(dist_mtx, pos_arr, dim):
979 # Anneal node locations based on the Kamada-Kawai cost-function,
980 # using the supplied matrix of preferred inter-node distances,
981 # and starting locations.
982
983 import numpy as np
984 import scipy as sp
985
986 meanwt = 1e-3
987 costargs = (np, 1 / (dist_mtx + np.eye(dist_mtx.shape[0]) * 1e-3), meanwt, dim)
988
989 optresult = sp.optimize.minimize(
990 _kamada_kawai_costfn,
991 pos_arr.ravel(),
992 method="L-BFGS-B",
993 args=costargs,
994 jac=True,
995 )
996
997 return optresult.x.reshape((-1, dim))
998
999
1000def _kamada_kawai_costfn(pos_vec, np, invdist, meanweight, dim):
1001 # Cost-function and gradient for Kamada-Kawai layout algorithm
1002 nNodes = invdist.shape[0]
1003 pos_arr = pos_vec.reshape((nNodes, dim))
1004
1005 delta = pos_arr[:, np.newaxis, :] - pos_arr[np.newaxis, :, :]
1006 nodesep = np.linalg.norm(delta, axis=-1)
1007 direction = np.einsum("ijk,ij->ijk", delta, 1 / (nodesep + np.eye(nNodes) * 1e-3))
1008
1009 offset = nodesep * invdist - 1.0
1010 offset[np.diag_indices(nNodes)] = 0
1011
1012 cost = 0.5 * np.sum(offset**2)
1013 grad = np.einsum("ij,ij,ijk->ik", invdist, offset, direction) - np.einsum(
1014 "ij,ij,ijk->jk", invdist, offset, direction
1015 )
1016
1017 # Additional parabolic term to encourage mean position to be near origin:
1018 sumpos = np.sum(pos_arr, axis=0)
1019 cost += 0.5 * meanweight * np.sum(sumpos**2)
1020 grad += meanweight * sumpos
1021
1022 return (cost, grad.ravel())
1023
1024
1025def spectral_layout(G, weight="weight", scale=1, center=None, dim=2, store_pos_as=None):
1026 """Position nodes using the eigenvectors of the graph Laplacian.
1027
1028 Using the unnormalized Laplacian, the layout shows possible clusters of
1029 nodes which are an approximation of the ratio cut. If dim is the number of
1030 dimensions then the positions are the entries of the dim eigenvectors
1031 corresponding to the ascending eigenvalues starting from the second one.
1032
1033 Parameters
1034 ----------
1035 G : NetworkX graph or list of nodes
1036 A position will be assigned to every node in G.
1037
1038 weight : string or None optional (default='weight')
1039 The edge attribute that holds the numerical value used for
1040 the edge weight. If None, then all edge weights are 1.
1041
1042 scale : number (default: 1)
1043 Scale factor for positions.
1044
1045 center : array-like or None
1046 Coordinate pair around which to center the layout.
1047
1048 dim : int
1049 Dimension of layout.
1050
1051 store_pos_as : str, default None
1052 If non-None, the position of each node will be stored on the graph as
1053 an attribute with this string as its name, which can be accessed with
1054 ``G.nodes[...][store_pos_as]``. The function still returns the dictionary.
1055
1056 Returns
1057 -------
1058 pos : dict
1059 A dictionary of positions keyed by node
1060
1061 Examples
1062 --------
1063 >>> from pprint import pprint
1064 >>> G = nx.path_graph(4)
1065 >>> pos = nx.spectral_layout(G)
1066 >>> # suppress the returned dict and store on the graph directly
1067 >>> _ = nx.spectral_layout(G, store_pos_as="pos")
1068 >>> pprint(nx.get_node_attributes(G, "pos"))
1069 {0: array([-1. , 0.76536686]),
1070 1: array([-0.41421356, -0.76536686]),
1071 2: array([ 0.41421356, -0.76536686]),
1072 3: array([1. , 0.76536686])}
1073
1074
1075 Notes
1076 -----
1077 Directed graphs will be considered as undirected graphs when
1078 positioning the nodes.
1079
1080 For larger graphs (>500 nodes) this will use the SciPy sparse
1081 eigenvalue solver (ARPACK).
1082 """
1083 # handle some special cases that break the eigensolvers
1084 import numpy as np
1085
1086 G, center = _process_params(G, center, dim)
1087
1088 if len(G) <= 2:
1089 if len(G) == 0:
1090 pos = np.array([])
1091 elif len(G) == 1:
1092 pos = np.array([center])
1093 else:
1094 pos = np.array([np.zeros(dim), np.array(center) * 2.0])
1095 return dict(zip(G, pos))
1096 try:
1097 # Sparse matrix
1098 if len(G) < 500: # dense solver is faster for small graphs
1099 raise ValueError
1100 A = nx.to_scipy_sparse_array(G, weight=weight, dtype="d")
1101 # Symmetrize directed graphs
1102 if G.is_directed():
1103 A = A + np.transpose(A)
1104 pos = _sparse_spectral(A, dim)
1105 except (ImportError, ValueError):
1106 # Dense matrix
1107 A = nx.to_numpy_array(G, weight=weight)
1108 # Symmetrize directed graphs
1109 if G.is_directed():
1110 A += A.T
1111 pos = _spectral(A, dim)
1112
1113 pos = rescale_layout(pos, scale=scale) + center
1114 pos = dict(zip(G, pos))
1115
1116 if store_pos_as is not None:
1117 nx.set_node_attributes(G, pos, store_pos_as)
1118
1119 return pos
1120
1121
1122def _spectral(A, dim=2):
1123 # Input adjacency matrix A
1124 # Uses dense eigenvalue solver from numpy
1125 import numpy as np
1126
1127 try:
1128 nnodes, _ = A.shape
1129 except AttributeError as err:
1130 msg = "spectral() takes an adjacency matrix as input"
1131 raise nx.NetworkXError(msg) from err
1132
1133 # form Laplacian matrix where D is diagonal of degrees
1134 D = np.identity(nnodes, dtype=A.dtype) * np.sum(A, axis=1)
1135 L = D - A
1136
1137 eigenvalues, eigenvectors = np.linalg.eig(L)
1138 # sort and keep smallest nonzero
1139 index = np.argsort(eigenvalues)[1 : dim + 1] # 0 index is zero eigenvalue
1140 return np.real(eigenvectors[:, index])
1141
1142
1143def _sparse_spectral(A, dim=2):
1144 # Input adjacency matrix A
1145 # Uses sparse eigenvalue solver from scipy
1146 # Could use multilevel methods here, see Koren "On spectral graph drawing"
1147 import numpy as np
1148 import scipy as sp
1149
1150 try:
1151 nnodes, _ = A.shape
1152 except AttributeError as err:
1153 msg = "sparse_spectral() takes an adjacency matrix as input"
1154 raise nx.NetworkXError(msg) from err
1155
1156 # form Laplacian matrix
1157 D = sp.sparse.dia_array((A.sum(axis=1), 0), shape=(nnodes, nnodes)).tocsr()
1158 L = D - A
1159
1160 k = dim + 1
1161 # number of Lanczos vectors for ARPACK solver.What is the right scaling?
1162 ncv = max(2 * k + 1, int(np.sqrt(nnodes)))
1163 # return smallest k eigenvalues and eigenvectors
1164 eigenvalues, eigenvectors = sp.sparse.linalg.eigsh(L, k, which="SM", ncv=ncv)
1165 index = np.argsort(eigenvalues)[1:k] # 0 index is zero eigenvalue
1166 return np.real(eigenvectors[:, index])
1167
1168
1169def planar_layout(G, scale=1, center=None, dim=2, store_pos_as=None):
1170 """Position nodes without edge intersections.
1171
1172 Parameters
1173 ----------
1174 G : NetworkX graph or list of nodes
1175 A position will be assigned to every node in G. If G is of type
1176 nx.PlanarEmbedding, the positions are selected accordingly.
1177
1178 scale : number (default: 1)
1179 Scale factor for positions.
1180
1181 center : array-like or None
1182 Coordinate pair around which to center the layout.
1183
1184 dim : int
1185 Dimension of layout.
1186
1187 store_pos_as : str, default None
1188 If non-None, the position of each node will be stored on the graph as
1189 an attribute with this string as its name, which can be accessed with
1190 ``G.nodes[...][store_pos_as]``. The function still returns the dictionary.
1191
1192 Returns
1193 -------
1194 pos : dict
1195 A dictionary of positions keyed by node
1196
1197 Raises
1198 ------
1199 NetworkXException
1200 If G is not planar
1201
1202 Examples
1203 --------
1204 >>> from pprint import pprint
1205 >>> G = nx.path_graph(4)
1206 >>> pos = nx.planar_layout(G)
1207 >>> # suppress the returned dict and store on the graph directly
1208 >>> _ = nx.planar_layout(G, store_pos_as="pos")
1209 >>> pprint(nx.get_node_attributes(G, "pos"))
1210 {0: array([-0.77777778, -0.33333333]),
1211 1: array([ 1. , -0.33333333]),
1212 2: array([0.11111111, 0.55555556]),
1213 3: array([-0.33333333, 0.11111111])}
1214 """
1215 import numpy as np
1216
1217 if dim != 2:
1218 raise ValueError("can only handle 2 dimensions")
1219
1220 G, center = _process_params(G, center, dim)
1221
1222 if len(G) == 0:
1223 return {}
1224
1225 if isinstance(G, nx.PlanarEmbedding):
1226 embedding = G
1227 else:
1228 is_planar, embedding = nx.check_planarity(G)
1229 if not is_planar:
1230 raise nx.NetworkXException("G is not planar.")
1231 pos = nx.combinatorial_embedding_to_pos(embedding)
1232 node_list = list(embedding)
1233 pos = np.vstack([pos[x] for x in node_list])
1234 pos = pos.astype(np.float64)
1235 pos = rescale_layout(pos, scale=scale) + center
1236 pos = dict(zip(node_list, pos))
1237 if store_pos_as is not None:
1238 nx.set_node_attributes(G, pos, store_pos_as)
1239 return pos
1240
1241
1242def spiral_layout(
1243 G,
1244 scale=1,
1245 center=None,
1246 dim=2,
1247 resolution=0.35,
1248 equidistant=False,
1249 store_pos_as=None,
1250):
1251 """Position nodes in a spiral layout.
1252
1253 Parameters
1254 ----------
1255 G : NetworkX graph or list of nodes
1256 A position will be assigned to every node in G.
1257
1258 scale : number (default: 1)
1259 Scale factor for positions.
1260
1261 center : array-like or None
1262 Coordinate pair around which to center the layout.
1263
1264 dim : int, default=2
1265 Dimension of layout, currently only dim=2 is supported.
1266 Other dimension values result in a ValueError.
1267
1268 resolution : float, default=0.35
1269 The compactness of the spiral layout returned.
1270 Lower values result in more compressed spiral layouts.
1271
1272 equidistant : bool, default=False
1273 If True, nodes will be positioned equidistant from each other
1274 by decreasing angle further from center.
1275 If False, nodes will be positioned at equal angles
1276 from each other by increasing separation further from center.
1277
1278 store_pos_as : str, default None
1279 If non-None, the position of each node will be stored on the graph as
1280 an attribute with this string as its name, which can be accessed with
1281 ``G.nodes[...][store_pos_as]``. The function still returns the dictionary.
1282
1283 Returns
1284 -------
1285 pos : dict
1286 A dictionary of positions keyed by node
1287
1288 Raises
1289 ------
1290 ValueError
1291 If dim != 2
1292
1293 Examples
1294 --------
1295 >>> from pprint import pprint
1296 >>> G = nx.path_graph(4)
1297 >>> pos = nx.spiral_layout(G)
1298 >>> nx.draw(G, pos=pos)
1299 >>> # suppress the returned dict and store on the graph directly
1300 >>> _ = nx.spiral_layout(G, store_pos_as="pos")
1301 >>> pprint(nx.get_node_attributes(G, "pos"))
1302 {0: array([-0.64153279, -0.68555087]),
1303 1: array([-0.03307913, -0.46344795]),
1304 2: array([0.34927952, 0.14899882]),
1305 3: array([0.32533239, 1. ])}
1306
1307 Notes
1308 -----
1309 This algorithm currently only works in two dimensions.
1310
1311 """
1312 import numpy as np
1313
1314 if dim != 2:
1315 raise ValueError("can only handle 2 dimensions")
1316
1317 G, center = _process_params(G, center, dim)
1318
1319 if len(G) == 0:
1320 return {}
1321 if len(G) == 1:
1322 pos = {nx.utils.arbitrary_element(G): center}
1323 if store_pos_as is not None:
1324 nx.set_node_attributes(G, pos, store_pos_as)
1325 return pos
1326
1327 pos = []
1328 if equidistant:
1329 chord = 1
1330 step = 0.5
1331 theta = resolution
1332 theta += chord / (step * theta)
1333 for _ in range(len(G)):
1334 r = step * theta
1335 theta += chord / r
1336 pos.append([np.cos(theta) * r, np.sin(theta) * r])
1337
1338 else:
1339 dist = np.arange(len(G), dtype=float)
1340 angle = resolution * dist
1341 pos = np.transpose(dist * np.array([np.cos(angle), np.sin(angle)]))
1342
1343 pos = rescale_layout(np.array(pos), scale=scale) + center
1344
1345 pos = dict(zip(G, pos))
1346
1347 if store_pos_as is not None:
1348 nx.set_node_attributes(G, pos, store_pos_as)
1349
1350 return pos
1351
1352
1353def multipartite_layout(
1354 G, subset_key="subset", align="vertical", scale=1, center=None, store_pos_as=None
1355):
1356 """Position nodes in layers of straight lines.
1357
1358 Parameters
1359 ----------
1360 G : NetworkX graph or list of nodes
1361 A position will be assigned to every node in G.
1362
1363 subset_key : string or dict (default='subset')
1364 If a string, the key of node data in G that holds the node subset.
1365 If a dict, keyed by layer number to the nodes in that layer/subset.
1366
1367 align : string (default='vertical')
1368 The alignment of nodes. Vertical or horizontal.
1369
1370 scale : number (default: 1)
1371 Scale factor for positions.
1372
1373 center : array-like or None
1374 Coordinate pair around which to center the layout.
1375
1376 store_pos_as : str, default None
1377 If non-None, the position of each node will be stored on the graph as
1378 an attribute with this string as its name, which can be accessed with
1379 ``G.nodes[...][store_pos_as]``. The function still returns the dictionary.
1380
1381 Returns
1382 -------
1383 pos : dict
1384 A dictionary of positions keyed by node.
1385
1386 Examples
1387 --------
1388 >>> G = nx.complete_multipartite_graph(28, 16, 10)
1389 >>> pos = nx.multipartite_layout(G)
1390 >>> # suppress the returned dict and store on the graph directly
1391 >>> G = nx.complete_multipartite_graph(28, 16, 10)
1392 >>> _ = nx.multipartite_layout(G, store_pos_as="pos")
1393
1394 or use a dict to provide the layers of the layout
1395
1396 >>> G = nx.Graph([(0, 1), (1, 2), (1, 3), (3, 4)])
1397 >>> layers = {"a": [0], "b": [1], "c": [2, 3], "d": [4]}
1398 >>> pos = nx.multipartite_layout(G, subset_key=layers)
1399
1400 Notes
1401 -----
1402 This algorithm currently only works in two dimensions and does not
1403 try to minimize edge crossings.
1404
1405 Network does not need to be a complete multipartite graph. As long as nodes
1406 have subset_key data, they will be placed in the corresponding layers.
1407
1408 """
1409 import numpy as np
1410
1411 if align not in ("vertical", "horizontal"):
1412 msg = "align must be either vertical or horizontal."
1413 raise ValueError(msg)
1414
1415 G, center = _process_params(G, center=center, dim=2)
1416 if len(G) == 0:
1417 return {}
1418
1419 try:
1420 # check if subset_key is dict-like
1421 if len(G) != sum(len(nodes) for nodes in subset_key.values()):
1422 raise nx.NetworkXError(
1423 "all nodes must be in one subset of `subset_key` dict"
1424 )
1425 except AttributeError:
1426 # subset_key is not a dict, hence a string
1427 node_to_subset = nx.get_node_attributes(G, subset_key)
1428 if len(node_to_subset) != len(G):
1429 raise nx.NetworkXError(
1430 f"all nodes need a subset_key attribute: {subset_key}"
1431 )
1432 subset_key = nx.utils.groups(node_to_subset)
1433
1434 # Sort by layer, if possible
1435 try:
1436 layers = dict(sorted(subset_key.items()))
1437 except TypeError:
1438 layers = subset_key
1439
1440 pos = None
1441 nodes = []
1442 width = len(layers)
1443 for i, layer in enumerate(layers.values()):
1444 height = len(layer)
1445 xs = np.repeat(i, height)
1446 ys = np.arange(0, height, dtype=float)
1447 offset = ((width - 1) / 2, (height - 1) / 2)
1448 layer_pos = np.column_stack([xs, ys]) - offset
1449 if pos is None:
1450 pos = layer_pos
1451 else:
1452 pos = np.concatenate([pos, layer_pos])
1453 nodes.extend(layer)
1454 pos = rescale_layout(pos, scale=scale) + center
1455 if align == "horizontal":
1456 pos = pos[:, ::-1] # swap x and y coords
1457 pos = dict(zip(nodes, pos))
1458
1459 if store_pos_as is not None:
1460 nx.set_node_attributes(G, pos, store_pos_as)
1461
1462 return pos
1463
1464
1465@np_random_state("seed")
1466def arf_layout(
1467 G,
1468 pos=None,
1469 scaling=1,
1470 a=1.1,
1471 etol=1e-6,
1472 dt=1e-3,
1473 max_iter=1000,
1474 *,
1475 seed=None,
1476 store_pos_as=None,
1477):
1478 """Arf layout for networkx
1479
1480 The attractive and repulsive forces (arf) layout [1] improves the spring
1481 layout in three ways. First, it prevents congestion of highly connected nodes
1482 due to strong forcing between nodes. Second, it utilizes the layout space
1483 more effectively by preventing large gaps that spring layout tends to create.
1484 Lastly, the arf layout represents symmetries in the layout better than the
1485 default spring layout.
1486
1487 Parameters
1488 ----------
1489 G : nx.Graph or nx.DiGraph
1490 Networkx graph.
1491 pos : dict
1492 Initial position of the nodes. If set to None a
1493 random layout will be used.
1494 scaling : float
1495 Scales the radius of the circular layout space.
1496 a : float
1497 Strength of springs between connected nodes. Should be larger than 1.
1498 The greater a, the clearer the separation of unconnected sub clusters.
1499 etol : float
1500 Gradient sum of spring forces must be larger than `etol` before successful
1501 termination.
1502 dt : float
1503 Time step for force differential equation simulations.
1504 max_iter : int
1505 Max iterations before termination of the algorithm.
1506 seed : int, RandomState instance or None optional (default=None)
1507 Set the random state for deterministic node layouts.
1508 If int, `seed` is the seed used by the random number generator,
1509 if numpy.random.RandomState instance, `seed` is the random
1510 number generator,
1511 if None, the random number generator is the RandomState instance used
1512 by numpy.random.
1513 store_pos_as : str, default None
1514 If non-None, the position of each node will be stored on the graph as
1515 an attribute with this string as its name, which can be accessed with
1516 ``G.nodes[...][store_pos_as]``. The function still returns the dictionary.
1517
1518 Returns
1519 -------
1520 pos : dict
1521 A dictionary of positions keyed by node.
1522
1523 Examples
1524 --------
1525 >>> G = nx.grid_graph((5, 5))
1526 >>> pos = nx.arf_layout(G)
1527 >>> # suppress the returned dict and store on the graph directly
1528 >>> G = nx.grid_graph((5, 5))
1529 >>> _ = nx.arf_layout(G, store_pos_as="pos")
1530
1531 References
1532 ----------
1533 .. [1] "Self-Organization Applied to Dynamic Network Layout", M. Geipel,
1534 International Journal of Modern Physics C, 2007, Vol 18, No 10,
1535 pp. 1537-1549.
1536 https://doi.org/10.1142/S0129183107011558 https://arxiv.org/abs/0704.1748
1537 """
1538 import warnings
1539
1540 import numpy as np
1541
1542 if a <= 1:
1543 msg = "The parameter a should be larger than 1"
1544 raise ValueError(msg)
1545
1546 pos_tmp = nx.random_layout(G, seed=seed)
1547 if pos is None:
1548 pos = pos_tmp
1549 else:
1550 for node in G.nodes():
1551 if node not in pos:
1552 pos[node] = pos_tmp[node].copy()
1553
1554 # Initialize spring constant matrix
1555 N = len(G)
1556 # No nodes no computation
1557 if N == 0:
1558 return pos
1559
1560 # init force of springs
1561 K = np.ones((N, N)) - np.eye(N)
1562 node_order = {node: i for i, node in enumerate(G)}
1563 for x, y in G.edges():
1564 if x != y:
1565 idx, jdx = (node_order[i] for i in (x, y))
1566 K[idx, jdx] = a
1567
1568 # vectorize values
1569 p = np.asarray(list(pos.values()))
1570
1571 # equation 10 in [1]
1572 rho = scaling * np.sqrt(N)
1573
1574 # looping variables
1575 error = etol + 1
1576 n_iter = 0
1577 while error > etol:
1578 diff = p[:, np.newaxis] - p[np.newaxis]
1579 A = np.linalg.norm(diff, axis=-1)[..., np.newaxis]
1580 # attraction_force - repulsions force
1581 # suppress nans due to division; caused by diagonal set to zero.
1582 # Does not affect the computation due to nansum
1583 with warnings.catch_warnings():
1584 warnings.simplefilter("ignore")
1585 change = K[..., np.newaxis] * diff - rho / A * diff
1586 change = np.nansum(change, axis=0)
1587 p += change * dt
1588
1589 error = np.linalg.norm(change, axis=-1).sum()
1590 if n_iter > max_iter:
1591 break
1592 n_iter += 1
1593
1594 pos = dict(zip(G.nodes(), p))
1595
1596 if store_pos_as is not None:
1597 nx.set_node_attributes(G, pos, store_pos_as)
1598
1599 return pos
1600
1601
1602@np_random_state("seed")
1603@nx._dispatchable(edge_attrs="weight", mutates_input={"store_pos_as": 15})
1604def forceatlas2_layout(
1605 G,
1606 pos=None,
1607 *,
1608 max_iter=100,
1609 jitter_tolerance=1.0,
1610 scaling_ratio=2.0,
1611 gravity=1.0,
1612 distributed_action=False,
1613 strong_gravity=False,
1614 node_mass=None,
1615 node_size=None,
1616 weight=None,
1617 linlog=False,
1618 seed=None,
1619 dim=2,
1620 store_pos_as=None,
1621):
1622 """Position nodes using the ForceAtlas2 force-directed layout algorithm.
1623
1624 This function applies the ForceAtlas2 layout algorithm [1]_ to a NetworkX graph,
1625 positioning the nodes in a way that visually represents the structure of the graph.
1626 The algorithm uses physical simulation to minimize the energy of the system,
1627 resulting in a more readable layout.
1628
1629 Parameters
1630 ----------
1631 G : nx.Graph
1632 A NetworkX graph to be laid out.
1633 pos : dict or None, optional
1634 Initial positions of the nodes. If None, random initial positions are used.
1635 max_iter : int (default: 100)
1636 Number of iterations for the layout optimization.
1637 jitter_tolerance : float (default: 1.0)
1638 Controls the tolerance for adjusting the speed of layout generation.
1639 scaling_ratio : float (default: 2.0)
1640 Determines the scaling of attraction and repulsion forces.
1641 gravity : float (default: 1.0)
1642 Determines the amount of attraction on nodes to the center. Prevents islands
1643 (i.e. weakly connected or disconnected parts of the graph)
1644 from drifting away.
1645 distributed_action : bool (default: False)
1646 Distributes the attraction force evenly among nodes.
1647 strong_gravity : bool (default: False)
1648 Applies a strong gravitational pull towards the center.
1649 node_mass : dict or None, optional
1650 Maps nodes to their masses, influencing the attraction to other nodes.
1651 node_size : dict or None, optional
1652 Maps nodes to their sizes, preventing crowding by creating a halo effect.
1653 weight : string or None, optional (default: None)
1654 The edge attribute that holds the numerical value used for
1655 the edge weight. If None, then all edge weights are 1.
1656 linlog : bool (default: False)
1657 Uses logarithmic attraction instead of linear.
1658 seed : int, RandomState instance or None optional (default=None)
1659 Used only for the initial positions in the algorithm.
1660 Set the random state for deterministic node layouts.
1661 If int, `seed` is the seed used by the random number generator,
1662 if numpy.random.RandomState instance, `seed` is the random
1663 number generator,
1664 if None, the random number generator is the RandomState instance used
1665 by numpy.random.
1666 dim : int (default: 2)
1667 Sets the dimensions for the layout. Ignored if `pos` is provided.
1668 store_pos_as : str, default None
1669 If non-None, the position of each node will be stored on the graph as
1670 an attribute with this string as its name, which can be accessed with
1671 ``G.nodes[...][store_pos_as]``. The function still returns the dictionary.
1672
1673 Examples
1674 --------
1675 >>> import networkx as nx
1676 >>> G = nx.florentine_families_graph()
1677 >>> pos = nx.forceatlas2_layout(G)
1678 >>> nx.draw(G, pos=pos)
1679 >>> # suppress the returned dict and store on the graph directly
1680 >>> pos = nx.forceatlas2_layout(G, store_pos_as="pos")
1681 >>> _ = nx.forceatlas2_layout(G, store_pos_as="pos")
1682
1683 References
1684 ----------
1685 .. [1] Jacomy, M., Venturini, T., Heymann, S., & Bastian, M. (2014).
1686 ForceAtlas2, a continuous graph layout algorithm for handy network
1687 visualization designed for the Gephi software. PloS one, 9(6), e98679.
1688 https://doi.org/10.1371/journal.pone.0098679
1689 """
1690 import numpy as np
1691
1692 if len(G) == 0:
1693 return {}
1694 # parse optional pos positions
1695 if pos is None:
1696 pos = nx.random_layout(G, dim=dim, seed=seed)
1697 pos_arr = np.array(list(pos.values()))
1698 elif len(pos) == len(G):
1699 pos_arr = np.array([pos[node].copy() for node in G])
1700 else:
1701 # set random node pos within the initial pos values
1702 pos_init = np.array(list(pos.values()))
1703 max_pos = pos_init.max(axis=0)
1704 min_pos = pos_init.min(axis=0)
1705 dim = max_pos.size
1706 pos_arr = min_pos + seed.rand(len(G), dim) * (max_pos - min_pos)
1707 for idx, node in enumerate(G):
1708 if node in pos:
1709 pos_arr[idx] = pos[node].copy()
1710
1711 mass = np.zeros(len(G))
1712 size = np.zeros(len(G))
1713
1714 # Only adjust for size when the users specifies size other than default (1)
1715 adjust_sizes = False
1716 if node_size is None:
1717 node_size = {}
1718 else:
1719 adjust_sizes = True
1720
1721 if node_mass is None:
1722 node_mass = {}
1723
1724 for idx, node in enumerate(G):
1725 mass[idx] = node_mass.get(node, G.degree(node) + 1)
1726 size[idx] = node_size.get(node, 1)
1727
1728 n = len(G)
1729 gravities = np.zeros((n, dim))
1730 attraction = np.zeros((n, dim))
1731 repulsion = np.zeros((n, dim))
1732 A = nx.to_numpy_array(G, weight=weight)
1733
1734 def estimate_factor(n, swing, traction, speed, speed_efficiency, jitter_tolerance):
1735 """Computes the scaling factor for the force in the ForceAtlas2 layout algorithm.
1736
1737 This helper function adjusts the speed and
1738 efficiency of the layout generation based on the
1739 current state of the system, such as the number of
1740 nodes, current swing, and traction forces.
1741
1742 Parameters
1743 ----------
1744 n : int
1745 Number of nodes in the graph.
1746 swing : float
1747 The current swing, representing the oscillation of the nodes.
1748 traction : float
1749 The current traction force, representing the attraction between nodes.
1750 speed : float
1751 The current speed of the layout generation.
1752 speed_efficiency : float
1753 The efficiency of the current speed, influencing how fast the layout converges.
1754 jitter_tolerance : float
1755 The tolerance for jitter, affecting how much speed adjustment is allowed.
1756
1757 Returns
1758 -------
1759 tuple
1760 A tuple containing the updated speed and speed efficiency.
1761
1762 Notes
1763 -----
1764 This function is a part of the ForceAtlas2 layout algorithm and is used to dynamically adjust the
1765 layout parameters to achieve an optimal and stable visualization.
1766
1767 """
1768 import numpy as np
1769
1770 # estimate jitter
1771 opt_jitter = 0.05 * np.sqrt(n)
1772 min_jitter = np.sqrt(opt_jitter)
1773 max_jitter = 10
1774 min_speed_efficiency = 0.05
1775
1776 other = min(max_jitter, opt_jitter * traction / n**2)
1777 jitter = jitter_tolerance * max(min_jitter, other)
1778
1779 if swing / traction > 2.0:
1780 if speed_efficiency > min_speed_efficiency:
1781 speed_efficiency *= 0.5
1782 jitter = max(jitter, jitter_tolerance)
1783 if swing == 0:
1784 target_speed = np.inf
1785 else:
1786 target_speed = jitter * speed_efficiency * traction / swing
1787
1788 if swing > jitter * traction:
1789 if speed_efficiency > min_speed_efficiency:
1790 speed_efficiency *= 0.7
1791 elif speed < 1000:
1792 speed_efficiency *= 1.3
1793
1794 max_rise = 0.5
1795 speed = speed + min(target_speed - speed, max_rise * speed)
1796 return speed, speed_efficiency
1797
1798 speed = 1
1799 speed_efficiency = 1
1800 swing = 1
1801 traction = 1
1802 for _ in range(max_iter):
1803 # compute pairwise difference
1804 diff = pos_arr[:, None] - pos_arr[None]
1805 # compute pairwise distance
1806 distance = np.linalg.norm(diff, axis=-1)
1807
1808 # linear attraction
1809 if linlog:
1810 attraction = -np.log(1 + distance) / distance
1811 np.fill_diagonal(attraction, 0)
1812 attraction = np.einsum("ij, ij -> ij", attraction, A)
1813 attraction = np.einsum("ijk, ij -> ik", diff, attraction)
1814
1815 else:
1816 attraction = -np.einsum("ijk, ij -> ik", diff, A)
1817
1818 if distributed_action:
1819 attraction /= mass[:, None]
1820
1821 # repulsion
1822 tmp = mass[:, None] @ mass[None]
1823 if adjust_sizes:
1824 distance += -size[:, None] - size[None]
1825
1826 d2 = distance**2
1827 # remove self-interaction
1828 np.fill_diagonal(tmp, 0)
1829 np.fill_diagonal(d2, 1)
1830 factor = (tmp / d2) * scaling_ratio
1831 repulsion = np.einsum("ijk, ij -> ik", diff, factor)
1832
1833 # gravity
1834 pos_centered = pos_arr - np.mean(pos_arr, axis=0)
1835 if strong_gravity:
1836 gravities = -gravity * mass[:, None] * pos_centered
1837 else:
1838 # hide warnings for divide by zero. Then change nan to 0
1839 with np.errstate(divide="ignore", invalid="ignore"):
1840 unit_vec = pos_centered / np.linalg.norm(pos_centered, axis=-1)[:, None]
1841 unit_vec = np.nan_to_num(unit_vec, nan=0)
1842 gravities = -gravity * mass[:, None] * unit_vec
1843
1844 # total forces
1845 update = attraction + repulsion + gravities
1846
1847 # compute total swing and traction
1848 swing += (mass * np.linalg.norm(pos_arr - update, axis=-1)).sum()
1849 traction += (0.5 * mass * np.linalg.norm(pos_arr + update, axis=-1)).sum()
1850
1851 speed, speed_efficiency = estimate_factor(
1852 n,
1853 swing,
1854 traction,
1855 speed,
1856 speed_efficiency,
1857 jitter_tolerance,
1858 )
1859
1860 # update pos
1861 if adjust_sizes:
1862 df = np.linalg.norm(update, axis=-1)
1863 swinging = mass * df
1864 factor = 0.1 * speed / (1 + np.sqrt(speed * swinging))
1865 factor = np.minimum(factor * df, 10.0 * np.ones(df.shape)) / df
1866 else:
1867 swinging = mass * np.linalg.norm(update, axis=-1)
1868 factor = speed / (1 + np.sqrt(speed * swinging))
1869
1870 factored_update = update * factor[:, None]
1871 pos_arr += factored_update
1872 if abs(factored_update).sum() < 1e-10:
1873 break
1874
1875 pos = dict(zip(G, pos_arr))
1876 if store_pos_as is not None:
1877 nx.set_node_attributes(G, pos, store_pos_as)
1878
1879 return pos
1880
1881
1882def rescale_layout(pos, scale=1):
1883 """Returns scaled position array to (-scale, scale) in all axes.
1884
1885 The function acts on NumPy arrays which hold position information.
1886 Each position is one row of the array. The dimension of the space
1887 equals the number of columns. Each coordinate in one column.
1888
1889 To rescale, the mean (center) is subtracted from each axis separately.
1890 Then all values are scaled so that the largest magnitude value
1891 from all axes equals `scale` (thus, the aspect ratio is preserved).
1892 The resulting NumPy Array is returned (order of rows unchanged).
1893
1894 Parameters
1895 ----------
1896 pos : numpy array
1897 positions to be scaled. Each row is a position.
1898
1899 scale : number (default: 1)
1900 The size of the resulting extent in all directions.
1901
1902 attribute : str, default None
1903 If non-None, the position of each node will be stored on the graph as
1904 an attribute named `attribute` which can be accessed with
1905 `G.nodes[...][attribute]`. The function still returns the dictionary.
1906
1907 Returns
1908 -------
1909 pos : numpy array
1910 scaled positions. Each row is a position.
1911
1912 See Also
1913 --------
1914 rescale_layout_dict
1915 """
1916 import numpy as np
1917
1918 # Find max length over all dimensions
1919 pos -= pos.mean(axis=0)
1920 lim = np.abs(pos).max() # max coordinate for all axes
1921 # rescale to (-scale, scale) in all directions, preserves aspect
1922 if lim > 0:
1923 pos *= scale / lim
1924 return pos
1925
1926
1927def rescale_layout_dict(pos, scale=1):
1928 """Return a dictionary of scaled positions keyed by node
1929
1930 Parameters
1931 ----------
1932 pos : A dictionary of positions keyed by node
1933
1934 scale : number (default: 1)
1935 The size of the resulting extent in all directions.
1936
1937 Returns
1938 -------
1939 pos : A dictionary of positions keyed by node
1940
1941 Examples
1942 --------
1943 >>> import numpy as np
1944 >>> pos = {0: np.array((0, 0)), 1: np.array((1, 1)), 2: np.array((0.5, 0.5))}
1945 >>> nx.rescale_layout_dict(pos)
1946 {0: array([-1., -1.]), 1: array([1., 1.]), 2: array([0., 0.])}
1947
1948 >>> pos = {0: np.array((0, 0)), 1: np.array((-1, 1)), 2: np.array((-0.5, 0.5))}
1949 >>> nx.rescale_layout_dict(pos, scale=2)
1950 {0: array([ 2., -2.]), 1: array([-2., 2.]), 2: array([0., 0.])}
1951
1952 See Also
1953 --------
1954 rescale_layout
1955 """
1956 import numpy as np
1957
1958 if not pos: # empty_graph
1959 return {}
1960 pos_v = np.array(list(pos.values()))
1961 pos_v = rescale_layout(pos_v, scale=scale)
1962 return dict(zip(pos, pos_v))
1963
1964
1965def bfs_layout(G, start, *, align="vertical", scale=1, center=None, store_pos_as=None):
1966 """Position nodes according to breadth-first search algorithm.
1967
1968 Parameters
1969 ----------
1970 G : NetworkX graph
1971 A position will be assigned to every node in G.
1972
1973 start : node in `G`
1974 Starting node for bfs
1975
1976 align : string (default='vertical')
1977 The alignment of nodes within a layer, either `"vertical"` or
1978 `"horizontal"`.
1979
1980 scale : number (default: 1)
1981 Scale factor for positions.
1982
1983 center : array-like or None
1984 Coordinate pair around which to center the layout.
1985
1986 store_pos_as : str, default None
1987 If non-None, the position of each node will be stored on the graph as
1988 an attribute with this string as its name, which can be accessed with
1989 ``G.nodes[...][store_pos_as]``. The function still returns the dictionary.
1990
1991 Returns
1992 -------
1993 pos : dict
1994 A dictionary of positions keyed by node.
1995
1996 Examples
1997 --------
1998 >>> from pprint import pprint
1999 >>> G = nx.path_graph(4)
2000 >>> pos = nx.bfs_layout(G, 0)
2001 >>> # suppress the returned dict and store on the graph directly
2002 >>> _ = nx.bfs_layout(G, 0, store_pos_as="pos")
2003 >>> pprint(nx.get_node_attributes(G, "pos"))
2004 {0: array([-1., 0.]),
2005 1: array([-0.33333333, 0. ]),
2006 2: array([0.33333333, 0. ]),
2007 3: array([1., 0.])}
2008
2009
2010
2011 Notes
2012 -----
2013 This algorithm currently only works in two dimensions and does not
2014 try to minimize edge crossings.
2015
2016 """
2017 G, center = _process_params(G, center, 2)
2018
2019 # Compute layers with BFS
2020 layers = dict(enumerate(nx.bfs_layers(G, start)))
2021
2022 if len(G) != sum(len(nodes) for nodes in layers.values()):
2023 raise nx.NetworkXError(
2024 "bfs_layout didn't include all nodes. Perhaps use input graph:\n"
2025 " G.subgraph(nx.node_connected_component(G, start))"
2026 )
2027
2028 # Compute node positions with multipartite_layout
2029 pos = multipartite_layout(
2030 G, subset_key=layers, align=align, scale=scale, center=center
2031 )
2032
2033 if store_pos_as is not None:
2034 nx.set_node_attributes(G, pos, store_pos_as)
2035
2036 return pos