13

My question is a bit similar to this question that draws line with width given in data coordinates. What makes my question a bit more challenging is that unlike the linked question, the segment that I wish to expand is of a random orientation.

Let's say if the line segment goes from (0, 10) to (10, 10), and I wish to expand it to a width of 6. Then it is simply

x = [0, 10]
y = [10, 10]
ax.fill_between(x, y - 3, y + 3)

However, my line segment is of random orientation. That is, it is not necessarily along x-axis or y-axis. It has a certain slope.

A line segment s is defined as a list of its starting and ending points: [(x1, y1), (x2, y2)].

Now I wish to expand the line segment to a certain width w. The solution is expected to work for a line segment in any orientation. How to do this?

plt.plot(x, y, linewidth=6.0) cannot do the trick, because I want my width to be in the same unit as my data.

Trenton McKinney
  • 56,955
  • 33
  • 144
  • 158
  • Why can't you use the line width parameter? `plt.plot(x, y, linewidth=6.0)` – beroe Oct 16 '13 at 03:10
  • @beroe Because I want the width to be in the same unit as the data. Say my data is in meter. Then I want my line width to be 6m. –  Oct 16 '13 at 03:11
  • 1
    I suspect you really want to be drawing rectangles. – tacaswell Oct 16 '13 at 03:27
  • @tcaswell That may be a good idea. Currently what I have is the centerline. Any suggestion on how to do that? –  Oct 16 '13 at 03:32
  • http://matplotlib.org/api/artist_api.html#matplotlib.patches.Rectangle – tacaswell Oct 16 '13 at 03:40
  • 1
    This looks very similar to this question http://stackoverflow.com/q/15670973/2870069 – Jakob Oct 16 '13 at 10:53
  • 1
    @Jakob, not totally the same, if your target is the saved figure, then zoom support is not necessary. mavErick, I think I got it working below, but you would have to adjust the scaling factor if you want multiple subplots. – beroe Oct 16 '13 at 18:06
  • Check this answer: [Line width in axis units](https://stackoverflow.com/a/75792204/5520444) – Tony Power Mar 20 '23 at 15:42

3 Answers3

15

The following code is a generic example on how to make a line plot in matplotlib using data coordinates as linewidth. There are two solutions; one using callbacks, one using subclassing Line2D.

Using callbacks.

It is implemted as a class data_linewidth_plot that can be called with a signature pretty close the the normal plt.plot command,

l = data_linewidth_plot(x, y, ax=ax, label='some line', linewidth=1, alpha=0.4)

where ax is the axes to plot to. The ax argument can be omitted, when only one subplot exists in the figure. The linewidth argument is interpreted in (y-)data units.

Further features:

  1. It's independend on the subplot placements, margins or figure size.
  2. If the aspect ratio is unequal, it uses y data coordinates as the linewidth.
  3. It also takes care that the legend handle is correctly set (we may want to have a huge line in the plot, but certainly not in the legend).
  4. It is compatible with changes to the figure size, zoom or pan events, as it takes care of resizing the linewidth on such events.

Here is the complete code.

import matplotlib.pyplot as plt

class data_linewidth_plot():
    def __init__(self, x, y, **kwargs):
        self.ax = kwargs.pop("ax", plt.gca())
        self.fig = self.ax.get_figure()
        self.lw_data = kwargs.pop("linewidth", 1)
        self.lw = 1
        self.fig.canvas.draw()

        self.ppd = 72./self.fig.dpi
        self.trans = self.ax.transData.transform
        self.linehandle, = self.ax.plot([],[],**kwargs)
        if "label" in kwargs: kwargs.pop("label")
        self.line, = self.ax.plot(x, y, **kwargs)
        self.line.set_color(self.linehandle.get_color())
        self._resize()
        self.cid = self.fig.canvas.mpl_connect('draw_event', self._resize)

    def _resize(self, event=None):
        lw =  ((self.trans((1, self.lw_data))-self.trans((0, 0)))*self.ppd)[1]
        if lw != self.lw:
            self.line.set_linewidth(lw)
            self.lw = lw
            self._redraw_later()

    def _redraw_later(self):
        self.timer = self.fig.canvas.new_timer(interval=10)
        self.timer.single_shot = True
        self.timer.add_callback(lambda : self.fig.canvas.draw_idle())
        self.timer.start()

fig1, ax1 = plt.subplots()
#ax.set_aspect('equal') #<-not necessary 
ax1.set_ylim(0,3)
x = [0,1,2,3]
y = [1,1,2,2]

# plot a line, with 'linewidth' in (y-)data coordinates.       
l = data_linewidth_plot(x, y, ax=ax1, label='some 1 data unit wide line', 
                        linewidth=1, alpha=0.4)

plt.legend() # <- legend possible
plt.show()

enter image description here

(I updated the code to use a timer to redraw the canvas, due to this issue)

Subclassing Line2D

The above solution has some drawbacks. It requires a timer and callbacks to update itself on changing axis limits or figure size. The following is a solution without such needs. It will use a dynamic property to always calculate the linewidth in points from the desired linewidth in data coordinates on the fly. It is much shorter than the above. A drawback here is that a legend needs to be created manually via a proxyartist.

import matplotlib.pyplot as plt
from matplotlib.lines import Line2D

class LineDataUnits(Line2D):
    def __init__(self, *args, **kwargs):
        _lw_data = kwargs.pop("linewidth", 1) 
        super().__init__(*args, **kwargs)
        self._lw_data = _lw_data

    def _get_lw(self):
        if self.axes is not None:
            ppd = 72./self.axes.figure.dpi
            trans = self.axes.transData.transform
            return ((trans((1, self._lw_data))-trans((0, 0)))*ppd)[1]
        else:
            return 1

    def _set_lw(self, lw):
        self._lw_data = lw

    _linewidth = property(_get_lw, _set_lw)


fig, ax = plt.subplots()

#ax.set_aspect('equal') # <-not necessary, if not given, y data is assumed 
ax.set_xlim(0,3)
ax.set_ylim(0,3)
x = [0,1,2,3]
y = [1,1,2,2]

line = LineDataUnits(x, y, linewidth=1, alpha=0.4)
ax.add_line(line)

ax.legend([Line2D([],[], linewidth=3, alpha=0.4)], 
           ['some 1 data unit wide line'])    # <- legend possible via proxy artist
plt.show()
ImportanceOfBeingErnest
  • 321,279
  • 53
  • 665
  • 712
  • 1
    thank you, but... as far, as you know, is it still so complex to obtain such a basic goal as of today April 25, 2021? – Antonio Sesto Apr 25 '21 at 11:09
11

Just to add to the previous answer (can't comment yet), here's a function that automates this process without the need for equal axes or the heuristic value of 0.8 for labels. The data limits and size of the axis need to be fixed and not changed after this function is called.

def linewidth_from_data_units(linewidth, axis, reference='y'):
    """
    Convert a linewidth in data units to linewidth in points.

    Parameters
    ----------
    linewidth: float
        Linewidth in data units of the respective reference-axis
    axis: matplotlib axis
        The axis which is used to extract the relevant transformation
        data (data limits and size must not change afterwards)
    reference: string
        The axis that is taken as a reference for the data width.
        Possible values: 'x' and 'y'. Defaults to 'y'.

    Returns
    -------
    linewidth: float
        Linewidth in points
    """
    fig = axis.get_figure()
    if reference == 'x':
        length = fig.bbox_inches.width * axis.get_position().width
        value_range = np.diff(axis.get_xlim())
    elif reference == 'y':
        length = fig.bbox_inches.height * axis.get_position().height
        value_range = np.diff(axis.get_ylim())
    # Convert length to points
    length *= 72
    # Scale linewidth to value range
    return linewidth * (length / value_range)
Felix
  • 111
  • 1
  • 2
  • Shouldn't it be `fig.dpi` instead of `72`? – Phyks Mar 19 '16 at 08:56
  • After further tests, seems to be `72` indeed, and not `fig.dpi`. I am not sure why though… – Phyks Mar 30 '16 at 19:39
  • Perfect, except if a scalar is desired rather than an array: `np.diff(axis.get_lim())` should be `np.diff(axis.get_lim())[0]`. Thanks! – Walt W Aug 25 '16 at 17:31
  • 3
    @Phyks To answer the question why `72` is the correct number: The linewidth is given in points. The points unit is commonly 72 points/inch, also in matplotlib. While the dots per inch may change, points per inch stay constant. – ImportanceOfBeingErnest Mar 23 '17 at 09:58
7

Explanation:

  • Set up the figure with a known height and make the scale of the two axes equal (or else the idea of "data coordinates" does not apply). Make sure the proportions of the figure match the expected proportions of the x and y axes.

  • Compute the height of the whole figure point_hei (including margins) in units of points by multiplying inches by 72

  • Manually assign the y-axis range yrange (You could do this by plotting a "dummy" series first and then querying the plot axis to get the lower and upper y limits.)

  • Provide the width of the line that you would like in data units linewid

  • Calculate what those units would be in points pointlinewid while adjusting for the margins. In a single-frame plot, the plot is 80% of the full image height.

  • Plot the lines, using a capstyle that does not pad the ends of the line (has a big effect at these large line sizes)

Voilà? (Note: this should generate the proper image in the saved file, but no guarantees if you resize a plot window.)

import matplotlib.pyplot as plt
rez=600
wid=8.0 # Must be proportional to x and y limits below
hei=6.0
fig = plt.figure(1, figsize=(wid, hei))
sp = fig.add_subplot(111)
# # plt.figure.tight_layout() 
# fig.set_autoscaley_on(False)
sp.set_xlim([0,4000])
sp.set_ylim([0,3000])
plt.axes().set_aspect('equal')

# line is in points: 72 points per inch
point_hei=hei*72 

xval=[100,1300,2200,3000,3900]
yval=[10,200,2500,1750,1750]
x1,x2,y1,y2 = plt.axis()
yrange =   y2 - y1
# print yrange

linewid = 500     # in data units

# For the calculation below, you have to adjust width by 0.8
# because the top and bottom 10% of the figure are labels & axis
pointlinewid = (linewid * (point_hei/yrange)) * 0.8  # corresponding width in pts

plt.plot(xval,yval,linewidth = pointlinewid,color="blue",solid_capstyle="butt")
# just for fun, plot the half-width line on top of it
plt.plot(xval,yval,linewidth = pointlinewid/2,color="red",solid_capstyle="butt")

plt.savefig('mymatplot2.png',dpi=rez)

enter image description here

beroe
  • 11,784
  • 5
  • 34
  • 79