pythonmatplotlibdata-visualizationpyqtgraph

How to draw "two directions widths line" in matplotlib


How to use matplotlib or pyqtgraph draw plot like this: two dirrections widths line

Line AB is a two-directions street, green part represents the direction from point A to point B, red part represents B to A, width of each part represents the traffic volume. Widths are measured in point, will not changed at different zoom levels or dpi settings.

This is only an example, in fact I have hunderds of streets. This kind of plot is very common in many traffic softwares. I tried to use matplotlib's patheffect but result is frustrated:

from matplotlib import pyplot as plt
import matplotlib.patheffects as path_effects

x=[0,1,2,3]
y=[1,0,0,-1]
ab_width=20
ba_width=30

fig, axes= plt.subplots(1,1)
center_line, = axes.plot(x,y,color='k',linewidth=2)

center_line.set_path_effects(
[path_effects.SimpleLineShadow(offset=(0, -ab_width/2),shadow_color='g', alpha=1, linewidth=ab_width),
path_effects.SimpleLineShadow(offset=(0, ba_width/2), shadow_color='r', alpha=1, linewidth=ba_width),
path_effects.SimpleLineShadow(offset=(0, -ab_width), shadow_color='k', alpha=1, linewidth=2),
path_effects.SimpleLineShadow(offset=(0, ba_width), shadow_color='k', alpha=1, linewidth=2),
path_effects.Normal()])

axes.set_xlim(-1,4)
axes.set_ylim(-1.5,1.5)

enter image description here

One idea came to me is to take each part of the line as a standalone line, and recalculate it's position when changing zoom level, but it's too complicated and slow.

If there any easy way to use matplotlib or pyqtgraph draw what I want? Any suggestion will be appreciated!


Solution

  • Many years later, I figured it out! Do do it, one has to create a new Path Effect which performs the necessary rendering:

    class MultilinePath(AbstractPathEffect):
    def __init__(self, offset=(0, 0), part_widths=(1,), part_colors=('b',), **kwargs):
        super().__init__(offset=(0, 0))
        self._path_colors = [mcolors.to_rgba(color) for color in part_colors]
        self._path_widths = np.array(part_widths)
        self._path_offsets = np.cumsum(self._path_widths) - self._path_widths/2 - np.sum(self._path_widths)/2
        self._gc = kwargs
    
    def draw_path(self, renderer, gc, tpath, affine, rgbFace=None):
        """
        Overrides the standard draw_path to instead draw several paths slightly offset
        """
        gc0 = renderer.new_gc()  # Don't modify gc, but a copy!
        gc0.copy_properties(gc)
    
        gc0 = self._update_gc(gc0, self._gc)
        offset_px = renderer.points_to_pixels(self._path_offsets*gc0.get_linewidth())
        base_linewidth = renderer.points_to_pixels(gc0.get_linewidth())
        # Transform before evaluation because to_polygons works at resolution
        # of one -- assuming it is working in pixel space.
        transpath = affine.transform_path(tpath)
        # Evaluate to straight line segments to we can get the normal
        polys = transpath.to_polygons(closed_only=False)
        for p in polys:
            x = p[:, 0]
            y = p[:, 1]
    
            # Can not interpolate points or draw line if only one point in
            # polyline.
            if x.size < 2:
                continue
    
            dx = np.concatenate([[x[1] - x[0]], x[2:] - x[:-2], [x[-1] - x[-2]]])
            dy = np.concatenate([[y[1] - y[0]], y[2:] - y[:-2], [y[-1] - y[-2]]])
            l = np.hypot(dx, dy)
            nx = dy / l
            ny = -dx / l
    
            for i, width in enumerate(self._path_widths):
                xyt = np.copy(p)
                xyt[:, 0] += nx * offset_px[i]
                xyt[:, 1] += ny * offset_px[i]
    
                h = Path(xyt)
                # Transform back to data space during render
                gc0.set_linewidth(width * base_linewidth)
                gc0.set_foreground(self._path_colors[i])
    
                renderer.draw_path(gc0, h, trf.IdentityTransform(), rgbFace)
    
        gc0.restore()
    

    And then you can just apply it to any path rendered by matplotlib. For example:

    nx = 101
    x = np.linspace(0.0, 1.0, nx)
    y = 0.3*np.sin(x*8) + 0.4
    ax.plot(x, y, label="Curve", path_effects=[MultilinePath(part_widths=[1, 1, 1], part_colors=['r', 'g', 'b'])])
    plt.show()
    

    Sine Curve of three paths next to each other