Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draw Edge Labels in Multi Graph with curved edges #3813

Closed
Jogala opened this issue Feb 5, 2020 · 13 comments
Closed

Draw Edge Labels in Multi Graph with curved edges #3813

Jogala opened this issue Feb 5, 2020 · 13 comments
Labels
type: Enhancements Visualization Related to graph visualization or layout

Comments

@Jogala
Copy link

Jogala commented Feb 5, 2020

The problem is when drawing a multigraph, with curved edges, that there is no possibility to change the position of the labels, such that they are aligned with the edges:

fig = plt.figure(figsize=(12,12)) 
ax = fig.add_subplot(111)
    
G=nx.MultiDiGraph()

G.add_node(0,pos=(0,0.8))
G.add_node(1,pos=(0,0))

G.add_edge(0,1) 
G.add_edge(1,0)

pos=nx.get_node_attributes(G,'pos')

nx.draw_networkx_nodes(G,pos, node_size = 500)

edges_curves = [(0,1), (1,0)]
nx.draw_networkx_edges(G,pos,connectionstyle='arc3, rad = 1',edgelist = edges_curves)

nx.draw_networkx_edge_labels(G,pos,label_pos=0.2,edge_labels={(0, 1):'on',(1, 0):'off'},font_size = 30)

ax.set_axis_off()
fig.set_tight_layout('tight')
fig.show()

produces
Figure_1

So I suggest that it should be possible to pass a list with x and y offset for each edge, for example like

label_pos = {(0,1):[0.1,0.1],(1,0):[-0.2,-0.2]}

@ShirsenduP
Copy link

An alternative would be to put the labels in relation to the path of the edge that they relate to. Taking arc3 to be a quadratic Bezier curve, its quite straightforward to get the coordinates at any point on the line, as well as necessary angle. The equations are available here .

Presently, the edge label position and angle between two nodes n1 and n2 are:

## Position
(x1, y1) = pos[n1]  
(x2, y2) = pos[n2]
        
(x, y) = (x1 * label_pos + x2 * (1.0 - label_pos),
          y1 * label_pos + y2 * (1.0 - label_pos))

## Angle
angle = np.arctan2(y2 - y1, x2 - x1) / (2.0 * np.pi) * 360

