Search code examples
pythonmatplotlibnetworkxbezier

Spiraling Bezier path outside of circular layout


Networkx circular layout (double circle)

Hi. I have a circular layout graph with 12 nodes outside of the layout (by design).

num_miR_nodes = len(miR_nodes['nodes'])
angle_increment = 2*math.pi / num_miR_nodes
miR_radius = 1.5
for i, node in enumerate(miR_nodes['nodes']):
    angle = i * angle_increment
    x = miR_radius * math.cos(angle)
    y = miR_radius * math.sin(angle)
    pos[node] = (x, y)
    G.add_node(node)

I want to make a bezier path (or similar to bezier) for each of the (straight) edges of the outer 12 nodes, that will stay outside of the nodes in the circular layout until reaching the circular layout target node, by spiraling in (I don't want the edge to leap away from the graph like what happens when you increase the midpoint of the edge too much).

Currently I only have the bezier curve math worked out for the inner circular layout edges:

def draw_curved_edges2(G, pos, ax, alpha):
    for u, v, d in G.edges(data=True):
        edge_color = d['edge_color']
        weight = d['width']
        pos_u = pos[u]
        pos_v = pos[v]
        
        x_u, y_u = pos_u
        x_v, y_v = pos_v

        if 'miR' not in u:
            # midpoint of the edge
            x_mid = 0 * (x_u + x_v)
            y_mid = 0 * (y_u + y_v)
            
            # control point for Bezier
            x_ctrl = 0.25 * (x_mid + 0.5 * (x_u + x_v))
            y_ctrl = 0.25 * (y_mid + 0.5 * (y_u + y_v))
            
            # Bezier curve path
            bezier_path = Path([(x_u, y_u), (x_ctrl, y_ctrl), (x_v, y_v)], [Path.MOVETO, Path.CURVE3, Path.CURVE3])
            width = G[u][v]['width']# for u, v in G.edges()]
            #patch = PathPatch(bezier_path, facecolor='none', edgecolor=edge_color, linewidth=width, alpha=alpha)
            #ax.add_patch(patch)
            arrow = FancyArrowPatch(path=bezier_path, color=edge_color, linewidth=width, alpha=alpha, 
                                    arrowstyle="->, head_length=6, head_width=2, widthA=1.0, widthB=1.0, lengthA=0.4, lengthB=0.4")
            ax.add_patch(arrow)

draw_curved_edges2(G, pos, ax, alpha=0.4)

Solution

  • This solution creates splines between two points that are routed around a central origin. For each spline, the distances of its interior points to the origin are interpolated between the distances of the start and end points of the spline to the same origin, resulting in spiral-like appearance.

    This solution also selects the shortest path around the origin (rather than always wrapping around counter-clockwise).

    enter image description here

    import numpy as np
    import matplotlib.pyplot as plt
    
    from scipy.interpolate import BSpline
    
    
    def _get_unit_vector(vector):
        """Returns the unit vector of the vector."""
        return vector / np.linalg.norm(vector)
    
    
    def _get_interior_angle_between(v1, v2, radians=True):
        """Returns the interior angle between vectors v1 and v2.
    
        Parameters
        ----------
        v1, v2 : numpy.array
            The vectors in question.
        radians : bool, default False
            If True, return the angle in radians (otherwise it is in degrees).
    
        Returns
        -------
        angle : float
            The interior angle between two vectors.
    
        Examples
        --------
        >>> angle_between((1, 0, 0), (0, 1, 0))
        1.5707963267948966
        >>> angle_between((1, 0, 0), (1, 0, 0))
        0.0
        >>> angle_between((1, 0, 0), (-1, 0, 0))
        3.141592653589793
    
        Notes
        -----
        Adapted from https://stackoverflow.com/a/13849249/2912349
    
        """
    
        v1_u = _get_unit_vector(v1)
        v2_u = _get_unit_vector(v2)
        angle = np.arccos(np.clip(np.dot(v1_u, v2_u), -1.0, 1.0))
        if radians:
            return angle
        else:
            return angle * 360 / (2 * np.pi)
    
    
    def _get_signed_angle_between(v1, v2, radians=True):
        """Returns the signed angle between vectors v1 and v2.
    
        Parameters
        ----------
        v1, v2 : numpy.array
            The vectors in question.
        radians : bool, default False
            If True, return the angle in radians (otherwise it is in degrees).
    
        Returns
        -------
        angle : float
            The signed angle between two vectors.
    
        Notes
        -----
        Adapted from https://stackoverflow.com/a/16544330/2912349
    
        """
    
        x1, y1 = v1
        x2, y2 = v2
        dot = x1*x2 + y1*y2
        det = x1*y2 - y1*x2
        angle = np.arctan2(det, dot)
    
        if radians:
            return angle
        else:
            return angle * 360 / (2 * np.pi)
    
    
    def _bspline(cv, n=100, degree=5, periodic=False):
        """Calculate n samples on a bspline.
    
        Parameters
        ----------
        cv : numpy.array
            Array of (x, y) control vertices.
        n : int
            Number of samples to return.
        degree : int
            Curve degree
        periodic : bool, default True
            If True, the curve is closed.
    
        Returns
        -------
        numpy.array
            Array of (x, y) spline vertices.
    
        Notes
        -----
        Adapted from https://stackoverflow.com/a/35007804/2912349
    
        """
    
        cv = np.asarray(cv)
        count = cv.shape[0]
    
        # Closed curve
        if periodic:
            kv = np.arange(-degree,count+degree+1)
            factor, fraction = divmod(count+degree+1, count)
            cv = np.roll(np.concatenate((cv,) * factor + (cv[:fraction],)),-1,axis=0)
            degree = np.clip(degree,1,degree)
    
        # Opened curve
        else:
            degree = np.clip(degree,1,count-1)
            kv = np.clip(np.arange(count+degree+1)-degree,0,count-degree)
    
        # Return samples
        max_param = count - (degree * (1-periodic))
        spl = BSpline(kv, cv, degree)
        return spl(np.linspace(0,max_param,n))
    
    
    def get_path_around_origin(source, target, origin):
        # determine vectors from origin to end points
        v1 = source - origin
        v2 = target - origin
    
        # determine control point angles
        delta_angle = 10 # angle between control points in degrees
        interior_angle = _get_interior_angle_between(v1, v2) # in radians
        total_control_points = int(interior_angle / (2 * np.pi) * 360 / delta_angle)
        a1 = _get_signed_angle_between(np.array([1, 0]), v1) # start angle
        a2 = _get_signed_angle_between(np.array([1, 0]), v2) # stop angle
        # angles = np.linspace(a1, a2, total_control_points + 1)[1:] # always counter-clockwise
        if np.isclose(interior_angle, _get_signed_angle_between(v1, v2)):
            angles = a1 + np.linspace(0, 1, total_control_points+1)[1:] * interior_angle
        else: # go the other way
            angles = a1 - np.linspace(0, 1, total_control_points+1)[1:] * interior_angle
    
        # determine control point magnitudes
        m1 = np.linalg.norm(v1)
        m2 = np.linalg.norm(v2)
        # magnitudes = np.linspace(m1, m2, total_control_points+1)[1:] # very shallow approach
        magnitudes = np.linspace(m1, m2 + 0.25 * (m1 - m2), total_control_points+1)[1:] # for a more perpendicular approach to the target
    
        # determine vectors from origin to control points
        dx = np.cos(angles) * magnitudes
        dy = np.sin(angles) * magnitudes
        v = np.c_[dx, dy]
    
        points = np.vstack((source, origin[np.newaxis, :] + v, target))
    
        return _bspline(points) # interpolate & smooth
    
    
    if __name__ == "__main__":
    
        fig, ax = plt.subplots()
        origin = np.array([0, 0])
        radius = 1
        ax.add_patch(plt.Circle(origin, radius, alpha=0.1))
    
        source = np.array([-1.25,  0])
        for target in [np.array([0, 1]), np.array([1, 0]), np.array([0, -1])]:
            vertices = get_path_around_origin(source, target, origin)
            ax.plot(*vertices.T, color="tab:red")
    
        ax.axis([-1.5, 1.5, -1.5, 1.5])
        ax.set_aspect("equal")
        plt.show()