Reputation: 5384
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
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 pointsPatch
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:
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