This could be improved by passing in the rad keyword parameter of the curve into the nx.draw_networkx_edge_labels (must be the same as given in nx.draw_networkx_edges(G, pos, connectionstyle="arc3,rad=xx") and calculating the position and angles as a function of label_pos:

## Position
(x1, y1) = pos[n1]  
(x2, y2) = pos[n2]

# calculate the quadratic bezier curve control point
cx = (x1 + x2) / 2 + rad * (y2 - y1) 
cy = (y1 + y2) / 2 - rad * (x2 - x1)
            
# position of the label along the curve as a function of label_pos    
x, y = (1-label_pos) * ( (1-label_pos)*x1 + label_pos*cx ) + label_pos * ( (1-label_pos)*cx + label_pos*x2 ), 
       (1-label_pos) * ( (1-label_pos)*y1 + label_pos*cy ) + label_pos * ( (1-label_pos)*cy + label_pos*y2 )

## Angle
# calculate the derivative at `label_pos` on the curve and calculate the angle
change_x, change_y = 2 * (1-label_pos) * (cx - x1) + 2 * label_pos * (x2 - cx), 2 * (1-label_pos) * (cy - y1) + 2 * label_pos * (y2 - cy)
angle = (np.arctan2(change_y, change_x) / (2 * np.pi)) * 360

An offset perpendicular to the line at label_pos is also possible by adding in a po argument for perpendicular offset.:

# given x, y calculated as above

# Offset label perpendicular to the curve
# dot product: change_x * u + change_y * v = 0
#              v = - (change_x * u) / change_y
if po is None:
    po = 0

# get perpendicular vector (can improve by changing to unit vector) 
perp_x, perp_y = 1, - change_x / change_y

# add offets to the position
x += perp_x * po
y += perp_y * po

example-curved-networkx-edge-labels

I can only get this to work when ax.set_aspect("equal") is used to ease the transformation from data coordinates to display coordinates prior to any plotting. I know there is a way to solve this problem more elegantly but I am a little lost in how to adapt this code to work for any aspect ratio. Can someone please take a look?

@dschult
Copy link
Member

dschult commented Jul 27, 2020

I think the aspect ratio comes into play anytime you take ratios of x-values and y-values. Think of it as translating from x-units to y-units in each of these ratios. For example, in the numpy.arctan2 function: np.arctan2(change_y, change_x * aspect_ratio) The only other spot I found is when computing the perpendicular vector, but I might have missed something: - change_x * aspect_ratio / change_y

@ShirsenduP
Copy link

Multiplying by the aspect ratio whenever we have the ratio of x and y doesn't seem to work if I understood you correctly. I tried various combinations of setting up the figure to try to find out why some cases failed but others didn't but haven't been able to figure it out. I think someone with some more knowledge of matplotlib might need to take a look. Here is the full code of the function and something to test it with.

I have omitted po just for simplicity for now.

def draw_networkx_edge_labels(G, pos, 
                              rad=None,
                              edge_labels=None,
                              label_pos=0.5,
                              font_size=10,
                              font_color='k',
                              font_family='sans-serif',
                              font_weight='normal',
                              alpha=1.0,
                              bbox=None,
                              ax=None,
                              rotate=True,
                              **kwds):

    try:
        import matplotlib.pyplot as plt
        import matplotlib.cbook as cb
        import numpy
    except ImportError:
        raise ImportError("Matplotlib required for draw()")
    except RuntimeError:
        print("Matplotlib unable to open display")
        raise

    if ax is None:
        ax = plt.gca()
    if edge_labels is None:
        labels = dict(((u, v), d) for u, v, d in G.edges(data=True))
    else:
        labels = edge_labels
    text_items = {}
    ratio = ax.get_data_ratio()
    for (n1, n2), label in labels.items():
        (x1, y1) = pos[n1]
        (x2, y2) = pos[n2]
        
        # label position for straight edge
        if rad is None:
            (x, y) = (x1 * label_pos + x2 * (1.0 - label_pos),
                      y1 * label_pos + y2 * (1.0 - label_pos))
        # label position for curved quadratic bezier curve (connectionstyle="arc3,rad=r")
        else:
            # calculate the quadratic bezier curve control point
            cx = (x1 + x2) / 2 + rad * (y2 - y1) 
            cy = (y1 + y2) / 2 - rad * (x2 - x1)
            # position of the label along the curve as a function of label_pos    
            x, y = (1-label_pos) * ( (1-label_pos)*x1 + label_pos*cx ) + label_pos * ( (1-label_pos)*cx + label_pos*x2 ), \
                    (1-label_pos) * ( (1-label_pos)*y1 + label_pos*cy ) + label_pos * ( (1-label_pos)*cy + label_pos*y2 )

        # Label angle
        if rad is not None:
            # derivative of the curve at the point t
            change_x, change_y = 2 * (1-label_pos) * (cx - x1) + 2 * label_pos * (x2 - cx), 2 * (1-label_pos) * (cy - y1) + 2 * label_pos * (y2 - cy)
            angle = (np.arctan2(change_y, change_x) / (2 * np.pi)) * 360
        elif rotate:
            angle = numpy.arctan2(y2-y1, x2-x1)/(2.0*numpy.pi)*360  # degrees
        else:
            angle = 0
            
        # make label orientation "right-side-up"
        if angle > 90:
            angle -= 180
        if angle < - 90:
            angle += 180          
            
        # transform data coordinate angle to screen coordinate angle
        xy = numpy.array((x, y))
        trans_angle = ax.transData.transform_angles(numpy.array((angle,)), xy.reshape((1, 2)))[0]
        
        # use default box of white with white border
        if bbox is None:
            bbox = dict(boxstyle='round',
                        ec=(1.0, 1.0, 1.0),
                        fc=(1.0, 1.0, 1.0),
                        )
#         if not cb.is_string_like(label):
#             label = str(label)  # this will cause "1" and 1 to be labeled the same

        # set optional alignment
        horizontalalignment = kwds.get('horizontalalignment', 'center')
        verticalalignment = kwds.get('verticalalignment', 'center')

        t = ax.text(x, y,
                    label,
                    size=font_size,
                    color=font_color,
                    family=font_family,
                    weight=font_weight,
                    horizontalalignment=horizontalalignment,
                    verticalalignment=verticalalignment,
                    rotation=trans_angle,
                    transform=ax.transData,
                    bbox=bbox,
                    zorder=1,
                    clip_on=True,
                    )
        text_items[(n1, n2)] = t

    return text_items

Minimal working example:

import networkx as nx
import numpy as np
import matplotlib.pyplot as plt

G = nx.MultiDiGraph()

## WORKS
fig, ax = plt.subplots(figsize=(6,6))
ax.set_xlim([-2, 2])
ax.set_ylim([-2, 2])

## WORKS
# fig, ax = plt.subplots()
# ax.set_xlim([-1, 1])
# ax.set_ylim([-0.5, 0.5])
# ax.set_aspect("equal")

## Does not work
#  Labels at wrong position
# fig, ax = plt.subplots(figsize=(6,6))
# ax.set_xlim([-2, 2])
# ax.set_ylim([-1, 1])

## Does not work
##  The figure is squashed into a tiny strip, shows nothing
# fig, ax = plt.subplots(figsize=(6,6))
# ax.set_aspect("equal")

## Does not work
##  Labels are not on the screen
# fig, ax = plt.subplots(figsize=(6,6))

## Does not work
##  Labels are not on the screen
# fig, ax = plt.subplots()

## Does not work
##  Labels at wrong position
# fig, ax = plt.subplots()
# ax.set_xlim([-2, 2])
# ax.set_ylim([-2, 2])

# add nodes
G.add_nodes_from(list(range(2)))
pos = nx.circular_layout(G)

# add edges and their labels
G.add_edge(0, 1, label="Edge1")
G.add_edge(1, 0, label="Edge2")
labels = {edge[:2]:edge[2]['label'] for edge in G.edges(data=True)}

# curvature of the edges
rad = 0.2

# plotting
nx.draw_networkx_nodes(G, pos)
nx.draw_networkx_edges(G, pos, connectionstyle=f"arc3,rad={rad}")
draw_networkx_edge_labels(G, pos, edge_labels=labels, 
                          label_pos=0.75, rad=rad)

plt.show()

@alexmalins
Copy link
Contributor

alexmalins commented Sep 25, 2021

@ShirsenduP: super work on this issue - thank you

I think the root cause here is the same as this Q on stackoverflow.

The edges when created in networkx.draw_networkx_edges() use matplotlib FancyArrowPatch, which is using the display space coordinates. Your code calculates the quadratic bezier curve control point in the data coordinates. This is OK when the aspect ratio is 1 as the control points will coincide, but they will be different otherwise.

Two solutions come to mind:

  • Either take control of drawing the edges away from matplotlib and FancyArrowPatch, and bring it within networkx by creating matplotlib Path objects directly (as per the stackoverflow Q). This way we set the control points coordinates directly in the data coordinates space.
  • Or work out what the control point of the FancyArrowPatch is in the data coordinates then use this to set the position of the edge labels.

Of the two ideas, the second definitely feels preferable as it is easier to ensure consistency with the output images with previous networkx versions.

I'm looking into this topic because of an issue raised on one of my projects, which will require labels for curved edges of a MultiDiGraph to solve. I'm also running up against a slight issue that the curve radius rad needs to be specified differently for different edges. This can be solved though by looping through the edges and plotting them one by one with networkx.draw_networkx_edges(...edgelist=...), as per the answer here.

Anyway, now I'll start looking into getting the control points for the bezier FancyArrowPatch edges and converting them to the data coordinates.

@rossbar rossbar added the Visualization Related to graph visualization or layout label Sep 25, 2021
@alexmalins
Copy link
Contributor

alexmalins commented Sep 27, 2021

Looked at this again and it is more complicated than just setting the text location once after finding the FancyArrowPatch control point. The control point location changes depending on the aspect ratio set up by the GUI when the figure is rendered, and also upon some user operations in interactive mode.

The text will need to be aware of both the start and end points of FancyArrowPatch, i.e. the node locations. This is so the text location can be updated depending on the aspect ratio set by the GUI and by users interactions in interactive mode, as the FancyArrowPatch's are.

My gut feeling is this might be possible by inheriting from FancyArrowPatch and binding a matplotlib.text.Text instance for the label somehow. Then the label position and updates upon rerendering can be accomplished with minimal duplication of existing matplotlib code.

The easiest solution however is just to take @ShirsenduP's solution and set ax.set_aspect("equal"). This works in my testing and stays working until the user or other code instigates a change in the aspect ratio.

@rossbar
Copy link
Contributor

rossbar commented Jun 28, 2022

IMO this is would be one of the most impactful features we could add to nx_pylab! I too like @ShirsenduP 's solution and it is likely to work well for some use-cases, but I'm hesitant about adding it to draw_networkx_edge_labels as I don't think it'd be good to have the implicit dependence on the aspect ratio.

My gut feeling is this might be possible by inheriting from FancyArrowPatch and binding a matplotlib.text.Text instance for the label somehow. Then the label position and updates upon rerendering can be accomplished with minimal duplication of existing matplotlib code.

This is a really interesting idea and in principle would solve some of the trickiest problems re: making sure the labels are kept in sync with their corresponding edges. I'm not sure how feasible it is, but 👍 if someone has the bandwidth to investigate along these lines. If it proves very useful we might even consider trying to upstream the fancy arrow patch + text object back into matplotlib itself.

@ShirsenduP
Copy link

ShirsenduP commented Dec 25, 2022

I have been playing around with this for quite a while, frustrated with learning how Fancy objects work under the hood in matplotlib, and I think I might finally have an answer following on from @alexmalins and @rossbar's of combining the FancyArrowPatch and matplotlib.text.Text object. This solution is still quite rough. To integrate into nx.draw_networkx_edge_labels, the arrows themselves can be passed into it

import numpy as np

import networkx as nx
from matplotlib import pyplot as plt
from matplotlib.patches import FancyArrowPatch
from matplotlib.text import Text


class CurvedArrowText(Text):
    def __init__(
        self, arrow, horizontal_offset=0.5, labels_horizontal=False, *args, **kwargs
    ):
        # how far along the text should be on the curve,
        # 0 is at start, 1 is at end etc.
        self.offset = horizontal_offset
        self.labels_horizontal = labels_horizontal

        # Initialise text position and angle
        self.x = None
        self.y = None
        self.angle = None
        self._update_text_pos_angle(arrow)  # overwrites x y angle member variables

        # Create text object
        Text.__init__(self, x=self.x, y=self.y, rotation=self.angle, *args, **kwargs)

        # Bind to FancyArrowPatch
        self.arrow = arrow

        # Bind to axis
        plt.gca().add_artist(self)
        # plt.gca().add_artist(self.arrow)

    def _update_text_pos_angle(self, arrow):
        # Start and end point of arrow in data coordinates
        posA, posB = arrow._posA_posB

        # Get start and end in display coordinates
        x1, y1 = plt.gca().transData.transform(posA)
        x2, y2 = plt.gca().transData.transform(posB)
        rad = arrow.get_connectionstyle().rad
        t = self.offset

        # Calculate control point in display coords
        cx = (x1 + x2) / 2 + rad * (y2 - y1)
        cy = (y1 + y2) / 2 - rad * (x2 - x1)

        # Text position at a proportion t along the line in display coords
        # default is 0.5 so text appears at the halfway point
        x, y = (1 - t) * ((1 - t) * x1 + t * cx) + t * ((1 - t) * cx + t * x2), (
            1 - t
        ) * ((1 - t) * y1 + t * cy) + t * ((1 - t) * cy + t * y2)
        x, y = plt.gca().transData.inverted().transform((x, y))
        self.x = x
        self.y = y

        if self.labels_horizontal:
            # Horizontal text labels
            angle = 0
        else:
            # Labels parallel to curve
            change_x, change_y = 2 * (1 - t) * (cx - x1) + 2 * t * (x2 - cx), 2 * (
                1 - t
            ) * (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

        self.angle = angle

    def draw(self, renderer):

        # recalculate the text position and angle
        self._update_text_pos_angle(self.arrow)
        self.set_position((self.x, self.y))
        self.set_rotation(self.angle)

        # redraw text
        Text.draw(self, renderer)

and to test it

def mvp():
    np.random.seed(3)

    # Create graph
    edges = {
        (0, 1): "A1",
        (1, 0): "A2",
        (1, 2): "B1",
        (2, 1): "B2",
        (2, 0): "C1",
        (0, 2): "C2",
    }
    G = nx.DiGraph()
    G.add_edges_from(edges.keys())
    connectionstyle = "arc3,rad=0.2"

    # Draw nodes and edges
    pos = nx.spring_layout(G)
    nx.draw_networkx_nodes(G, pos, node_color="white", edgecolors="black")
    nx.draw_networkx_edges(G, pos, connectionstyle=connectionstyle, arrowsize=20)

    # Draw labels
    for (n1, n2), label in edges.items():
        posA = pos[n1]
        posB = pos[n2]

        # Draw edges
        arrow = FancyArrowPatch(
            posA,
            posB,
            connectionstyle=connectionstyle,
            color="black",
        )
        # Draw edge labels
        CurvedArrowText(
            arrow,
            text=label,
            horizontal_offset=0.5,
            labels_horizontal=False,
            verticalalignment="center",
            horizontalalignment="center",
            color="red",
            bbox=dict(
                boxstyle="round",
                ec="red",
                fc="white",
            ),
        )

    plt.savefig("test.png")


if __name__ == "__main__":
    mvp()

Final result:

test

This seems to me to work well even when the figure window is resized etc. However, it requires recreating the FancyArrowPatches. Instead of letting nx.draw_networkx_edges draw the edges it can be entirely delegated to the CurvedArrowText object instead (last line of init) but this method seems incompatible with the current idea of separating the functionality of drawing the edges and then drawing the edge labels. At the very least this is a starting point that solves the limitation of a 1:1 aspect ratio.

@dschult
Copy link
Member

dschult commented Dec 25, 2022

See also #5882

@Zalnd
Copy link

Zalnd commented Feb 17, 2023

Multiplying by the aspect ratio whenever we have the ratio of x and y doesn't seem to work if I understood you correctly. I tried various combinations of setting up the figure to try to find out why some cases failed but others didn't but haven't been able to figure it out. I think someone with some more knowledge of matplotlib might need to take a look. Here is the full code of the function and something to test it with.

Have you tried to transform y-coordinates using ax._get_aspect_ratio() when calculating x-related variables and vice-versa?

I mean:

# calculate the quadratic bezier curve control point
cx = (x1 + x2) / 2 + rad * (y2 - y1) * ax._get_aspect_ratio()
cy = (y1 + y2) / 2 - rad * (x2 - x1) / ax._get_aspect_ratio()

@Zalnd
Copy link

Zalnd commented Feb 17, 2023

Multiplying by the aspect ratio whenever we have the ratio of x and y doesn't seem to work if I understood you correctly. I tried various combinations of setting up the figure to try to find out why some cases failed but others didn't but haven't been able to figure it out. I think someone with some more knowledge of matplotlib might need to take a look. Here is the full code of the function and something to test it with.

Have you tried to transform y-coordinates using ax._get_aspect_ratio() when calculating x-related variables and vice-versa?

Just for the record, it seems that changing those two lines does solve the problem.

your working examples

image
image
image
image
image
image
image
image

@dgrigonis
Copy link
Contributor

See #7010

I tried to keep it minimal. Your approach seems more comprehensive. If you continue to work on this, maybe some of the notes will be helpful.

@dschult
Copy link
Member

dschult commented Feb 29, 2024

I believe the changes in #7010 fix this issue of label placement along curved edges.
Are there aspects of this we should address/highlight before closing this issue?
Thanks

@rossbar
Copy link
Contributor

rossbar commented Feb 29, 2024

I believe the changes in #7010 fix this issue of label placement along curved edges.

Agreed - from a quick readthrough it looks like all of the major features have been addressed - there are potentially some minor tweaks/improvements in which case I'd vote to open new issues. @Jogala you may be interested to take a look at #7010 or give the new multiedge label drawing a try. It's not yet in a released networkx version but you can try the development version with pip install --pre --upgrade --extra-index-url https://pypi.anaconda.org/scientific-python-nightly-wheels/simple networkx. Feedback welcome!

@rossbar rossbar closed this as completed Feb 29, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
type: Enhancements Visualization Related to graph visualization or layout
Development

No branches or pull requests

7 participants