Spiraling Bezier path outside of circular layout

57 Views Asked by At

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)
1

There are 1 best solutions below

1
Paul Brodersen On BEST ANSWER

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()