diff options
author | S. Solomon Darnell | 2025-03-28 21:52:21 -0500 |
---|---|---|
committer | S. Solomon Darnell | 2025-03-28 21:52:21 -0500 |
commit | 4a52a71956a8d46fcb7294ac71734504bb09bcc2 (patch) | |
tree | ee3dc5af3b6313e921cd920906356f5d4febc4ed /.venv/lib/python3.12/site-packages/networkx/drawing | |
parent | cc961e04ba734dd72309fb548a2f97d67d578813 (diff) | |
download | gn-ai-master.tar.gz |
Diffstat (limited to '.venv/lib/python3.12/site-packages/networkx/drawing')
13 files changed, 7250 insertions, 0 deletions
diff --git a/.venv/lib/python3.12/site-packages/networkx/drawing/__init__.py b/.venv/lib/python3.12/site-packages/networkx/drawing/__init__.py new file mode 100644 index 00000000..0f53309d --- /dev/null +++ b/.venv/lib/python3.12/site-packages/networkx/drawing/__init__.py @@ -0,0 +1,7 @@ +# graph drawing and interface to graphviz + +from .layout import * +from .nx_latex import * +from .nx_pylab import * +from . import nx_agraph +from . import nx_pydot diff --git a/.venv/lib/python3.12/site-packages/networkx/drawing/layout.py b/.venv/lib/python3.12/site-packages/networkx/drawing/layout.py new file mode 100644 index 00000000..20d34a18 --- /dev/null +++ b/.venv/lib/python3.12/site-packages/networkx/drawing/layout.py @@ -0,0 +1,1630 @@ +""" +****** +Layout +****** + +Node positioning algorithms for graph drawing. + +For `random_layout()` the possible resulting shape +is a square of side [0, scale] (default: [0, 1]) +Changing `center` shifts the layout by that amount. + +For the other layout routines, the extent is +[center - scale, center + scale] (default: [-1, 1]). + +Warning: Most layout routines have only been tested in 2-dimensions. + +""" + +import networkx as nx +from networkx.utils import np_random_state + +__all__ = [ + "bipartite_layout", + "circular_layout", + "forceatlas2_layout", + "kamada_kawai_layout", + "random_layout", + "rescale_layout", + "rescale_layout_dict", + "shell_layout", + "spring_layout", + "spectral_layout", + "planar_layout", + "fruchterman_reingold_layout", + "spiral_layout", + "multipartite_layout", + "bfs_layout", + "arf_layout", +] + + +def _process_params(G, center, dim): + # Some boilerplate code. + import numpy as np + + if not isinstance(G, nx.Graph): + empty_graph = nx.Graph() + empty_graph.add_nodes_from(G) + G = empty_graph + + if center is None: + center = np.zeros(dim) + else: + center = np.asarray(center) + + if len(center) != dim: + msg = "length of center coordinates must match dimension of layout" + raise ValueError(msg) + + return G, center + + +@np_random_state(3) +def random_layout(G, center=None, dim=2, seed=None): + """Position nodes uniformly at random in the unit square. + + For every node, a position is generated by choosing each of dim + coordinates uniformly at random on the interval [0.0, 1.0). + + NumPy (http://scipy.org) is required for this function. + + Parameters + ---------- + G : NetworkX graph or list of nodes + A position will be assigned to every node in G. + + center : array-like or None + Coordinate pair around which to center the layout. + + dim : int + Dimension of layout. + + seed : int, RandomState instance or None optional (default=None) + Set the random state for deterministic node layouts. + If int, `seed` is the seed used by the random number generator, + if numpy.random.RandomState instance, `seed` is the random + number generator, + if None, the random number generator is the RandomState instance used + by numpy.random. + + Returns + ------- + pos : dict + A dictionary of positions keyed by node + + Examples + -------- + >>> G = nx.lollipop_graph(4, 3) + >>> pos = nx.random_layout(G) + + """ + import numpy as np + + G, center = _process_params(G, center, dim) + pos = seed.rand(len(G), dim) + center + pos = pos.astype(np.float32) + pos = dict(zip(G, pos)) + + return pos + + +def circular_layout(G, scale=1, center=None, dim=2): + # dim=2 only + """Position nodes on a circle. + + Parameters + ---------- + G : NetworkX graph or list of nodes + A position will be assigned to every node in G. + + scale : number (default: 1) + Scale factor for positions. + + center : array-like or None + Coordinate pair around which to center the layout. + + dim : int + Dimension of layout. + If dim>2, the remaining dimensions are set to zero + in the returned positions. + If dim<2, a ValueError is raised. + + Returns + ------- + pos : dict + A dictionary of positions keyed by node + + Raises + ------ + ValueError + If dim < 2 + + Examples + -------- + >>> G = nx.path_graph(4) + >>> pos = nx.circular_layout(G) + + Notes + ----- + This algorithm currently only works in two dimensions and does not + try to minimize edge crossings. + + """ + import numpy as np + + if dim < 2: + raise ValueError("cannot handle dimensions < 2") + + G, center = _process_params(G, center, dim) + + paddims = max(0, (dim - 2)) + + if len(G) == 0: + pos = {} + elif len(G) == 1: + pos = {nx.utils.arbitrary_element(G): center} + else: + # Discard the extra angle since it matches 0 radians. + theta = np.linspace(0, 1, len(G) + 1)[:-1] * 2 * np.pi + theta = theta.astype(np.float32) + pos = np.column_stack( + [np.cos(theta), np.sin(theta), np.zeros((len(G), paddims))] + ) + pos = rescale_layout(pos, scale=scale) + center + pos = dict(zip(G, pos)) + + return pos + + +def shell_layout(G, nlist=None, rotate=None, scale=1, center=None, dim=2): + """Position nodes in concentric circles. + + Parameters + ---------- + G : NetworkX graph or list of nodes + A position will be assigned to every node in G. + + nlist : list of lists + List of node lists for each shell. + + rotate : angle in radians (default=pi/len(nlist)) + Angle by which to rotate the starting position of each shell + relative to the starting position of the previous shell. + To recreate behavior before v2.5 use rotate=0. + + scale : number (default: 1) + Scale factor for positions. + + center : array-like or None + Coordinate pair around which to center the layout. + + dim : int + Dimension of layout, currently only dim=2 is supported. + Other dimension values result in a ValueError. + + Returns + ------- + pos : dict + A dictionary of positions keyed by node + + Raises + ------ + ValueError + If dim != 2 + + Examples + -------- + >>> G = nx.path_graph(4) + >>> shells = [[0], [1, 2, 3]] + >>> pos = nx.shell_layout(G, shells) + + Notes + ----- + This algorithm currently only works in two dimensions and does not + try to minimize edge crossings. + + """ + import numpy as np + + if dim != 2: + raise ValueError("can only handle 2 dimensions") + + G, center = _process_params(G, center, dim) + + if len(G) == 0: + return {} + if len(G) == 1: + return {nx.utils.arbitrary_element(G): center} + + if nlist is None: + # draw the whole graph in one shell + nlist = [list(G)] + + radius_bump = scale / len(nlist) + + if len(nlist[0]) == 1: + # single node at center + radius = 0.0 + else: + # else start at r=1 + radius = radius_bump + + if rotate is None: + rotate = np.pi / len(nlist) + first_theta = rotate + npos = {} + for nodes in nlist: + # Discard the last angle (endpoint=False) since 2*pi matches 0 radians + theta = ( + np.linspace(0, 2 * np.pi, len(nodes), endpoint=False, dtype=np.float32) + + first_theta + ) + pos = radius * np.column_stack([np.cos(theta), np.sin(theta)]) + center + npos.update(zip(nodes, pos)) + radius += radius_bump + first_theta += rotate + + return npos + + +def bipartite_layout( + G, nodes, align="vertical", scale=1, center=None, aspect_ratio=4 / 3 +): + """Position nodes in two straight lines. + + Parameters + ---------- + G : NetworkX graph or list of nodes + A position will be assigned to every node in G. + + nodes : list or container + Nodes in one node set of the bipartite graph. + This set will be placed on left or top. + + align : string (default='vertical') + The alignment of nodes. Vertical or horizontal. + + scale : number (default: 1) + Scale factor for positions. + + center : array-like or None + Coordinate pair around which to center the layout. + + aspect_ratio : number (default=4/3): + The ratio of the width to the height of the layout. + + Returns + ------- + pos : dict + A dictionary of positions keyed by node. + + Examples + -------- + >>> G = nx.bipartite.gnmk_random_graph(3, 5, 10, seed=123) + >>> top = nx.bipartite.sets(G)[0] + >>> pos = nx.bipartite_layout(G, top) + + Notes + ----- + This algorithm currently only works in two dimensions and does not + try to minimize edge crossings. + + """ + + import numpy as np + + if align not in ("vertical", "horizontal"): + msg = "align must be either vertical or horizontal." + raise ValueError(msg) + + G, center = _process_params(G, center=center, dim=2) + if len(G) == 0: + return {} + + height = 1 + width = aspect_ratio * height + offset = (width / 2, height / 2) + + top = dict.fromkeys(nodes) + bottom = [v for v in G if v not in top] + nodes = list(top) + bottom + + left_xs = np.repeat(0, len(top)) + right_xs = np.repeat(width, len(bottom)) + left_ys = np.linspace(0, height, len(top)) + right_ys = np.linspace(0, height, len(bottom)) + + top_pos = np.column_stack([left_xs, left_ys]) - offset + bottom_pos = np.column_stack([right_xs, right_ys]) - offset + + pos = np.concatenate([top_pos, bottom_pos]) + pos = rescale_layout(pos, scale=scale) + center + if align == "horizontal": + pos = pos[:, ::-1] # swap x and y coords + pos = dict(zip(nodes, pos)) + return pos + + +@np_random_state(10) +def spring_layout( + G, + k=None, + pos=None, + fixed=None, + iterations=50, + threshold=1e-4, + weight="weight", + scale=1, + center=None, + dim=2, + seed=None, +): + """Position nodes using Fruchterman-Reingold force-directed algorithm. + + The algorithm simulates a force-directed representation of the network + treating edges as springs holding nodes close, while treating nodes + as repelling objects, sometimes called an anti-gravity force. + Simulation continues until the positions are close to an equilibrium. + + There are some hard-coded values: minimal distance between + nodes (0.01) and "temperature" of 0.1 to ensure nodes don't fly away. + During the simulation, `k` helps determine the distance between nodes, + though `scale` and `center` determine the size and place after + rescaling occurs at the end of the simulation. + + Fixing some nodes doesn't allow them to move in the simulation. + It also turns off the rescaling feature at the simulation's end. + In addition, setting `scale` to `None` turns off rescaling. + + Parameters + ---------- + G : NetworkX graph or list of nodes + A position will be assigned to every node in G. + + k : float (default=None) + Optimal distance between nodes. If None the distance is set to + 1/sqrt(n) where n is the number of nodes. Increase this value + to move nodes farther apart. + + pos : dict or None optional (default=None) + Initial positions for nodes as a dictionary with node as keys + and values as a coordinate list or tuple. If None, then use + random initial positions. + + fixed : list or None optional (default=None) + Nodes to keep fixed at initial position. + Nodes not in ``G.nodes`` are ignored. + ValueError raised if `fixed` specified and `pos` not. + + iterations : int optional (default=50) + Maximum number of iterations taken + + threshold: float optional (default = 1e-4) + Threshold for relative error in node position changes. + The iteration stops if the error is below this threshold. + + weight : string or None optional (default='weight') + The edge attribute that holds the numerical value used for + the edge weight. Larger means a stronger attractive force. + If None, then all edge weights are 1. + + scale : number or None (default: 1) + Scale factor for positions. Not used unless `fixed is None`. + If scale is None, no rescaling is performed. + + center : array-like or None + Coordinate pair around which to center the layout. + Not used unless `fixed is None`. + + dim : int + Dimension of layout. + + seed : int, RandomState instance or None optional (default=None) + Used only for the initial positions in the algorithm. + Set the random state for deterministic node layouts. + If int, `seed` is the seed used by the random number generator, + if numpy.random.RandomState instance, `seed` is the random + number generator, + if None, the random number generator is the RandomState instance used + by numpy.random. + + Returns + ------- + pos : dict + A dictionary of positions keyed by node + + Examples + -------- + >>> G = nx.path_graph(4) + >>> pos = nx.spring_layout(G) + + # The same using longer but equivalent function name + >>> pos = nx.fruchterman_reingold_layout(G) + """ + import numpy as np + + G, center = _process_params(G, center, dim) + + if fixed is not None: + if pos is None: + raise ValueError("nodes are fixed without positions given") + for node in fixed: + if node not in pos: + raise ValueError("nodes are fixed without positions given") + nfixed = {node: i for i, node in enumerate(G)} + fixed = np.asarray([nfixed[node] for node in fixed if node in nfixed]) + + if pos is not None: + # Determine size of existing domain to adjust initial positions + dom_size = max(coord for pos_tup in pos.values() for coord in pos_tup) + if dom_size == 0: + dom_size = 1 + pos_arr = seed.rand(len(G), dim) * dom_size + center + + for i, n in enumerate(G): + if n in pos: + pos_arr[i] = np.asarray(pos[n]) + else: + pos_arr = None + dom_size = 1 + + if len(G) == 0: + return {} + if len(G) == 1: + return {nx.utils.arbitrary_element(G.nodes()): center} + + try: + # Sparse matrix + if len(G) < 500: # sparse solver for large graphs + raise ValueError + A = nx.to_scipy_sparse_array(G, weight=weight, dtype="f") + if k is None and fixed is not None: + # We must adjust k by domain size for layouts not near 1x1 + nnodes, _ = A.shape + k = dom_size / np.sqrt(nnodes) + pos = _sparse_fruchterman_reingold( + A, k, pos_arr, fixed, iterations, threshold, dim, seed + ) + except ValueError: + A = nx.to_numpy_array(G, weight=weight) + if k is None and fixed is not None: + # We must adjust k by domain size for layouts not near 1x1 + nnodes, _ = A.shape + k = dom_size / np.sqrt(nnodes) + pos = _fruchterman_reingold( + A, k, pos_arr, fixed, iterations, threshold, dim, seed + ) + if fixed is None and scale is not None: + pos = rescale_layout(pos, scale=scale) + center + pos = dict(zip(G, pos)) + return pos + + +fruchterman_reingold_layout = spring_layout + + +@np_random_state(7) +def _fruchterman_reingold( + A, k=None, pos=None, fixed=None, iterations=50, threshold=1e-4, dim=2, seed=None +): + # Position nodes in adjacency matrix A using Fruchterman-Reingold + # Entry point for NetworkX graph is fruchterman_reingold_layout() + import numpy as np + + try: + nnodes, _ = A.shape + except AttributeError as err: + msg = "fruchterman_reingold() takes an adjacency matrix as input" + raise nx.NetworkXError(msg) from err + + if pos is None: + # random initial positions + pos = np.asarray(seed.rand(nnodes, dim), dtype=A.dtype) + else: + # make sure positions are of same type as matrix + pos = pos.astype(A.dtype) + + # optimal distance between nodes + if k is None: + k = np.sqrt(1.0 / nnodes) + # the initial "temperature" is about .1 of domain area (=1x1) + # this is the largest step allowed in the dynamics. + # We need to calculate this in case our fixed positions force our domain + # to be much bigger than 1x1 + t = max(max(pos.T[0]) - min(pos.T[0]), max(pos.T[1]) - min(pos.T[1])) * 0.1 + # simple cooling scheme. + # linearly step down by dt on each iteration so last iteration is size dt. + dt = t / (iterations + 1) + delta = np.zeros((pos.shape[0], pos.shape[0], pos.shape[1]), dtype=A.dtype) + # the inscrutable (but fast) version + # this is still O(V^2) + # could use multilevel methods to speed this up significantly + for iteration in range(iterations): + # matrix of difference between points + delta = pos[:, np.newaxis, :] - pos[np.newaxis, :, :] + # distance between points + distance = np.linalg.norm(delta, axis=-1) + # enforce minimum distance of 0.01 + np.clip(distance, 0.01, None, out=distance) + # displacement "force" + displacement = np.einsum( + "ijk,ij->ik", delta, (k * k / distance**2 - A * distance / k) + ) + # update positions + length = np.linalg.norm(displacement, axis=-1) + length = np.where(length < 0.01, 0.1, length) + delta_pos = np.einsum("ij,i->ij", displacement, t / length) + if fixed is not None: + # don't change positions of fixed nodes + delta_pos[fixed] = 0.0 + pos += delta_pos + # cool temperature + t -= dt + if (np.linalg.norm(delta_pos) / nnodes) < threshold: + break + return pos + + +@np_random_state(7) +def _sparse_fruchterman_reingold( + A, k=None, pos=None, fixed=None, iterations=50, threshold=1e-4, dim=2, seed=None +): + # Position nodes in adjacency matrix A using Fruchterman-Reingold + # Entry point for NetworkX graph is fruchterman_reingold_layout() + # Sparse version + import numpy as np + import scipy as sp + + try: + nnodes, _ = A.shape + except AttributeError as err: + msg = "fruchterman_reingold() takes an adjacency matrix as input" + raise nx.NetworkXError(msg) from err + # make sure we have a LIst of Lists representation + try: + A = A.tolil() + except AttributeError: + A = (sp.sparse.coo_array(A)).tolil() + + if pos is None: + # random initial positions + pos = np.asarray(seed.rand(nnodes, dim), dtype=A.dtype) + else: + # make sure positions are of same type as matrix + pos = pos.astype(A.dtype) + + # no fixed nodes + if fixed is None: + fixed = [] + + # optimal distance between nodes + if k is None: + k = np.sqrt(1.0 / nnodes) + # the initial "temperature" is about .1 of domain area (=1x1) + # this is the largest step allowed in the dynamics. + t = max(max(pos.T[0]) - min(pos.T[0]), max(pos.T[1]) - min(pos.T[1])) * 0.1 + # simple cooling scheme. + # linearly step down by dt on each iteration so last iteration is size dt. + dt = t / (iterations + 1) + + displacement = np.zeros((dim, nnodes)) + for iteration in range(iterations): + displacement *= 0 + # loop over rows + for i in range(A.shape[0]): + if i in fixed: + continue + # difference between this row's node position and all others + delta = (pos[i] - pos).T + # distance between points + distance = np.sqrt((delta**2).sum(axis=0)) + # enforce minimum distance of 0.01 + distance = np.where(distance < 0.01, 0.01, distance) + # the adjacency matrix row + Ai = A.getrowview(i).toarray() # TODO: revisit w/ sparse 1D container + # displacement "force" + displacement[:, i] += ( + delta * (k * k / distance**2 - Ai * distance / k) + ).sum(axis=1) + # update positions + length = np.sqrt((displacement**2).sum(axis=0)) + length = np.where(length < 0.01, 0.1, length) + delta_pos = (displacement * t / length).T + pos += delta_pos + # cool temperature + t -= dt + if (np.linalg.norm(delta_pos) / nnodes) < threshold: + break + return pos + + +def kamada_kawai_layout( + G, dist=None, pos=None, weight="weight", scale=1, center=None, dim=2 +): + """Position nodes using Kamada-Kawai path-length cost-function. + + Parameters + ---------- + G : NetworkX graph or list of nodes + A position will be assigned to every node in G. + + dist : dict (default=None) + A two-level dictionary of optimal distances between nodes, + indexed by source and destination node. + If None, the distance is computed using shortest_path_length(). + + pos : dict or None optional (default=None) + Initial positions for nodes as a dictionary with node as keys + and values as a coordinate list or tuple. If None, then use + circular_layout() for dim >= 2 and a linear layout for dim == 1. + + weight : string or None optional (default='weight') + The edge attribute that holds the numerical value used for + the edge weight. If None, then all edge weights are 1. + + scale : number (default: 1) + Scale factor for positions. + + center : array-like or None + Coordinate pair around which to center the layout. + + dim : int + Dimension of layout. + + Returns + ------- + pos : dict + A dictionary of positions keyed by node + + Examples + -------- + >>> G = nx.path_graph(4) + >>> pos = nx.kamada_kawai_layout(G) + """ + import numpy as np + + G, center = _process_params(G, center, dim) + nNodes = len(G) + if nNodes == 0: + return {} + + if dist is None: + dist = dict(nx.shortest_path_length(G, weight=weight)) + dist_mtx = 1e6 * np.ones((nNodes, nNodes)) + for row, nr in enumerate(G): + if nr not in dist: + continue + rdist = dist[nr] + for col, nc in enumerate(G): + if nc not in rdist: + continue + dist_mtx[row][col] = rdist[nc] + + if pos is None: + if dim >= 3: + pos = random_layout(G, dim=dim) + elif dim == 2: + pos = circular_layout(G, dim=dim) + else: + pos = dict(zip(G, np.linspace(0, 1, len(G)))) + pos_arr = np.array([pos[n] for n in G]) + + pos = _kamada_kawai_solve(dist_mtx, pos_arr, dim) + + pos = rescale_layout(pos, scale=scale) + center + return dict(zip(G, pos)) + + +def _kamada_kawai_solve(dist_mtx, pos_arr, dim): + # Anneal node locations based on the Kamada-Kawai cost-function, + # using the supplied matrix of preferred inter-node distances, + # and starting locations. + + import numpy as np + import scipy as sp + + meanwt = 1e-3 + costargs = (np, 1 / (dist_mtx + np.eye(dist_mtx.shape[0]) * 1e-3), meanwt, dim) + + optresult = sp.optimize.minimize( + _kamada_kawai_costfn, + pos_arr.ravel(), + method="L-BFGS-B", + args=costargs, + jac=True, + ) + + return optresult.x.reshape((-1, dim)) + + +def _kamada_kawai_costfn(pos_vec, np, invdist, meanweight, dim): + # Cost-function and gradient for Kamada-Kawai layout algorithm + nNodes = invdist.shape[0] + pos_arr = pos_vec.reshape((nNodes, dim)) + + delta = pos_arr[:, np.newaxis, :] - pos_arr[np.newaxis, :, :] + nodesep = np.linalg.norm(delta, axis=-1) + direction = np.einsum("ijk,ij->ijk", delta, 1 / (nodesep + np.eye(nNodes) * 1e-3)) + + offset = nodesep * invdist - 1.0 + offset[np.diag_indices(nNodes)] = 0 + + cost = 0.5 * np.sum(offset**2) + grad = np.einsum("ij,ij,ijk->ik", invdist, offset, direction) - np.einsum( + "ij,ij,ijk->jk", invdist, offset, direction + ) + + # Additional parabolic term to encourage mean position to be near origin: + sumpos = np.sum(pos_arr, axis=0) + cost += 0.5 * meanweight * np.sum(sumpos**2) + grad += meanweight * sumpos + + return (cost, grad.ravel()) + + +def spectral_layout(G, weight="weight", scale=1, center=None, dim=2): + """Position nodes using the eigenvectors of the graph Laplacian. + + Using the unnormalized Laplacian, the layout shows possible clusters of + nodes which are an approximation of the ratio cut. If dim is the number of + dimensions then the positions are the entries of the dim eigenvectors + corresponding to the ascending eigenvalues starting from the second one. + + Parameters + ---------- + G : NetworkX graph or list of nodes + A position will be assigned to every node in G. + + weight : string or None optional (default='weight') + The edge attribute that holds the numerical value used for + the edge weight. If None, then all edge weights are 1. + + scale : number (default: 1) + Scale factor for positions. + + center : array-like or None + Coordinate pair around which to center the layout. + + dim : int + Dimension of layout. + + Returns + ------- + pos : dict + A dictionary of positions keyed by node + + Examples + -------- + >>> G = nx.path_graph(4) + >>> pos = nx.spectral_layout(G) + + Notes + ----- + Directed graphs will be considered as undirected graphs when + positioning the nodes. + + For larger graphs (>500 nodes) this will use the SciPy sparse + eigenvalue solver (ARPACK). + """ + # handle some special cases that break the eigensolvers + import numpy as np + + G, center = _process_params(G, center, dim) + + if len(G) <= 2: + if len(G) == 0: + pos = np.array([]) + elif len(G) == 1: + pos = np.array([center]) + else: + pos = np.array([np.zeros(dim), np.array(center) * 2.0]) + return dict(zip(G, pos)) + try: + # Sparse matrix + if len(G) < 500: # dense solver is faster for small graphs + raise ValueError + A = nx.to_scipy_sparse_array(G, weight=weight, dtype="d") + # Symmetrize directed graphs + if G.is_directed(): + A = A + np.transpose(A) + pos = _sparse_spectral(A, dim) + except (ImportError, ValueError): + # Dense matrix + A = nx.to_numpy_array(G, weight=weight) + # Symmetrize directed graphs + if G.is_directed(): + A += A.T + pos = _spectral(A, dim) + + pos = rescale_layout(pos, scale=scale) + center + pos = dict(zip(G, pos)) + return pos + + +def _spectral(A, dim=2): + # Input adjacency matrix A + # Uses dense eigenvalue solver from numpy + import numpy as np + + try: + nnodes, _ = A.shape + except AttributeError as err: + msg = "spectral() takes an adjacency matrix as input" + raise nx.NetworkXError(msg) from err + + # form Laplacian matrix where D is diagonal of degrees + D = np.identity(nnodes, dtype=A.dtype) * np.sum(A, axis=1) + L = D - A + + eigenvalues, eigenvectors = np.linalg.eig(L) + # sort and keep smallest nonzero + index = np.argsort(eigenvalues)[1 : dim + 1] # 0 index is zero eigenvalue + return np.real(eigenvectors[:, index]) + + +def _sparse_spectral(A, dim=2): + # Input adjacency matrix A + # Uses sparse eigenvalue solver from scipy + # Could use multilevel methods here, see Koren "On spectral graph drawing" + import numpy as np + import scipy as sp + + try: + nnodes, _ = A.shape + except AttributeError as err: + msg = "sparse_spectral() takes an adjacency matrix as input" + raise nx.NetworkXError(msg) from err + + # form Laplacian matrix + # TODO: Rm csr_array wrapper in favor of spdiags array constructor when available + D = sp.sparse.csr_array(sp.sparse.spdiags(A.sum(axis=1), 0, nnodes, nnodes)) + L = D - A + + k = dim + 1 + # number of Lanczos vectors for ARPACK solver.What is the right scaling? + ncv = max(2 * k + 1, int(np.sqrt(nnodes))) + # return smallest k eigenvalues and eigenvectors + eigenvalues, eigenvectors = sp.sparse.linalg.eigsh(L, k, which="SM", ncv=ncv) + index = np.argsort(eigenvalues)[1:k] # 0 index is zero eigenvalue + return np.real(eigenvectors[:, index]) + + +def planar_layout(G, scale=1, center=None, dim=2): + """Position nodes without edge intersections. + + Parameters + ---------- + G : NetworkX graph or list of nodes + A position will be assigned to every node in G. If G is of type + nx.PlanarEmbedding, the positions are selected accordingly. + + scale : number (default: 1) + Scale factor for positions. + + center : array-like or None + Coordinate pair around which to center the layout. + + dim : int + Dimension of layout. + + Returns + ------- + pos : dict + A dictionary of positions keyed by node + + Raises + ------ + NetworkXException + If G is not planar + + Examples + -------- + >>> G = nx.path_graph(4) + >>> pos = nx.planar_layout(G) + """ + import numpy as np + + if dim != 2: + raise ValueError("can only handle 2 dimensions") + + G, center = _process_params(G, center, dim) + + if len(G) == 0: + return {} + + if isinstance(G, nx.PlanarEmbedding): + embedding = G + else: + is_planar, embedding = nx.check_planarity(G) + if not is_planar: + raise nx.NetworkXException("G is not planar.") + pos = nx.combinatorial_embedding_to_pos(embedding) + node_list = list(embedding) + pos = np.vstack([pos[x] for x in node_list]) + pos = pos.astype(np.float64) + pos = rescale_layout(pos, scale=scale) + center + return dict(zip(node_list, pos)) + + +def spiral_layout(G, scale=1, center=None, dim=2, resolution=0.35, equidistant=False): + """Position nodes in a spiral layout. + + Parameters + ---------- + G : NetworkX graph or list of nodes + A position will be assigned to every node in G. + scale : number (default: 1) + Scale factor for positions. + center : array-like or None + Coordinate pair around which to center the layout. + dim : int, default=2 + Dimension of layout, currently only dim=2 is supported. + Other dimension values result in a ValueError. + resolution : float, default=0.35 + The compactness of the spiral layout returned. + Lower values result in more compressed spiral layouts. + equidistant : bool, default=False + If True, nodes will be positioned equidistant from each other + by decreasing angle further from center. + If False, nodes will be positioned at equal angles + from each other by increasing separation further from center. + + Returns + ------- + pos : dict + A dictionary of positions keyed by node + + Raises + ------ + ValueError + If dim != 2 + + Examples + -------- + >>> G = nx.path_graph(4) + >>> pos = nx.spiral_layout(G) + >>> nx.draw(G, pos=pos) + + Notes + ----- + This algorithm currently only works in two dimensions. + + """ + import numpy as np + + if dim != 2: + raise ValueError("can only handle 2 dimensions") + + G, center = _process_params(G, center, dim) + + if len(G) == 0: + return {} + if len(G) == 1: + return {nx.utils.arbitrary_element(G): center} + + pos = [] + if equidistant: + chord = 1 + step = 0.5 + theta = resolution + theta += chord / (step * theta) + for _ in range(len(G)): + r = step * theta + theta += chord / r + pos.append([np.cos(theta) * r, np.sin(theta) * r]) + + else: + dist = np.arange(len(G), dtype=float) + angle = resolution * dist + pos = np.transpose(dist * np.array([np.cos(angle), np.sin(angle)])) + + pos = rescale_layout(np.array(pos), scale=scale) + center + + pos = dict(zip(G, pos)) + + return pos + + +def multipartite_layout(G, subset_key="subset", align="vertical", scale=1, center=None): + """Position nodes in layers of straight lines. + + Parameters + ---------- + G : NetworkX graph or list of nodes + A position will be assigned to every node in G. + + subset_key : string or dict (default='subset') + If a string, the key of node data in G that holds the node subset. + If a dict, keyed by layer number to the nodes in that layer/subset. + + align : string (default='vertical') + The alignment of nodes. Vertical or horizontal. + + scale : number (default: 1) + Scale factor for positions. + + center : array-like or None + Coordinate pair around which to center the layout. + + Returns + ------- + pos : dict + A dictionary of positions keyed by node. + + Examples + -------- + >>> G = nx.complete_multipartite_graph(28, 16, 10) + >>> pos = nx.multipartite_layout(G) + + or use a dict to provide the layers of the layout + + >>> G = nx.Graph([(0, 1), (1, 2), (1, 3), (3, 4)]) + >>> layers = {"a": [0], "b": [1], "c": [2, 3], "d": [4]} + >>> pos = nx.multipartite_layout(G, subset_key=layers) + + Notes + ----- + This algorithm currently only works in two dimensions and does not + try to minimize edge crossings. + + Network does not need to be a complete multipartite graph. As long as nodes + have subset_key data, they will be placed in the corresponding layers. + + """ + import numpy as np + + if align not in ("vertical", "horizontal"): + msg = "align must be either vertical or horizontal." + raise ValueError(msg) + + G, center = _process_params(G, center=center, dim=2) + if len(G) == 0: + return {} + + try: + # check if subset_key is dict-like + if len(G) != sum(len(nodes) for nodes in subset_key.values()): + raise nx.NetworkXError( + "all nodes must be in one subset of `subset_key` dict" + ) + except AttributeError: + # subset_key is not a dict, hence a string + node_to_subset = nx.get_node_attributes(G, subset_key) + if len(node_to_subset) != len(G): + raise nx.NetworkXError( + f"all nodes need a subset_key attribute: {subset_key}" + ) + subset_key = nx.utils.groups(node_to_subset) + + # Sort by layer, if possible + try: + layers = dict(sorted(subset_key.items())) + except TypeError: + layers = subset_key + + pos = None + nodes = [] + width = len(layers) + for i, layer in enumerate(layers.values()): + height = len(layer) + xs = np.repeat(i, height) + ys = np.arange(0, height, dtype=float) + offset = ((width - 1) / 2, (height - 1) / 2) + layer_pos = np.column_stack([xs, ys]) - offset + if pos is None: + pos = layer_pos + else: + pos = np.concatenate([pos, layer_pos]) + nodes.extend(layer) + pos = rescale_layout(pos, scale=scale) + center + if align == "horizontal": + pos = pos[:, ::-1] # swap x and y coords + pos = dict(zip(nodes, pos)) + return pos + + +@np_random_state("seed") +def arf_layout( + G, + pos=None, + scaling=1, + a=1.1, + etol=1e-6, + dt=1e-3, + max_iter=1000, + *, + seed=None, +): + """Arf layout for networkx + + The attractive and repulsive forces (arf) layout [1] + improves the spring layout in three ways. First, it + prevents congestion of highly connected nodes due to + strong forcing between nodes. Second, it utilizes the + layout space more effectively by preventing large gaps + that spring layout tends to create. Lastly, the arf + layout represents symmetries in the layout better than + the default spring layout. + + Parameters + ---------- + G : nx.Graph or nx.DiGraph + Networkx graph. + pos : dict + Initial position of the nodes. If set to None a + random layout will be used. + scaling : float + Scales the radius of the circular layout space. + a : float + Strength of springs between connected nodes. Should be larger than 1. The greater a, the clearer the separation ofunconnected sub clusters. + etol : float + Gradient sum of spring forces must be larger than `etol` before successful termination. + dt : float + Time step for force differential equation simulations. + max_iter : int + Max iterations before termination of the algorithm. + seed : int, RandomState instance or None optional (default=None) + Set the random state for deterministic node layouts. + If int, `seed` is the seed used by the random number generator, + if numpy.random.RandomState instance, `seed` is the random + number generator, + if None, the random number generator is the RandomState instance used + by numpy.random. + + References + .. [1] "Self-Organization Applied to Dynamic Network Layout", M. Geipel, + International Journal of Modern Physics C, 2007, Vol 18, No 10, pp. 1537-1549. + https://doi.org/10.1142/S0129183107011558 https://arxiv.org/abs/0704.1748 + + Returns + ------- + pos : dict + A dictionary of positions keyed by node. + + Examples + -------- + >>> G = nx.grid_graph((5, 5)) + >>> pos = nx.arf_layout(G) + + """ + import warnings + + import numpy as np + + if a <= 1: + msg = "The parameter a should be larger than 1" + raise ValueError(msg) + + pos_tmp = nx.random_layout(G, seed=seed) + if pos is None: + pos = pos_tmp + else: + for node in G.nodes(): + if node not in pos: + pos[node] = pos_tmp[node].copy() + + # Initialize spring constant matrix + N = len(G) + # No nodes no computation + if N == 0: + return pos + + # init force of springs + K = np.ones((N, N)) - np.eye(N) + node_order = {node: i for i, node in enumerate(G)} + for x, y in G.edges(): + if x != y: + idx, jdx = (node_order[i] for i in (x, y)) + K[idx, jdx] = a + + # vectorize values + p = np.asarray(list(pos.values())) + + # equation 10 in [1] + rho = scaling * np.sqrt(N) + + # looping variables + error = etol + 1 + n_iter = 0 + while error > etol: + diff = p[:, np.newaxis] - p[np.newaxis] + A = np.linalg.norm(diff, axis=-1)[..., np.newaxis] + # attraction_force - repulsions force + # suppress nans due to division; caused by diagonal set to zero. + # Does not affect the computation due to nansum + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + change = K[..., np.newaxis] * diff - rho / A * diff + change = np.nansum(change, axis=0) + p += change * dt + + error = np.linalg.norm(change, axis=-1).sum() + if n_iter > max_iter: + break + n_iter += 1 + return dict(zip(G.nodes(), p)) + + +@np_random_state("seed") +def forceatlas2_layout( + G, + pos=None, + *, + max_iter=100, + jitter_tolerance=1.0, + scaling_ratio=2.0, + gravity=1.0, + distributed_action=False, + strong_gravity=False, + node_mass=None, + node_size=None, + weight=None, + dissuade_hubs=False, + linlog=False, + seed=None, + dim=2, +): + """Position nodes using the ForceAtlas2 force-directed layout algorithm. + + This function applies the ForceAtlas2 layout algorithm [1]_ to a NetworkX graph, + positioning the nodes in a way that visually represents the structure of the graph. + The algorithm uses physical simulation to minimize the energy of the system, + resulting in a more readable layout. + + Parameters + ---------- + G : nx.Graph + A NetworkX graph to be laid out. + pos : dict or None, optional + Initial positions of the nodes. If None, random initial positions are used. + max_iter : int (default: 100) + Number of iterations for the layout optimization. + jitter_tolerance : float (default: 1.0) + Controls the tolerance for adjusting the speed of layout generation. + scaling_ratio : float (default: 2.0) + Determines the scaling of attraction and repulsion forces. + distributed_attraction : bool (default: False) + Distributes the attraction force evenly among nodes. + strong_gravity : bool (default: False) + Applies a strong gravitational pull towards the center. + node_mass : dict or None, optional + Maps nodes to their masses, influencing the attraction to other nodes. + node_size : dict or None, optional + Maps nodes to their sizes, preventing crowding by creating a halo effect. + dissuade_hubs : bool (default: False) + Prevents the clustering of hub nodes. + linlog : bool (default: False) + Uses logarithmic attraction instead of linear. + seed : int, RandomState instance or None optional (default=None) + Used only for the initial positions in the algorithm. + Set the random state for deterministic node layouts. + If int, `seed` is the seed used by the random number generator, + if numpy.random.RandomState instance, `seed` is the random + number generator, + if None, the random number generator is the RandomState instance used + by numpy.random. + dim : int (default: 2) + Sets the dimensions for the layout. Ignored if `pos` is provided. + + Examples + -------- + >>> import networkx as nx + >>> G = nx.florentine_families_graph() + >>> pos = nx.forceatlas2_layout(G) + >>> nx.draw(G, pos=pos) + + References + ---------- + .. [1] Jacomy, M., Venturini, T., Heymann, S., & Bastian, M. (2014). + ForceAtlas2, a continuous graph layout algorithm for handy network + visualization designed for the Gephi software. PloS one, 9(6), e98679. + https://doi.org/10.1371/journal.pone.0098679 + """ + import numpy as np + + if len(G) == 0: + return {} + # parse optional pos positions + if pos is None: + pos = nx.random_layout(G, dim=dim, seed=seed) + pos_arr = np.array(list(pos.values())) + else: + # set default node interval within the initial pos values + pos_init = np.array(list(pos.values())) + max_pos = pos_init.max(axis=0) + min_pos = pos_init.min(axis=0) + dim = max_pos.size + pos_arr = min_pos + seed.rand(len(G), dim) * (max_pos - min_pos) + for idx, node in enumerate(G): + if node in pos: + pos_arr[idx] = pos[node].copy() + + mass = np.zeros(len(G)) + size = np.zeros(len(G)) + + # Only adjust for size when the users specifies size other than default (1) + adjust_sizes = False + if node_size is None: + node_size = {} + else: + adjust_sizes = True + + if node_mass is None: + node_mass = {} + + for idx, node in enumerate(G): + mass[idx] = node_mass.get(node, G.degree(node) + 1) + size[idx] = node_size.get(node, 1) + + n = len(G) + gravities = np.zeros((n, dim)) + attraction = np.zeros((n, dim)) + repulsion = np.zeros((n, dim)) + A = nx.to_numpy_array(G, weight=weight) + + def estimate_factor(n, swing, traction, speed, speed_efficiency, jitter_tolerance): + """Computes the scaling factor for the force in the ForceAtlas2 layout algorithm. + + This helper function adjusts the speed and + efficiency of the layout generation based on the + current state of the system, such as the number of + nodes, current swing, and traction forces. + + Parameters + ---------- + n : int + Number of nodes in the graph. + swing : float + The current swing, representing the oscillation of the nodes. + traction : float + The current traction force, representing the attraction between nodes. + speed : float + The current speed of the layout generation. + speed_efficiency : float + The efficiency of the current speed, influencing how fast the layout converges. + jitter_tolerance : float + The tolerance for jitter, affecting how much speed adjustment is allowed. + + Returns + ------- + tuple + A tuple containing the updated speed and speed efficiency. + + Notes + ----- + This function is a part of the ForceAtlas2 layout algorithm and is used to dynamically adjust the + layout parameters to achieve an optimal and stable visualization. + + """ + import numpy as np + + # estimate jitter + opt_jitter = 0.05 * np.sqrt(n) + min_jitter = np.sqrt(opt_jitter) + max_jitter = 10 + min_speed_efficiency = 0.05 + + other = min(max_jitter, opt_jitter * traction / n**2) + jitter = jitter_tolerance * max(min_jitter, other) + + if swing / traction > 2.0: + if speed_efficiency > min_speed_efficiency: + speed_efficiency *= 0.5 + jitter = max(jitter, jitter_tolerance) + if swing == 0: + target_speed = np.inf + else: + target_speed = jitter * speed_efficiency * traction / swing + + if swing > jitter * traction: + if speed_efficiency > min_speed_efficiency: + speed_efficiency *= 0.7 + elif speed < 1000: + speed_efficiency *= 1.3 + + max_rise = 0.5 + speed = speed + min(target_speed - speed, max_rise * speed) + return speed, speed_efficiency + + speed = 1 + speed_efficiency = 1 + swing = 1 + traction = 1 + for _ in range(max_iter): + # compute pairwise difference + diff = pos_arr[:, None] - pos_arr[None] + # compute pairwise distance + distance = np.linalg.norm(diff, axis=-1) + + # linear attraction + if linlog: + attraction = -np.log(1 + distance) / distance + np.fill_diagonal(attraction, 0) + attraction = np.einsum("ij, ij -> ij", attraction, A) + attraction = np.einsum("ijk, ij -> ik", diff, attraction) + + else: + attraction = -np.einsum("ijk, ij -> ik", diff, A) + + if distributed_action: + attraction /= mass[:, None] + + # repulsion + tmp = mass[:, None] @ mass[None] + if adjust_sizes: + distance += -size[:, None] - size[None] + + d2 = distance**2 + # remove self-interaction + np.fill_diagonal(tmp, 0) + np.fill_diagonal(d2, 1) + factor = (tmp / d2) * scaling_ratio + repulsion = np.einsum("ijk, ij -> ik", diff, factor) + + # gravity + gravities = ( + -gravity + * mass[:, None] + * pos_arr + / np.linalg.norm(pos_arr, axis=-1)[:, None] + ) + + if strong_gravity: + gravities *= np.linalg.norm(pos_arr, axis=-1)[:, None] + # total forces + update = attraction + repulsion + gravities + + # compute total swing and traction + swing += (mass * np.linalg.norm(pos_arr - update, axis=-1)).sum() + traction += (0.5 * mass * np.linalg.norm(pos_arr + update, axis=-1)).sum() + + speed, speed_efficiency = estimate_factor( + n, + swing, + traction, + speed, + speed_efficiency, + jitter_tolerance, + ) + + # update pos + if adjust_sizes: + swinging = mass * np.linalg.norm(update, axis=-1) + factor = 0.1 * speed / (1 + np.sqrt(speed * swinging)) + df = np.linalg.norm(update, axis=-1) + factor = np.minimum(factor * df, 10.0 * np.ones(df.shape)) / df + else: + swinging = mass * np.linalg.norm(update, axis=-1) + factor = speed / (1 + np.sqrt(speed * swinging)) + + pos_arr += update * factor[:, None] + if abs((update * factor[:, None]).sum()) < 1e-10: + break + + return dict(zip(G, pos_arr)) + + +def rescale_layout(pos, scale=1): + """Returns scaled position array to (-scale, scale) in all axes. + + The function acts on NumPy arrays which hold position information. + Each position is one row of the array. The dimension of the space + equals the number of columns. Each coordinate in one column. + + To rescale, the mean (center) is subtracted from each axis separately. + Then all values are scaled so that the largest magnitude value + from all axes equals `scale` (thus, the aspect ratio is preserved). + The resulting NumPy Array is returned (order of rows unchanged). + + Parameters + ---------- + pos : numpy array + positions to be scaled. Each row is a position. + + scale : number (default: 1) + The size of the resulting extent in all directions. + + Returns + ------- + pos : numpy array + scaled positions. Each row is a position. + + See Also + -------- + rescale_layout_dict + """ + import numpy as np + + # Find max length over all dimensions + pos -= pos.mean(axis=0) + lim = np.abs(pos).max() # max coordinate for all axes + # rescale to (-scale, scale) in all directions, preserves aspect + if lim > 0: + pos *= scale / lim + return pos + + +def rescale_layout_dict(pos, scale=1): + """Return a dictionary of scaled positions keyed by node + + Parameters + ---------- + pos : A dictionary of positions keyed by node + + scale : number (default: 1) + The size of the resulting extent in all directions. + + Returns + ------- + pos : A dictionary of positions keyed by node + + Examples + -------- + >>> import numpy as np + >>> pos = {0: np.array((0, 0)), 1: np.array((1, 1)), 2: np.array((0.5, 0.5))} + >>> nx.rescale_layout_dict(pos) + {0: array([-1., -1.]), 1: array([1., 1.]), 2: array([0., 0.])} + + >>> pos = {0: np.array((0, 0)), 1: np.array((-1, 1)), 2: np.array((-0.5, 0.5))} + >>> nx.rescale_layout_dict(pos, scale=2) + {0: array([ 2., -2.]), 1: array([-2., 2.]), 2: array([0., 0.])} + + See Also + -------- + rescale_layout + """ + import numpy as np + + if not pos: # empty_graph + return {} + pos_v = np.array(list(pos.values())) + pos_v = rescale_layout(pos_v, scale=scale) + return dict(zip(pos, pos_v)) + + +def bfs_layout(G, start, *, align="vertical", scale=1, center=None): + """Position nodes according to breadth-first search algorithm. + + Parameters + ---------- + G : NetworkX graph + A position will be assigned to every node in G. + + start : node in `G` + Starting node for bfs + + center : array-like or None + Coordinate pair around which to center the layout. + + Returns + ------- + pos : dict + A dictionary of positions keyed by node. + + Examples + -------- + >>> G = nx.path_graph(4) + >>> pos = nx.bfs_layout(G, 0) + + Notes + ----- + This algorithm currently only works in two dimensions and does not + try to minimize edge crossings. + + """ + G, center = _process_params(G, center, 2) + + # Compute layers with BFS + layers = dict(enumerate(nx.bfs_layers(G, start))) + + if len(G) != sum(len(nodes) for nodes in layers.values()): + raise nx.NetworkXError( + "bfs_layout didn't include all nodes. Perhaps use input graph:\n" + " G.subgraph(nx.node_connected_component(G, start))" + ) + + # Compute node positions with multipartite_layout + return multipartite_layout( + G, subset_key=layers, align=align, scale=scale, center=center + ) diff --git a/.venv/lib/python3.12/site-packages/networkx/drawing/nx_agraph.py b/.venv/lib/python3.12/site-packages/networkx/drawing/nx_agraph.py new file mode 100644 index 00000000..b394729f --- /dev/null +++ b/.venv/lib/python3.12/site-packages/networkx/drawing/nx_agraph.py @@ -0,0 +1,464 @@ +""" +*************** +Graphviz AGraph +*************** + +Interface to pygraphviz AGraph class. + +Examples +-------- +>>> G = nx.complete_graph(5) +>>> A = nx.nx_agraph.to_agraph(G) +>>> H = nx.nx_agraph.from_agraph(A) + +See Also +-------- + - Pygraphviz: http://pygraphviz.github.io/ + - Graphviz: https://www.graphviz.org + - DOT Language: http://www.graphviz.org/doc/info/lang.html +""" + +import os +import tempfile + +import networkx as nx + +__all__ = [ + "from_agraph", + "to_agraph", + "write_dot", + "read_dot", + "graphviz_layout", + "pygraphviz_layout", + "view_pygraphviz", +] + + +@nx._dispatchable(graphs=None, returns_graph=True) +def from_agraph(A, create_using=None): + """Returns a NetworkX Graph or DiGraph from a PyGraphviz graph. + + Parameters + ---------- + A : PyGraphviz AGraph + A graph created with PyGraphviz + + create_using : NetworkX graph constructor, optional (default=None) + Graph type to create. If graph instance, then cleared before populated. + If `None`, then the appropriate Graph type is inferred from `A`. + + Examples + -------- + >>> K5 = nx.complete_graph(5) + >>> A = nx.nx_agraph.to_agraph(K5) + >>> G = nx.nx_agraph.from_agraph(A) + + Notes + ----- + The Graph G will have a dictionary G.graph_attr containing + the default graphviz attributes for graphs, nodes and edges. + + Default node attributes will be in the dictionary G.node_attr + which is keyed by node. + + Edge attributes will be returned as edge data in G. With + edge_attr=False the edge data will be the Graphviz edge weight + attribute or the value 1 if no edge weight attribute is found. + + """ + if create_using is None: + if A.is_directed(): + if A.is_strict(): + create_using = nx.DiGraph + else: + create_using = nx.MultiDiGraph + else: + if A.is_strict(): + create_using = nx.Graph + else: + create_using = nx.MultiGraph + + # assign defaults + N = nx.empty_graph(0, create_using) + if A.name is not None: + N.name = A.name + + # add graph attributes + N.graph.update(A.graph_attr) + + # add nodes, attributes to N.node_attr + for n in A.nodes(): + str_attr = {str(k): v for k, v in n.attr.items()} + N.add_node(str(n), **str_attr) + + # add edges, assign edge data as dictionary of attributes + for e in A.edges(): + u, v = str(e[0]), str(e[1]) + attr = dict(e.attr) + str_attr = {str(k): v for k, v in attr.items()} + if not N.is_multigraph(): + if e.name is not None: + str_attr["key"] = e.name + N.add_edge(u, v, **str_attr) + else: + N.add_edge(u, v, key=e.name, **str_attr) + + # add default attributes for graph, nodes, and edges + # hang them on N.graph_attr + N.graph["graph"] = dict(A.graph_attr) + N.graph["node"] = dict(A.node_attr) + N.graph["edge"] = dict(A.edge_attr) + return N + + +def to_agraph(N): + """Returns a pygraphviz graph from a NetworkX graph N. + + Parameters + ---------- + N : NetworkX graph + A graph created with NetworkX + + Examples + -------- + >>> K5 = nx.complete_graph(5) + >>> A = nx.nx_agraph.to_agraph(K5) + + Notes + ----- + If N has an dict N.graph_attr an attempt will be made first + to copy properties attached to the graph (see from_agraph) + and then updated with the calling arguments if any. + + """ + try: + import pygraphviz + except ImportError as err: + raise ImportError("requires pygraphviz http://pygraphviz.github.io/") from err + directed = N.is_directed() + strict = nx.number_of_selfloops(N) == 0 and not N.is_multigraph() + + A = pygraphviz.AGraph(name=N.name, strict=strict, directed=directed) + + # default graph attributes + A.graph_attr.update(N.graph.get("graph", {})) + A.node_attr.update(N.graph.get("node", {})) + A.edge_attr.update(N.graph.get("edge", {})) + + A.graph_attr.update( + (k, v) for k, v in N.graph.items() if k not in ("graph", "node", "edge") + ) + + # add nodes + for n, nodedata in N.nodes(data=True): + A.add_node(n) + # Add node data + a = A.get_node(n) + for key, val in nodedata.items(): + if key == "pos": + a.attr["pos"] = f"{val[0]},{val[1]}!" + else: + a.attr[key] = str(val) + + # loop over edges + if N.is_multigraph(): + for u, v, key, edgedata in N.edges(data=True, keys=True): + str_edgedata = {k: str(v) for k, v in edgedata.items() if k != "key"} + A.add_edge(u, v, key=str(key)) + # Add edge data + a = A.get_edge(u, v) + a.attr.update(str_edgedata) + + else: + for u, v, edgedata in N.edges(data=True): + str_edgedata = {k: str(v) for k, v in edgedata.items()} + A.add_edge(u, v) + # Add edge data + a = A.get_edge(u, v) + a.attr.update(str_edgedata) + + return A + + +def write_dot(G, path): + """Write NetworkX graph G to Graphviz dot format on path. + + Parameters + ---------- + G : graph + A networkx graph + path : filename + Filename or file handle to write + + Notes + ----- + To use a specific graph layout, call ``A.layout`` prior to `write_dot`. + Note that some graphviz layouts are not guaranteed to be deterministic, + see https://gitlab.com/graphviz/graphviz/-/issues/1767 for more info. + """ + A = to_agraph(G) + A.write(path) + A.clear() + return + + +@nx._dispatchable(name="agraph_read_dot", graphs=None, returns_graph=True) +def read_dot(path): + """Returns a NetworkX graph from a dot file on path. + + Parameters + ---------- + path : file or string + File name or file handle to read. + """ + try: + import pygraphviz + except ImportError as err: + raise ImportError( + "read_dot() requires pygraphviz http://pygraphviz.github.io/" + ) from err + A = pygraphviz.AGraph(file=path) + gr = from_agraph(A) + A.clear() + return gr + + +def graphviz_layout(G, prog="neato", root=None, args=""): + """Create node positions for G using Graphviz. + + Parameters + ---------- + G : NetworkX graph + A graph created with NetworkX + prog : string + Name of Graphviz layout program + root : string, optional + Root node for twopi layout + args : string, optional + Extra arguments to Graphviz layout program + + Returns + ------- + Dictionary of x, y, positions keyed by node. + + Examples + -------- + >>> G = nx.petersen_graph() + >>> pos = nx.nx_agraph.graphviz_layout(G) + >>> pos = nx.nx_agraph.graphviz_layout(G, prog="dot") + + Notes + ----- + This is a wrapper for pygraphviz_layout. + + Note that some graphviz layouts are not guaranteed to be deterministic, + see https://gitlab.com/graphviz/graphviz/-/issues/1767 for more info. + """ + return pygraphviz_layout(G, prog=prog, root=root, args=args) + + +def pygraphviz_layout(G, prog="neato", root=None, args=""): + """Create node positions for G using Graphviz. + + Parameters + ---------- + G : NetworkX graph + A graph created with NetworkX + prog : string + Name of Graphviz layout program + root : string, optional + Root node for twopi layout + args : string, optional + Extra arguments to Graphviz layout program + + Returns + ------- + node_pos : dict + Dictionary of x, y, positions keyed by node. + + Examples + -------- + >>> G = nx.petersen_graph() + >>> pos = nx.nx_agraph.graphviz_layout(G) + >>> pos = nx.nx_agraph.graphviz_layout(G, prog="dot") + + Notes + ----- + If you use complex node objects, they may have the same string + representation and GraphViz could treat them as the same node. + The layout may assign both nodes a single location. See Issue #1568 + If this occurs in your case, consider relabeling the nodes just + for the layout computation using something similar to:: + + >>> H = nx.convert_node_labels_to_integers(G, label_attribute="node_label") + >>> H_layout = nx.nx_agraph.pygraphviz_layout(G, prog="dot") + >>> G_layout = {H.nodes[n]["node_label"]: p for n, p in H_layout.items()} + + Note that some graphviz layouts are not guaranteed to be deterministic, + see https://gitlab.com/graphviz/graphviz/-/issues/1767 for more info. + """ + try: + import pygraphviz + except ImportError as err: + raise ImportError("requires pygraphviz http://pygraphviz.github.io/") from err + if root is not None: + args += f"-Groot={root}" + A = to_agraph(G) + A.layout(prog=prog, args=args) + node_pos = {} + for n in G: + node = pygraphviz.Node(A, n) + try: + xs = node.attr["pos"].split(",") + node_pos[n] = tuple(float(x) for x in xs) + except: + print("no position for node", n) + node_pos[n] = (0.0, 0.0) + return node_pos + + +@nx.utils.open_file(5, "w+b") +def view_pygraphviz( + G, edgelabel=None, prog="dot", args="", suffix="", path=None, show=True +): + """Views the graph G using the specified layout algorithm. + + Parameters + ---------- + G : NetworkX graph + The machine to draw. + edgelabel : str, callable, None + If a string, then it specifies the edge attribute to be displayed + on the edge labels. If a callable, then it is called for each + edge and it should return the string to be displayed on the edges. + The function signature of `edgelabel` should be edgelabel(data), + where `data` is the edge attribute dictionary. + prog : string + Name of Graphviz layout program. + args : str + Additional arguments to pass to the Graphviz layout program. + suffix : str + If `filename` is None, we save to a temporary file. The value of + `suffix` will appear at the tail end of the temporary filename. + path : str, None + The filename used to save the image. If None, save to a temporary + file. File formats are the same as those from pygraphviz.agraph.draw. + show : bool, default = True + Whether to display the graph with :mod:`PIL.Image.show`, + default is `True`. If `False`, the rendered graph is still available + at `path`. + + Returns + ------- + path : str + The filename of the generated image. + A : PyGraphviz graph + The PyGraphviz graph instance used to generate the image. + + Notes + ----- + If this function is called in succession too quickly, sometimes the + image is not displayed. So you might consider time.sleep(.5) between + calls if you experience problems. + + Note that some graphviz layouts are not guaranteed to be deterministic, + see https://gitlab.com/graphviz/graphviz/-/issues/1767 for more info. + + """ + if not len(G): + raise nx.NetworkXException("An empty graph cannot be drawn.") + + # If we are providing default values for graphviz, these must be set + # before any nodes or edges are added to the PyGraphviz graph object. + # The reason for this is that default values only affect incoming objects. + # If you change the default values after the objects have been added, + # then they inherit no value and are set only if explicitly set. + + # to_agraph() uses these values. + attrs = ["edge", "node", "graph"] + for attr in attrs: + if attr not in G.graph: + G.graph[attr] = {} + + # These are the default values. + edge_attrs = {"fontsize": "10"} + node_attrs = { + "style": "filled", + "fillcolor": "#0000FF40", + "height": "0.75", + "width": "0.75", + "shape": "circle", + } + graph_attrs = {} + + def update_attrs(which, attrs): + # Update graph attributes. Return list of those which were added. + added = [] + for k, v in attrs.items(): + if k not in G.graph[which]: + G.graph[which][k] = v + added.append(k) + + def clean_attrs(which, added): + # Remove added attributes + for attr in added: + del G.graph[which][attr] + if not G.graph[which]: + del G.graph[which] + + # Update all default values + update_attrs("edge", edge_attrs) + update_attrs("node", node_attrs) + update_attrs("graph", graph_attrs) + + # Convert to agraph, so we inherit default values + A = to_agraph(G) + + # Remove the default values we added to the original graph. + clean_attrs("edge", edge_attrs) + clean_attrs("node", node_attrs) + clean_attrs("graph", graph_attrs) + + # If the user passed in an edgelabel, we update the labels for all edges. + if edgelabel is not None: + if not callable(edgelabel): + + def func(data): + return "".join([" ", str(data[edgelabel]), " "]) + + else: + func = edgelabel + + # update all the edge labels + if G.is_multigraph(): + for u, v, key, data in G.edges(keys=True, data=True): + # PyGraphviz doesn't convert the key to a string. See #339 + edge = A.get_edge(u, v, str(key)) + edge.attr["label"] = str(func(data)) + else: + for u, v, data in G.edges(data=True): + edge = A.get_edge(u, v) + edge.attr["label"] = str(func(data)) + + if path is None: + ext = "png" + if suffix: + suffix = f"_{suffix}.{ext}" + else: + suffix = f".{ext}" + path = tempfile.NamedTemporaryFile(suffix=suffix, delete=False) + else: + # Assume the decorator worked and it is a file-object. + pass + + # Write graph to file + A.draw(path=path, format=None, prog=prog, args=args) + path.close() + + # Show graph in a new window (depends on platform configuration) + if show: + from PIL import Image + + Image.open(path.name).show() + + return path.name, A diff --git a/.venv/lib/python3.12/site-packages/networkx/drawing/nx_latex.py b/.venv/lib/python3.12/site-packages/networkx/drawing/nx_latex.py new file mode 100644 index 00000000..5fdbf78b --- /dev/null +++ b/.venv/lib/python3.12/site-packages/networkx/drawing/nx_latex.py @@ -0,0 +1,572 @@ +r""" +***** +LaTeX +***** + +Export NetworkX graphs in LaTeX format using the TikZ library within TeX/LaTeX. +Usually, you will want the drawing to appear in a figure environment so +you use ``to_latex(G, caption="A caption")``. If you want the raw +drawing commands without a figure environment use :func:`to_latex_raw`. +And if you want to write to a file instead of just returning the latex +code as a string, use ``write_latex(G, "filename.tex", caption="A caption")``. + +To construct a figure with subfigures for each graph to be shown, provide +``to_latex`` or ``write_latex`` a list of graphs, a list of subcaptions, +and a number of rows of subfigures inside the figure. + +To be able to refer to the figures or subfigures in latex using ``\\ref``, +the keyword ``latex_label`` is available for figures and `sub_labels` for +a list of labels, one for each subfigure. + +We intend to eventually provide an interface to the TikZ Graph +features which include e.g. layout algorithms. + +Let us know via github what you'd like to see available, or better yet +give us some code to do it, or even better make a github pull request +to add the feature. + +The TikZ approach +================= +Drawing options can be stored on the graph as node/edge attributes, or +can be provided as dicts keyed by node/edge to a string of the options +for that node/edge. Similarly a label can be shown for each node/edge +by specifying the labels as graph node/edge attributes or by providing +a dict keyed by node/edge to the text to be written for that node/edge. + +Options for the tikzpicture environment (e.g. "[scale=2]") can be provided +via a keyword argument. Similarly default node and edge options can be +provided through keywords arguments. The default node options are applied +to the single TikZ "path" that draws all nodes (and no edges). The default edge +options are applied to a TikZ "scope" which contains a path for each edge. + +Examples +======== +>>> G = nx.path_graph(3) +>>> nx.write_latex(G, "just_my_figure.tex", as_document=True) +>>> nx.write_latex(G, "my_figure.tex", caption="A path graph", latex_label="fig1") +>>> latex_code = nx.to_latex(G) # a string rather than a file + +You can change many features of the nodes and edges. + +>>> G = nx.path_graph(4, create_using=nx.DiGraph) +>>> pos = {n: (n, n) for n in G} # nodes set on a line + +>>> G.nodes[0]["style"] = "blue" +>>> G.nodes[2]["style"] = "line width=3,draw" +>>> G.nodes[3]["label"] = "Stop" +>>> G.edges[(0, 1)]["label"] = "1st Step" +>>> G.edges[(0, 1)]["label_opts"] = "near start" +>>> G.edges[(1, 2)]["style"] = "line width=3" +>>> G.edges[(1, 2)]["label"] = "2nd Step" +>>> G.edges[(2, 3)]["style"] = "green" +>>> G.edges[(2, 3)]["label"] = "3rd Step" +>>> G.edges[(2, 3)]["label_opts"] = "near end" + +>>> nx.write_latex(G, "latex_graph.tex", pos=pos, as_document=True) + +Then compile the LaTeX using something like ``pdflatex latex_graph.tex`` +and view the pdf file created: ``latex_graph.pdf``. + +If you want **subfigures** each containing one graph, you can input a list of graphs. + +>>> H1 = nx.path_graph(4) +>>> H2 = nx.complete_graph(4) +>>> H3 = nx.path_graph(8) +>>> H4 = nx.complete_graph(8) +>>> graphs = [H1, H2, H3, H4] +>>> caps = ["Path 4", "Complete graph 4", "Path 8", "Complete graph 8"] +>>> lbls = ["fig2a", "fig2b", "fig2c", "fig2d"] +>>> nx.write_latex(graphs, "subfigs.tex", n_rows=2, sub_captions=caps, sub_labels=lbls) +>>> latex_code = nx.to_latex(graphs, n_rows=2, sub_captions=caps, sub_labels=lbls) + +>>> node_color = {0: "red", 1: "orange", 2: "blue", 3: "gray!90"} +>>> edge_width = {e: "line width=1.5" for e in H3.edges} +>>> pos = nx.circular_layout(H3) +>>> latex_code = nx.to_latex(H3, pos, node_options=node_color, edge_options=edge_width) +>>> print(latex_code) +\documentclass{report} +\usepackage{tikz} +\usepackage{subcaption} +<BLANKLINE> +\begin{document} +\begin{figure} + \begin{tikzpicture} + \draw + (1.0, 0.0) node[red] (0){0} + (0.707, 0.707) node[orange] (1){1} + (-0.0, 1.0) node[blue] (2){2} + (-0.707, 0.707) node[gray!90] (3){3} + (-1.0, -0.0) node (4){4} + (-0.707, -0.707) node (5){5} + (0.0, -1.0) node (6){6} + (0.707, -0.707) node (7){7}; + \begin{scope}[-] + \draw[line width=1.5] (0) to (1); + \draw[line width=1.5] (1) to (2); + \draw[line width=1.5] (2) to (3); + \draw[line width=1.5] (3) to (4); + \draw[line width=1.5] (4) to (5); + \draw[line width=1.5] (5) to (6); + \draw[line width=1.5] (6) to (7); + \end{scope} + \end{tikzpicture} +\end{figure} +\end{document} + +Notes +----- +If you want to change the preamble/postamble of the figure/document/subfigure +environment, use the keyword arguments: `figure_wrapper`, `document_wrapper`, +`subfigure_wrapper`. The default values are stored in private variables +e.g. ``nx.nx_layout._DOCUMENT_WRAPPER`` + +References +---------- +TikZ: https://tikz.dev/ + +TikZ options details: https://tikz.dev/tikz-actions +""" + +import numbers +import os + +import networkx as nx + +__all__ = [ + "to_latex_raw", + "to_latex", + "write_latex", +] + + +@nx.utils.not_implemented_for("multigraph") +def to_latex_raw( + G, + pos="pos", + tikz_options="", + default_node_options="", + node_options="node_options", + node_label="label", + default_edge_options="", + edge_options="edge_options", + edge_label="label", + edge_label_options="edge_label_options", +): + """Return a string of the LaTeX/TikZ code to draw `G` + + This function produces just the code for the tikzpicture + without any enclosing environment. + + Parameters + ========== + G : NetworkX graph + The NetworkX graph to be drawn + pos : string or dict (default "pos") + The name of the node attribute on `G` that holds the position of each node. + Positions can be sequences of length 2 with numbers for (x,y) coordinates. + They can also be strings to denote positions in TikZ style, such as (x, y) + or (angle:radius). + If a dict, it should be keyed by node to a position. + If an empty dict, a circular layout is computed by TikZ. + tikz_options : string + The tikzpicture options description defining the options for the picture. + Often large scale options like `[scale=2]`. + default_node_options : string + The draw options for a path of nodes. Individual node options override these. + node_options : string or dict + The name of the node attribute on `G` that holds the options for each node. + Or a dict keyed by node to a string holding the options for that node. + node_label : string or dict + The name of the node attribute on `G` that holds the node label (text) + displayed for each node. If the attribute is "" or not present, the node + itself is drawn as a string. LaTeX processing such as ``"$A_1$"`` is allowed. + Or a dict keyed by node to a string holding the label for that node. + default_edge_options : string + The options for the scope drawing all edges. The default is "[-]" for + undirected graphs and "[->]" for directed graphs. + edge_options : string or dict + The name of the edge attribute on `G` that holds the options for each edge. + If the edge is a self-loop and ``"loop" not in edge_options`` the option + "loop," is added to the options for the self-loop edge. Hence you can + use "[loop above]" explicitly, but the default is "[loop]". + Or a dict keyed by edge to a string holding the options for that edge. + edge_label : string or dict + The name of the edge attribute on `G` that holds the edge label (text) + displayed for each edge. If the attribute is "" or not present, no edge + label is drawn. + Or a dict keyed by edge to a string holding the label for that edge. + edge_label_options : string or dict + The name of the edge attribute on `G` that holds the label options for + each edge. For example, "[sloped,above,blue]". The default is no options. + Or a dict keyed by edge to a string holding the label options for that edge. + + Returns + ======= + latex_code : string + The text string which draws the desired graph(s) when compiled by LaTeX. + + See Also + ======== + to_latex + write_latex + """ + i4 = "\n " + i8 = "\n " + + # set up position dict + # TODO allow pos to be None and use a nice TikZ default + if not isinstance(pos, dict): + pos = nx.get_node_attributes(G, pos) + if not pos: + # circular layout with radius 2 + pos = {n: f"({round(360.0 * i / len(G), 3)}:2)" for i, n in enumerate(G)} + for node in G: + if node not in pos: + raise nx.NetworkXError(f"node {node} has no specified pos {pos}") + posnode = pos[node] + if not isinstance(posnode, str): + try: + posx, posy = posnode + pos[node] = f"({round(posx, 3)}, {round(posy, 3)})" + except (TypeError, ValueError): + msg = f"position pos[{node}] is not 2-tuple or a string: {posnode}" + raise nx.NetworkXError(msg) + + # set up all the dicts + if not isinstance(node_options, dict): + node_options = nx.get_node_attributes(G, node_options) + if not isinstance(node_label, dict): + node_label = nx.get_node_attributes(G, node_label) + if not isinstance(edge_options, dict): + edge_options = nx.get_edge_attributes(G, edge_options) + if not isinstance(edge_label, dict): + edge_label = nx.get_edge_attributes(G, edge_label) + if not isinstance(edge_label_options, dict): + edge_label_options = nx.get_edge_attributes(G, edge_label_options) + + # process default options (add brackets or not) + topts = "" if tikz_options == "" else f"[{tikz_options.strip('[]')}]" + defn = "" if default_node_options == "" else f"[{default_node_options.strip('[]')}]" + linestyle = f"{'->' if G.is_directed() else '-'}" + if default_edge_options == "": + defe = "[" + linestyle + "]" + elif "-" in default_edge_options: + defe = default_edge_options + else: + defe = f"[{linestyle},{default_edge_options.strip('[]')}]" + + # Construct the string line by line + result = " \\begin{tikzpicture}" + topts + result += i4 + " \\draw" + defn + # load the nodes + for n in G: + # node options goes inside square brackets + nopts = f"[{node_options[n].strip('[]')}]" if n in node_options else "" + # node text goes inside curly brackets {} + ntext = f"{{{node_label[n]}}}" if n in node_label else f"{{{n}}}" + + result += i8 + f"{pos[n]} node{nopts} ({n}){ntext}" + result += ";\n" + + # load the edges + result += " \\begin{scope}" + defe + for edge in G.edges: + u, v = edge[:2] + e_opts = f"{edge_options[edge]}".strip("[]") if edge in edge_options else "" + # add loop options for selfloops if not present + if u == v and "loop" not in e_opts: + e_opts = "loop," + e_opts + e_opts = f"[{e_opts}]" if e_opts != "" else "" + # TODO -- handle bending of multiedges + + els = edge_label_options[edge] if edge in edge_label_options else "" + # edge label options goes inside square brackets [] + els = f"[{els.strip('[]')}]" + # edge text is drawn using the TikZ node command inside curly brackets {} + e_label = f" node{els} {{{edge_label[edge]}}}" if edge in edge_label else "" + + result += i8 + f"\\draw{e_opts} ({u}) to{e_label} ({v});" + + result += "\n \\end{scope}\n \\end{tikzpicture}\n" + return result + + +_DOC_WRAPPER_TIKZ = r"""\documentclass{{report}} +\usepackage{{tikz}} +\usepackage{{subcaption}} + +\begin{{document}} +{content} +\end{{document}}""" + + +_FIG_WRAPPER = r"""\begin{{figure}} +{content}{caption}{label} +\end{{figure}}""" + + +_SUBFIG_WRAPPER = r""" \begin{{subfigure}}{{{size}\textwidth}} +{content}{caption}{label} + \end{{subfigure}}""" + + +def to_latex( + Gbunch, + pos="pos", + tikz_options="", + default_node_options="", + node_options="node_options", + node_label="node_label", + default_edge_options="", + edge_options="edge_options", + edge_label="edge_label", + edge_label_options="edge_label_options", + caption="", + latex_label="", + sub_captions=None, + sub_labels=None, + n_rows=1, + as_document=True, + document_wrapper=_DOC_WRAPPER_TIKZ, + figure_wrapper=_FIG_WRAPPER, + subfigure_wrapper=_SUBFIG_WRAPPER, +): + """Return latex code to draw the graph(s) in `Gbunch` + + The TikZ drawing utility in LaTeX is used to draw the graph(s). + If `Gbunch` is a graph, it is drawn in a figure environment. + If `Gbunch` is an iterable of graphs, each is drawn in a subfigure environment + within a single figure environment. + + If `as_document` is True, the figure is wrapped inside a document environment + so that the resulting string is ready to be compiled by LaTeX. Otherwise, + the string is ready for inclusion in a larger tex document using ``\\include`` + or ``\\input`` statements. + + Parameters + ========== + Gbunch : NetworkX graph or iterable of NetworkX graphs + The NetworkX graph to be drawn or an iterable of graphs + to be drawn inside subfigures of a single figure. + pos : string or list of strings + The name of the node attribute on `G` that holds the position of each node. + Positions can be sequences of length 2 with numbers for (x,y) coordinates. + They can also be strings to denote positions in TikZ style, such as (x, y) + or (angle:radius). + If a dict, it should be keyed by node to a position. + If an empty dict, a circular layout is computed by TikZ. + If you are drawing many graphs in subfigures, use a list of position dicts. + tikz_options : string + The tikzpicture options description defining the options for the picture. + Often large scale options like `[scale=2]`. + default_node_options : string + The draw options for a path of nodes. Individual node options override these. + node_options : string or dict + The name of the node attribute on `G` that holds the options for each node. + Or a dict keyed by node to a string holding the options for that node. + node_label : string or dict + The name of the node attribute on `G` that holds the node label (text) + displayed for each node. If the attribute is "" or not present, the node + itself is drawn as a string. LaTeX processing such as ``"$A_1$"`` is allowed. + Or a dict keyed by node to a string holding the label for that node. + default_edge_options : string + The options for the scope drawing all edges. The default is "[-]" for + undirected graphs and "[->]" for directed graphs. + edge_options : string or dict + The name of the edge attribute on `G` that holds the options for each edge. + If the edge is a self-loop and ``"loop" not in edge_options`` the option + "loop," is added to the options for the self-loop edge. Hence you can + use "[loop above]" explicitly, but the default is "[loop]". + Or a dict keyed by edge to a string holding the options for that edge. + edge_label : string or dict + The name of the edge attribute on `G` that holds the edge label (text) + displayed for each edge. If the attribute is "" or not present, no edge + label is drawn. + Or a dict keyed by edge to a string holding the label for that edge. + edge_label_options : string or dict + The name of the edge attribute on `G` that holds the label options for + each edge. For example, "[sloped,above,blue]". The default is no options. + Or a dict keyed by edge to a string holding the label options for that edge. + caption : string + The caption string for the figure environment + latex_label : string + The latex label used for the figure for easy referral from the main text + sub_captions : list of strings + The sub_caption string for each subfigure in the figure + sub_latex_labels : list of strings + The latex label for each subfigure in the figure + n_rows : int + The number of rows of subfigures to arrange for multiple graphs + as_document : bool + Whether to wrap the latex code in a document environment for compiling + document_wrapper : formatted text string with variable ``content``. + This text is called to evaluate the content embedded in a document + environment with a preamble setting up TikZ. + figure_wrapper : formatted text string + This text is evaluated with variables ``content``, ``caption`` and ``label``. + It wraps the content and if a caption is provided, adds the latex code for + that caption, and if a label is provided, adds the latex code for a label. + subfigure_wrapper : formatted text string + This text evaluate variables ``size``, ``content``, ``caption`` and ``label``. + It wraps the content and if a caption is provided, adds the latex code for + that caption, and if a label is provided, adds the latex code for a label. + The size is the vertical size of each row of subfigures as a fraction. + + Returns + ======= + latex_code : string + The text string which draws the desired graph(s) when compiled by LaTeX. + + See Also + ======== + write_latex + to_latex_raw + """ + if hasattr(Gbunch, "adj"): + raw = to_latex_raw( + Gbunch, + pos, + tikz_options, + default_node_options, + node_options, + node_label, + default_edge_options, + edge_options, + edge_label, + edge_label_options, + ) + else: # iterator of graphs + sbf = subfigure_wrapper + size = 1 / n_rows + + N = len(Gbunch) + if isinstance(pos, str | dict): + pos = [pos] * N + if sub_captions is None: + sub_captions = [""] * N + if sub_labels is None: + sub_labels = [""] * N + if not (len(Gbunch) == len(pos) == len(sub_captions) == len(sub_labels)): + raise nx.NetworkXError( + "length of Gbunch, sub_captions and sub_figures must agree" + ) + + raw = "" + for G, pos, subcap, sublbl in zip(Gbunch, pos, sub_captions, sub_labels): + subraw = to_latex_raw( + G, + pos, + tikz_options, + default_node_options, + node_options, + node_label, + default_edge_options, + edge_options, + edge_label, + edge_label_options, + ) + cap = f" \\caption{{{subcap}}}" if subcap else "" + lbl = f"\\label{{{sublbl}}}" if sublbl else "" + raw += sbf.format(size=size, content=subraw, caption=cap, label=lbl) + raw += "\n" + + # put raw latex code into a figure environment and optionally into a document + raw = raw[:-1] + cap = f"\n \\caption{{{caption}}}" if caption else "" + lbl = f"\\label{{{latex_label}}}" if latex_label else "" + fig = figure_wrapper.format(content=raw, caption=cap, label=lbl) + if as_document: + return document_wrapper.format(content=fig) + return fig + + +@nx.utils.open_file(1, mode="w") +def write_latex(Gbunch, path, **options): + """Write the latex code to draw the graph(s) onto `path`. + + This convenience function creates the latex drawing code as a string + and writes that to a file ready to be compiled when `as_document` is True + or ready to be ``import`` ed or ``include`` ed into your main LaTeX document. + + The `path` argument can be a string filename or a file handle to write to. + + Parameters + ---------- + Gbunch : NetworkX graph or iterable of NetworkX graphs + If Gbunch is a graph, it is drawn in a figure environment. + If Gbunch is an iterable of graphs, each is drawn in a subfigure + environment within a single figure environment. + path : filename + Filename or file handle to write to + options : dict + By default, TikZ is used with options: (others are ignored):: + + pos : string or dict or list + The name of the node attribute on `G` that holds the position of each node. + Positions can be sequences of length 2 with numbers for (x,y) coordinates. + They can also be strings to denote positions in TikZ style, such as (x, y) + or (angle:radius). + If a dict, it should be keyed by node to a position. + If an empty dict, a circular layout is computed by TikZ. + If you are drawing many graphs in subfigures, use a list of position dicts. + tikz_options : string + The tikzpicture options description defining the options for the picture. + Often large scale options like `[scale=2]`. + default_node_options : string + The draw options for a path of nodes. Individual node options override these. + node_options : string or dict + The name of the node attribute on `G` that holds the options for each node. + Or a dict keyed by node to a string holding the options for that node. + node_label : string or dict + The name of the node attribute on `G` that holds the node label (text) + displayed for each node. If the attribute is "" or not present, the node + itself is drawn as a string. LaTeX processing such as ``"$A_1$"`` is allowed. + Or a dict keyed by node to a string holding the label for that node. + default_edge_options : string + The options for the scope drawing all edges. The default is "[-]" for + undirected graphs and "[->]" for directed graphs. + edge_options : string or dict + The name of the edge attribute on `G` that holds the options for each edge. + If the edge is a self-loop and ``"loop" not in edge_options`` the option + "loop," is added to the options for the self-loop edge. Hence you can + use "[loop above]" explicitly, but the default is "[loop]". + Or a dict keyed by edge to a string holding the options for that edge. + edge_label : string or dict + The name of the edge attribute on `G` that holds the edge label (text) + displayed for each edge. If the attribute is "" or not present, no edge + label is drawn. + Or a dict keyed by edge to a string holding the label for that edge. + edge_label_options : string or dict + The name of the edge attribute on `G` that holds the label options for + each edge. For example, "[sloped,above,blue]". The default is no options. + Or a dict keyed by edge to a string holding the label options for that edge. + caption : string + The caption string for the figure environment + latex_label : string + The latex label used for the figure for easy referral from the main text + sub_captions : list of strings + The sub_caption string for each subfigure in the figure + sub_latex_labels : list of strings + The latex label for each subfigure in the figure + n_rows : int + The number of rows of subfigures to arrange for multiple graphs + as_document : bool + Whether to wrap the latex code in a document environment for compiling + document_wrapper : formatted text string with variable ``content``. + This text is called to evaluate the content embedded in a document + environment with a preamble setting up the TikZ syntax. + figure_wrapper : formatted text string + This text is evaluated with variables ``content``, ``caption`` and ``label``. + It wraps the content and if a caption is provided, adds the latex code for + that caption, and if a label is provided, adds the latex code for a label. + subfigure_wrapper : formatted text string + This text evaluate variables ``size``, ``content``, ``caption`` and ``label``. + It wraps the content and if a caption is provided, adds the latex code for + that caption, and if a label is provided, adds the latex code for a label. + The size is the vertical size of each row of subfigures as a fraction. + + See Also + ======== + to_latex + """ + path.write(to_latex(Gbunch, **options)) diff --git a/.venv/lib/python3.12/site-packages/networkx/drawing/nx_pydot.py b/.venv/lib/python3.12/site-packages/networkx/drawing/nx_pydot.py new file mode 100644 index 00000000..7df0c111 --- /dev/null +++ b/.venv/lib/python3.12/site-packages/networkx/drawing/nx_pydot.py @@ -0,0 +1,352 @@ +""" +***** +Pydot +***** + +Import and export NetworkX graphs in Graphviz dot format using pydot. + +Either this module or nx_agraph can be used to interface with graphviz. + +Examples +-------- +>>> G = nx.complete_graph(5) +>>> PG = nx.nx_pydot.to_pydot(G) +>>> H = nx.nx_pydot.from_pydot(PG) + +See Also +-------- + - pydot: https://github.com/erocarrera/pydot + - Graphviz: https://www.graphviz.org + - DOT Language: http://www.graphviz.org/doc/info/lang.html +""" + +from locale import getpreferredencoding + +import networkx as nx +from networkx.utils import open_file + +__all__ = [ + "write_dot", + "read_dot", + "graphviz_layout", + "pydot_layout", + "to_pydot", + "from_pydot", +] + + +@open_file(1, mode="w") +def write_dot(G, path): + """Write NetworkX graph G to Graphviz dot format on path. + + Path can be a string or a file handle. + """ + P = to_pydot(G) + path.write(P.to_string()) + return + + +@open_file(0, mode="r") +@nx._dispatchable(name="pydot_read_dot", graphs=None, returns_graph=True) +def read_dot(path): + """Returns a NetworkX :class:`MultiGraph` or :class:`MultiDiGraph` from the + dot file with the passed path. + + If this file contains multiple graphs, only the first such graph is + returned. All graphs _except_ the first are silently ignored. + + Parameters + ---------- + path : str or file + Filename or file handle. + + Returns + ------- + G : MultiGraph or MultiDiGraph + A :class:`MultiGraph` or :class:`MultiDiGraph`. + + Notes + ----- + Use `G = nx.Graph(nx.nx_pydot.read_dot(path))` to return a :class:`Graph` instead of a + :class:`MultiGraph`. + """ + import pydot + + data = path.read() + + # List of one or more "pydot.Dot" instances deserialized from this file. + P_list = pydot.graph_from_dot_data(data) + + # Convert only the first such instance into a NetworkX graph. + return from_pydot(P_list[0]) + + +@nx._dispatchable(graphs=None, returns_graph=True) +def from_pydot(P): + """Returns a NetworkX graph from a Pydot graph. + + Parameters + ---------- + P : Pydot graph + A graph created with Pydot + + Returns + ------- + G : NetworkX multigraph + A MultiGraph or MultiDiGraph. + + Examples + -------- + >>> K5 = nx.complete_graph(5) + >>> A = nx.nx_pydot.to_pydot(K5) + >>> G = nx.nx_pydot.from_pydot(A) # return MultiGraph + + # make a Graph instead of MultiGraph + >>> G = nx.Graph(nx.nx_pydot.from_pydot(A)) + + """ + + if P.get_strict(None): # pydot bug: get_strict() shouldn't take argument + multiedges = False + else: + multiedges = True + + if P.get_type() == "graph": # undirected + if multiedges: + N = nx.MultiGraph() + else: + N = nx.Graph() + else: + if multiedges: + N = nx.MultiDiGraph() + else: + N = nx.DiGraph() + + # assign defaults + name = P.get_name().strip('"') + if name != "": + N.name = name + + # add nodes, attributes to N.node_attr + for p in P.get_node_list(): + n = p.get_name().strip('"') + if n in ("node", "graph", "edge"): + continue + N.add_node(n, **p.get_attributes()) + + # add edges + for e in P.get_edge_list(): + u = e.get_source() + v = e.get_destination() + attr = e.get_attributes() + s = [] + d = [] + + if isinstance(u, str): + s.append(u.strip('"')) + else: + for unodes in u["nodes"]: + s.append(unodes.strip('"')) + + if isinstance(v, str): + d.append(v.strip('"')) + else: + for vnodes in v["nodes"]: + d.append(vnodes.strip('"')) + + for source_node in s: + for destination_node in d: + N.add_edge(source_node, destination_node, **attr) + + # add default attributes for graph, nodes, edges + pattr = P.get_attributes() + if pattr: + N.graph["graph"] = pattr + try: + N.graph["node"] = P.get_node_defaults()[0] + except (IndexError, TypeError): + pass # N.graph['node']={} + try: + N.graph["edge"] = P.get_edge_defaults()[0] + except (IndexError, TypeError): + pass # N.graph['edge']={} + return N + + +def to_pydot(N): + """Returns a pydot graph from a NetworkX graph N. + + Parameters + ---------- + N : NetworkX graph + A graph created with NetworkX + + Examples + -------- + >>> K5 = nx.complete_graph(5) + >>> P = nx.nx_pydot.to_pydot(K5) + + Notes + ----- + + """ + import pydot + + # set Graphviz graph type + if N.is_directed(): + graph_type = "digraph" + else: + graph_type = "graph" + strict = nx.number_of_selfloops(N) == 0 and not N.is_multigraph() + + name = N.name + graph_defaults = N.graph.get("graph", {}) + if name == "": + P = pydot.Dot("", graph_type=graph_type, strict=strict, **graph_defaults) + else: + P = pydot.Dot( + f'"{name}"', graph_type=graph_type, strict=strict, **graph_defaults + ) + try: + P.set_node_defaults(**N.graph["node"]) + except KeyError: + pass + try: + P.set_edge_defaults(**N.graph["edge"]) + except KeyError: + pass + + for n, nodedata in N.nodes(data=True): + str_nodedata = {str(k): str(v) for k, v in nodedata.items()} + n = str(n) + p = pydot.Node(n, **str_nodedata) + P.add_node(p) + + if N.is_multigraph(): + for u, v, key, edgedata in N.edges(data=True, keys=True): + str_edgedata = {str(k): str(v) for k, v in edgedata.items() if k != "key"} + u, v = str(u), str(v) + edge = pydot.Edge(u, v, key=str(key), **str_edgedata) + P.add_edge(edge) + + else: + for u, v, edgedata in N.edges(data=True): + str_edgedata = {str(k): str(v) for k, v in edgedata.items()} + u, v = str(u), str(v) + edge = pydot.Edge(u, v, **str_edgedata) + P.add_edge(edge) + return P + + +def graphviz_layout(G, prog="neato", root=None): + """Create node positions using Pydot and Graphviz. + + Returns a dictionary of positions keyed by node. + + Parameters + ---------- + G : NetworkX Graph + The graph for which the layout is computed. + prog : string (default: 'neato') + The name of the GraphViz program to use for layout. + Options depend on GraphViz version but may include: + 'dot', 'twopi', 'fdp', 'sfdp', 'circo' + root : Node from G or None (default: None) + The node of G from which to start some layout algorithms. + + Returns + ------- + Dictionary of (x, y) positions keyed by node. + + Examples + -------- + >>> G = nx.complete_graph(4) + >>> pos = nx.nx_pydot.graphviz_layout(G) + >>> pos = nx.nx_pydot.graphviz_layout(G, prog="dot") + + Notes + ----- + This is a wrapper for pydot_layout. + """ + return pydot_layout(G=G, prog=prog, root=root) + + +def pydot_layout(G, prog="neato", root=None): + """Create node positions using :mod:`pydot` and Graphviz. + + Parameters + ---------- + G : Graph + NetworkX graph to be laid out. + prog : string (default: 'neato') + Name of the GraphViz command to use for layout. + Options depend on GraphViz version but may include: + 'dot', 'twopi', 'fdp', 'sfdp', 'circo' + root : Node from G or None (default: None) + The node of G from which to start some layout algorithms. + + Returns + ------- + dict + Dictionary of positions keyed by node. + + Examples + -------- + >>> G = nx.complete_graph(4) + >>> pos = nx.nx_pydot.pydot_layout(G) + >>> pos = nx.nx_pydot.pydot_layout(G, prog="dot") + + Notes + ----- + If you use complex node objects, they may have the same string + representation and GraphViz could treat them as the same node. + The layout may assign both nodes a single location. See Issue #1568 + If this occurs in your case, consider relabeling the nodes just + for the layout computation using something similar to:: + + H = nx.convert_node_labels_to_integers(G, label_attribute="node_label") + H_layout = nx.nx_pydot.pydot_layout(H, prog="dot") + G_layout = {H.nodes[n]["node_label"]: p for n, p in H_layout.items()} + + """ + import pydot + + P = to_pydot(G) + if root is not None: + P.set("root", str(root)) + + # List of low-level bytes comprising a string in the dot language converted + # from the passed graph with the passed external GraphViz command. + D_bytes = P.create_dot(prog=prog) + + # Unique string decoded from these bytes with the preferred locale encoding + D = str(D_bytes, encoding=getpreferredencoding()) + + if D == "": # no data returned + print(f"Graphviz layout with {prog} failed") + print() + print("To debug what happened try:") + print("P = nx.nx_pydot.to_pydot(G)") + print('P.write_dot("file.dot")') + print(f"And then run {prog} on file.dot") + return + + # List of one or more "pydot.Dot" instances deserialized from this string. + Q_list = pydot.graph_from_dot_data(D) + assert len(Q_list) == 1 + + # The first and only such instance, as guaranteed by the above assertion. + Q = Q_list[0] + + node_pos = {} + for n in G.nodes(): + str_n = str(n) + node = Q.get_node(pydot.quote_id_if_necessary(str_n)) + + if isinstance(node, list): + node = node[0] + pos = node.get_pos()[1:-1] # strip leading and trailing double quotes + if pos is not None: + xx, yy = pos.split(",") + node_pos[n] = (float(xx), float(yy)) + return node_pos diff --git a/.venv/lib/python3.12/site-packages/networkx/drawing/nx_pylab.py b/.venv/lib/python3.12/site-packages/networkx/drawing/nx_pylab.py new file mode 100644 index 00000000..c4d24cc0 --- /dev/null +++ b/.venv/lib/python3.12/site-packages/networkx/drawing/nx_pylab.py @@ -0,0 +1,1979 @@ +""" +********** +Matplotlib +********** + +Draw networks with matplotlib. + +Examples +-------- +>>> G = nx.complete_graph(5) +>>> nx.draw(G) + +See Also +-------- + - :doc:`matplotlib <matplotlib:index>` + - :func:`matplotlib.pyplot.scatter` + - :obj:`matplotlib.patches.FancyArrowPatch` +""" + +import collections +import itertools +from numbers import Number + +import networkx as nx +from networkx.drawing.layout import ( + circular_layout, + forceatlas2_layout, + kamada_kawai_layout, + planar_layout, + random_layout, + shell_layout, + spectral_layout, + spring_layout, +) + +__all__ = [ + "draw", + "draw_networkx", + "draw_networkx_nodes", + "draw_networkx_edges", + "draw_networkx_labels", + "draw_networkx_edge_labels", + "draw_circular", + "draw_kamada_kawai", + "draw_random", + "draw_spectral", + "draw_spring", + "draw_planar", + "draw_shell", + "draw_forceatlas2", +] + + +def draw(G, pos=None, ax=None, **kwds): + """Draw the graph G with Matplotlib. + + Draw the graph as a simple representation with no node + labels or edge labels and using the full Matplotlib figure area + and no axis labels by default. See draw_networkx() for more + full-featured drawing that allows title, axis labels etc. + + Parameters + ---------- + G : graph + A networkx graph + + pos : dictionary, optional + A dictionary with nodes as keys and positions as values. + If not specified a spring layout positioning will be computed. + See :py:mod:`networkx.drawing.layout` for functions that + compute node positions. + + ax : Matplotlib Axes object, optional + Draw the graph in specified Matplotlib axes. + + kwds : optional keywords + See networkx.draw_networkx() for a description of optional keywords. + + Examples + -------- + >>> G = nx.dodecahedral_graph() + >>> nx.draw(G) + >>> nx.draw(G, pos=nx.spring_layout(G)) # use spring layout + + See Also + -------- + draw_networkx + draw_networkx_nodes + draw_networkx_edges + draw_networkx_labels + draw_networkx_edge_labels + + Notes + ----- + This function has the same name as pylab.draw and pyplot.draw + so beware when using `from networkx import *` + + since you might overwrite the pylab.draw function. + + With pyplot use + + >>> import matplotlib.pyplot as plt + >>> G = nx.dodecahedral_graph() + >>> nx.draw(G) # networkx draw() + >>> plt.draw() # pyplot draw() + + Also see the NetworkX drawing examples at + https://networkx.org/documentation/latest/auto_examples/index.html + """ + import matplotlib.pyplot as plt + + if ax is None: + cf = plt.gcf() + else: + cf = ax.get_figure() + cf.set_facecolor("w") + if ax is None: + if cf.axes: + ax = cf.gca() + else: + ax = cf.add_axes((0, 0, 1, 1)) + + if "with_labels" not in kwds: + kwds["with_labels"] = "labels" in kwds + + draw_networkx(G, pos=pos, ax=ax, **kwds) + ax.set_axis_off() + plt.draw_if_interactive() + return + + +def draw_networkx(G, pos=None, arrows=None, with_labels=True, **kwds): + r"""Draw the graph G using Matplotlib. + + Draw the graph with Matplotlib with options for node positions, + labeling, titles, and many other drawing features. + See draw() for simple drawing without labels or axes. + + Parameters + ---------- + G : graph + A networkx graph + + pos : dictionary, optional + A dictionary with nodes as keys and positions as values. + If not specified a spring layout positioning will be computed. + See :py:mod:`networkx.drawing.layout` for functions that + compute node positions. + + arrows : bool or None, optional (default=None) + If `None`, directed graphs draw arrowheads with + `~matplotlib.patches.FancyArrowPatch`, while undirected graphs draw edges + via `~matplotlib.collections.LineCollection` for speed. + If `True`, draw arrowheads with FancyArrowPatches (bendable and stylish). + If `False`, draw edges using LineCollection (linear and fast). + For directed graphs, if True draw arrowheads. + Note: Arrows will be the same color as edges. + + arrowstyle : str (default='-\|>' for directed graphs) + For directed graphs, choose the style of the arrowsheads. + For undirected graphs default to '-' + + See `matplotlib.patches.ArrowStyle` for more options. + + arrowsize : int or list (default=10) + For directed graphs, choose the size of the arrow head's length and + width. A list of values can be passed in to assign a different size for arrow head's length and width. + See `matplotlib.patches.FancyArrowPatch` for attribute `mutation_scale` + for more info. + + with_labels : bool (default=True) + Set to True to draw labels on the nodes. + + ax : Matplotlib Axes object, optional + Draw the graph in the specified Matplotlib axes. + + nodelist : list (default=list(G)) + Draw only specified nodes + + edgelist : list (default=list(G.edges())) + Draw only specified edges + + node_size : scalar or array (default=300) + Size of nodes. If an array is specified it must be the + same length as nodelist. + + node_color : color or array of colors (default='#1f78b4') + Node color. Can be a single color or a sequence of colors with the same + length as nodelist. Color can be string or rgb (or rgba) tuple of + floats from 0-1. If numeric values are specified they will be + mapped to colors using the cmap and vmin,vmax parameters. See + matplotlib.scatter for more details. + + node_shape : string (default='o') + The shape of the node. Specification is as matplotlib.scatter + marker, one of 'so^>v<dph8'. + + alpha : float or None (default=None) + The node and edge transparency + + cmap : Matplotlib colormap, optional + Colormap for mapping intensities of nodes + + vmin,vmax : float, optional + Minimum and maximum for node colormap scaling + + linewidths : scalar or sequence (default=1.0) + Line width of symbol border + + width : float or array of floats (default=1.0) + Line width of edges + + edge_color : color or array of colors (default='k') + Edge color. Can be a single color or a sequence of colors with the same + length as edgelist. Color can be string or rgb (or rgba) tuple of + floats from 0-1. If numeric values are specified they will be + mapped to colors using the edge_cmap and edge_vmin,edge_vmax parameters. + + edge_cmap : Matplotlib colormap, optional + Colormap for mapping intensities of edges + + edge_vmin,edge_vmax : floats, optional + Minimum and maximum for edge colormap scaling + + style : string (default=solid line) + Edge line style e.g.: '-', '--', '-.', ':' + or words like 'solid' or 'dashed'. + (See `matplotlib.patches.FancyArrowPatch`: `linestyle`) + + labels : dictionary (default=None) + Node labels in a dictionary of text labels keyed by node + + font_size : int (default=12 for nodes, 10 for edges) + Font size for text labels + + font_color : color (default='k' black) + Font color string. Color can be string or rgb (or rgba) tuple of + floats from 0-1. + + font_weight : string (default='normal') + Font weight + + font_family : string (default='sans-serif') + Font family + + label : string, optional + Label for graph legend + + hide_ticks : bool, optional + Hide ticks of axes. When `True` (the default), ticks and ticklabels + are removed from the axes. To set ticks and tick labels to the pyplot default, + use ``hide_ticks=False``. + + kwds : optional keywords + See networkx.draw_networkx_nodes(), networkx.draw_networkx_edges(), and + networkx.draw_networkx_labels() for a description of optional keywords. + + Notes + ----- + For directed graphs, arrows are drawn at the head end. Arrows can be + turned off with keyword arrows=False. + + Examples + -------- + >>> G = nx.dodecahedral_graph() + >>> nx.draw(G) + >>> nx.draw(G, pos=nx.spring_layout(G)) # use spring layout + + >>> import matplotlib.pyplot as plt + >>> limits = plt.axis("off") # turn off axis + + Also see the NetworkX drawing examples at + https://networkx.org/documentation/latest/auto_examples/index.html + + See Also + -------- + draw + draw_networkx_nodes + draw_networkx_edges + draw_networkx_labels + draw_networkx_edge_labels + """ + from inspect import signature + + import matplotlib.pyplot as plt + + # Get all valid keywords by inspecting the signatures of draw_networkx_nodes, + # draw_networkx_edges, draw_networkx_labels + + valid_node_kwds = signature(draw_networkx_nodes).parameters.keys() + valid_edge_kwds = signature(draw_networkx_edges).parameters.keys() + valid_label_kwds = signature(draw_networkx_labels).parameters.keys() + + # Create a set with all valid keywords across the three functions and + # remove the arguments of this function (draw_networkx) + valid_kwds = (valid_node_kwds | valid_edge_kwds | valid_label_kwds) - { + "G", + "pos", + "arrows", + "with_labels", + } + + if any(k not in valid_kwds for k in kwds): + invalid_args = ", ".join([k for k in kwds if k not in valid_kwds]) + raise ValueError(f"Received invalid argument(s): {invalid_args}") + + node_kwds = {k: v for k, v in kwds.items() if k in valid_node_kwds} + edge_kwds = {k: v for k, v in kwds.items() if k in valid_edge_kwds} + label_kwds = {k: v for k, v in kwds.items() if k in valid_label_kwds} + + if pos is None: + pos = nx.drawing.spring_layout(G) # default to spring layout + + draw_networkx_nodes(G, pos, **node_kwds) + draw_networkx_edges(G, pos, arrows=arrows, **edge_kwds) + if with_labels: + draw_networkx_labels(G, pos, **label_kwds) + plt.draw_if_interactive() + + +def draw_networkx_nodes( + G, + pos, + nodelist=None, + node_size=300, + node_color="#1f78b4", + node_shape="o", + alpha=None, + cmap=None, + vmin=None, + vmax=None, + ax=None, + linewidths=None, + edgecolors=None, + label=None, + margins=None, + hide_ticks=True, +): + """Draw the nodes of the graph G. + + This draws only the nodes of the graph G. + + Parameters + ---------- + G : graph + A networkx graph + + pos : dictionary + A dictionary with nodes as keys and positions as values. + Positions should be sequences of length 2. + + ax : Matplotlib Axes object, optional + Draw the graph in the specified Matplotlib axes. + + nodelist : list (default list(G)) + Draw only specified nodes + + node_size : scalar or array (default=300) + Size of nodes. If an array it must be the same length as nodelist. + + node_color : color or array of colors (default='#1f78b4') + Node color. Can be a single color or a sequence of colors with the same + length as nodelist. Color can be string or rgb (or rgba) tuple of + floats from 0-1. If numeric values are specified they will be + mapped to colors using the cmap and vmin,vmax parameters. See + matplotlib.scatter for more details. + + node_shape : string (default='o') + The shape of the node. Specification is as matplotlib.scatter + marker, one of 'so^>v<dph8'. + + alpha : float or array of floats (default=None) + The node transparency. This can be a single alpha value, + in which case it will be applied to all the nodes of color. Otherwise, + if it is an array, the elements of alpha will be applied to the colors + in order (cycling through alpha multiple times if necessary). + + cmap : Matplotlib colormap (default=None) + Colormap for mapping intensities of nodes + + vmin,vmax : floats or None (default=None) + Minimum and maximum for node colormap scaling + + linewidths : [None | scalar | sequence] (default=1.0) + Line width of symbol border + + edgecolors : [None | scalar | sequence] (default = node_color) + Colors of node borders. Can be a single color or a sequence of colors with the + same length as nodelist. Color can be string or rgb (or rgba) tuple of floats + from 0-1. If numeric values are specified they will be mapped to colors + using the cmap and vmin,vmax parameters. See `~matplotlib.pyplot.scatter` for more details. + + label : [None | string] + Label for legend + + margins : float or 2-tuple, optional + Sets the padding for axis autoscaling. Increase margin to prevent + clipping for nodes that are near the edges of an image. Values should + be in the range ``[0, 1]``. See :meth:`matplotlib.axes.Axes.margins` + for details. The default is `None`, which uses the Matplotlib default. + + hide_ticks : bool, optional + Hide ticks of axes. When `True` (the default), ticks and ticklabels + are removed from the axes. To set ticks and tick labels to the pyplot default, + use ``hide_ticks=False``. + + Returns + ------- + matplotlib.collections.PathCollection + `PathCollection` of the nodes. + + Examples + -------- + >>> G = nx.dodecahedral_graph() + >>> nodes = nx.draw_networkx_nodes(G, pos=nx.spring_layout(G)) + + Also see the NetworkX drawing examples at + https://networkx.org/documentation/latest/auto_examples/index.html + + See Also + -------- + draw + draw_networkx + draw_networkx_edges + draw_networkx_labels + draw_networkx_edge_labels + """ + from collections.abc import Iterable + + import matplotlib as mpl + import matplotlib.collections # call as mpl.collections + import matplotlib.pyplot as plt + import numpy as np + + if ax is None: + ax = plt.gca() + + if nodelist is None: + nodelist = list(G) + + if len(nodelist) == 0: # empty nodelist, no drawing + return mpl.collections.PathCollection(None) + + try: + xy = np.asarray([pos[v] for v in nodelist]) + except KeyError as err: + raise nx.NetworkXError(f"Node {err} has no position.") from err + + if isinstance(alpha, Iterable): + node_color = apply_alpha(node_color, alpha, nodelist, cmap, vmin, vmax) + alpha = None + + if not isinstance(node_shape, np.ndarray) and not isinstance(node_shape, list): + node_shape = np.array([node_shape for _ in range(len(nodelist))]) + + for shape in np.unique(node_shape): + node_collection = ax.scatter( + xy[node_shape == shape, 0], + xy[node_shape == shape, 1], + s=node_size, + c=node_color, + marker=shape, + cmap=cmap, + vmin=vmin, + vmax=vmax, + alpha=alpha, + linewidths=linewidths, + edgecolors=edgecolors, + label=label, + ) + if hide_ticks: + ax.tick_params( + axis="both", + which="both", + bottom=False, + left=False, + labelbottom=False, + labelleft=False, + ) + + if margins is not None: + if isinstance(margins, Iterable): + ax.margins(*margins) + else: + ax.margins(margins) + + node_collection.set_zorder(2) + return node_collection + + +class FancyArrowFactory: + """Draw arrows with `matplotlib.patches.FancyarrowPatch`""" + + class ConnectionStyleFactory: + def __init__(self, connectionstyles, selfloop_height, ax=None): + import matplotlib as mpl + import matplotlib.path # call as mpl.path + import numpy as np + + self.ax = ax + self.mpl = mpl + self.np = np + self.base_connection_styles = [ + mpl.patches.ConnectionStyle(cs) for cs in connectionstyles + ] + self.n = len(self.base_connection_styles) + self.selfloop_height = selfloop_height + + def curved(self, edge_index): + return self.base_connection_styles[edge_index % self.n] + + def self_loop(self, edge_index): + def self_loop_connection(posA, posB, *args, **kwargs): + if not self.np.all(posA == posB): + raise nx.NetworkXError( + "`self_loop` connection style method" + "is only to be used for self-loops" + ) + # this is called with _screen space_ values + # so convert back to data space + data_loc = self.ax.transData.inverted().transform(posA) + v_shift = 0.1 * self.selfloop_height + h_shift = v_shift * 0.5 + # put the top of the loop first so arrow is not hidden by node + path = self.np.asarray( + [ + # 1 + [0, v_shift], + # 4 4 4 + [h_shift, v_shift], + [h_shift, 0], + [0, 0], + # 4 4 4 + [-h_shift, 0], + [-h_shift, v_shift], + [0, v_shift], + ] + ) + # Rotate self loop 90 deg. if more than 1 + # This will allow for maximum of 4 visible self loops + if edge_index % 4: + x, y = path.T + for _ in range(edge_index % 4): + x, y = y, -x + path = self.np.array([x, y]).T + return self.mpl.path.Path( + self.ax.transData.transform(data_loc + path), [1, 4, 4, 4, 4, 4, 4] + ) + + return self_loop_connection + + def __init__( + self, + edge_pos, + edgelist, + nodelist, + edge_indices, + node_size, + selfloop_height, + connectionstyle="arc3", + node_shape="o", + arrowstyle="-", + arrowsize=10, + edge_color="k", + alpha=None, + linewidth=1.0, + style="solid", + min_source_margin=0, + min_target_margin=0, + ax=None, + ): + import matplotlib as mpl + import matplotlib.patches # call as mpl.patches + import matplotlib.pyplot as plt + import numpy as np + + if isinstance(connectionstyle, str): + connectionstyle = [connectionstyle] + elif np.iterable(connectionstyle): + connectionstyle = list(connectionstyle) + else: + msg = "ConnectionStyleFactory arg `connectionstyle` must be str or iterable" + raise nx.NetworkXError(msg) + self.ax = ax + self.mpl = mpl + self.np = np + self.edge_pos = edge_pos + self.edgelist = edgelist + self.nodelist = nodelist + self.node_shape = node_shape + self.min_source_margin = min_source_margin + self.min_target_margin = min_target_margin + self.edge_indices = edge_indices + self.node_size = node_size + self.connectionstyle_factory = self.ConnectionStyleFactory( + connectionstyle, selfloop_height, ax + ) + self.arrowstyle = arrowstyle + self.arrowsize = arrowsize + self.arrow_colors = mpl.colors.colorConverter.to_rgba_array(edge_color, alpha) + self.linewidth = linewidth + self.style = style + if isinstance(arrowsize, list) and len(arrowsize) != len(edge_pos): + raise ValueError("arrowsize should have the same length as edgelist") + + def __call__(self, i): + (x1, y1), (x2, y2) = self.edge_pos[i] + shrink_source = 0 # space from source to tail + shrink_target = 0 # space from head to target + if ( + self.np.iterable(self.min_source_margin) + and not isinstance(self.min_source_margin, str) + and not isinstance(self.min_source_margin, tuple) + ): + min_source_margin = self.min_source_margin[i] + else: + min_source_margin = self.min_source_margin + + if ( + self.np.iterable(self.min_target_margin) + and not isinstance(self.min_target_margin, str) + and not isinstance(self.min_target_margin, tuple) + ): + min_target_margin = self.min_target_margin[i] + else: + min_target_margin = self.min_target_margin + + if self.np.iterable(self.node_size): # many node sizes + source, target = self.edgelist[i][:2] + source_node_size = self.node_size[self.nodelist.index(source)] + target_node_size = self.node_size[self.nodelist.index(target)] + shrink_source = self.to_marker_edge(source_node_size, self.node_shape) + shrink_target = self.to_marker_edge(target_node_size, self.node_shape) + else: + shrink_source = self.to_marker_edge(self.node_size, self.node_shape) + shrink_target = shrink_source + shrink_source = max(shrink_source, min_source_margin) + shrink_target = max(shrink_target, min_target_margin) + + # scale factor of arrow head + if isinstance(self.arrowsize, list): + mutation_scale = self.arrowsize[i] + else: + mutation_scale = self.arrowsize + + if len(self.arrow_colors) > i: + arrow_color = self.arrow_colors[i] + elif len(self.arrow_colors) == 1: + arrow_color = self.arrow_colors[0] + else: # Cycle through colors + arrow_color = self.arrow_colors[i % len(self.arrow_colors)] + + if self.np.iterable(self.linewidth): + if len(self.linewidth) > i: + linewidth = self.linewidth[i] + else: + linewidth = self.linewidth[i % len(self.linewidth)] + else: + linewidth = self.linewidth + + if ( + self.np.iterable(self.style) + and not isinstance(self.style, str) + and not isinstance(self.style, tuple) + ): + if len(self.style) > i: + linestyle = self.style[i] + else: # Cycle through styles + linestyle = self.style[i % len(self.style)] + else: + linestyle = self.style + + if x1 == x2 and y1 == y2: + connectionstyle = self.connectionstyle_factory.self_loop( + self.edge_indices[i] + ) + else: + connectionstyle = self.connectionstyle_factory.curved(self.edge_indices[i]) + + if ( + self.np.iterable(self.arrowstyle) + and not isinstance(self.arrowstyle, str) + and not isinstance(self.arrowstyle, tuple) + ): + arrowstyle = self.arrowstyle[i] + else: + arrowstyle = self.arrowstyle + + return self.mpl.patches.FancyArrowPatch( + (x1, y1), + (x2, y2), + arrowstyle=arrowstyle, + shrinkA=shrink_source, + shrinkB=shrink_target, + mutation_scale=mutation_scale, + color=arrow_color, + linewidth=linewidth, + connectionstyle=connectionstyle, + linestyle=linestyle, + zorder=1, # arrows go behind nodes + ) + + def to_marker_edge(self, marker_size, marker): + if marker in "s^>v<d": # `large` markers need extra space + return self.np.sqrt(2 * marker_size) / 2 + else: + return self.np.sqrt(marker_size) / 2 + + +def draw_networkx_edges( + G, + pos, + edgelist=None, + width=1.0, + edge_color="k", + style="solid", + alpha=None, + arrowstyle=None, + arrowsize=10, + edge_cmap=None, + edge_vmin=None, + edge_vmax=None, + ax=None, + arrows=None, + label=None, + node_size=300, + nodelist=None, + node_shape="o", + connectionstyle="arc3", + min_source_margin=0, + min_target_margin=0, + hide_ticks=True, +): + r"""Draw the edges of the graph G. + + This draws only the edges of the graph G. + + Parameters + ---------- + G : graph + A networkx graph + + pos : dictionary + A dictionary with nodes as keys and positions as values. + Positions should be sequences of length 2. + + edgelist : collection of edge tuples (default=G.edges()) + Draw only specified edges + + width : float or array of floats (default=1.0) + Line width of edges + + edge_color : color or array of colors (default='k') + Edge color. Can be a single color or a sequence of colors with the same + length as edgelist. Color can be string or rgb (or rgba) tuple of + floats from 0-1. If numeric values are specified they will be + mapped to colors using the edge_cmap and edge_vmin,edge_vmax parameters. + + style : string or array of strings (default='solid') + Edge line style e.g.: '-', '--', '-.', ':' + or words like 'solid' or 'dashed'. + Can be a single style or a sequence of styles with the same + length as the edge list. + If less styles than edges are given the styles will cycle. + If more styles than edges are given the styles will be used sequentially + and not be exhausted. + Also, `(offset, onoffseq)` tuples can be used as style instead of a strings. + (See `matplotlib.patches.FancyArrowPatch`: `linestyle`) + + alpha : float or array of floats (default=None) + The edge transparency. This can be a single alpha value, + in which case it will be applied to all specified edges. Otherwise, + if it is an array, the elements of alpha will be applied to the colors + in order (cycling through alpha multiple times if necessary). + + edge_cmap : Matplotlib colormap, optional + Colormap for mapping intensities of edges + + edge_vmin,edge_vmax : floats, optional + Minimum and maximum for edge colormap scaling + + ax : Matplotlib Axes object, optional + Draw the graph in the specified Matplotlib axes. + + arrows : bool or None, optional (default=None) + If `None`, directed graphs draw arrowheads with + `~matplotlib.patches.FancyArrowPatch`, while undirected graphs draw edges + via `~matplotlib.collections.LineCollection` for speed. + If `True`, draw arrowheads with FancyArrowPatches (bendable and stylish). + If `False`, draw edges using LineCollection (linear and fast). + + Note: Arrowheads will be the same color as edges. + + arrowstyle : str or list of strs (default='-\|>' for directed graphs) + For directed graphs and `arrows==True` defaults to '-\|>', + For undirected graphs default to '-'. + + See `matplotlib.patches.ArrowStyle` for more options. + + arrowsize : int or list of ints(default=10) + For directed graphs, choose the size of the arrow head's length and + width. See `matplotlib.patches.FancyArrowPatch` for attribute + `mutation_scale` for more info. + + connectionstyle : string or iterable of strings (default="arc3") + Pass the connectionstyle parameter to create curved arc of rounding + radius rad. For example, connectionstyle='arc3,rad=0.2'. + See `matplotlib.patches.ConnectionStyle` and + `matplotlib.patches.FancyArrowPatch` for more info. + If Iterable, index indicates i'th edge key of MultiGraph + + node_size : scalar or array (default=300) + Size of nodes. Though the nodes are not drawn with this function, the + node size is used in determining edge positioning. + + nodelist : list, optional (default=G.nodes()) + This provides the node order for the `node_size` array (if it is an array). + + node_shape : string (default='o') + The marker used for nodes, used in determining edge positioning. + Specification is as a `matplotlib.markers` marker, e.g. one of 'so^>v<dph8'. + + label : None or string + Label for legend + + min_source_margin : int or list of ints (default=0) + The minimum margin (gap) at the beginning of the edge at the source. + + min_target_margin : int or list of ints (default=0) + The minimum margin (gap) at the end of the edge at the target. + + hide_ticks : bool, optional + Hide ticks of axes. When `True` (the default), ticks and ticklabels + are removed from the axes. To set ticks and tick labels to the pyplot default, + use ``hide_ticks=False``. + + Returns + ------- + matplotlib.collections.LineCollection or a list of matplotlib.patches.FancyArrowPatch + If ``arrows=True``, a list of FancyArrowPatches is returned. + If ``arrows=False``, a LineCollection is returned. + If ``arrows=None`` (the default), then a LineCollection is returned if + `G` is undirected, otherwise returns a list of FancyArrowPatches. + + Notes + ----- + For directed graphs, arrows are drawn at the head end. Arrows can be + turned off with keyword arrows=False or by passing an arrowstyle without + an arrow on the end. + + Be sure to include `node_size` as a keyword argument; arrows are + drawn considering the size of nodes. + + Self-loops are always drawn with `~matplotlib.patches.FancyArrowPatch` + regardless of the value of `arrows` or whether `G` is directed. + When ``arrows=False`` or ``arrows=None`` and `G` is undirected, the + FancyArrowPatches corresponding to the self-loops are not explicitly + returned. They should instead be accessed via the ``Axes.patches`` + attribute (see examples). + + Examples + -------- + >>> G = nx.dodecahedral_graph() + >>> edges = nx.draw_networkx_edges(G, pos=nx.spring_layout(G)) + + >>> G = nx.DiGraph() + >>> G.add_edges_from([(1, 2), (1, 3), (2, 3)]) + >>> arcs = nx.draw_networkx_edges(G, pos=nx.spring_layout(G)) + >>> alphas = [0.3, 0.4, 0.5] + >>> for i, arc in enumerate(arcs): # change alpha values of arcs + ... arc.set_alpha(alphas[i]) + + The FancyArrowPatches corresponding to self-loops are not always + returned, but can always be accessed via the ``patches`` attribute of the + `matplotlib.Axes` object. + + >>> import matplotlib.pyplot as plt + >>> fig, ax = plt.subplots() + >>> G = nx.Graph([(0, 1), (0, 0)]) # Self-loop at node 0 + >>> edge_collection = nx.draw_networkx_edges(G, pos=nx.circular_layout(G), ax=ax) + >>> self_loop_fap = ax.patches[0] + + Also see the NetworkX drawing examples at + https://networkx.org/documentation/latest/auto_examples/index.html + + See Also + -------- + draw + draw_networkx + draw_networkx_nodes + draw_networkx_labels + draw_networkx_edge_labels + + """ + import warnings + + import matplotlib as mpl + import matplotlib.collections # call as mpl.collections + import matplotlib.colors # call as mpl.colors + import matplotlib.pyplot as plt + import numpy as np + + # The default behavior is to use LineCollection to draw edges for + # undirected graphs (for performance reasons) and use FancyArrowPatches + # for directed graphs. + # The `arrows` keyword can be used to override the default behavior + if arrows is None: + use_linecollection = not (G.is_directed() or G.is_multigraph()) + else: + if not isinstance(arrows, bool): + raise TypeError("Argument `arrows` must be of type bool or None") + use_linecollection = not arrows + + if isinstance(connectionstyle, str): + connectionstyle = [connectionstyle] + elif np.iterable(connectionstyle): + connectionstyle = list(connectionstyle) + else: + msg = "draw_networkx_edges arg `connectionstyle` must be str or iterable" + raise nx.NetworkXError(msg) + + # Some kwargs only apply to FancyArrowPatches. Warn users when they use + # non-default values for these kwargs when LineCollection is being used + # instead of silently ignoring the specified option + if use_linecollection: + msg = ( + "\n\nThe {0} keyword argument is not applicable when drawing edges\n" + "with LineCollection.\n\n" + "To make this warning go away, either specify `arrows=True` to\n" + "force FancyArrowPatches or use the default values.\n" + "Note that using FancyArrowPatches may be slow for large graphs.\n" + ) + if arrowstyle is not None: + warnings.warn(msg.format("arrowstyle"), category=UserWarning, stacklevel=2) + if arrowsize != 10: + warnings.warn(msg.format("arrowsize"), category=UserWarning, stacklevel=2) + if min_source_margin != 0: + warnings.warn( + msg.format("min_source_margin"), category=UserWarning, stacklevel=2 + ) + if min_target_margin != 0: + warnings.warn( + msg.format("min_target_margin"), category=UserWarning, stacklevel=2 + ) + if any(cs != "arc3" for cs in connectionstyle): + warnings.warn( + msg.format("connectionstyle"), category=UserWarning, stacklevel=2 + ) + + # NOTE: Arrowstyle modification must occur after the warnings section + if arrowstyle is None: + arrowstyle = "-|>" if G.is_directed() else "-" + + if ax is None: + ax = plt.gca() + + if edgelist is None: + edgelist = list(G.edges) # (u, v, k) for multigraph (u, v) otherwise + + if len(edgelist): + if G.is_multigraph(): + key_count = collections.defaultdict(lambda: itertools.count(0)) + edge_indices = [next(key_count[tuple(e[:2])]) for e in edgelist] + else: + edge_indices = [0] * len(edgelist) + else: # no edges! + return [] + + if nodelist is None: + nodelist = list(G.nodes()) + + # FancyArrowPatch handles color=None different from LineCollection + if edge_color is None: + edge_color = "k" + + # set edge positions + edge_pos = np.asarray([(pos[e[0]], pos[e[1]]) for e in edgelist]) + + # Check if edge_color is an array of floats and map to edge_cmap. + # This is the only case handled differently from matplotlib + if ( + np.iterable(edge_color) + and (len(edge_color) == len(edge_pos)) + and np.all([isinstance(c, Number) for c in edge_color]) + ): + if edge_cmap is not None: + assert isinstance(edge_cmap, mpl.colors.Colormap) + else: + edge_cmap = plt.get_cmap() + if edge_vmin is None: + edge_vmin = min(edge_color) + if edge_vmax is None: + edge_vmax = max(edge_color) + color_normal = mpl.colors.Normalize(vmin=edge_vmin, vmax=edge_vmax) + edge_color = [edge_cmap(color_normal(e)) for e in edge_color] + + # compute initial view + minx = np.amin(np.ravel(edge_pos[:, :, 0])) + maxx = np.amax(np.ravel(edge_pos[:, :, 0])) + miny = np.amin(np.ravel(edge_pos[:, :, 1])) + maxy = np.amax(np.ravel(edge_pos[:, :, 1])) + w = maxx - minx + h = maxy - miny + + # Self-loops are scaled by view extent, except in cases the extent + # is 0, e.g. for a single node. In this case, fall back to scaling + # by the maximum node size + selfloop_height = h if h != 0 else 0.005 * np.array(node_size).max() + fancy_arrow_factory = FancyArrowFactory( + edge_pos, + edgelist, + nodelist, + edge_indices, + node_size, + selfloop_height, + connectionstyle, + node_shape, + arrowstyle, + arrowsize, + edge_color, + alpha, + width, + style, + min_source_margin, + min_target_margin, + ax=ax, + ) + + # Draw the edges + if use_linecollection: + edge_collection = mpl.collections.LineCollection( + edge_pos, + colors=edge_color, + linewidths=width, + antialiaseds=(1,), + linestyle=style, + alpha=alpha, + ) + edge_collection.set_cmap(edge_cmap) + edge_collection.set_clim(edge_vmin, edge_vmax) + edge_collection.set_zorder(1) # edges go behind nodes + edge_collection.set_label(label) + ax.add_collection(edge_collection) + edge_viz_obj = edge_collection + + # Make sure selfloop edges are also drawn + # --------------------------------------- + selfloops_to_draw = [loop for loop in nx.selfloop_edges(G) if loop in edgelist] + if selfloops_to_draw: + edgelist_tuple = list(map(tuple, edgelist)) + arrow_collection = [] + for loop in selfloops_to_draw: + i = edgelist_tuple.index(loop) + arrow = fancy_arrow_factory(i) + arrow_collection.append(arrow) + ax.add_patch(arrow) + else: + edge_viz_obj = [] + for i in range(len(edgelist)): + arrow = fancy_arrow_factory(i) + ax.add_patch(arrow) + edge_viz_obj.append(arrow) + + # update view after drawing + padx, pady = 0.05 * w, 0.05 * h + corners = (minx - padx, miny - pady), (maxx + padx, maxy + pady) + ax.update_datalim(corners) + ax.autoscale_view() + + if hide_ticks: + ax.tick_params( + axis="both", + which="both", + bottom=False, + left=False, + labelbottom=False, + labelleft=False, + ) + + return edge_viz_obj + + +def draw_networkx_labels( + G, + pos, + labels=None, + font_size=12, + font_color="k", + font_family="sans-serif", + font_weight="normal", + alpha=None, + bbox=None, + horizontalalignment="center", + verticalalignment="center", + ax=None, + clip_on=True, + hide_ticks=True, +): + """Draw node labels on the graph G. + + Parameters + ---------- + G : graph + A networkx graph + + pos : dictionary + A dictionary with nodes as keys and positions as values. + Positions should be sequences of length 2. + + labels : dictionary (default={n: n for n in G}) + Node labels in a dictionary of text labels keyed by node. + Node-keys in labels should appear as keys in `pos`. + If needed use: `{n:lab for n,lab in labels.items() if n in pos}` + + font_size : int or dictionary of nodes to ints (default=12) + Font size for text labels. + + font_color : color or dictionary of nodes to colors (default='k' black) + Font color string. Color can be string or rgb (or rgba) tuple of + floats from 0-1. + + font_weight : string or dictionary of nodes to strings (default='normal') + Font weight. + + font_family : string or dictionary of nodes to strings (default='sans-serif') + Font family. + + alpha : float or None or dictionary of nodes to floats (default=None) + The text transparency. + + bbox : Matplotlib bbox, (default is Matplotlib's ax.text default) + Specify text box properties (e.g. shape, color etc.) for node labels. + + horizontalalignment : string or array of strings (default='center') + Horizontal alignment {'center', 'right', 'left'}. If an array is + specified it must be the same length as `nodelist`. + + verticalalignment : string (default='center') + Vertical alignment {'center', 'top', 'bottom', 'baseline', 'center_baseline'}. + If an array is specified it must be the same length as `nodelist`. + + ax : Matplotlib Axes object, optional + Draw the graph in the specified Matplotlib axes. + + clip_on : bool (default=True) + Turn on clipping of node labels at axis boundaries + + hide_ticks : bool, optional + Hide ticks of axes. When `True` (the default), ticks and ticklabels + are removed from the axes. To set ticks and tick labels to the pyplot default, + use ``hide_ticks=False``. + + Returns + ------- + dict + `dict` of labels keyed on the nodes + + Examples + -------- + >>> G = nx.dodecahedral_graph() + >>> labels = nx.draw_networkx_labels(G, pos=nx.spring_layout(G)) + + Also see the NetworkX drawing examples at + https://networkx.org/documentation/latest/auto_examples/index.html + + See Also + -------- + draw + draw_networkx + draw_networkx_nodes + draw_networkx_edges + draw_networkx_edge_labels + """ + import matplotlib.pyplot as plt + + if ax is None: + ax = plt.gca() + + if labels is None: + labels = {n: n for n in G.nodes()} + + individual_params = set() + + def check_individual_params(p_value, p_name): + if isinstance(p_value, dict): + if len(p_value) != len(labels): + raise ValueError(f"{p_name} must have the same length as labels.") + individual_params.add(p_name) + + def get_param_value(node, p_value, p_name): + if p_name in individual_params: + return p_value[node] + return p_value + + check_individual_params(font_size, "font_size") + check_individual_params(font_color, "font_color") + check_individual_params(font_weight, "font_weight") + check_individual_params(font_family, "font_family") + check_individual_params(alpha, "alpha") + + text_items = {} # there is no text collection so we'll fake one + for n, label in labels.items(): + (x, y) = pos[n] + if not isinstance(label, str): + label = str(label) # this makes "1" and 1 labeled the same + t = ax.text( + x, + y, + label, + size=get_param_value(n, font_size, "font_size"), + color=get_param_value(n, font_color, "font_color"), + family=get_param_value(n, font_family, "font_family"), + weight=get_param_value(n, font_weight, "font_weight"), + alpha=get_param_value(n, alpha, "alpha"), + horizontalalignment=horizontalalignment, + verticalalignment=verticalalignment, + transform=ax.transData, + bbox=bbox, + clip_on=clip_on, + ) + text_items[n] = t + + if hide_ticks: + ax.tick_params( + axis="both", + which="both", + bottom=False, + left=False, + labelbottom=False, + labelleft=False, + ) + + return text_items + + +def draw_networkx_edge_labels( + G, + pos, + edge_labels=None, + label_pos=0.5, + font_size=10, + font_color="k", + font_family="sans-serif", + font_weight="normal", + alpha=None, + bbox=None, + horizontalalignment="center", + verticalalignment="center", + ax=None, + rotate=True, + clip_on=True, + node_size=300, + nodelist=None, + connectionstyle="arc3", + hide_ticks=True, +): + """Draw edge labels. + + Parameters + ---------- + G : graph + A networkx graph + + pos : dictionary + A dictionary with nodes as keys and positions as values. + Positions should be sequences of length 2. + + edge_labels : dictionary (default=None) + Edge labels in a dictionary of labels keyed by edge two-tuple. + Only labels for the keys in the dictionary are drawn. + + label_pos : float (default=0.5) + Position of edge label along edge (0=head, 0.5=center, 1=tail) + + font_size : int (default=10) + Font size for text labels + + font_color : color (default='k' black) + Font color string. Color can be string or rgb (or rgba) tuple of + floats from 0-1. + + font_weight : string (default='normal') + Font weight + + font_family : string (default='sans-serif') + Font family + + alpha : float or None (default=None) + The text transparency + + bbox : Matplotlib bbox, optional + Specify text box properties (e.g. shape, color etc.) for edge labels. + Default is {boxstyle='round', ec=(1.0, 1.0, 1.0), fc=(1.0, 1.0, 1.0)}. + + horizontalalignment : string (default='center') + Horizontal alignment {'center', 'right', 'left'} + + verticalalignment : string (default='center') + Vertical alignment {'center', 'top', 'bottom', 'baseline', 'center_baseline'} + + ax : Matplotlib Axes object, optional + Draw the graph in the specified Matplotlib axes. + + rotate : bool (default=True) + Rotate edge labels to lie parallel to edges + + clip_on : bool (default=True) + Turn on clipping of edge labels at axis boundaries + + node_size : scalar or array (default=300) + Size of nodes. If an array it must be the same length as nodelist. + + nodelist : list, optional (default=G.nodes()) + This provides the node order for the `node_size` array (if it is an array). + + connectionstyle : string or iterable of strings (default="arc3") + Pass the connectionstyle parameter to create curved arc of rounding + radius rad. For example, connectionstyle='arc3,rad=0.2'. + See `matplotlib.patches.ConnectionStyle` and + `matplotlib.patches.FancyArrowPatch` for more info. + If Iterable, index indicates i'th edge key of MultiGraph + + hide_ticks : bool, optional + Hide ticks of axes. When `True` (the default), ticks and ticklabels + are removed from the axes. To set ticks and tick labels to the pyplot default, + use ``hide_ticks=False``. + + Returns + ------- + dict + `dict` of labels keyed by edge + + Examples + -------- + >>> G = nx.dodecahedral_graph() + >>> edge_labels = nx.draw_networkx_edge_labels(G, pos=nx.spring_layout(G)) + + Also see the NetworkX drawing examples at + https://networkx.org/documentation/latest/auto_examples/index.html + + See Also + -------- + draw + draw_networkx + draw_networkx_nodes + draw_networkx_edges + draw_networkx_labels + """ + import matplotlib as mpl + import matplotlib.pyplot as plt + import numpy as np + + class CurvedArrowText(mpl.text.Text): + def __init__( + self, + arrow, + *args, + label_pos=0.5, + labels_horizontal=False, + ax=None, + **kwargs, + ): + # Bind to FancyArrowPatch + self.arrow = arrow + # how far along the text should be on the curve, + # 0 is at start, 1 is at end etc. + self.label_pos = label_pos + self.labels_horizontal = labels_horizontal + if ax is None: + ax = plt.gca() + self.ax = ax + self.x, self.y, self.angle = self._update_text_pos_angle(arrow) + + # Create text object + super().__init__(self.x, self.y, *args, rotation=self.angle, **kwargs) + # Bind to axis + self.ax.add_artist(self) + + def _get_arrow_path_disp(self, arrow): + """ + This is part of FancyArrowPatch._get_path_in_displaycoord + It omits the second part of the method where path is converted + to polygon based on width + The transform is taken from ax, not the object, as the object + has not been added yet, and doesn't have transform + """ + dpi_cor = arrow._dpi_cor + # trans_data = arrow.get_transform() + trans_data = self.ax.transData + if arrow._posA_posB is not None: + posA = arrow._convert_xy_units(arrow._posA_posB[0]) + posB = arrow._convert_xy_units(arrow._posA_posB[1]) + (posA, posB) = trans_data.transform((posA, posB)) + _path = arrow.get_connectionstyle()( + posA, + posB, + patchA=arrow.patchA, + patchB=arrow.patchB, + shrinkA=arrow.shrinkA * dpi_cor, + shrinkB=arrow.shrinkB * dpi_cor, + ) + else: + _path = trans_data.transform_path(arrow._path_original) + # Return is in display coordinates + return _path + + def _update_text_pos_angle(self, arrow): + # Fractional label position + path_disp = self._get_arrow_path_disp(arrow) + (x1, y1), (cx, cy), (x2, y2) = path_disp.vertices + # Text position at a proportion t along the line in display coords + # default is 0.5 so text appears at the halfway point + t = self.label_pos + tt = 1 - t + x = tt**2 * x1 + 2 * t * tt * cx + t**2 * x2 + y = tt**2 * y1 + 2 * t * tt * cy + t**2 * y2 + if self.labels_horizontal: + # Horizontal text labels + angle = 0 + else: + # Labels parallel to curve + change_x = 2 * tt * (cx - x1) + 2 * t * (x2 - cx) + change_y = 2 * tt * (cy - y1) + 2 * t * (y2 - cy) + angle = (np.arctan2(change_y, change_x) / (2 * np.pi)) * 360 + # Text is "right way up" + if angle > 90: + angle -= 180 + if angle < -90: + angle += 180 + (x, y) = self.ax.transData.inverted().transform((x, y)) + return x, y, angle + + def draw(self, renderer): + # recalculate the text position and angle + self.x, self.y, self.angle = self._update_text_pos_angle(self.arrow) + self.set_position((self.x, self.y)) + self.set_rotation(self.angle) + # redraw text + super().draw(renderer) + + # use default box of white with white border + if bbox is None: + bbox = {"boxstyle": "round", "ec": (1.0, 1.0, 1.0), "fc": (1.0, 1.0, 1.0)} + + if isinstance(connectionstyle, str): + connectionstyle = [connectionstyle] + elif np.iterable(connectionstyle): + connectionstyle = list(connectionstyle) + else: + raise nx.NetworkXError( + "draw_networkx_edges arg `connectionstyle` must be" + "string or iterable of strings" + ) + + if ax is None: + ax = plt.gca() + + if edge_labels is None: + kwds = {"keys": True} if G.is_multigraph() else {} + edge_labels = {tuple(edge): d for *edge, d in G.edges(data=True, **kwds)} + # NOTHING TO PLOT + if not edge_labels: + return {} + edgelist, labels = zip(*edge_labels.items()) + + if nodelist is None: + nodelist = list(G.nodes()) + + # set edge positions + edge_pos = np.asarray([(pos[e[0]], pos[e[1]]) for e in edgelist]) + + if G.is_multigraph(): + key_count = collections.defaultdict(lambda: itertools.count(0)) + edge_indices = [next(key_count[tuple(e[:2])]) for e in edgelist] + else: + edge_indices = [0] * len(edgelist) + + # Used to determine self loop mid-point + # Note, that this will not be accurate, + # if not drawing edge_labels for all edges drawn + h = 0 + if edge_labels: + miny = np.amin(np.ravel(edge_pos[:, :, 1])) + maxy = np.amax(np.ravel(edge_pos[:, :, 1])) + h = maxy - miny + selfloop_height = h if h != 0 else 0.005 * np.array(node_size).max() + fancy_arrow_factory = FancyArrowFactory( + edge_pos, + edgelist, + nodelist, + edge_indices, + node_size, + selfloop_height, + connectionstyle, + ax=ax, + ) + + individual_params = {} + + def check_individual_params(p_value, p_name): + # TODO should this be list or array (as in a numpy array)? + if isinstance(p_value, list): + if len(p_value) != len(edgelist): + raise ValueError(f"{p_name} must have the same length as edgelist.") + individual_params[p_name] = p_value.iter() + + # Don't need to pass in an edge because these are lists, not dicts + def get_param_value(p_value, p_name): + if p_name in individual_params: + return next(individual_params[p_name]) + return p_value + + check_individual_params(font_size, "font_size") + check_individual_params(font_color, "font_color") + check_individual_params(font_weight, "font_weight") + check_individual_params(alpha, "alpha") + check_individual_params(horizontalalignment, "horizontalalignment") + check_individual_params(verticalalignment, "verticalalignment") + check_individual_params(rotate, "rotate") + check_individual_params(label_pos, "label_pos") + + text_items = {} + for i, (edge, label) in enumerate(zip(edgelist, labels)): + if not isinstance(label, str): + label = str(label) # this makes "1" and 1 labeled the same + + n1, n2 = edge[:2] + arrow = fancy_arrow_factory(i) + if n1 == n2: + connectionstyle_obj = arrow.get_connectionstyle() + posA = ax.transData.transform(pos[n1]) + path_disp = connectionstyle_obj(posA, posA) + path_data = ax.transData.inverted().transform_path(path_disp) + x, y = path_data.vertices[0] + text_items[edge] = ax.text( + x, + y, + label, + size=get_param_value(font_size, "font_size"), + color=get_param_value(font_color, "font_color"), + family=get_param_value(font_family, "font_family"), + weight=get_param_value(font_weight, "font_weight"), + alpha=get_param_value(alpha, "alpha"), + horizontalalignment=get_param_value( + horizontalalignment, "horizontalalignment" + ), + verticalalignment=get_param_value( + verticalalignment, "verticalalignment" + ), + rotation=0, + transform=ax.transData, + bbox=bbox, + zorder=1, + clip_on=clip_on, + ) + else: + text_items[edge] = CurvedArrowText( + arrow, + label, + size=get_param_value(font_size, "font_size"), + color=get_param_value(font_color, "font_color"), + family=get_param_value(font_family, "font_family"), + weight=get_param_value(font_weight, "font_weight"), + alpha=get_param_value(alpha, "alpha"), + horizontalalignment=get_param_value( + horizontalalignment, "horizontalalignment" + ), + verticalalignment=get_param_value( + verticalalignment, "verticalalignment" + ), + transform=ax.transData, + bbox=bbox, + zorder=1, + clip_on=clip_on, + label_pos=get_param_value(label_pos, "label_pos"), + labels_horizontal=not get_param_value(rotate, "rotate"), + ax=ax, + ) + + if hide_ticks: + ax.tick_params( + axis="both", + which="both", + bottom=False, + left=False, + labelbottom=False, + labelleft=False, + ) + + return text_items + + +def draw_circular(G, **kwargs): + """Draw the graph `G` with a circular layout. + + This is a convenience function equivalent to:: + + nx.draw(G, pos=nx.circular_layout(G), **kwargs) + + Parameters + ---------- + G : graph + A networkx graph + + kwargs : optional keywords + See `draw_networkx` for a description of optional keywords. + + Notes + ----- + The layout is computed each time this function is called. For + repeated drawing it is much more efficient to call + `~networkx.drawing.layout.circular_layout` directly and reuse the result:: + + >>> G = nx.complete_graph(5) + >>> pos = nx.circular_layout(G) + >>> nx.draw(G, pos=pos) # Draw the original graph + >>> # Draw a subgraph, reusing the same node positions + >>> nx.draw(G.subgraph([0, 1, 2]), pos=pos, node_color="red") + + Examples + -------- + >>> G = nx.path_graph(5) + >>> nx.draw_circular(G) + + See Also + -------- + :func:`~networkx.drawing.layout.circular_layout` + """ + draw(G, circular_layout(G), **kwargs) + + +def draw_kamada_kawai(G, **kwargs): + """Draw the graph `G` with a Kamada-Kawai force-directed layout. + + This is a convenience function equivalent to:: + + nx.draw(G, pos=nx.kamada_kawai_layout(G), **kwargs) + + Parameters + ---------- + G : graph + A networkx graph + + kwargs : optional keywords + See `draw_networkx` for a description of optional keywords. + + Notes + ----- + The layout is computed each time this function is called. + For repeated drawing it is much more efficient to call + `~networkx.drawing.layout.kamada_kawai_layout` directly and reuse the + result:: + + >>> G = nx.complete_graph(5) + >>> pos = nx.kamada_kawai_layout(G) + >>> nx.draw(G, pos=pos) # Draw the original graph + >>> # Draw a subgraph, reusing the same node positions + >>> nx.draw(G.subgraph([0, 1, 2]), pos=pos, node_color="red") + + Examples + -------- + >>> G = nx.path_graph(5) + >>> nx.draw_kamada_kawai(G) + + See Also + -------- + :func:`~networkx.drawing.layout.kamada_kawai_layout` + """ + draw(G, kamada_kawai_layout(G), **kwargs) + + +def draw_random(G, **kwargs): + """Draw the graph `G` with a random layout. + + This is a convenience function equivalent to:: + + nx.draw(G, pos=nx.random_layout(G), **kwargs) + + Parameters + ---------- + G : graph + A networkx graph + + kwargs : optional keywords + See `draw_networkx` for a description of optional keywords. + + Notes + ----- + The layout is computed each time this function is called. + For repeated drawing it is much more efficient to call + `~networkx.drawing.layout.random_layout` directly and reuse the result:: + + >>> G = nx.complete_graph(5) + >>> pos = nx.random_layout(G) + >>> nx.draw(G, pos=pos) # Draw the original graph + >>> # Draw a subgraph, reusing the same node positions + >>> nx.draw(G.subgraph([0, 1, 2]), pos=pos, node_color="red") + + Examples + -------- + >>> G = nx.lollipop_graph(4, 3) + >>> nx.draw_random(G) + + See Also + -------- + :func:`~networkx.drawing.layout.random_layout` + """ + draw(G, random_layout(G), **kwargs) + + +def draw_spectral(G, **kwargs): + """Draw the graph `G` with a spectral 2D layout. + + This is a convenience function equivalent to:: + + nx.draw(G, pos=nx.spectral_layout(G), **kwargs) + + For more information about how node positions are determined, see + `~networkx.drawing.layout.spectral_layout`. + + Parameters + ---------- + G : graph + A networkx graph + + kwargs : optional keywords + See `draw_networkx` for a description of optional keywords. + + Notes + ----- + The layout is computed each time this function is called. + For repeated drawing it is much more efficient to call + `~networkx.drawing.layout.spectral_layout` directly and reuse the result:: + + >>> G = nx.complete_graph(5) + >>> pos = nx.spectral_layout(G) + >>> nx.draw(G, pos=pos) # Draw the original graph + >>> # Draw a subgraph, reusing the same node positions + >>> nx.draw(G.subgraph([0, 1, 2]), pos=pos, node_color="red") + + Examples + -------- + >>> G = nx.path_graph(5) + >>> nx.draw_spectral(G) + + See Also + -------- + :func:`~networkx.drawing.layout.spectral_layout` + """ + draw(G, spectral_layout(G), **kwargs) + + +def draw_spring(G, **kwargs): + """Draw the graph `G` with a spring layout. + + This is a convenience function equivalent to:: + + nx.draw(G, pos=nx.spring_layout(G), **kwargs) + + Parameters + ---------- + G : graph + A networkx graph + + kwargs : optional keywords + See `draw_networkx` for a description of optional keywords. + + Notes + ----- + `~networkx.drawing.layout.spring_layout` is also the default layout for + `draw`, so this function is equivalent to `draw`. + + The layout is computed each time this function is called. + For repeated drawing it is much more efficient to call + `~networkx.drawing.layout.spring_layout` directly and reuse the result:: + + >>> G = nx.complete_graph(5) + >>> pos = nx.spring_layout(G) + >>> nx.draw(G, pos=pos) # Draw the original graph + >>> # Draw a subgraph, reusing the same node positions + >>> nx.draw(G.subgraph([0, 1, 2]), pos=pos, node_color="red") + + Examples + -------- + >>> G = nx.path_graph(20) + >>> nx.draw_spring(G) + + See Also + -------- + draw + :func:`~networkx.drawing.layout.spring_layout` + """ + draw(G, spring_layout(G), **kwargs) + + +def draw_shell(G, nlist=None, **kwargs): + """Draw networkx graph `G` with shell layout. + + This is a convenience function equivalent to:: + + nx.draw(G, pos=nx.shell_layout(G, nlist=nlist), **kwargs) + + Parameters + ---------- + G : graph + A networkx graph + + nlist : list of list of nodes, optional + A list containing lists of nodes representing the shells. + Default is `None`, meaning all nodes are in a single shell. + See `~networkx.drawing.layout.shell_layout` for details. + + kwargs : optional keywords + See `draw_networkx` for a description of optional keywords. + + Notes + ----- + The layout is computed each time this function is called. + For repeated drawing it is much more efficient to call + `~networkx.drawing.layout.shell_layout` directly and reuse the result:: + + >>> G = nx.complete_graph(5) + >>> pos = nx.shell_layout(G) + >>> nx.draw(G, pos=pos) # Draw the original graph + >>> # Draw a subgraph, reusing the same node positions + >>> nx.draw(G.subgraph([0, 1, 2]), pos=pos, node_color="red") + + Examples + -------- + >>> G = nx.path_graph(4) + >>> shells = [[0], [1, 2, 3]] + >>> nx.draw_shell(G, nlist=shells) + + See Also + -------- + :func:`~networkx.drawing.layout.shell_layout` + """ + draw(G, shell_layout(G, nlist=nlist), **kwargs) + + +def draw_planar(G, **kwargs): + """Draw a planar networkx graph `G` with planar layout. + + This is a convenience function equivalent to:: + + nx.draw(G, pos=nx.planar_layout(G), **kwargs) + + Parameters + ---------- + G : graph + A planar networkx graph + + kwargs : optional keywords + See `draw_networkx` for a description of optional keywords. + + Raises + ------ + NetworkXException + When `G` is not planar + + Notes + ----- + The layout is computed each time this function is called. + For repeated drawing it is much more efficient to call + `~networkx.drawing.layout.planar_layout` directly and reuse the result:: + + >>> G = nx.path_graph(5) + >>> pos = nx.planar_layout(G) + >>> nx.draw(G, pos=pos) # Draw the original graph + >>> # Draw a subgraph, reusing the same node positions + >>> nx.draw(G.subgraph([0, 1, 2]), pos=pos, node_color="red") + + Examples + -------- + >>> G = nx.path_graph(4) + >>> nx.draw_planar(G) + + See Also + -------- + :func:`~networkx.drawing.layout.planar_layout` + """ + draw(G, planar_layout(G), **kwargs) + + +def draw_forceatlas2(G, **kwargs): + """Draw a networkx graph with forceatlas2 layout. + + This is a convenience function equivalent to:: + + nx.draw(G, pos=nx.forceatlas2_layout(G), **kwargs) + + Parameters + ---------- + G : graph + A networkx graph + + kwargs : optional keywords + See networkx.draw_networkx() for a description of optional keywords, + with the exception of the pos parameter which is not used by this + function. + """ + draw(G, forceatlas2_layout(G), **kwargs) + + +def apply_alpha(colors, alpha, elem_list, cmap=None, vmin=None, vmax=None): + """Apply an alpha (or list of alphas) to the colors provided. + + Parameters + ---------- + + colors : color string or array of floats (default='r') + Color of element. Can be a single color format string, + or a sequence of colors with the same length as nodelist. + If numeric values are specified they will be mapped to + colors using the cmap and vmin,vmax parameters. See + matplotlib.scatter for more details. + + alpha : float or array of floats + Alpha values for elements. This can be a single alpha value, in + which case it will be applied to all the elements of color. Otherwise, + if it is an array, the elements of alpha will be applied to the colors + in order (cycling through alpha multiple times if necessary). + + elem_list : array of networkx objects + The list of elements which are being colored. These could be nodes, + edges or labels. + + cmap : matplotlib colormap + Color map for use if colors is a list of floats corresponding to points + on a color mapping. + + vmin, vmax : float + Minimum and maximum values for normalizing colors if a colormap is used + + Returns + ------- + + rgba_colors : numpy ndarray + Array containing RGBA format values for each of the node colours. + + """ + from itertools import cycle, islice + + import matplotlib as mpl + import matplotlib.cm # call as mpl.cm + import matplotlib.colors # call as mpl.colors + import numpy as np + + # If we have been provided with a list of numbers as long as elem_list, + # apply the color mapping. + if len(colors) == len(elem_list) and isinstance(colors[0], Number): + mapper = mpl.cm.ScalarMappable(cmap=cmap) + mapper.set_clim(vmin, vmax) + rgba_colors = mapper.to_rgba(colors) + # Otherwise, convert colors to matplotlib's RGB using the colorConverter + # object. These are converted to numpy ndarrays to be consistent with the + # to_rgba method of ScalarMappable. + else: + try: + rgba_colors = np.array([mpl.colors.colorConverter.to_rgba(colors)]) + except ValueError: + rgba_colors = np.array( + [mpl.colors.colorConverter.to_rgba(color) for color in colors] + ) + # Set the final column of the rgba_colors to have the relevant alpha values + try: + # If alpha is longer than the number of colors, resize to the number of + # elements. Also, if rgba_colors.size (the number of elements of + # rgba_colors) is the same as the number of elements, resize the array, + # to avoid it being interpreted as a colormap by scatter() + if len(alpha) > len(rgba_colors) or rgba_colors.size == len(elem_list): + rgba_colors = np.resize(rgba_colors, (len(elem_list), 4)) + rgba_colors[1:, 0] = rgba_colors[0, 0] + rgba_colors[1:, 1] = rgba_colors[0, 1] + rgba_colors[1:, 2] = rgba_colors[0, 2] + rgba_colors[:, 3] = list(islice(cycle(alpha), len(rgba_colors))) + except TypeError: + rgba_colors[:, -1] = alpha + return rgba_colors diff --git a/.venv/lib/python3.12/site-packages/networkx/drawing/tests/__init__.py b/.venv/lib/python3.12/site-packages/networkx/drawing/tests/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/.venv/lib/python3.12/site-packages/networkx/drawing/tests/__init__.py diff --git a/.venv/lib/python3.12/site-packages/networkx/drawing/tests/baseline/test_house_with_colors.png b/.venv/lib/python3.12/site-packages/networkx/drawing/tests/baseline/test_house_with_colors.png Binary files differnew file mode 100644 index 00000000..31f4962e --- /dev/null +++ b/.venv/lib/python3.12/site-packages/networkx/drawing/tests/baseline/test_house_with_colors.png diff --git a/.venv/lib/python3.12/site-packages/networkx/drawing/tests/test_agraph.py b/.venv/lib/python3.12/site-packages/networkx/drawing/tests/test_agraph.py new file mode 100644 index 00000000..b351a1d9 --- /dev/null +++ b/.venv/lib/python3.12/site-packages/networkx/drawing/tests/test_agraph.py @@ -0,0 +1,241 @@ +"""Unit tests for PyGraphviz interface.""" + +import warnings + +import pytest + +pygraphviz = pytest.importorskip("pygraphviz") + + +import networkx as nx +from networkx.utils import edges_equal, graphs_equal, nodes_equal + + +class TestAGraph: + def build_graph(self, G): + edges = [("A", "B"), ("A", "C"), ("A", "C"), ("B", "C"), ("A", "D")] + G.add_edges_from(edges) + G.add_node("E") + G.graph["metal"] = "bronze" + return G + + def assert_equal(self, G1, G2): + assert nodes_equal(G1.nodes(), G2.nodes()) + assert edges_equal(G1.edges(), G2.edges()) + assert G1.graph["metal"] == G2.graph["metal"] + + @pytest.mark.parametrize( + "G", (nx.Graph(), nx.DiGraph(), nx.MultiGraph(), nx.MultiDiGraph()) + ) + def test_agraph_roundtripping(self, G, tmp_path): + G = self.build_graph(G) + A = nx.nx_agraph.to_agraph(G) + H = nx.nx_agraph.from_agraph(A) + self.assert_equal(G, H) + + fname = tmp_path / "test.dot" + nx.drawing.nx_agraph.write_dot(H, fname) + Hin = nx.nx_agraph.read_dot(fname) + self.assert_equal(H, Hin) + + fname = tmp_path / "fh_test.dot" + with open(fname, "w") as fh: + nx.drawing.nx_agraph.write_dot(H, fh) + + with open(fname) as fh: + Hin = nx.nx_agraph.read_dot(fh) + self.assert_equal(H, Hin) + + def test_from_agraph_name(self): + G = nx.Graph(name="test") + A = nx.nx_agraph.to_agraph(G) + H = nx.nx_agraph.from_agraph(A) + assert G.name == "test" + + @pytest.mark.parametrize( + "graph_class", (nx.Graph, nx.DiGraph, nx.MultiGraph, nx.MultiDiGraph) + ) + def test_from_agraph_create_using(self, graph_class): + G = nx.path_graph(3) + A = nx.nx_agraph.to_agraph(G) + H = nx.nx_agraph.from_agraph(A, create_using=graph_class) + assert isinstance(H, graph_class) + + def test_from_agraph_named_edges(self): + # Create an AGraph from an existing (non-multi) Graph + G = nx.Graph() + G.add_nodes_from([0, 1]) + A = nx.nx_agraph.to_agraph(G) + # Add edge (+ name, given by key) to the AGraph + A.add_edge(0, 1, key="foo") + # Verify a.name roundtrips out to 'key' in from_agraph + H = nx.nx_agraph.from_agraph(A) + assert isinstance(H, nx.Graph) + assert ("0", "1", {"key": "foo"}) in H.edges(data=True) + + def test_to_agraph_with_nodedata(self): + G = nx.Graph() + G.add_node(1, color="red") + A = nx.nx_agraph.to_agraph(G) + assert dict(A.nodes()[0].attr) == {"color": "red"} + + @pytest.mark.parametrize("graph_class", (nx.Graph, nx.MultiGraph)) + def test_to_agraph_with_edgedata(self, graph_class): + G = graph_class() + G.add_nodes_from([0, 1]) + G.add_edge(0, 1, color="yellow") + A = nx.nx_agraph.to_agraph(G) + assert dict(A.edges()[0].attr) == {"color": "yellow"} + + def test_view_pygraphviz_path(self, tmp_path): + G = nx.complete_graph(3) + input_path = str(tmp_path / "graph.png") + out_path, A = nx.nx_agraph.view_pygraphviz(G, path=input_path, show=False) + assert out_path == input_path + # Ensure file is not empty + with open(input_path, "rb") as fh: + data = fh.read() + assert len(data) > 0 + + def test_view_pygraphviz_file_suffix(self, tmp_path): + G = nx.complete_graph(3) + path, A = nx.nx_agraph.view_pygraphviz(G, suffix=1, show=False) + assert path[-6:] == "_1.png" + + def test_view_pygraphviz(self): + G = nx.Graph() # "An empty graph cannot be drawn." + pytest.raises(nx.NetworkXException, nx.nx_agraph.view_pygraphviz, G) + G = nx.barbell_graph(4, 6) + nx.nx_agraph.view_pygraphviz(G, show=False) + + def test_view_pygraphviz_edgelabel(self): + G = nx.Graph() + G.add_edge(1, 2, weight=7) + G.add_edge(2, 3, weight=8) + path, A = nx.nx_agraph.view_pygraphviz(G, edgelabel="weight", show=False) + for edge in A.edges(): + assert edge.attr["weight"] in ("7", "8") + + def test_view_pygraphviz_callable_edgelabel(self): + G = nx.complete_graph(3) + + def foo_label(data): + return "foo" + + path, A = nx.nx_agraph.view_pygraphviz(G, edgelabel=foo_label, show=False) + for edge in A.edges(): + assert edge.attr["label"] == "foo" + + def test_view_pygraphviz_multigraph_edgelabels(self): + G = nx.MultiGraph() + G.add_edge(0, 1, key=0, name="left_fork") + G.add_edge(0, 1, key=1, name="right_fork") + path, A = nx.nx_agraph.view_pygraphviz(G, edgelabel="name", show=False) + edges = A.edges() + assert len(edges) == 2 + for edge in edges: + assert edge.attr["label"].strip() in ("left_fork", "right_fork") + + def test_graph_with_reserved_keywords(self): + # test attribute/keyword clash case for #1582 + # node: n + # edges: u,v + G = nx.Graph() + G = self.build_graph(G) + G.nodes["E"]["n"] = "keyword" + G.edges[("A", "B")]["u"] = "keyword" + G.edges[("A", "B")]["v"] = "keyword" + A = nx.nx_agraph.to_agraph(G) + + def test_view_pygraphviz_no_added_attrs_to_input(self): + G = nx.complete_graph(2) + path, A = nx.nx_agraph.view_pygraphviz(G, show=False) + assert G.graph == {} + + @pytest.mark.xfail(reason="known bug in clean_attrs") + def test_view_pygraphviz_leaves_input_graph_unmodified(self): + G = nx.complete_graph(2) + # Add entries to graph dict that to_agraph handles specially + G.graph["node"] = {"width": "0.80"} + G.graph["edge"] = {"fontsize": "14"} + path, A = nx.nx_agraph.view_pygraphviz(G, show=False) + assert G.graph == {"node": {"width": "0.80"}, "edge": {"fontsize": "14"}} + + def test_graph_with_AGraph_attrs(self): + G = nx.complete_graph(2) + # Add entries to graph dict that to_agraph handles specially + G.graph["node"] = {"width": "0.80"} + G.graph["edge"] = {"fontsize": "14"} + path, A = nx.nx_agraph.view_pygraphviz(G, show=False) + # Ensure user-specified values are not lost + assert dict(A.node_attr)["width"] == "0.80" + assert dict(A.edge_attr)["fontsize"] == "14" + + def test_round_trip_empty_graph(self): + G = nx.Graph() + A = nx.nx_agraph.to_agraph(G) + H = nx.nx_agraph.from_agraph(A) + # assert graphs_equal(G, H) + AA = nx.nx_agraph.to_agraph(H) + HH = nx.nx_agraph.from_agraph(AA) + assert graphs_equal(H, HH) + G.graph["graph"] = {} + G.graph["node"] = {} + G.graph["edge"] = {} + assert graphs_equal(G, HH) + + @pytest.mark.xfail(reason="integer->string node conversion in round trip") + def test_round_trip_integer_nodes(self): + G = nx.complete_graph(3) + A = nx.nx_agraph.to_agraph(G) + H = nx.nx_agraph.from_agraph(A) + assert graphs_equal(G, H) + + def test_graphviz_alias(self): + G = self.build_graph(nx.Graph()) + pos_graphviz = nx.nx_agraph.graphviz_layout(G) + pos_pygraphviz = nx.nx_agraph.pygraphviz_layout(G) + assert pos_graphviz == pos_pygraphviz + + @pytest.mark.parametrize("root", range(5)) + def test_pygraphviz_layout_root(self, root): + # NOTE: test depends on layout prog being deterministic + G = nx.complete_graph(5) + A = nx.nx_agraph.to_agraph(G) + # Get layout with root arg is not None + pygv_layout = nx.nx_agraph.pygraphviz_layout(G, prog="circo", root=root) + # Equivalent layout directly on AGraph + A.layout(args=f"-Groot={root}", prog="circo") + # Parse AGraph layout + a1_pos = tuple(float(v) for v in dict(A.get_node("1").attr)["pos"].split(",")) + assert pygv_layout[1] == a1_pos + + def test_2d_layout(self): + G = nx.Graph() + G = self.build_graph(G) + G.graph["dimen"] = 2 + pos = nx.nx_agraph.pygraphviz_layout(G, prog="neato") + pos = list(pos.values()) + assert len(pos) == 5 + assert len(pos[0]) == 2 + + def test_3d_layout(self): + G = nx.Graph() + G = self.build_graph(G) + G.graph["dimen"] = 3 + pos = nx.nx_agraph.pygraphviz_layout(G, prog="neato") + pos = list(pos.values()) + assert len(pos) == 5 + assert len(pos[0]) == 3 + + def test_no_warnings_raised(self): + # Test that no warnings are raised when Networkx graph + # is converted to Pygraphviz graph and 'pos' + # attribute is given + G = nx.Graph() + G.add_node(0, pos=(0, 0)) + G.add_node(1, pos=(1, 1)) + A = nx.nx_agraph.to_agraph(G) + with warnings.catch_warnings(record=True) as record: + A.layout() + assert len(record) == 0 diff --git a/.venv/lib/python3.12/site-packages/networkx/drawing/tests/test_latex.py b/.venv/lib/python3.12/site-packages/networkx/drawing/tests/test_latex.py new file mode 100644 index 00000000..14ab5423 --- /dev/null +++ b/.venv/lib/python3.12/site-packages/networkx/drawing/tests/test_latex.py @@ -0,0 +1,292 @@ +import pytest + +import networkx as nx + + +def test_tikz_attributes(): + G = nx.path_graph(4, create_using=nx.DiGraph) + pos = {n: (n, n) for n in G} + + G.add_edge(0, 0) + G.edges[(0, 0)]["label"] = "Loop" + G.edges[(0, 0)]["label_options"] = "midway" + + G.nodes[0]["style"] = "blue" + G.nodes[1]["style"] = "line width=3,draw" + G.nodes[2]["style"] = "circle,draw,blue!50" + G.nodes[3]["label"] = "Stop" + G.edges[(0, 1)]["label"] = "1st Step" + G.edges[(0, 1)]["label_options"] = "near end" + G.edges[(2, 3)]["label"] = "3rd Step" + G.edges[(2, 3)]["label_options"] = "near start" + G.edges[(2, 3)]["style"] = "bend left,green" + G.edges[(1, 2)]["label"] = "2nd" + G.edges[(1, 2)]["label_options"] = "pos=0.5" + G.edges[(1, 2)]["style"] = ">->,bend right,line width=3,green!90" + + output_tex = nx.to_latex( + G, + pos=pos, + as_document=False, + tikz_options="[scale=3]", + node_options="style", + edge_options="style", + node_label="label", + edge_label="label", + edge_label_options="label_options", + ) + expected_tex = r"""\begin{figure} + \begin{tikzpicture}[scale=3] + \draw + (0, 0) node[blue] (0){0} + (1, 1) node[line width=3,draw] (1){1} + (2, 2) node[circle,draw,blue!50] (2){2} + (3, 3) node (3){Stop}; + \begin{scope}[->] + \draw (0) to node[near end] {1st Step} (1); + \draw[loop,] (0) to node[midway] {Loop} (0); + \draw[>->,bend right,line width=3,green!90] (1) to node[pos=0.5] {2nd} (2); + \draw[bend left,green] (2) to node[near start] {3rd Step} (3); + \end{scope} + \end{tikzpicture} +\end{figure}""" + + assert output_tex == expected_tex + # print(output_tex) + # # Pretty way to assert that A.to_document() == expected_tex + # content_same = True + # for aa, bb in zip(expected_tex.split("\n"), output_tex.split("\n")): + # if aa != bb: + # content_same = False + # print(f"-{aa}|\n+{bb}|") + # assert content_same + + +def test_basic_multiple_graphs(): + H1 = nx.path_graph(4) + H2 = nx.complete_graph(4) + H3 = nx.path_graph(8) + H4 = nx.complete_graph(8) + captions = [ + "Path on 4 nodes", + "Complete graph on 4 nodes", + "Path on 8 nodes", + "Complete graph on 8 nodes", + ] + labels = ["fig2a", "fig2b", "fig2c", "fig2d"] + latex_code = nx.to_latex( + [H1, H2, H3, H4], + n_rows=2, + sub_captions=captions, + sub_labels=labels, + ) + # print(latex_code) + assert "begin{document}" in latex_code + assert "begin{figure}" in latex_code + assert latex_code.count("begin{subfigure}") == 4 + assert latex_code.count("tikzpicture") == 8 + assert latex_code.count("[-]") == 4 + + +def test_basic_tikz(): + expected_tex = r"""\documentclass{report} +\usepackage{tikz} +\usepackage{subcaption} + +\begin{document} +\begin{figure} + \begin{subfigure}{0.5\textwidth} + \begin{tikzpicture}[scale=2] + \draw[gray!90] + (0.749, 0.702) node[red!90] (0){0} + (1.0, -0.014) node[red!90] (1){1} + (-0.777, -0.705) node (2){2} + (-0.984, 0.042) node (3){3} + (-0.028, 0.375) node[cyan!90] (4){4} + (-0.412, 0.888) node (5){5} + (0.448, -0.856) node (6){6} + (0.003, -0.431) node[cyan!90] (7){7}; + \begin{scope}[->,gray!90] + \draw (0) to (4); + \draw (0) to (5); + \draw (0) to (6); + \draw (0) to (7); + \draw (1) to (4); + \draw (1) to (5); + \draw (1) to (6); + \draw (1) to (7); + \draw (2) to (4); + \draw (2) to (5); + \draw (2) to (6); + \draw (2) to (7); + \draw (3) to (4); + \draw (3) to (5); + \draw (3) to (6); + \draw (3) to (7); + \end{scope} + \end{tikzpicture} + \caption{My tikz number 1 of 2}\label{tikz_1_2} + \end{subfigure} + \begin{subfigure}{0.5\textwidth} + \begin{tikzpicture}[scale=2] + \draw[gray!90] + (0.749, 0.702) node[green!90] (0){0} + (1.0, -0.014) node[green!90] (1){1} + (-0.777, -0.705) node (2){2} + (-0.984, 0.042) node (3){3} + (-0.028, 0.375) node[purple!90] (4){4} + (-0.412, 0.888) node (5){5} + (0.448, -0.856) node (6){6} + (0.003, -0.431) node[purple!90] (7){7}; + \begin{scope}[->,gray!90] + \draw (0) to (4); + \draw (0) to (5); + \draw (0) to (6); + \draw (0) to (7); + \draw (1) to (4); + \draw (1) to (5); + \draw (1) to (6); + \draw (1) to (7); + \draw (2) to (4); + \draw (2) to (5); + \draw (2) to (6); + \draw (2) to (7); + \draw (3) to (4); + \draw (3) to (5); + \draw (3) to (6); + \draw (3) to (7); + \end{scope} + \end{tikzpicture} + \caption{My tikz number 2 of 2}\label{tikz_2_2} + \end{subfigure} + \caption{A graph generated with python and latex.} +\end{figure} +\end{document}""" + + edges = [ + (0, 4), + (0, 5), + (0, 6), + (0, 7), + (1, 4), + (1, 5), + (1, 6), + (1, 7), + (2, 4), + (2, 5), + (2, 6), + (2, 7), + (3, 4), + (3, 5), + (3, 6), + (3, 7), + ] + G = nx.DiGraph() + G.add_nodes_from(range(8)) + G.add_edges_from(edges) + pos = { + 0: (0.7490296171687696, 0.702353520257394), + 1: (1.0, -0.014221357723796535), + 2: (-0.7765783344161441, -0.7054170966808919), + 3: (-0.9842690223417624, 0.04177547602465483), + 4: (-0.02768523817180917, 0.3745724439551441), + 5: (-0.41154855146767433, 0.8880106515525136), + 6: (0.44780153389148264, -0.8561492709269164), + 7: (0.0032499953371383505, -0.43092436645809945), + } + + rc_node_color = {0: "red!90", 1: "red!90", 4: "cyan!90", 7: "cyan!90"} + gp_node_color = {0: "green!90", 1: "green!90", 4: "purple!90", 7: "purple!90"} + + H = G.copy() + nx.set_node_attributes(G, rc_node_color, "color") + nx.set_node_attributes(H, gp_node_color, "color") + + sub_captions = ["My tikz number 1 of 2", "My tikz number 2 of 2"] + sub_labels = ["tikz_1_2", "tikz_2_2"] + + output_tex = nx.to_latex( + [G, H], + [pos, pos], + tikz_options="[scale=2]", + default_node_options="gray!90", + default_edge_options="gray!90", + node_options="color", + sub_captions=sub_captions, + sub_labels=sub_labels, + caption="A graph generated with python and latex.", + n_rows=2, + as_document=True, + ) + + assert output_tex == expected_tex + # print(output_tex) + # # Pretty way to assert that A.to_document() == expected_tex + # content_same = True + # for aa, bb in zip(expected_tex.split("\n"), output_tex.split("\n")): + # if aa != bb: + # content_same = False + # print(f"-{aa}|\n+{bb}|") + # assert content_same + + +def test_exception_pos_single_graph(to_latex=nx.to_latex): + # smoke test that pos can be a string + G = nx.path_graph(4) + to_latex(G, pos="pos") + + # must include all nodes + pos = {0: (1, 2), 1: (0, 1), 2: (2, 1)} + with pytest.raises(nx.NetworkXError): + to_latex(G, pos) + + # must have 2 values + pos[3] = (1, 2, 3) + with pytest.raises(nx.NetworkXError): + to_latex(G, pos) + pos[3] = 2 + with pytest.raises(nx.NetworkXError): + to_latex(G, pos) + + # check that passes with 2 values + pos[3] = (3, 2) + to_latex(G, pos) + + +def test_exception_multiple_graphs(to_latex=nx.to_latex): + G = nx.path_graph(3) + pos_bad = {0: (1, 2), 1: (0, 1)} + pos_OK = {0: (1, 2), 1: (0, 1), 2: (2, 1)} + fourG = [G, G, G, G] + fourpos = [pos_OK, pos_OK, pos_OK, pos_OK] + + # input single dict to use for all graphs + to_latex(fourG, pos_OK) + with pytest.raises(nx.NetworkXError): + to_latex(fourG, pos_bad) + + # input list of dicts to use for all graphs + to_latex(fourG, fourpos) + with pytest.raises(nx.NetworkXError): + to_latex(fourG, [pos_bad, pos_bad, pos_bad, pos_bad]) + + # every pos dict must include all nodes + with pytest.raises(nx.NetworkXError): + to_latex(fourG, [pos_OK, pos_OK, pos_bad, pos_OK]) + + # test sub_captions and sub_labels (len must match Gbunch) + with pytest.raises(nx.NetworkXError): + to_latex(fourG, fourpos, sub_captions=["hi", "hi"]) + + with pytest.raises(nx.NetworkXError): + to_latex(fourG, fourpos, sub_labels=["hi", "hi"]) + + # all pass + to_latex(fourG, fourpos, sub_captions=["hi"] * 4, sub_labels=["lbl"] * 4) + + +def test_exception_multigraph(): + G = nx.path_graph(4, create_using=nx.MultiGraph) + G.add_edge(1, 2) + with pytest.raises(nx.NetworkXNotImplemented): + nx.to_latex(G) diff --git a/.venv/lib/python3.12/site-packages/networkx/drawing/tests/test_layout.py b/.venv/lib/python3.12/site-packages/networkx/drawing/tests/test_layout.py new file mode 100644 index 00000000..7f0412ce --- /dev/null +++ b/.venv/lib/python3.12/site-packages/networkx/drawing/tests/test_layout.py @@ -0,0 +1,538 @@ +"""Unit tests for layout functions.""" + +import pytest + +import networkx as nx + +np = pytest.importorskip("numpy") +pytest.importorskip("scipy") + + +class TestLayout: + @classmethod + def setup_class(cls): + cls.Gi = nx.grid_2d_graph(5, 5) + cls.Gs = nx.Graph() + nx.add_path(cls.Gs, "abcdef") + cls.bigG = nx.grid_2d_graph(25, 25) # > 500 nodes for sparse + + def test_spring_fixed_without_pos(self): + G = nx.path_graph(4) + pytest.raises(ValueError, nx.spring_layout, G, fixed=[0]) + pos = {0: (1, 1), 2: (0, 0)} + pytest.raises(ValueError, nx.spring_layout, G, fixed=[0, 1], pos=pos) + nx.spring_layout(G, fixed=[0, 2], pos=pos) # No ValueError + + def test_spring_init_pos(self): + # Tests GH #2448 + import math + + G = nx.Graph() + G.add_edges_from([(0, 1), (1, 2), (2, 0), (2, 3)]) + + init_pos = {0: (0.0, 0.0)} + fixed_pos = [0] + pos = nx.fruchterman_reingold_layout(G, pos=init_pos, fixed=fixed_pos) + has_nan = any(math.isnan(c) for coords in pos.values() for c in coords) + assert not has_nan, "values should not be nan" + + def test_smoke_empty_graph(self): + G = [] + nx.random_layout(G) + nx.circular_layout(G) + nx.planar_layout(G) + nx.spring_layout(G) + nx.fruchterman_reingold_layout(G) + nx.spectral_layout(G) + nx.shell_layout(G) + nx.bipartite_layout(G, G) + nx.spiral_layout(G) + nx.multipartite_layout(G) + nx.kamada_kawai_layout(G) + + def test_smoke_int(self): + G = self.Gi + nx.random_layout(G) + nx.circular_layout(G) + nx.planar_layout(G) + nx.spring_layout(G) + nx.forceatlas2_layout(G) + nx.fruchterman_reingold_layout(G) + nx.fruchterman_reingold_layout(self.bigG) + nx.spectral_layout(G) + nx.spectral_layout(G.to_directed()) + nx.spectral_layout(self.bigG) + nx.spectral_layout(self.bigG.to_directed()) + nx.shell_layout(G) + nx.spiral_layout(G) + nx.kamada_kawai_layout(G) + nx.kamada_kawai_layout(G, dim=1) + nx.kamada_kawai_layout(G, dim=3) + nx.arf_layout(G) + + def test_smoke_string(self): + G = self.Gs + nx.random_layout(G) + nx.circular_layout(G) + nx.planar_layout(G) + nx.spring_layout(G) + nx.forceatlas2_layout(G) + nx.fruchterman_reingold_layout(G) + nx.spectral_layout(G) + nx.shell_layout(G) + nx.spiral_layout(G) + nx.kamada_kawai_layout(G) + nx.kamada_kawai_layout(G, dim=1) + nx.kamada_kawai_layout(G, dim=3) + nx.arf_layout(G) + + def check_scale_and_center(self, pos, scale, center): + center = np.array(center) + low = center - scale + hi = center + scale + vpos = np.array(list(pos.values())) + length = vpos.max(0) - vpos.min(0) + assert (length <= 2 * scale).all() + assert (vpos >= low).all() + assert (vpos <= hi).all() + + def test_scale_and_center_arg(self): + sc = self.check_scale_and_center + c = (4, 5) + G = nx.complete_graph(9) + G.add_node(9) + sc(nx.random_layout(G, center=c), scale=0.5, center=(4.5, 5.5)) + # rest can have 2*scale length: [-scale, scale] + sc(nx.spring_layout(G, scale=2, center=c), scale=2, center=c) + sc(nx.spectral_layout(G, scale=2, center=c), scale=2, center=c) + sc(nx.circular_layout(G, scale=2, center=c), scale=2, center=c) + sc(nx.shell_layout(G, scale=2, center=c), scale=2, center=c) + sc(nx.spiral_layout(G, scale=2, center=c), scale=2, center=c) + sc(nx.kamada_kawai_layout(G, scale=2, center=c), scale=2, center=c) + + c = (2, 3, 5) + sc(nx.kamada_kawai_layout(G, dim=3, scale=2, center=c), scale=2, center=c) + + def test_planar_layout_non_planar_input(self): + G = nx.complete_graph(9) + pytest.raises(nx.NetworkXException, nx.planar_layout, G) + + def test_smoke_planar_layout_embedding_input(self): + embedding = nx.PlanarEmbedding() + embedding.set_data({0: [1, 2], 1: [0, 2], 2: [0, 1]}) + nx.planar_layout(embedding) + + def test_default_scale_and_center(self): + sc = self.check_scale_and_center + c = (0, 0) + G = nx.complete_graph(9) + G.add_node(9) + sc(nx.random_layout(G), scale=0.5, center=(0.5, 0.5)) + sc(nx.spring_layout(G), scale=1, center=c) + sc(nx.spectral_layout(G), scale=1, center=c) + sc(nx.circular_layout(G), scale=1, center=c) + sc(nx.shell_layout(G), scale=1, center=c) + sc(nx.spiral_layout(G), scale=1, center=c) + sc(nx.kamada_kawai_layout(G), scale=1, center=c) + + c = (0, 0, 0) + sc(nx.kamada_kawai_layout(G, dim=3), scale=1, center=c) + + def test_circular_planar_and_shell_dim_error(self): + G = nx.path_graph(4) + pytest.raises(ValueError, nx.circular_layout, G, dim=1) + pytest.raises(ValueError, nx.shell_layout, G, dim=1) + pytest.raises(ValueError, nx.shell_layout, G, dim=3) + pytest.raises(ValueError, nx.planar_layout, G, dim=1) + pytest.raises(ValueError, nx.planar_layout, G, dim=3) + + def test_adjacency_interface_numpy(self): + A = nx.to_numpy_array(self.Gs) + pos = nx.drawing.layout._fruchterman_reingold(A) + assert pos.shape == (6, 2) + pos = nx.drawing.layout._fruchterman_reingold(A, dim=3) + assert pos.shape == (6, 3) + pos = nx.drawing.layout._sparse_fruchterman_reingold(A) + assert pos.shape == (6, 2) + + def test_adjacency_interface_scipy(self): + A = nx.to_scipy_sparse_array(self.Gs, dtype="d") + pos = nx.drawing.layout._sparse_fruchterman_reingold(A) + assert pos.shape == (6, 2) + pos = nx.drawing.layout._sparse_spectral(A) + assert pos.shape == (6, 2) + pos = nx.drawing.layout._sparse_fruchterman_reingold(A, dim=3) + assert pos.shape == (6, 3) + + def test_single_nodes(self): + G = nx.path_graph(1) + vpos = nx.shell_layout(G) + assert not vpos[0].any() + G = nx.path_graph(4) + vpos = nx.shell_layout(G, [[0], [1, 2], [3]]) + assert not vpos[0].any() + assert vpos[3].any() # ensure node 3 not at origin (#3188) + assert np.linalg.norm(vpos[3]) <= 1 # ensure node 3 fits (#3753) + vpos = nx.shell_layout(G, [[0], [1, 2], [3]], rotate=0) + assert np.linalg.norm(vpos[3]) <= 1 # ensure node 3 fits (#3753) + + def test_smoke_initial_pos_forceatlas2(self): + pos = nx.circular_layout(self.Gi) + npos = nx.forceatlas2_layout(self.Gi, pos=pos) + + def test_smoke_initial_pos_fruchterman_reingold(self): + pos = nx.circular_layout(self.Gi) + npos = nx.fruchterman_reingold_layout(self.Gi, pos=pos) + + def test_smoke_initial_pos_arf(self): + pos = nx.circular_layout(self.Gi) + npos = nx.arf_layout(self.Gi, pos=pos) + + def test_fixed_node_fruchterman_reingold(self): + # Dense version (numpy based) + pos = nx.circular_layout(self.Gi) + npos = nx.spring_layout(self.Gi, pos=pos, fixed=[(0, 0)]) + assert tuple(pos[(0, 0)]) == tuple(npos[(0, 0)]) + # Sparse version (scipy based) + pos = nx.circular_layout(self.bigG) + npos = nx.spring_layout(self.bigG, pos=pos, fixed=[(0, 0)]) + for axis in range(2): + assert pos[(0, 0)][axis] == pytest.approx(npos[(0, 0)][axis], abs=1e-7) + + def test_center_parameter(self): + G = nx.path_graph(1) + nx.random_layout(G, center=(1, 1)) + vpos = nx.circular_layout(G, center=(1, 1)) + assert tuple(vpos[0]) == (1, 1) + vpos = nx.planar_layout(G, center=(1, 1)) + assert tuple(vpos[0]) == (1, 1) + vpos = nx.spring_layout(G, center=(1, 1)) + assert tuple(vpos[0]) == (1, 1) + vpos = nx.fruchterman_reingold_layout(G, center=(1, 1)) + assert tuple(vpos[0]) == (1, 1) + vpos = nx.spectral_layout(G, center=(1, 1)) + assert tuple(vpos[0]) == (1, 1) + vpos = nx.shell_layout(G, center=(1, 1)) + assert tuple(vpos[0]) == (1, 1) + vpos = nx.spiral_layout(G, center=(1, 1)) + assert tuple(vpos[0]) == (1, 1) + + def test_center_wrong_dimensions(self): + G = nx.path_graph(1) + assert id(nx.spring_layout) == id(nx.fruchterman_reingold_layout) + pytest.raises(ValueError, nx.random_layout, G, center=(1, 1, 1)) + pytest.raises(ValueError, nx.circular_layout, G, center=(1, 1, 1)) + pytest.raises(ValueError, nx.planar_layout, G, center=(1, 1, 1)) + pytest.raises(ValueError, nx.spring_layout, G, center=(1, 1, 1)) + pytest.raises(ValueError, nx.spring_layout, G, dim=3, center=(1, 1)) + pytest.raises(ValueError, nx.spectral_layout, G, center=(1, 1, 1)) + pytest.raises(ValueError, nx.spectral_layout, G, dim=3, center=(1, 1)) + pytest.raises(ValueError, nx.shell_layout, G, center=(1, 1, 1)) + pytest.raises(ValueError, nx.spiral_layout, G, center=(1, 1, 1)) + pytest.raises(ValueError, nx.kamada_kawai_layout, G, center=(1, 1, 1)) + + def test_empty_graph(self): + G = nx.empty_graph() + vpos = nx.random_layout(G, center=(1, 1)) + assert vpos == {} + vpos = nx.circular_layout(G, center=(1, 1)) + assert vpos == {} + vpos = nx.planar_layout(G, center=(1, 1)) + assert vpos == {} + vpos = nx.bipartite_layout(G, G) + assert vpos == {} + vpos = nx.spring_layout(G, center=(1, 1)) + assert vpos == {} + vpos = nx.fruchterman_reingold_layout(G, center=(1, 1)) + assert vpos == {} + vpos = nx.spectral_layout(G, center=(1, 1)) + assert vpos == {} + vpos = nx.shell_layout(G, center=(1, 1)) + assert vpos == {} + vpos = nx.spiral_layout(G, center=(1, 1)) + assert vpos == {} + vpos = nx.multipartite_layout(G, center=(1, 1)) + assert vpos == {} + vpos = nx.kamada_kawai_layout(G, center=(1, 1)) + assert vpos == {} + vpos = nx.forceatlas2_layout(G) + assert vpos == {} + vpos = nx.arf_layout(G) + assert vpos == {} + + def test_bipartite_layout(self): + G = nx.complete_bipartite_graph(3, 5) + top, bottom = nx.bipartite.sets(G) + + vpos = nx.bipartite_layout(G, top) + assert len(vpos) == len(G) + + top_x = vpos[list(top)[0]][0] + bottom_x = vpos[list(bottom)[0]][0] + for node in top: + assert vpos[node][0] == top_x + for node in bottom: + assert vpos[node][0] == bottom_x + + vpos = nx.bipartite_layout( + G, top, align="horizontal", center=(2, 2), scale=2, aspect_ratio=1 + ) + assert len(vpos) == len(G) + + top_y = vpos[list(top)[0]][1] + bottom_y = vpos[list(bottom)[0]][1] + for node in top: + assert vpos[node][1] == top_y + for node in bottom: + assert vpos[node][1] == bottom_y + + pytest.raises(ValueError, nx.bipartite_layout, G, top, align="foo") + + def test_multipartite_layout(self): + sizes = (0, 5, 7, 2, 8) + G = nx.complete_multipartite_graph(*sizes) + + vpos = nx.multipartite_layout(G) + assert len(vpos) == len(G) + + start = 0 + for n in sizes: + end = start + n + assert all(vpos[start][0] == vpos[i][0] for i in range(start + 1, end)) + start += n + + vpos = nx.multipartite_layout(G, align="horizontal", scale=2, center=(2, 2)) + assert len(vpos) == len(G) + + start = 0 + for n in sizes: + end = start + n + assert all(vpos[start][1] == vpos[i][1] for i in range(start + 1, end)) + start += n + + pytest.raises(ValueError, nx.multipartite_layout, G, align="foo") + + def test_kamada_kawai_costfn_1d(self): + costfn = nx.drawing.layout._kamada_kawai_costfn + + pos = np.array([4.0, 7.0]) + invdist = 1 / np.array([[0.1, 2.0], [2.0, 0.3]]) + + cost, grad = costfn(pos, np, invdist, meanweight=0, dim=1) + + assert cost == pytest.approx(((3 / 2.0 - 1) ** 2), abs=1e-7) + assert grad[0] == pytest.approx((-0.5), abs=1e-7) + assert grad[1] == pytest.approx(0.5, abs=1e-7) + + def check_kamada_kawai_costfn(self, pos, invdist, meanwt, dim): + costfn = nx.drawing.layout._kamada_kawai_costfn + + cost, grad = costfn(pos.ravel(), np, invdist, meanweight=meanwt, dim=dim) + + expected_cost = 0.5 * meanwt * np.sum(np.sum(pos, axis=0) ** 2) + for i in range(pos.shape[0]): + for j in range(i + 1, pos.shape[0]): + diff = np.linalg.norm(pos[i] - pos[j]) + expected_cost += (diff * invdist[i][j] - 1.0) ** 2 + + assert cost == pytest.approx(expected_cost, abs=1e-7) + + dx = 1e-4 + for nd in range(pos.shape[0]): + for dm in range(pos.shape[1]): + idx = nd * pos.shape[1] + dm + ps = pos.flatten() + + ps[idx] += dx + cplus = costfn(ps, np, invdist, meanweight=meanwt, dim=pos.shape[1])[0] + + ps[idx] -= 2 * dx + cminus = costfn(ps, np, invdist, meanweight=meanwt, dim=pos.shape[1])[0] + + assert grad[idx] == pytest.approx((cplus - cminus) / (2 * dx), abs=1e-5) + + def test_kamada_kawai_costfn(self): + invdist = 1 / np.array([[0.1, 2.1, 1.7], [2.1, 0.2, 0.6], [1.7, 0.6, 0.3]]) + meanwt = 0.3 + + # 2d + pos = np.array([[1.3, -3.2], [2.7, -0.3], [5.1, 2.5]]) + + self.check_kamada_kawai_costfn(pos, invdist, meanwt, 2) + + # 3d + pos = np.array([[0.9, 8.6, -8.7], [-10, -0.5, -7.1], [9.1, -8.1, 1.6]]) + + self.check_kamada_kawai_costfn(pos, invdist, meanwt, 3) + + def test_spiral_layout(self): + G = self.Gs + + # a lower value of resolution should result in a more compact layout + # intuitively, the total distance from the start and end nodes + # via each node in between (transiting through each) will be less, + # assuming rescaling does not occur on the computed node positions + pos_standard = np.array(list(nx.spiral_layout(G, resolution=0.35).values())) + pos_tighter = np.array(list(nx.spiral_layout(G, resolution=0.34).values())) + distances = np.linalg.norm(pos_standard[:-1] - pos_standard[1:], axis=1) + distances_tighter = np.linalg.norm(pos_tighter[:-1] - pos_tighter[1:], axis=1) + assert sum(distances) > sum(distances_tighter) + + # return near-equidistant points after the first value if set to true + pos_equidistant = np.array(list(nx.spiral_layout(G, equidistant=True).values())) + distances_equidistant = np.linalg.norm( + pos_equidistant[:-1] - pos_equidistant[1:], axis=1 + ) + assert np.allclose( + distances_equidistant[1:], distances_equidistant[-1], atol=0.01 + ) + + def test_spiral_layout_equidistant(self): + G = nx.path_graph(10) + pos = nx.spiral_layout(G, equidistant=True) + # Extract individual node positions as an array + p = np.array(list(pos.values())) + # Elementwise-distance between node positions + dist = np.linalg.norm(p[1:] - p[:-1], axis=1) + assert np.allclose(np.diff(dist), 0, atol=1e-3) + + def test_forceatlas2_layout_partial_input_test(self): + # check whether partial pos input still returns a full proper position + G = self.Gs + node = nx.utils.arbitrary_element(G) + pos = nx.circular_layout(G) + del pos[node] + pos = nx.forceatlas2_layout(G, pos=pos) + assert len(pos) == len(G) + + def test_rescale_layout_dict(self): + G = nx.empty_graph() + vpos = nx.random_layout(G, center=(1, 1)) + assert nx.rescale_layout_dict(vpos) == {} + + G = nx.empty_graph(2) + vpos = {0: (0.0, 0.0), 1: (1.0, 1.0)} + s_vpos = nx.rescale_layout_dict(vpos) + assert np.linalg.norm([sum(x) for x in zip(*s_vpos.values())]) < 1e-6 + + G = nx.empty_graph(3) + vpos = {0: (0, 0), 1: (1, 1), 2: (0.5, 0.5)} + s_vpos = nx.rescale_layout_dict(vpos) + + expectation = { + 0: np.array((-1, -1)), + 1: np.array((1, 1)), + 2: np.array((0, 0)), + } + for k, v in expectation.items(): + assert (s_vpos[k] == v).all() + s_vpos = nx.rescale_layout_dict(vpos, scale=2) + expectation = { + 0: np.array((-2, -2)), + 1: np.array((2, 2)), + 2: np.array((0, 0)), + } + for k, v in expectation.items(): + assert (s_vpos[k] == v).all() + + def test_arf_layout_partial_input_test(self): + # Checks whether partial pos input still returns a proper position. + G = self.Gs + node = nx.utils.arbitrary_element(G) + pos = nx.circular_layout(G) + del pos[node] + pos = nx.arf_layout(G, pos=pos) + assert len(pos) == len(G) + + def test_arf_layout_negative_a_check(self): + """ + Checks input parameters correctly raises errors. For example, `a` should be larger than 1 + """ + G = self.Gs + pytest.raises(ValueError, nx.arf_layout, G=G, a=-1) + + def test_smoke_seed_input(self): + G = self.Gs + nx.random_layout(G, seed=42) + nx.spring_layout(G, seed=42) + nx.arf_layout(G, seed=42) + nx.forceatlas2_layout(G, seed=42) + + +def test_multipartite_layout_nonnumeric_partition_labels(): + """See gh-5123.""" + G = nx.Graph() + G.add_node(0, subset="s0") + G.add_node(1, subset="s0") + G.add_node(2, subset="s1") + G.add_node(3, subset="s1") + G.add_edges_from([(0, 2), (0, 3), (1, 2)]) + pos = nx.multipartite_layout(G) + assert len(pos) == len(G) + + +def test_multipartite_layout_layer_order(): + """Return the layers in sorted order if the layers of the multipartite + graph are sortable. See gh-5691""" + G = nx.Graph() + node_group = dict(zip(("a", "b", "c", "d", "e"), (2, 3, 1, 2, 4))) + for node, layer in node_group.items(): + G.add_node(node, subset=layer) + + # Horizontal alignment, therefore y-coord determines layers + pos = nx.multipartite_layout(G, align="horizontal") + + layers = nx.utils.groups(node_group) + pos_from_layers = nx.multipartite_layout(G, align="horizontal", subset_key=layers) + for (n1, p1), (n2, p2) in zip(pos.items(), pos_from_layers.items()): + assert n1 == n2 and (p1 == p2).all() + + # Nodes "a" and "d" are in the same layer + assert pos["a"][-1] == pos["d"][-1] + # positions should be sorted according to layer + assert pos["c"][-1] < pos["a"][-1] < pos["b"][-1] < pos["e"][-1] + + # Make sure that multipartite_layout still works when layers are not sortable + G.nodes["a"]["subset"] = "layer_0" # Can't sort mixed strs/ints + pos_nosort = nx.multipartite_layout(G) # smoke test: this should not raise + assert pos_nosort.keys() == pos.keys() + + +def _num_nodes_per_bfs_layer(pos): + """Helper function to extract the number of nodes in each layer of bfs_layout""" + x = np.array(list(pos.values()))[:, 0] # node positions in layered dimension + _, layer_count = np.unique(x, return_counts=True) + return layer_count + + +@pytest.mark.parametrize("n", range(2, 7)) +def test_bfs_layout_complete_graph(n): + """The complete graph should result in two layers: the starting node and + a second layer containing all neighbors.""" + G = nx.complete_graph(n) + pos = nx.bfs_layout(G, start=0) + assert np.array_equal(_num_nodes_per_bfs_layer(pos), [1, n - 1]) + + +def test_bfs_layout_barbell(): + G = nx.barbell_graph(5, 3) + # Start in one of the "bells" + pos = nx.bfs_layout(G, start=0) + # start, bell-1, [1] * len(bar)+1, bell-1 + expected_nodes_per_layer = [1, 4, 1, 1, 1, 1, 4] + assert np.array_equal(_num_nodes_per_bfs_layer(pos), expected_nodes_per_layer) + # Start in the other "bell" - expect same layer pattern + pos = nx.bfs_layout(G, start=12) + assert np.array_equal(_num_nodes_per_bfs_layer(pos), expected_nodes_per_layer) + # Starting in the center of the bar, expect layers to be symmetric + pos = nx.bfs_layout(G, start=6) + # Expected layers: {6 (start)}, {5, 7}, {4, 8}, {8 nodes from remainder of bells} + expected_nodes_per_layer = [1, 2, 2, 8] + assert np.array_equal(_num_nodes_per_bfs_layer(pos), expected_nodes_per_layer) + + +def test_bfs_layout_disconnected(): + G = nx.complete_graph(5) + G.add_edges_from([(10, 11), (11, 12)]) + with pytest.raises(nx.NetworkXError, match="bfs_layout didn't include all nodes"): + nx.bfs_layout(G, start=0) diff --git a/.venv/lib/python3.12/site-packages/networkx/drawing/tests/test_pydot.py b/.venv/lib/python3.12/site-packages/networkx/drawing/tests/test_pydot.py new file mode 100644 index 00000000..acf93d77 --- /dev/null +++ b/.venv/lib/python3.12/site-packages/networkx/drawing/tests/test_pydot.py @@ -0,0 +1,146 @@ +"""Unit tests for pydot drawing functions.""" + +from io import StringIO + +import pytest + +import networkx as nx +from networkx.utils import graphs_equal + +pydot = pytest.importorskip("pydot") + + +class TestPydot: + @pytest.mark.parametrize("G", (nx.Graph(), nx.DiGraph())) + @pytest.mark.parametrize("prog", ("neato", "dot")) + def test_pydot(self, G, prog, tmp_path): + """ + Validate :mod:`pydot`-based usage of the passed NetworkX graph with the + passed basename of an external GraphViz command (e.g., `dot`, `neato`). + """ + + # Set the name of this graph to... "G". Failing to do so will + # subsequently trip an assertion expecting this name. + G.graph["name"] = "G" + + # Add arbitrary nodes and edges to the passed empty graph. + G.add_edges_from([("A", "B"), ("A", "C"), ("B", "C"), ("A", "D")]) + G.add_node("E") + + # Validate layout of this graph with the passed GraphViz command. + graph_layout = nx.nx_pydot.pydot_layout(G, prog=prog) + assert isinstance(graph_layout, dict) + + # Convert this graph into a "pydot.Dot" instance. + P = nx.nx_pydot.to_pydot(G) + + # Convert this "pydot.Dot" instance back into a graph of the same type. + G2 = G.__class__(nx.nx_pydot.from_pydot(P)) + + # Validate the original and resulting graphs to be the same. + assert graphs_equal(G, G2) + + fname = tmp_path / "out.dot" + + # Serialize this "pydot.Dot" instance to a temporary file in dot format + P.write_raw(fname) + + # Deserialize a list of new "pydot.Dot" instances back from this file. + Pin_list = pydot.graph_from_dot_file(path=fname, encoding="utf-8") + + # Validate this file to contain only one graph. + assert len(Pin_list) == 1 + + # The single "pydot.Dot" instance deserialized from this file. + Pin = Pin_list[0] + + # Sorted list of all nodes in the original "pydot.Dot" instance. + n1 = sorted(p.get_name() for p in P.get_node_list()) + + # Sorted list of all nodes in the deserialized "pydot.Dot" instance. + n2 = sorted(p.get_name() for p in Pin.get_node_list()) + + # Validate these instances to contain the same nodes. + assert n1 == n2 + + # Sorted list of all edges in the original "pydot.Dot" instance. + e1 = sorted((e.get_source(), e.get_destination()) for e in P.get_edge_list()) + + # Sorted list of all edges in the original "pydot.Dot" instance. + e2 = sorted((e.get_source(), e.get_destination()) for e in Pin.get_edge_list()) + + # Validate these instances to contain the same edges. + assert e1 == e2 + + # Deserialize a new graph of the same type back from this file. + Hin = nx.nx_pydot.read_dot(fname) + Hin = G.__class__(Hin) + + # Validate the original and resulting graphs to be the same. + assert graphs_equal(G, Hin) + + def test_read_write(self): + G = nx.MultiGraph() + G.graph["name"] = "G" + G.add_edge("1", "2", key="0") # read assumes strings + fh = StringIO() + nx.nx_pydot.write_dot(G, fh) + fh.seek(0) + H = nx.nx_pydot.read_dot(fh) + assert graphs_equal(G, H) + + +def test_pydot_issue_7581(tmp_path): + """Validate that `nx_pydot.pydot_layout` handles nodes + with characters like "\n", " ". + + Those characters cause `pydot` to escape and quote them on output, + which caused #7581. + """ + G = nx.Graph() + G.add_edges_from([("A\nbig test", "B"), ("A\nbig test", "C"), ("B", "C")]) + + graph_layout = nx.nx_pydot.pydot_layout(G, prog="dot") + assert isinstance(graph_layout, dict) + + # Convert the graph to pydot and back into a graph. There should be no difference. + P = nx.nx_pydot.to_pydot(G) + G2 = nx.Graph(nx.nx_pydot.from_pydot(P)) + assert graphs_equal(G, G2) + + +@pytest.mark.parametrize( + "graph_type", [nx.Graph, nx.DiGraph, nx.MultiGraph, nx.MultiDiGraph] +) +def test_hashable_pydot(graph_type): + # gh-5790 + G = graph_type() + G.add_edge("5", frozenset([1]), t='"Example:A"', l=False) + G.add_edge("1", 2, w=True, t=("node1",), l=frozenset(["node1"])) + G.add_edge("node", (3, 3), w="string") + + assert [ + {"t": '"Example:A"', "l": "False"}, + {"w": "True", "t": "('node1',)", "l": "frozenset({'node1'})"}, + {"w": "string"}, + ] == [ + attr + for _, _, attr in nx.nx_pydot.from_pydot(nx.nx_pydot.to_pydot(G)).edges.data() + ] + + assert {str(i) for i in G.nodes()} == set( + nx.nx_pydot.from_pydot(nx.nx_pydot.to_pydot(G)).nodes + ) + + +def test_pydot_numerical_name(): + G = nx.Graph() + G.add_edges_from([("A", "B"), (0, 1)]) + graph_layout = nx.nx_pydot.pydot_layout(G, prog="dot") + assert isinstance(graph_layout, dict) + assert "0" not in graph_layout + assert 0 in graph_layout + assert "1" not in graph_layout + assert 1 in graph_layout + assert "A" in graph_layout + assert "B" in graph_layout diff --git a/.venv/lib/python3.12/site-packages/networkx/drawing/tests/test_pylab.py b/.venv/lib/python3.12/site-packages/networkx/drawing/tests/test_pylab.py new file mode 100644 index 00000000..c9931db8 --- /dev/null +++ b/.venv/lib/python3.12/site-packages/networkx/drawing/tests/test_pylab.py @@ -0,0 +1,1029 @@ +"""Unit tests for matplotlib drawing functions.""" + +import itertools +import os +import warnings + +import pytest + +mpl = pytest.importorskip("matplotlib") +np = pytest.importorskip("numpy") +mpl.use("PS") +plt = pytest.importorskip("matplotlib.pyplot") +plt.rcParams["text.usetex"] = False + + +import networkx as nx + +barbell = nx.barbell_graph(4, 6) + + +def test_draw(): + try: + functions = [ + nx.draw_circular, + nx.draw_kamada_kawai, + nx.draw_planar, + nx.draw_random, + nx.draw_spectral, + nx.draw_spring, + nx.draw_shell, + ] + options = [{"node_color": "black", "node_size": 100, "width": 3}] + for function, option in itertools.product(functions, options): + function(barbell, **option) + plt.savefig("test.ps") + except ModuleNotFoundError: # draw_kamada_kawai requires scipy + pass + finally: + try: + os.unlink("test.ps") + except OSError: + pass + + +def test_draw_shell_nlist(): + try: + nlist = [list(range(4)), list(range(4, 10)), list(range(10, 14))] + nx.draw_shell(barbell, nlist=nlist) + plt.savefig("test.ps") + finally: + try: + os.unlink("test.ps") + except OSError: + pass + + +def test_edge_colormap(): + colors = range(barbell.number_of_edges()) + nx.draw_spring( + barbell, edge_color=colors, width=4, edge_cmap=plt.cm.Blues, with_labels=True + ) + # plt.show() + + +def test_arrows(): + nx.draw_spring(barbell.to_directed()) + # plt.show() + + +@pytest.mark.parametrize( + ("edge_color", "expected"), + ( + (None, "black"), # Default + ("r", "red"), # Non-default color string + (["r"], "red"), # Single non-default color in a list + ((1.0, 1.0, 0.0), "yellow"), # single color as rgb tuple + ([(1.0, 1.0, 0.0)], "yellow"), # single color as rgb tuple in list + ((0, 1, 0, 1), "lime"), # single color as rgba tuple + ([(0, 1, 0, 1)], "lime"), # single color as rgba tuple in list + ("#0000ff", "blue"), # single color hex code + (["#0000ff"], "blue"), # hex code in list + ), +) +@pytest.mark.parametrize("edgelist", (None, [(0, 1)])) +def test_single_edge_color_undirected(edge_color, expected, edgelist): + """Tests ways of specifying all edges have a single color for edges + drawn with a LineCollection""" + + G = nx.path_graph(3) + drawn_edges = nx.draw_networkx_edges( + G, pos=nx.random_layout(G), edgelist=edgelist, edge_color=edge_color + ) + assert mpl.colors.same_color(drawn_edges.get_color(), expected) + + +@pytest.mark.parametrize( + ("edge_color", "expected"), + ( + (None, "black"), # Default + ("r", "red"), # Non-default color string + (["r"], "red"), # Single non-default color in a list + ((1.0, 1.0, 0.0), "yellow"), # single color as rgb tuple + ([(1.0, 1.0, 0.0)], "yellow"), # single color as rgb tuple in list + ((0, 1, 0, 1), "lime"), # single color as rgba tuple + ([(0, 1, 0, 1)], "lime"), # single color as rgba tuple in list + ("#0000ff", "blue"), # single color hex code + (["#0000ff"], "blue"), # hex code in list + ), +) +@pytest.mark.parametrize("edgelist", (None, [(0, 1)])) +def test_single_edge_color_directed(edge_color, expected, edgelist): + """Tests ways of specifying all edges have a single color for edges drawn + with FancyArrowPatches""" + + G = nx.path_graph(3, create_using=nx.DiGraph) + drawn_edges = nx.draw_networkx_edges( + G, pos=nx.random_layout(G), edgelist=edgelist, edge_color=edge_color + ) + for fap in drawn_edges: + assert mpl.colors.same_color(fap.get_edgecolor(), expected) + + +def test_edge_color_tuple_interpretation(): + """If edge_color is a sequence with the same length as edgelist, then each + value in edge_color is mapped onto each edge via colormap.""" + G = nx.path_graph(6, create_using=nx.DiGraph) + pos = {n: (n, n) for n in range(len(G))} + + # num edges != 3 or 4 --> edge_color interpreted as rgb(a) + for ec in ((0, 0, 1), (0, 0, 1, 1)): + # More than 4 edges + drawn_edges = nx.draw_networkx_edges(G, pos, edge_color=ec) + for fap in drawn_edges: + assert mpl.colors.same_color(fap.get_edgecolor(), ec) + # Fewer than 3 edges + drawn_edges = nx.draw_networkx_edges( + G, pos, edgelist=[(0, 1), (1, 2)], edge_color=ec + ) + for fap in drawn_edges: + assert mpl.colors.same_color(fap.get_edgecolor(), ec) + + # num edges == 3, len(edge_color) == 4: interpreted as rgba + drawn_edges = nx.draw_networkx_edges( + G, pos, edgelist=[(0, 1), (1, 2), (2, 3)], edge_color=(0, 0, 1, 1) + ) + for fap in drawn_edges: + assert mpl.colors.same_color(fap.get_edgecolor(), "blue") + + # num edges == 4, len(edge_color) == 3: interpreted as rgb + drawn_edges = nx.draw_networkx_edges( + G, pos, edgelist=[(0, 1), (1, 2), (2, 3), (3, 4)], edge_color=(0, 0, 1) + ) + for fap in drawn_edges: + assert mpl.colors.same_color(fap.get_edgecolor(), "blue") + + # num edges == len(edge_color) == 3: interpreted with cmap, *not* as rgb + drawn_edges = nx.draw_networkx_edges( + G, pos, edgelist=[(0, 1), (1, 2), (2, 3)], edge_color=(0, 0, 1) + ) + assert mpl.colors.same_color( + drawn_edges[0].get_edgecolor(), drawn_edges[1].get_edgecolor() + ) + for fap in drawn_edges: + assert not mpl.colors.same_color(fap.get_edgecolor(), "blue") + + # num edges == len(edge_color) == 4: interpreted with cmap, *not* as rgba + drawn_edges = nx.draw_networkx_edges( + G, pos, edgelist=[(0, 1), (1, 2), (2, 3), (3, 4)], edge_color=(0, 0, 1, 1) + ) + assert mpl.colors.same_color( + drawn_edges[0].get_edgecolor(), drawn_edges[1].get_edgecolor() + ) + assert mpl.colors.same_color( + drawn_edges[2].get_edgecolor(), drawn_edges[3].get_edgecolor() + ) + for fap in drawn_edges: + assert not mpl.colors.same_color(fap.get_edgecolor(), "blue") + + +def test_fewer_edge_colors_than_num_edges_directed(): + """Test that the edge colors are cycled when there are fewer specified + colors than edges.""" + G = barbell.to_directed() + pos = nx.random_layout(barbell) + edgecolors = ("r", "g", "b") + drawn_edges = nx.draw_networkx_edges(G, pos, edge_color=edgecolors) + for fap, expected in zip(drawn_edges, itertools.cycle(edgecolors)): + assert mpl.colors.same_color(fap.get_edgecolor(), expected) + + +def test_more_edge_colors_than_num_edges_directed(): + """Test that extra edge colors are ignored when there are more specified + colors than edges.""" + G = nx.path_graph(4, create_using=nx.DiGraph) # 3 edges + pos = nx.random_layout(barbell) + edgecolors = ("r", "g", "b", "c") # 4 edge colors + drawn_edges = nx.draw_networkx_edges(G, pos, edge_color=edgecolors) + for fap, expected in zip(drawn_edges, edgecolors[:-1]): + assert mpl.colors.same_color(fap.get_edgecolor(), expected) + + +def test_edge_color_string_with_global_alpha_undirected(): + edge_collection = nx.draw_networkx_edges( + barbell, + pos=nx.random_layout(barbell), + edgelist=[(0, 1), (1, 2)], + edge_color="purple", + alpha=0.2, + ) + ec = edge_collection.get_color().squeeze() # as rgba tuple + assert len(edge_collection.get_paths()) == 2 + assert mpl.colors.same_color(ec[:-1], "purple") + assert ec[-1] == 0.2 + + +def test_edge_color_string_with_global_alpha_directed(): + drawn_edges = nx.draw_networkx_edges( + barbell.to_directed(), + pos=nx.random_layout(barbell), + edgelist=[(0, 1), (1, 2)], + edge_color="purple", + alpha=0.2, + ) + assert len(drawn_edges) == 2 + for fap in drawn_edges: + ec = fap.get_edgecolor() # As rgba tuple + assert mpl.colors.same_color(ec[:-1], "purple") + assert ec[-1] == 0.2 + + +@pytest.mark.parametrize("graph_type", (nx.Graph, nx.DiGraph)) +def test_edge_width_default_value(graph_type): + """Test the default linewidth for edges drawn either via LineCollection or + FancyArrowPatches.""" + G = nx.path_graph(2, create_using=graph_type) + pos = {n: (n, n) for n in range(len(G))} + drawn_edges = nx.draw_networkx_edges(G, pos) + if isinstance(drawn_edges, list): # directed case: list of FancyArrowPatch + drawn_edges = drawn_edges[0] + assert drawn_edges.get_linewidth() == 1 + + +@pytest.mark.parametrize( + ("edgewidth", "expected"), + ( + (3, 3), # single-value, non-default + ([3], 3), # Single value as a list + ), +) +def test_edge_width_single_value_undirected(edgewidth, expected): + G = nx.path_graph(4) + pos = {n: (n, n) for n in range(len(G))} + drawn_edges = nx.draw_networkx_edges(G, pos, width=edgewidth) + assert len(drawn_edges.get_paths()) == 3 + assert drawn_edges.get_linewidth() == expected + + +@pytest.mark.parametrize( + ("edgewidth", "expected"), + ( + (3, 3), # single-value, non-default + ([3], 3), # Single value as a list + ), +) +def test_edge_width_single_value_directed(edgewidth, expected): + G = nx.path_graph(4, create_using=nx.DiGraph) + pos = {n: (n, n) for n in range(len(G))} + drawn_edges = nx.draw_networkx_edges(G, pos, width=edgewidth) + assert len(drawn_edges) == 3 + for fap in drawn_edges: + assert fap.get_linewidth() == expected + + +@pytest.mark.parametrize( + "edgelist", + ( + [(0, 1), (1, 2), (2, 3)], # one width specification per edge + None, # fewer widths than edges - widths cycle + [(0, 1), (1, 2)], # More widths than edges - unused widths ignored + ), +) +def test_edge_width_sequence(edgelist): + G = barbell.to_directed() + pos = nx.random_layout(G) + widths = (0.5, 2.0, 12.0) + drawn_edges = nx.draw_networkx_edges(G, pos, edgelist=edgelist, width=widths) + for fap, expected_width in zip(drawn_edges, itertools.cycle(widths)): + assert fap.get_linewidth() == expected_width + + +def test_edge_color_with_edge_vmin_vmax(): + """Test that edge_vmin and edge_vmax properly set the dynamic range of the + color map when num edges == len(edge_colors).""" + G = nx.path_graph(3, create_using=nx.DiGraph) + pos = nx.random_layout(G) + # Extract colors from the original (unscaled) colormap + drawn_edges = nx.draw_networkx_edges(G, pos, edge_color=[0, 1.0]) + orig_colors = [e.get_edgecolor() for e in drawn_edges] + # Colors from scaled colormap + drawn_edges = nx.draw_networkx_edges( + G, pos, edge_color=[0.2, 0.8], edge_vmin=0.2, edge_vmax=0.8 + ) + scaled_colors = [e.get_edgecolor() for e in drawn_edges] + assert mpl.colors.same_color(orig_colors, scaled_colors) + + +def test_directed_edges_linestyle_default(): + """Test default linestyle for edges drawn with FancyArrowPatches.""" + G = nx.path_graph(4, create_using=nx.DiGraph) # Graph with 3 edges + pos = {n: (n, n) for n in range(len(G))} + + # edge with default style + drawn_edges = nx.draw_networkx_edges(G, pos) + assert len(drawn_edges) == 3 + for fap in drawn_edges: + assert fap.get_linestyle() == "solid" + + +@pytest.mark.parametrize( + "style", + ( + "dashed", # edge with string style + "--", # edge with simplified string style + (1, (1, 1)), # edge with (offset, onoffseq) style + ), +) +def test_directed_edges_linestyle_single_value(style): + """Tests support for specifying linestyles with a single value to be applied to + all edges in ``draw_networkx_edges`` for FancyArrowPatch outputs + (e.g. directed edges).""" + + G = nx.path_graph(4, create_using=nx.DiGraph) # Graph with 3 edges + pos = {n: (n, n) for n in range(len(G))} + + drawn_edges = nx.draw_networkx_edges(G, pos, style=style) + assert len(drawn_edges) == 3 + for fap in drawn_edges: + assert fap.get_linestyle() == style + + +@pytest.mark.parametrize( + "style_seq", + ( + ["dashed"], # edge with string style in list + ["--"], # edge with simplified string style in list + [(1, (1, 1))], # edge with (offset, onoffseq) style in list + ["--", "-", ":"], # edges with styles for each edge + ["--", "-"], # edges with fewer styles than edges (styles cycle) + ["--", "-", ":", "-."], # edges with more styles than edges (extra unused) + ), +) +def test_directed_edges_linestyle_sequence(style_seq): + """Tests support for specifying linestyles with sequences in + ``draw_networkx_edges`` for FancyArrowPatch outputs (e.g. directed edges).""" + + G = nx.path_graph(4, create_using=nx.DiGraph) # Graph with 3 edges + pos = {n: (n, n) for n in range(len(G))} + + drawn_edges = nx.draw_networkx_edges(G, pos, style=style_seq) + assert len(drawn_edges) == 3 + for fap, style in zip(drawn_edges, itertools.cycle(style_seq)): + assert fap.get_linestyle() == style + + +def test_return_types(): + from matplotlib.collections import LineCollection, PathCollection + from matplotlib.patches import FancyArrowPatch + + G = nx.cubical_graph(nx.Graph) + dG = nx.cubical_graph(nx.DiGraph) + pos = nx.spring_layout(G) + dpos = nx.spring_layout(dG) + # nodes + nodes = nx.draw_networkx_nodes(G, pos) + assert isinstance(nodes, PathCollection) + # edges + edges = nx.draw_networkx_edges(dG, dpos, arrows=True) + assert isinstance(edges, list) + if len(edges) > 0: + assert isinstance(edges[0], FancyArrowPatch) + edges = nx.draw_networkx_edges(dG, dpos, arrows=False) + assert isinstance(edges, LineCollection) + edges = nx.draw_networkx_edges(G, dpos, arrows=None) + assert isinstance(edges, LineCollection) + edges = nx.draw_networkx_edges(dG, pos, arrows=None) + assert isinstance(edges, list) + if len(edges) > 0: + assert isinstance(edges[0], FancyArrowPatch) + + +def test_labels_and_colors(): + G = nx.cubical_graph() + pos = nx.spring_layout(G) # positions for all nodes + # nodes + nx.draw_networkx_nodes( + G, pos, nodelist=[0, 1, 2, 3], node_color="r", node_size=500, alpha=0.75 + ) + nx.draw_networkx_nodes( + G, + pos, + nodelist=[4, 5, 6, 7], + node_color="b", + node_size=500, + alpha=[0.25, 0.5, 0.75, 1.0], + ) + # edges + nx.draw_networkx_edges(G, pos, width=1.0, alpha=0.5) + nx.draw_networkx_edges( + G, + pos, + edgelist=[(0, 1), (1, 2), (2, 3), (3, 0)], + width=8, + alpha=0.5, + edge_color="r", + ) + nx.draw_networkx_edges( + G, + pos, + edgelist=[(4, 5), (5, 6), (6, 7), (7, 4)], + width=8, + alpha=0.5, + edge_color="b", + ) + nx.draw_networkx_edges( + G, + pos, + edgelist=[(4, 5), (5, 6), (6, 7), (7, 4)], + arrows=True, + min_source_margin=0.5, + min_target_margin=0.75, + width=8, + edge_color="b", + ) + # some math labels + labels = {} + labels[0] = r"$a$" + labels[1] = r"$b$" + labels[2] = r"$c$" + labels[3] = r"$d$" + labels[4] = r"$\alpha$" + labels[5] = r"$\beta$" + labels[6] = r"$\gamma$" + labels[7] = r"$\delta$" + colors = {n: "k" if n % 2 == 0 else "r" for n in range(8)} + nx.draw_networkx_labels(G, pos, labels, font_size=16) + nx.draw_networkx_labels(G, pos, labels, font_size=16, font_color=colors) + nx.draw_networkx_edge_labels(G, pos, edge_labels=None, rotate=False) + nx.draw_networkx_edge_labels(G, pos, edge_labels={(4, 5): "4-5"}) + # plt.show() + + +@pytest.mark.mpl_image_compare +def test_house_with_colors(): + G = nx.house_graph() + # explicitly set positions + fig, ax = plt.subplots() + pos = {0: (0, 0), 1: (1, 0), 2: (0, 1), 3: (1, 1), 4: (0.5, 2.0)} + + # Plot nodes with different properties for the "wall" and "roof" nodes + nx.draw_networkx_nodes( + G, + pos, + node_size=3000, + nodelist=[0, 1, 2, 3], + node_color="tab:blue", + ) + nx.draw_networkx_nodes( + G, pos, node_size=2000, nodelist=[4], node_color="tab:orange" + ) + nx.draw_networkx_edges(G, pos, alpha=0.5, width=6) + # Customize axes + ax.margins(0.11) + plt.tight_layout() + plt.axis("off") + return fig + + +def test_axes(): + fig, ax = plt.subplots() + nx.draw(barbell, ax=ax) + nx.draw_networkx_edge_labels(barbell, nx.circular_layout(barbell), ax=ax) + + +def test_empty_graph(): + G = nx.Graph() + nx.draw(G) + + +def test_draw_empty_nodes_return_values(): + # See Issue #3833 + import matplotlib.collections # call as mpl.collections + + G = nx.Graph([(1, 2), (2, 3)]) + DG = nx.DiGraph([(1, 2), (2, 3)]) + pos = nx.circular_layout(G) + assert isinstance( + nx.draw_networkx_nodes(G, pos, nodelist=[]), mpl.collections.PathCollection + ) + assert isinstance( + nx.draw_networkx_nodes(DG, pos, nodelist=[]), mpl.collections.PathCollection + ) + + # drawing empty edges used to return an empty LineCollection or empty list. + # Now it is always an empty list (because edges are now lists of FancyArrows) + assert nx.draw_networkx_edges(G, pos, edgelist=[], arrows=True) == [] + assert nx.draw_networkx_edges(G, pos, edgelist=[], arrows=False) == [] + assert nx.draw_networkx_edges(DG, pos, edgelist=[], arrows=False) == [] + assert nx.draw_networkx_edges(DG, pos, edgelist=[], arrows=True) == [] + + +def test_multigraph_edgelist_tuples(): + # See Issue #3295 + G = nx.path_graph(3, create_using=nx.MultiDiGraph) + nx.draw_networkx(G, edgelist=[(0, 1, 0)]) + nx.draw_networkx(G, edgelist=[(0, 1, 0)], node_size=[10, 20, 0]) + + +def test_alpha_iter(): + pos = nx.random_layout(barbell) + fig = plt.figure() + # with fewer alpha elements than nodes + fig.add_subplot(131) # Each test in a new axis object + nx.draw_networkx_nodes(barbell, pos, alpha=[0.1, 0.2]) + # with equal alpha elements and nodes + num_nodes = len(barbell.nodes) + alpha = [x / num_nodes for x in range(num_nodes)] + colors = range(num_nodes) + fig.add_subplot(132) + nx.draw_networkx_nodes(barbell, pos, node_color=colors, alpha=alpha) + # with more alpha elements than nodes + alpha.append(1) + fig.add_subplot(133) + nx.draw_networkx_nodes(barbell, pos, alpha=alpha) + + +def test_multiple_node_shapes(): + G = nx.path_graph(4) + ax = plt.figure().add_subplot(111) + nx.draw(G, node_shape=["o", "h", "s", "^"], ax=ax) + scatters = [ + s for s in ax.get_children() if isinstance(s, mpl.collections.PathCollection) + ] + assert len(scatters) == 4 + + +def test_individualized_font_attributes(): + G = nx.karate_club_graph() + ax = plt.figure().add_subplot(111) + nx.draw( + G, + ax=ax, + font_color={n: "k" if n % 2 else "r" for n in G.nodes()}, + font_size={n: int(n / (34 / 15) + 5) for n in G.nodes()}, + ) + for n, t in zip( + G.nodes(), + [ + t + for t in ax.get_children() + if isinstance(t, mpl.text.Text) and len(t.get_text()) > 0 + ], + ): + expected = "black" if n % 2 else "red" + + assert mpl.colors.same_color(t.get_color(), expected) + assert int(n / (34 / 15) + 5) == t.get_size() + + +def test_individualized_edge_attributes(): + G = nx.karate_club_graph() + ax = plt.figure().add_subplot(111) + arrowstyles = ["-|>" if (u + v) % 2 == 0 else "-[" for u, v in G.edges()] + arrowsizes = [10 * (u % 2 + v % 2) + 10 for u, v in G.edges()] + nx.draw(G, ax=ax, arrows=True, arrowstyle=arrowstyles, arrowsize=arrowsizes) + arrows = [ + f for f in ax.get_children() if isinstance(f, mpl.patches.FancyArrowPatch) + ] + for e, a in zip(G.edges(), arrows): + assert a.get_mutation_scale() == 10 * (e[0] % 2 + e[1] % 2) + 10 + expected = ( + mpl.patches.ArrowStyle.BracketB + if sum(e) % 2 + else mpl.patches.ArrowStyle.CurveFilledB + ) + assert isinstance(a.get_arrowstyle(), expected) + + +def test_error_invalid_kwds(): + with pytest.raises(ValueError, match="Received invalid argument"): + nx.draw(barbell, foo="bar") + + +def test_draw_networkx_arrowsize_incorrect_size(): + G = nx.DiGraph([(0, 1), (0, 2), (0, 3), (1, 3)]) + arrowsize = [1, 2, 3] + with pytest.raises( + ValueError, match="arrowsize should have the same length as edgelist" + ): + nx.draw(G, arrowsize=arrowsize) + + +@pytest.mark.parametrize("arrowsize", (30, [10, 20, 30])) +def test_draw_edges_arrowsize(arrowsize): + G = nx.DiGraph([(0, 1), (0, 2), (1, 2)]) + pos = {0: (0, 0), 1: (0, 1), 2: (1, 0)} + edges = nx.draw_networkx_edges(G, pos=pos, arrowsize=arrowsize) + + arrowsize = itertools.repeat(arrowsize) if isinstance(arrowsize, int) else arrowsize + + for fap, expected in zip(edges, arrowsize): + assert isinstance(fap, mpl.patches.FancyArrowPatch) + assert fap.get_mutation_scale() == expected + + +@pytest.mark.parametrize("arrowstyle", ("-|>", ["-|>", "-[", "<|-|>"])) +def test_draw_edges_arrowstyle(arrowstyle): + G = nx.DiGraph([(0, 1), (0, 2), (1, 2)]) + pos = {0: (0, 0), 1: (0, 1), 2: (1, 0)} + edges = nx.draw_networkx_edges(G, pos=pos, arrowstyle=arrowstyle) + + arrowstyle = ( + itertools.repeat(arrowstyle) if isinstance(arrowstyle, str) else arrowstyle + ) + + arrow_objects = { + "-|>": mpl.patches.ArrowStyle.CurveFilledB, + "-[": mpl.patches.ArrowStyle.BracketB, + "<|-|>": mpl.patches.ArrowStyle.CurveFilledAB, + } + + for fap, expected in zip(edges, arrowstyle): + assert isinstance(fap, mpl.patches.FancyArrowPatch) + assert isinstance(fap.get_arrowstyle(), arrow_objects[expected]) + + +def test_np_edgelist(): + # see issue #4129 + nx.draw_networkx(barbell, edgelist=np.array([(0, 2), (0, 3)])) + + +def test_draw_nodes_missing_node_from_position(): + G = nx.path_graph(3) + pos = {0: (0, 0), 1: (1, 1)} # No position for node 2 + with pytest.raises(nx.NetworkXError, match="has no position"): + nx.draw_networkx_nodes(G, pos) + + +# NOTE: parametrizing on marker to test both branches of internal +# nx.draw_networkx_edges.to_marker_edge function +@pytest.mark.parametrize("node_shape", ("o", "s")) +def test_draw_edges_min_source_target_margins(node_shape): + """Test that there is a wider gap between the node and the start of an + incident edge when min_source_margin is specified. + + This test checks that the use of min_{source/target}_margin kwargs result + in shorter (more padding) between the edges and source and target nodes. + As a crude visual example, let 's' and 't' represent source and target + nodes, respectively: + + Default: + s-----------------------------t + + With margins: + s ----------------------- t + + """ + # Create a single axis object to get consistent pixel coords across + # multiple draws + fig, ax = plt.subplots() + G = nx.DiGraph([(0, 1)]) + pos = {0: (0, 0), 1: (1, 0)} # horizontal layout + # Get leftmost and rightmost points of the FancyArrowPatch object + # representing the edge between nodes 0 and 1 (in pixel coordinates) + default_patch = nx.draw_networkx_edges(G, pos, ax=ax, node_shape=node_shape)[0] + default_extent = default_patch.get_extents().corners()[::2, 0] + # Now, do the same but with "padding" for the source and target via the + # min_{source/target}_margin kwargs + padded_patch = nx.draw_networkx_edges( + G, + pos, + ax=ax, + node_shape=node_shape, + min_source_margin=100, + min_target_margin=100, + )[0] + padded_extent = padded_patch.get_extents().corners()[::2, 0] + + # With padding, the left-most extent of the edge should be further to the + # right + assert padded_extent[0] > default_extent[0] + # And the rightmost extent of the edge, further to the left + assert padded_extent[1] < default_extent[1] + + +# NOTE: parametrizing on marker to test both branches of internal +# nx.draw_networkx_edges.to_marker_edge function +@pytest.mark.parametrize("node_shape", ("o", "s")) +def test_draw_edges_min_source_target_margins_individual(node_shape): + """Test that there is a wider gap between the node and the start of an + incident edge when min_source_margin is specified. + + This test checks that the use of min_{source/target}_margin kwargs result + in shorter (more padding) between the edges and source and target nodes. + As a crude visual example, let 's' and 't' represent source and target + nodes, respectively: + + Default: + s-----------------------------t + + With margins: + s ----------------------- t + + """ + # Create a single axis object to get consistent pixel coords across + # multiple draws + fig, ax = plt.subplots() + G = nx.DiGraph([(0, 1), (1, 2)]) + pos = {0: (0, 0), 1: (1, 0), 2: (2, 0)} # horizontal layout + # Get leftmost and rightmost points of the FancyArrowPatch object + # representing the edge between nodes 0 and 1 (in pixel coordinates) + default_patch = nx.draw_networkx_edges(G, pos, ax=ax, node_shape=node_shape) + default_extent = [d.get_extents().corners()[::2, 0] for d in default_patch] + # Now, do the same but with "padding" for the source and target via the + # min_{source/target}_margin kwargs + padded_patch = nx.draw_networkx_edges( + G, + pos, + ax=ax, + node_shape=node_shape, + min_source_margin=[98, 102], + min_target_margin=[98, 102], + ) + padded_extent = [p.get_extents().corners()[::2, 0] for p in padded_patch] + for d, p in zip(default_extent, padded_extent): + print(f"{p=}, {d=}") + # With padding, the left-most extent of the edge should be further to the + # right + assert p[0] > d[0] + # And the rightmost extent of the edge, further to the left + assert p[1] < d[1] + + +def test_nonzero_selfloop_with_single_node(): + """Ensure that selfloop extent is non-zero when there is only one node.""" + # Create explicit axis object for test + fig, ax = plt.subplots() + # Graph with single node + self loop + G = nx.DiGraph() + G.add_node(0) + G.add_edge(0, 0) + # Draw + patch = nx.draw_networkx_edges(G, {0: (0, 0)})[0] + # The resulting patch must have non-zero extent + bbox = patch.get_extents() + assert bbox.width > 0 and bbox.height > 0 + # Cleanup + plt.delaxes(ax) + plt.close() + + +def test_nonzero_selfloop_with_single_edge_in_edgelist(): + """Ensure that selfloop extent is non-zero when only a single edge is + specified in the edgelist. + """ + # Create explicit axis object for test + fig, ax = plt.subplots() + # Graph with selfloop + G = nx.path_graph(2, create_using=nx.DiGraph) + G.add_edge(1, 1) + pos = {n: (n, n) for n in G.nodes} + # Draw only the selfloop edge via the `edgelist` kwarg + patch = nx.draw_networkx_edges(G, pos, edgelist=[(1, 1)])[0] + # The resulting patch must have non-zero extent + bbox = patch.get_extents() + assert bbox.width > 0 and bbox.height > 0 + # Cleanup + plt.delaxes(ax) + plt.close() + + +def test_apply_alpha(): + """Test apply_alpha when there is a mismatch between the number of + supplied colors and elements. + """ + nodelist = [0, 1, 2] + colorlist = ["r", "g", "b"] + alpha = 0.5 + rgba_colors = nx.drawing.nx_pylab.apply_alpha(colorlist, alpha, nodelist) + assert all(rgba_colors[:, -1] == alpha) + + +def test_draw_edges_toggling_with_arrows_kwarg(): + """ + The `arrows` keyword argument is used as a 3-way switch to select which + type of object to use for drawing edges: + - ``arrows=None`` -> default (FancyArrowPatches for directed, else LineCollection) + - ``arrows=True`` -> FancyArrowPatches + - ``arrows=False`` -> LineCollection + """ + import matplotlib.collections + import matplotlib.patches + + UG = nx.path_graph(3) + DG = nx.path_graph(3, create_using=nx.DiGraph) + pos = {n: (n, n) for n in UG} + + # Use FancyArrowPatches when arrows=True, regardless of graph type + for G in (UG, DG): + edges = nx.draw_networkx_edges(G, pos, arrows=True) + assert len(edges) == len(G.edges) + assert isinstance(edges[0], mpl.patches.FancyArrowPatch) + + # Use LineCollection when arrows=False, regardless of graph type + for G in (UG, DG): + edges = nx.draw_networkx_edges(G, pos, arrows=False) + assert isinstance(edges, mpl.collections.LineCollection) + + # Default behavior when arrows=None: FAPs for directed, LC's for undirected + edges = nx.draw_networkx_edges(UG, pos) + assert isinstance(edges, mpl.collections.LineCollection) + edges = nx.draw_networkx_edges(DG, pos) + assert len(edges) == len(G.edges) + assert isinstance(edges[0], mpl.patches.FancyArrowPatch) + + +@pytest.mark.parametrize("drawing_func", (nx.draw, nx.draw_networkx)) +def test_draw_networkx_arrows_default_undirected(drawing_func): + import matplotlib.collections + + G = nx.path_graph(3) + fig, ax = plt.subplots() + drawing_func(G, ax=ax) + assert any(isinstance(c, mpl.collections.LineCollection) for c in ax.collections) + assert not ax.patches + plt.delaxes(ax) + plt.close() + + +@pytest.mark.parametrize("drawing_func", (nx.draw, nx.draw_networkx)) +def test_draw_networkx_arrows_default_directed(drawing_func): + import matplotlib.collections + + G = nx.path_graph(3, create_using=nx.DiGraph) + fig, ax = plt.subplots() + drawing_func(G, ax=ax) + assert not any( + isinstance(c, mpl.collections.LineCollection) for c in ax.collections + ) + assert ax.patches + plt.delaxes(ax) + plt.close() + + +def test_edgelist_kwarg_not_ignored(): + # See gh-4994 + G = nx.path_graph(3) + G.add_edge(0, 0) + fig, ax = plt.subplots() + nx.draw(G, edgelist=[(0, 1), (1, 2)], ax=ax) # Exclude self-loop from edgelist + assert not ax.patches + plt.delaxes(ax) + plt.close() + + +@pytest.mark.parametrize( + ("G", "expected_n_edges"), + ([nx.DiGraph(), 2], [nx.MultiGraph(), 4], [nx.MultiDiGraph(), 4]), +) +def test_draw_networkx_edges_multiedge_connectionstyle(G, expected_n_edges): + """Draws edges correctly for 3 types of graphs and checks for valid length""" + for i, (u, v) in enumerate([(0, 1), (0, 1), (0, 1), (0, 2)]): + G.add_edge(u, v, weight=round(i / 3, 2)) + pos = {n: (n, n) for n in G} + # Raises on insufficient connectionstyle length + for conn_style in [ + "arc3,rad=0.1", + ["arc3,rad=0.1", "arc3,rad=0.1"], + ["arc3,rad=0.1", "arc3,rad=0.1", "arc3,rad=0.2"], + ]: + nx.draw_networkx_edges(G, pos, connectionstyle=conn_style) + arrows = nx.draw_networkx_edges(G, pos, connectionstyle=conn_style) + assert len(arrows) == expected_n_edges + + +@pytest.mark.parametrize( + ("G", "expected_n_edges"), + ([nx.DiGraph(), 2], [nx.MultiGraph(), 4], [nx.MultiDiGraph(), 4]), +) +def test_draw_networkx_edge_labels_multiedge_connectionstyle(G, expected_n_edges): + """Draws labels correctly for 3 types of graphs and checks for valid length and class names""" + for i, (u, v) in enumerate([(0, 1), (0, 1), (0, 1), (0, 2)]): + G.add_edge(u, v, weight=round(i / 3, 2)) + pos = {n: (n, n) for n in G} + # Raises on insufficient connectionstyle length + arrows = nx.draw_networkx_edges( + G, pos, connectionstyle=["arc3,rad=0.1", "arc3,rad=0.1", "arc3,rad=0.1"] + ) + for conn_style in [ + "arc3,rad=0.1", + ["arc3,rad=0.1", "arc3,rad=0.2"], + ["arc3,rad=0.1", "arc3,rad=0.1", "arc3,rad=0.1"], + ]: + text_items = nx.draw_networkx_edge_labels(G, pos, connectionstyle=conn_style) + assert len(text_items) == expected_n_edges + for ti in text_items.values(): + assert ti.__class__.__name__ == "CurvedArrowText" + + +def test_draw_networkx_edge_label_multiedge(): + G = nx.MultiGraph() + G.add_edge(0, 1, weight=10) + G.add_edge(0, 1, weight=20) + edge_labels = nx.get_edge_attributes(G, "weight") # Includes edge keys + pos = {n: (n, n) for n in G} + text_items = nx.draw_networkx_edge_labels( + G, + pos, + edge_labels=edge_labels, + connectionstyle=["arc3,rad=0.1", "arc3,rad=0.2"], + ) + assert len(text_items) == 2 + + +def test_draw_networkx_edge_label_empty_dict(): + """Regression test for draw_networkx_edge_labels with empty dict. See + gh-5372.""" + G = nx.path_graph(3) + pos = {n: (n, n) for n in G.nodes} + assert nx.draw_networkx_edge_labels(G, pos, edge_labels={}) == {} + + +def test_draw_networkx_edges_undirected_selfloop_colors(): + """When an edgelist is supplied along with a sequence of colors, check that + the self-loops have the correct colors.""" + fig, ax = plt.subplots() + # Edge list and corresponding colors + edgelist = [(1, 3), (1, 2), (2, 3), (1, 1), (3, 3), (2, 2)] + edge_colors = ["pink", "cyan", "black", "red", "blue", "green"] + + G = nx.Graph(edgelist) + pos = {n: (n, n) for n in G.nodes} + nx.draw_networkx_edges(G, pos, ax=ax, edgelist=edgelist, edge_color=edge_colors) + + # Verify that there are three fancy arrow patches (1 per self loop) + assert len(ax.patches) == 3 + + # These are points that should be contained in the self loops. For example, + # sl_points[0] will be (1, 1.1), which is inside the "path" of the first + # self-loop but outside the others + sl_points = np.array(edgelist[-3:]) + np.array([0, 0.1]) + + # Check that the mapping between self-loop locations and their colors is + # correct + for fap, clr, slp in zip(ax.patches, edge_colors[-3:], sl_points): + assert fap.get_path().contains_point(slp) + assert mpl.colors.same_color(fap.get_edgecolor(), clr) + plt.delaxes(ax) + plt.close() + + +@pytest.mark.parametrize( + "fap_only_kwarg", # Non-default values for kwargs that only apply to FAPs + ( + {"arrowstyle": "-"}, + {"arrowsize": 20}, + {"connectionstyle": "arc3,rad=0.2"}, + {"min_source_margin": 10}, + {"min_target_margin": 10}, + ), +) +def test_user_warnings_for_unused_edge_drawing_kwargs(fap_only_kwarg): + """Users should get a warning when they specify a non-default value for + one of the kwargs that applies only to edges drawn with FancyArrowPatches, + but FancyArrowPatches aren't being used under the hood.""" + G = nx.path_graph(3) + pos = {n: (n, n) for n in G} + fig, ax = plt.subplots() + # By default, an undirected graph will use LineCollection to represent + # the edges + kwarg_name = list(fap_only_kwarg.keys())[0] + with pytest.warns( + UserWarning, match=f"\n\nThe {kwarg_name} keyword argument is not applicable" + ): + nx.draw_networkx_edges(G, pos, ax=ax, **fap_only_kwarg) + # FancyArrowPatches are always used when `arrows=True` is specified. + # Check that warnings are *not* raised in this case + with warnings.catch_warnings(): + # Escalate warnings -> errors so tests fail if warnings are raised + warnings.simplefilter("error") + nx.draw_networkx_edges(G, pos, ax=ax, arrows=True, **fap_only_kwarg) + + plt.delaxes(ax) + plt.close() + + +@pytest.mark.parametrize("draw_fn", (nx.draw, nx.draw_circular)) +def test_no_warning_on_default_draw_arrowstyle(draw_fn): + # See gh-7284 + fig, ax = plt.subplots() + G = nx.cycle_graph(5) + with warnings.catch_warnings(record=True) as w: + draw_fn(G, ax=ax) + assert len(w) == 0 + + plt.delaxes(ax) + plt.close() + + +@pytest.mark.parametrize("hide_ticks", [False, True]) +@pytest.mark.parametrize( + "method", + [ + nx.draw_networkx, + nx.draw_networkx_edge_labels, + nx.draw_networkx_edges, + nx.draw_networkx_labels, + nx.draw_networkx_nodes, + ], +) +def test_hide_ticks(method, hide_ticks): + G = nx.path_graph(3) + pos = {n: (n, n) for n in G.nodes} + _, ax = plt.subplots() + method(G, pos=pos, ax=ax, hide_ticks=hide_ticks) + for axis in [ax.xaxis, ax.yaxis]: + assert bool(axis.get_ticklabels()) != hide_ticks + + plt.delaxes(ax) + plt.close() |