about summary refs log tree commit diff
path: root/.venv/lib/python3.12/site-packages/networkx/drawing
diff options
context:
space:
mode:
Diffstat (limited to '.venv/lib/python3.12/site-packages/networkx/drawing')
-rw-r--r--.venv/lib/python3.12/site-packages/networkx/drawing/__init__.py7
-rw-r--r--.venv/lib/python3.12/site-packages/networkx/drawing/layout.py1630
-rw-r--r--.venv/lib/python3.12/site-packages/networkx/drawing/nx_agraph.py464
-rw-r--r--.venv/lib/python3.12/site-packages/networkx/drawing/nx_latex.py572
-rw-r--r--.venv/lib/python3.12/site-packages/networkx/drawing/nx_pydot.py352
-rw-r--r--.venv/lib/python3.12/site-packages/networkx/drawing/nx_pylab.py1979
-rw-r--r--.venv/lib/python3.12/site-packages/networkx/drawing/tests/__init__.py0
-rw-r--r--.venv/lib/python3.12/site-packages/networkx/drawing/tests/baseline/test_house_with_colors.pngbin0 -> 21918 bytes
-rw-r--r--.venv/lib/python3.12/site-packages/networkx/drawing/tests/test_agraph.py241
-rw-r--r--.venv/lib/python3.12/site-packages/networkx/drawing/tests/test_latex.py292
-rw-r--r--.venv/lib/python3.12/site-packages/networkx/drawing/tests/test_layout.py538
-rw-r--r--.venv/lib/python3.12/site-packages/networkx/drawing/tests/test_pydot.py146
-rw-r--r--.venv/lib/python3.12/site-packages/networkx/drawing/tests/test_pylab.py1029
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
new file mode 100644
index 00000000..31f4962e
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/networkx/drawing/tests/baseline/test_house_with_colors.png
Binary files differdiff --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()