Repository: networkx/networkx

Issue:
Improvement to arrowheads of a DiGraph edges.
I was wondering if there was  planing on changing the arrowheads of the edges of directed graphs to be more arrow like (triangular shape instead of thicker line). For instance see [this question](https://stackoverflow.com/questions/47213650/changing-arrowhead-type-in-networkx) on stackoverflow.


Discussion:
That would be a great addition the the networkx/matplotlib drawing package.  It is technically possible but so far there have been no contributions that solve the general arrow drawing problem.  

Resolving patch:
diff --git a/networkx/drawing/nx_pylab.py b/networkx/drawing/nx_pylab.py
--- a/networkx/drawing/nx_pylab.py
+++ b/networkx/drawing/nx_pylab.py
@@ -151,6 +151,17 @@ def draw_networkx(G, pos=None, arrows=True, with_labels=True, **kwds):
 
     arrows : bool, optional (default=True)
        For directed graphs, if True draw arrowheads.
+       Note: Arrows will be the same color as edges.
+
+    arrowstyle : str, optional (default='-|>')
+        For directed graphs, choose the style of the arrowsheads.
+        See :py:class: `matplotlib.patches.ArrowStyle` for more
+        options.
+
+    arrowsize : int, optional (default=10)
+       For directed graphs, choose the size of the arrow head head's length and
+       width. See :py:class: `matplotlib.patches.FancyArrowPatch` for attribute
+       `mutation_scale` for more info.
 
     with_labels :  bool, optional (default=True)
        Set to True to draw labels on the nodes.
@@ -229,10 +240,8 @@ def draw_networkx(G, pos=None, arrows=True, with_labels=True, **kwds):
 
     Notes
     -----
-    For directed graphs, "arrows" (actually just thicker stubs) are drawn
-    at the head end.  Arrows can be turned off with keyword arrows=False.
-    Yes, it is ugly but drawing proper arrows with Matplotlib this
-    way is tricky.
+    For directed graphs, arrows  are drawn at the head end.  Arrows can be
+    turned off with keyword arrows=False.
 
     Examples
     --------
@@ -409,12 +418,17 @@ def draw_networkx_edges(G, pos,
                         edge_color='k',
                         style='solid',
                         alpha=1.0,
+                        arrowstyle='-|>',
+                        arrowsize=10,
                         edge_cmap=None,
                         edge_vmin=None,
                         edge_vmax=None,
                         ax=None,
                         arrows=True,
                         label=None,
+                        node_size=300,
+                        nodelist=None,
+                        node_shape="o",
                         **kwds):
     """Draw the edges of the graph G.
 
@@ -458,6 +472,17 @@ def draw_networkx_edges(G, pos,
 
     arrows : bool, optional (default=True)
        For directed graphs, if True draw arrowheads.
+       Note: Arrows will be the same color as edges.
+
+    arrowstyle : str, optional (default='-|>')
+       For directed graphs, choose the style of the arrow heads.
+       See :py:class: `matplotlib.patches.ArrowStyle` for more
+       options.
+
+    arrowsize : int, optional (default=10)
+       For directed graphs, choose the size of the arrow head head's length and
+       width. See :py:class: `matplotlib.patches.FancyArrowPatch` for attribute
+       `mutation_scale` for more info.
 
     label : [None| string]
        Label for legend
@@ -467,18 +492,29 @@ def draw_networkx_edges(G, pos,
     matplotlib.collection.LineCollection
         `LineCollection` of the edges
 
+    list of matplotlib.patches.FancyArrowPatch
+        `FancyArrowPatch` instances of the directed edges
+
+    Depending whether the drawing includes arrows or not.
+
     Notes
     -----
-    For directed graphs, "arrows" (actually just thicker stubs) are drawn
-    at the head end.  Arrows can be turned off with keyword arrows=False.
-    Yes, it is ugly but drawing proper arrows with Matplotlib this
-    way is tricky.
+    For directed graphs, arrows are drawn at the head end.  Arrows can be
+    turned off with keyword arrows=False. Be sure to include `node_size' as a
+    keyword argument; arrows are drawn considering the size of nodes.
 
     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])
+
     Also see the NetworkX drawing examples at
     https://networkx.github.io/documentation/latest/auto_examples/index.html
 
@@ -494,8 +530,9 @@ def draw_networkx_edges(G, pos,
         import matplotlib
         import matplotlib.pyplot as plt
         import matplotlib.cbook as cb
-        from matplotlib.colors import colorConverter, Colormap
+        from matplotlib.colors import colorConverter, Colormap, Normalize
         from matplotlib.collections import LineCollection
+        from matplotlib.patches import FancyArrowPatch
         import numpy as np
     except ImportError:
         raise ImportError("Matplotlib required for draw()")
@@ -512,6 +549,9 @@ def draw_networkx_edges(G, pos,
     if not edgelist or len(edgelist) == 0:  # no edges!
         return None
 
+    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])
 
@@ -537,84 +577,112 @@ def draw_networkx_edges(G, pos,
                 # numbers (which are going to be mapped with a colormap)
                 edge_colors = None
         else:
-            raise ValueError('edge_color must consist of either color names or numbers')
+            raise ValueError('edge_color must contain color names or numbers')
     else:
         if is_string_like(edge_color) or len(edge_color) == 1:
             edge_colors = (colorConverter.to_rgba(edge_color, alpha), )
         else:
-            raise ValueError(
-                'edge_color must be a single color or list of exactly m colors where m is the number or edges')
-
-    edge_collection = LineCollection(edge_pos,
-                                     colors=edge_colors,
-                                     linewidths=lw,
-                                     antialiaseds=(1,),
-                                     linestyle=style,
-                                     transOffset=ax.transData,
-                                     )
-
-    edge_collection.set_zorder(1)  # edges go behind nodes
-    edge_collection.set_label(label)
-    ax.add_collection(edge_collection)
-
-    # Note: there was a bug in mpl regarding the handling of alpha values for
-    # each line in a LineCollection.  It was fixed in matplotlib in r7184 and
-    # r7189 (June 6 2009).  We should then not set the alpha value globally,
-    # since the user can instead provide per-edge alphas now.  Only set it
-    # globally if provided as a scalar.
-    if cb.is_numlike(alpha):
-        edge_collection.set_alpha(alpha)
-
-    if edge_colors is None:
-        if edge_cmap is not None:
-            assert(isinstance(edge_cmap, Colormap))
-        edge_collection.set_array(np.asarray(edge_color))
-        edge_collection.set_cmap(edge_cmap)
-        if edge_vmin is not None or edge_vmax is not None:
-            edge_collection.set_clim(edge_vmin, edge_vmax)
-        else:
-            edge_collection.autoscale()
+            msg = 'edge_color must be a color or list of one color per edge'
+            raise ValueError(msg)
+
+    if (not G.is_directed() or not arrows):
+        edge_collection = LineCollection(edge_pos,
+                                         colors=edge_colors,
+                                         linewidths=lw,
+                                         antialiaseds=(1,),
+                                         linestyle=style,
+                                         transOffset=ax.transData,
+                                         )
+
+        edge_collection.set_zorder(1)  # edges go behind nodes
+        edge_collection.set_label(label)
+        ax.add_collection(edge_collection)
+
+        # Note: there was a bug in mpl regarding the handling of alpha values
+        # for each line in a LineCollection. It was fixed in matplotlib by
+        # r7184 and r7189 (June 6 2009). We should then not set the alpha
+        # value globally, since the user can instead provide per-edge alphas
+        # now.  Only set it globally if provided as a scalar.
+        if cb.is_numlike(alpha):
+            edge_collection.set_alpha(alpha)
+
+        if edge_colors is None:
+            if edge_cmap is not None:
+                assert(isinstance(edge_cmap, Colormap))
+            edge_collection.set_array(np.asarray(edge_color))
+            edge_collection.set_cmap(edge_cmap)
+            if edge_vmin is not None or edge_vmax is not None:
+                edge_collection.set_clim(edge_vmin, edge_vmax)
+            else:
+                edge_collection.autoscale()
+        return edge_collection
 
     arrow_collection = None
 
     if G.is_directed() and arrows:
+        # Note: Waiting for someone to implement arrow to intersection with
+        # marker.  Meanwhile, this works well for polygons with more than 4
+        # sides and circle.
+
+        def to_marker_edge(marker_size, marker):
+            if marker in "s^>v<d":  # `large` markers need extra space
+                return np.sqrt(2 * marker_size) / 2
+            else:
+                return np.sqrt(marker_size) / 2
 
-        # a directed graph hack
-        # draw thick line segments at head end of edge
-        # waiting for someone else to implement arrows that will work
+        # Draw arrows with `matplotlib.patches.FancyarrowPatch`
+        arrow_collection = []
+        mutation_scale = arrowsize  # scale factor of arrow head
         arrow_colors = edge_colors
-        a_pos = []
-        p = 1.0 - 0.25  # make head segment 25 percent of edge length
-        for src, dst in edge_pos:
+        if arrow_colors is None:
+            if edge_cmap is not None:
+                assert(isinstance(edge_cmap, Colormap))
+            else:
+                edge_cmap = plt.get_cmap()  # default matplotlib colormap
+            if edge_vmin is None:
+                edge_vmin = min(edge_color)
+            if edge_vmax is None:
+                edge_vmax = max(edge_color)
+            color_normal = Normalize(vmin=edge_vmin, vmax=edge_vmax)
+
+        for i, (src, dst) in enumerate(edge_pos):
             x1, y1 = src
             x2, y2 = dst
-            dx = x2 - x1   # x offset
-            dy = y2 - y1   # y offset
-            d = np.sqrt(float(dx**2 + dy**2))  # length of edge
-            if d == 0:   # source and target at same position
-                continue
-            if dx == 0:  # vertical edge
-                xa = x2
-                ya = dy * p + y1
-            if dy == 0:  # horizontal edge
-                ya = y2
-                xa = dx * p + x1
+            arrow_color = None
+            line_width = None
+            shrink_source = 0  # space from source to tail
+            shrink_target = 0  # space from  head to target
+            if cb.iterable(node_size):  # many node sizes
+                src_node, dst_node = edgelist[i]
+                index_node = nodelist.index(dst_node)
+                marker_size = node_size[index_node]
+                shrink_target = to_marker_edge(marker_size, node_shape)
             else:
-                theta = np.arctan2(dy, dx)
-                xa = p * d * np.cos(theta) + x1
-                ya = p * d * np.sin(theta) + y1
-
-            a_pos.append(((xa, ya), (x2, y2)))
-
-        arrow_collection = LineCollection(a_pos,
-                                          colors=arrow_colors,
-                                          linewidths=[4 * ww for ww in lw],
-                                          antialiaseds=(1,),
-                                          transOffset=ax.transData,
-                                          )
-
-        arrow_collection.set_zorder(1)  # edges go behind nodes
-        ax.add_collection(arrow_collection)
+                shrink_target = to_marker_edge(node_size, node_shape)
+            if arrow_colors is None:
+                arrow_color = edge_cmap(color_normal(edge_color[i]))
+            elif len(arrow_colors) > 1:
+                arrow_color = arrow_colors[i]
+            else:
+                arrow_color = arrow_colors[0]
+            if len(lw) > 1:
+                line_width = lw[i]
+            else:
+                line_width = lw[0]
+            arrow = FancyArrowPatch((x1, y1), (x2, y2),
+                                    arrowstyle=arrowstyle,
+                                    shrinkA=shrink_source,
+                                    shrinkB=shrink_target,
+                                    mutation_scale=mutation_scale,
+                                    color=arrow_color,
+                                    linewidth=line_width,
+                                    zorder=1)  # arrows go behind nodes
+
+            # There seems to be a bug in matplotlib to make collections of
+            # FancyArrowPatch instances. Until fixed, the patches are added
+            # individually to the axes instance.
+            arrow_collection.append(arrow)
+            ax.add_patch(arrow)
 
     # update view
     minx = np.amin(np.ravel(edge_pos[:, :, 0]))
@@ -629,9 +697,7 @@ def draw_networkx_edges(G, pos,
     ax.update_datalim(corners)
     ax.autoscale_view()
 
-#    if arrow_collection:
-
-    return edge_collection
+    return arrow_collection
 
 
 def draw_networkx_labels(G, pos,
@@ -720,7 +786,7 @@ def draw_networkx_labels(G, pos,
     for n, label in labels.items():
         (x, y) = pos[n]
         if not is_string_like(label):
-            label = str(label)  # this will cause "1" and 1 to be labeled the same
+            label = str(label)  # this makes "1" and 1 labeled the same
         t = ax.text(x, y,
                     label,
                     size=font_size,
@@ -817,7 +883,6 @@ def draw_networkx_edge_labels(G, pos,
     """
     try:
         import matplotlib.pyplot as plt
-        import matplotlib.cbook as cb
         import numpy as np
     except ImportError:
         raise ImportError("Matplotlib required for draw()")
@@ -839,7 +904,8 @@ def draw_networkx_edge_labels(G, pos,
                   y1 * label_pos + y2 * (1.0 - label_pos))
 
         if rotate:
-            angle = np.arctan2(y2 - y1, x2 - x1) / (2.0 * np.pi) * 360  # degrees
+            # in degrees
+            angle = np.arctan2(y2 - y1, x2 - x1) / (2.0 * np.pi) * 360
             # make label orientation "right-side-up"
             if angle > 90:
                 angle -= 180
@@ -858,7 +924,7 @@ def draw_networkx_edge_labels(G, pos,
                         fc=(1.0, 1.0, 1.0),
                         )
         if not is_string_like(label):
-            label = str(label)  # this will cause "1" and 1 to be labeled the same
+            label = str(label)  # this makes "1" and 1 labeled the same
 
         # set optional alignment
         horizontalalignment = kwds.get('horizontalalignment', 'center')
@@ -1003,15 +1069,16 @@ def apply_alpha(colors, alpha, elem_list, cmap=None, vmin=None, vmax=None):
        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.
+       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.
+       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 color mapping is used.
+       Minimum and maximum values for normalizing colors if a color mapping is
+       used.
 
     Returns
     -------
@@ -1021,7 +1088,7 @@ def apply_alpha(colors, alpha, elem_list, cmap=None, vmin=None, vmax=None):
 
     """
     import numbers
-    import itertools
+    from itertools import islice, cycle
 
     try:
         import numpy as np
@@ -1030,29 +1097,33 @@ def apply_alpha(colors, alpha, elem_list, cmap=None, vmin=None, vmax=None):
     except ImportError:
         raise ImportError("Matplotlib required for draw()")
 
-    # If we have been provided with a list of numbers as long as elem_list, apply the color mapping.
+    # 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], numbers.Number):
         mapper = 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.
+    # 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([colorConverter.to_rgba(colors)])
         except ValueError:
-            rgba_colors = np.array([colorConverter.to_rgba(color) for color in colors])
-    # Set the final column of the rgba_colors to have the relevant alpha values.
+            rgba_colors = np.array([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 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.resize((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(itertools.islice(itertools.cycle(alpha), len(rgba_colors)))
+        rgba_colors[:,  3] = list(islice(cycle(alpha), len(rgba_colors)))
     except TypeError:
         rgba_colors[:, -1] = alpha
     return rgba_colors


Test patch: