Burrito
Burrito

Reputation: 1624

How to draw semi-infinite line (ray) in matplotlib?

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:

Upvotes: 6

Views: 2229

Answers (2)

Artūrs Bērziņš
Artūrs Bērziņš

Reputation: 51

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

Upvotes: 5

Derek O
Derek O

Reputation: 19545

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

Upvotes: 2

Related Questions