5

We can draw infinite lines from a given point with given slope in matplotlib with plt.axline() (https://matplotlib.org/stable/api/_as_gen/matplotlib.pyplot.axline.html)

Is there a clean way to draw a semi-infinite line or ray from a given point in a given direction? Preferably without having to calculate the axis limits.

For axhline and axvline, we can use one of xmin, xmax, ymin, ymax arguments to get a ray, but axline doesn't accept these.

Related questions:

Burrito
  • 1,475
  • 19
  • 27
  • Out of curiosity what is your intended application for this? Do you need a semi-infinite line because you're intending to have a live animation where the x-axis moves (or something similar?) – Derek O Dec 25 '21 at 00:47
  • 1
    I have labeled data on a scatter plot and I'm trying to draw boundaries between each class, sort of like a Voronoi diagram. I see scipy has a tool for this, but for different types of data I may want finer control of the boundaries, which will generally include lines, line segments, rays, or maybe even curves. – Burrito Dec 25 '21 at 01:27

2 Answers2

4

Here is a modifications of axline which allows to specify a semi_x argument. It controls which x-halfplane around the xy1 point to draw.

This works with both slope and xy2 arguments. Ignoring semi_x preserves the default axline behaviour.

import matplotlib.pyplot as plt
import numpy as np
from matplotlib.transforms import Bbox, BboxTransformTo
from matplotlib.lines import Line2D

def axline(ax, xy1, xy2=None, *, slope=None, semi_x=None, **kwargs):
    if slope is not None and (ax.get_xscale() != 'linear' or
                                ax.get_yscale() != 'linear'):
        raise TypeError("'slope' cannot be used with non-linear scales")

    datalim = [xy1] if xy2 is None else [xy1, xy2]
    if "transform" in kwargs:
        # if a transform is passed (i.e. line points not in data space),
        # data limits should not be adjusted.
        datalim = []

    line = _AxLine(xy1, xy2, slope, semi_x, **kwargs)
    # Like add_line, but correctly handling data limits.
    ax._set_artist_props(line)
    if line.get_clip_path() is None:
        line.set_clip_path(ax.patch)
    if not line.get_label():
        line.set_label(f"_line{len(ax.lines)}")
    ax.lines.append(line)
    line._remove_method = ax.lines.remove
    ax.update_datalim(datalim)

    ax._request_autoscale_view()
    return line

class _AxLine(Line2D):
    def __init__(self, xy1, xy2, slope, semi_x, **kwargs):
        super().__init__([0, 1], [0, 1], **kwargs)

        if (xy2 is None and slope is None or
                xy2 is not None and slope is not None):
            raise TypeError(
                "Exactly one of 'xy2' and 'slope' must be given")

        self._slope = slope
        self._xy1 = xy1
        self._xy2 = xy2
        self._semi_x = semi_x

    def get_transform(self):
        ax = self.axes
        points_transform = self._transform - ax.transData + ax.transScale

        if self._xy2 is not None:
            # two points were given
            (x1, y1), (x2, y2) = \
                points_transform.transform([self._xy1, self._xy2])
            dx = x2 - x1
            dy = y2 - y1
            if np.allclose(x1, x2):
                if np.allclose(y1, y2):
                    raise ValueError(
                        f"Cannot draw a line through two identical points "
                        f"(x={(x1, x2)}, y={(y1, y2)})")
                slope = np.inf
            else:
                slope = dy / dx
        else:
            # one point and a slope were given
            x1, y1 = points_transform.transform(self._xy1)
            slope = self._slope
        (vxlo, vylo), (vxhi, vyhi) = ax.transScale.transform(ax.viewLim)
        # General case: find intersections with view limits in either
        # direction, and draw between the middle two points.
        if np.isclose(slope, 0):
            start = vxlo, y1
            stop = vxhi, y1
        elif np.isinf(slope):
            start = x1, vylo
            stop = x1, vyhi
        else:
            _, start, stop, _ = sorted([
                (vxlo, y1 + (vxlo - x1) * slope),
                (vxhi, y1 + (vxhi - x1) * slope),
                (x1 + (vylo - y1) / slope, vylo),
                (x1 + (vyhi - y1) / slope, vyhi),
            ])
        # Handle semi-plane
        if self._semi_x == True:
            start = (x1,y1)
        elif self._semi_x == False:
            stop = (x1,y1)
        return (BboxTransformTo(Bbox([start, stop]))
                + ax.transLimits + ax.transAxes)

    def draw(self, renderer):
        self._transformed_path = None  # Force regen.
        super().draw(renderer)


## Usage with slope
fig, ax = plt.subplots()
xy1 = (.5, .5)
slope = -1
ax.scatter(*xy1)
axline(ax, xy1, slope=slope, c='g', semi_x=True)
axline(ax, xy1, slope=slope, c='r', semi_x=False)
ax.set_xlim([0,1])
ax.set_ylim([0,1])
plt.show()


## Usage with xy2
fig, ax = plt.subplots()
xy1 = (.5, .5)
xy2 = (.75, .75)
ax.scatter(*xy1)
ax.scatter(*xy2)
axline(ax, xy1, xy2=xy2, c='g', semi_x=True)
axline(ax, xy1, xy2=xy2, c='r', semi_x=False)
ax.set_xlim([0,1])
ax.set_ylim([0,1])
plt.show()

Example use with slope

Example use with xy2

  • Nice! To my mind it would make more sense with an `axsemiline(xy1, xy2=None, *, angle=None)`, since the direction of the seminess can be inferred either from `xy2` or from using `angle` instead of `slope`. (i.e. angle = atan(slope)). – Erik Jan 01 '23 at 13:35
2

I can't find a clean way to do this based on the axline documentation, so I'll post my hacky workaround which is to obscure the portion of the line by drawing a line segment (with a larger linewidth than your axline) from xmin to the x value of your starting point.

I acknowledge that this is an ugly solution and will update my answer if I think of anything better.

import matplotlib.pyplot as plt

## draw infinite line starting from (0.5,0.5) with slope=1
x0,y0,m= 0.5,0.5,1
plt.axline((x0, y0), slope=m, color='k', transform=plt.gca().transAxes, linewidth=0.5, alpha=0.5)

## obscure the line segment from xmin to x0
ymin,ymax = plt.gca().get_ylim()
xmin = x0 - (y0-ymin / m)

## plot twice so that a portion of the axline can't be seen
plt.plot([xmin,x0], [ymin,y0], '#ffffff', linewidth=1.0, alpha=1.0)
plt.plot([xmin,x0], [ymin,y0], '#ffffff', linewidth=1.0, alpha=1.0)

plt.ylim([0, 1])
plt.xlim([0, 1])
plt.show()

enter image description here

Derek O
  • 16,770
  • 4
  • 24
  • 43
  • 2
    lol that is ugly xD but I appreciate the effort! Plotting the obscuring line twice is pretty ridiculous but seems like the kind of trick that comes in handy when wrestling with transparency issues. However, instead of all this, couldn't we just plot from x0, y0 to xmax, ymax? – Burrito Dec 25 '21 at 01:33
  • 1
    @Burrito if plotting from (x0, y0) to (xmax, ymax) works for you, then that's definitely a simpler solution. it doesn't seem like you explicitly need a ray to draw class boundaries as long the ranges for your axes never change for your plot. if the axes ranges did in fact change (like a live updating plot of stocks over time with a ray as a linear forecasting trendline, then i guess my ugly solution might be necessary so i'll leave this answer as is :P) – Derek O Dec 25 '21 at 01:53