sdbbs
sdbbs

Reputation: 5384

Creating custom Patch classes of multiple Artists in Matplotlib? (Rectangle + Text)

Basically, I would like to have a custom patch, that consists of a matplotlib.patches.Rectangle (which extends matplotlib.patches.Patch, which extends matplotlib.artist.Artist) and a matplotlib.text.Text (which extends matplotlib.artist.Artist)

The problem is, I absolutely cannot find any working examples of classes that are composed of multiple Patches or Artists, and extend existing Patches.

The initial idea that I got was to extend Rectangle with a new class, and in its init, instantiate a Text - but I had a suspicion this wouldn't quite be to my liking, and I saw it confirmed here:

How to use a cutom marker in Matplotlib with text inside a shape?

...
class PlanetPatch(mpl.patches.Circle):
...
    def add_to_ax(self, ax):
        ax.add_patch(self)
        ax.add_patch(self.ring_contour)
        ax.add_patch(self.ring_inner)
        ax.add_patch(self.top)
...
for x in range(100):
...
    planet = PlanetPatch(xy, r, linewidth = 20,
...
    planet.add_to_ax(ax)
...

This is what is nagging me: I wouldn't really keep individual reference to children in my subclass, and "manually" do add_patch for them individually through a custom function - what I'd want to achieve is a usage like for Rectangle itself:

...
myrect = Rectangle((0,0), 1,20)
mypatch = ax.add_patch(myrect)
...

... that is, if the custom class was MyRectangleText, its usage should be the same:

...
mymrt = MyRectangleText((0,0), 1,20, text="Hello")
mypatch = ax.add_patch(mymrt)
...

... basically, no calling of special functions; whatever the subclass constructor returns, goes as a single reference into add_patch - no matter how many other patches it consists of internally.

On the other hand, I looked at the Rectangle source, and it is quite complex - and I find it difficult to believe, that I'd have to rewrite a Rectangle+Text class on that level, especially since both Rectange and Text exist. Unfortunately, I cannot figure out how to do it in an easy way (inheriting from existing classes).

So, is it possible to create a Rectangle + Text matplotlib class easily (that is, reusing the existing Rectangle and Text classes), such that it is essentially a subclass of Patch (it has to be a subclass of Artist regardless, I guess), and is used in same way -- and if so, how?

Upvotes: 1

Views: 599

Answers (1)

sdbbs
sdbbs

Reputation: 5384

OK, this was a bit tricky - but I managed to get somewhere...

After looking at the API, it seemed easiest that the custom class does not in fact extend/inherit from Patch (also there is an example that draws multiple Rectangle (by way of Cell) which extends Artist - which is matplotlib.table.Table). Mostly because:

  • Rectangle extends Patch, where Patch extends Artist
  • Text extends Artist

In more detail:

  • Artist allows for clipping, children, transforms etc, - and it defines a (minimal/skeleton) draw method; but it does not "know" about paths or points
  • Patch introduces concept of a Path with points (and related methods), and in addition to the draw method, also get_path and set_path methods (which are the minimum required to extend a Patch class, see PathPatch)
  • Text also does not "know" points, and in fact uses a custom renderer in its draw method.

Therefore, the lowest common denominator of all three is the Artist class with the draw method, and that is what the custom class would primarily aim to extend; correspondingly, the objects of this class will be added to the plot not via add_patch, but with add_artist.

Then, our class can "merely" instantiate the Text and the Rectangle as its child properties, and call their individual draw methods in the parent draw. It turns out, in addition to draw, one should at least also overload set_figure and set_transform. (Overloading the axes getter/setter seems not really required, but without it, the child properties will lack a reference to _axes).

Additionally, in the example below, I have also overloaded set_clip_path - and that is because I wanted a graphic element which is a rectangle, which clips the text inside - and it turns out, with a custom class like this, we have to handle that manually, along with the clipping of both elements to the plot limits! Unfortunately, the transformations involved are very unclear to me - but I was lucky to find a setup which behaves exactly as I want in terms of plot panning, shown on the animated gif below:

matplotlib plot interaction with custom class

Finally, here is the code:

import matplotlib
import matplotlib.artist as artist
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import matplotlib.transforms as mtransforms
import numpy as np

class MyRectangleText(matplotlib.artist.Artist):
  def __init__(self, x=0, y=0, width=0, height=0, textstr="", rectsettings={}, textsettings={} ):
    super().__init__()
    self._x, self._y, self._width, self._height = x, y, width, height
    self._textstr = textstr
    self._textsettings, self._rectsettings = textsettings, rectsettings
    # instantiate children objects
    self._rect = mpatches.Rectangle((self._x, self._y), self._width, self._height, **self._rectsettings)
    self._text = matplotlib.text.Text(self._x, self._y, self._textstr, **self._textsettings)
    # must modify zorder, else is drawn behind the plot!
    # note rect by default has zorder 1, text has zorder 3
    self.update({'zorder': self._rect.zorder})

  def set_figure(self, fig): # gets autocalled by engine 
    super().set_figure(fig)
    self._rect.set_figure(fig)
    self._text.set_figure(fig)

  def set_transform(self, trans): # gets autocalled by engine 
    super().set_transform(trans)
    # "the text does not get the transform!" (Cell)
    self._rect.set_transform(trans)
    self._text.set_transform(trans)
    self.stale = True # not really needed?

  # .axes of self._rect/self._text are None, unless set in the setter here
  # overriding as per https://stackoverflow.com/q/7019643/
  # (setter name is derived from getter name)
  @matplotlib.artist.Artist.axes.getter
  def axes(self):
    return self._axes
  @axes.setter
  def axes(self, new_axes):
    self._axes = new_axes
    self._rect.axes = self._axes
    self._text.axes = self._axes

  #def set_clip_box(self, clipbox): # not autocalled!
  #def set_clip_on(self, b): # not autocalled!

  # note _get_clipping_extent_bbox: "extents of the intersection of the clip_path and clip_box for this artist"

  # set_clip_path is needed, else when dragging in plot,
  # rectangle might appear outside of plot boundaries (axes)!
  def set_clip_path(self, path, transform=None): # gets autocalled by engine 
    super().set_clip_path(path, transform)
    
    # NOTE: cannot really setup both _rect and _text here, such that
    # _rect is clipped at plot bounds (done here); and
    # _text is clipped both by _rect (not done here) and by plot bounds (done here)
    
    self._rect.set_clip_path(path, transform) # clip at bounds of plot - OK
    
    # try: _rect in set_clip_path, and path in set_clip_box:
    """
    self._text.set_clip_path(self._rect) # ok, clips at rect - but only without the next set_clip_box
    self._text.set_clip_box(path.get_extents()) # ok, clips at plot bounds - but overrides the previous set_clip_path(self._rect)
    """
    
    # try opposite:  path in set_clip_path, and _rect in set_clip_box:
    self._text.set_clip_path(path, transform) # ok, clips at plot bounds - but set_clip_box(self._rect.get_extents()) afterwards will override it
    #self._text.set_clip_box(self._rect.get_extents()) # wrong - texts not even shown!

  @artist.allow_rasterization
  def draw(self, renderer):
    super().draw(renderer) # handles get_visible
    self._rect.draw(renderer)
    # note: with self._text.set_clip_path(path..) above,
    # the below set_clip_box(_rect) makes the text be clipped by
    # BOTH the plot area, AND the self._rect area (is clipped by
    # the intersection of those two areas)
    # The only problem is, once text is fully out of plot area,
    # it will be shown fully again, because tbr becomes None - handle!
    tbr = self._rect.get_tightbbox(renderer)
    if tbr is not None:
      self._text.set_clip_box(tbr)
      self._text.draw(renderer)


fig = plt.figure()
ax = fig.add_subplot(111)

plt.autoscale(enable=True, axis='both', tight=None)

x = 0 ; y = 0 ; width = 100; height = 50; 
rect = mpatches.Rectangle((x, y), width, height, linewidth=1, edgecolor='black', facecolor='#AAAAAA88')
patch = ax.add_patch(rect)

rsets = { "linewidth":1, "edgecolor":'black', "facecolor":'green' }
mymrt = MyRectangleText(10,10, 30,5, "Hello World", rsets)
myartist = ax.add_artist(mymrt)

font_size = 40
tsets = { "size": font_size, "color": 'yellow' }
mymrt2 = MyRectangleText(40,30, 45,5, "Hello World", textsettings=tsets)
myartist = ax.add_artist(mymrt2)

plt.show()

Upvotes: 1

Related Questions