diff --git a/matplotview/_view_axes.py b/matplotview/_view_axes.py index e6f2ba3..de1adf9 100644 --- a/matplotview/_view_axes.py +++ b/matplotview/_view_axes.py @@ -1,10 +1,47 @@ +import itertools +from typing import Type, List from matplotlib.axes import Axes from matplotlib.transforms import Bbox import matplotlib.docstring as docstring from matplotview._transform_renderer import _TransformRenderer - - -def view_wrapper(axes_class): +from matplotlib.artist import Artist +from matplotlib.backend_bases import RendererBase + +class BoundRendererArtist: + def __init__(self, artist: Artist, renderer: RendererBase, clip_box: Bbox): + self._artist = artist + self._renderer = renderer + self._clip_box = clip_box + + def __getattribute__(self, item): + try: + return super().__getattribute__(item) + except AttributeError: + return self._artist.__getattribute__(item) + + def __setattr__(self, key, value): + try: + super().__setattr__(key, value) + except AttributeError: + self._artist.__setattr__(key, value) + + def draw(self, renderer: RendererBase): + # Disable the artist defined clip box, as the artist might be visible + # under the new renderer even if not on screen... + clip_box_orig = self._artist.get_clip_box() + full_extents = self._artist.get_window_extent(self._renderer) + self._artist.set_clip_box(full_extents) + + # Check and see if the passed limiting box and extents of the + # artist intersect, if not don't bother drawing this artist. + if(Bbox.intersection(full_extents, self._clip_box) is not None): + self._artist.draw(self._renderer) + + # Re-enable the clip box... + self._artist.set_clip_box(clip_box_orig) + + +def view_wrapper(axes_class: Type[Axes]) -> Type[Axes]: """ Construct a ViewAxes, which subclasses, or wraps a specific Axes subclass. A ViewAxes can be configured to display the contents of another Axes @@ -30,13 +67,13 @@ class ViewAxesImpl(axes_class): """ __module__ = axes_class.__module__ # The number of allowed recursions in the draw method - MAX_RENDER_DEPTH = 1 + MAX_RENDER_DEPTH = 5 def __init__( self, - axes_to_view, + axes_to_view: Axes, *args, - image_interpolation="nearest", + image_interpolation: str = "nearest", **kwargs ): """ @@ -70,90 +107,68 @@ def __init__( ViewAxes The new zoom view axes instance... """ - super().__init__(axes_to_view.figure, *args, zorder=zorder, - **kwargs) + super().__init__(axes_to_view.figure, *args, **kwargs) self._init_vars(axes_to_view, image_interpolation) - def _init_vars( self, - axes_to_view, - image_interpolation="nearest" + axes_to_view: Axes, + image_interpolation: str = "nearest" ): self.__view_axes = axes_to_view self.__image_interpolation = image_interpolation self._render_depth = 0 self.__scale_lines = True - - def draw(self, renderer=None): + self.__renderer = None + + def get_children(self) -> List[Artist]: + # We overload get_children to return artists from the view axes + # in addition to this axes when drawing. We wrap the artists + # in a BoundRendererArtist, so they are drawn with an alternate + # renderer, and therefore to the correct location. + if(self.__renderer is not None): + mock_renderer = _TransformRenderer( + self.__renderer, self.__view_axes.transData, + self.transData, self, self.__image_interpolation, + self.__scale_lines + ) + + x1, x2 = self.get_xlim() + y1, y2 = self.get_ylim() + axes_box = Bbox.from_extents(x1, y1, x2, y2).transformed( + self.__view_axes.transData + ) + + init_list = super().get_children() + init_list.extend([ + BoundRendererArtist(a, mock_renderer, axes_box) + for a in itertools.chain( + self.__view_axes._children, self.__view_axes.child_axes + ) if(a is not self) + ]) + + return init_list + else: + return super().get_children() + + def draw(self, renderer: RendererBase = None): + # It is possible to have two axes which are views of each other + # therefore we track the number of recursions and stop drawing + # at a certain depth if(self._render_depth >= self.MAX_RENDER_DEPTH): return self._render_depth += 1 + # Set the renderer, causing get_children to return the view's + # children also... + self.__renderer = renderer super().draw(renderer) - if(not self.get_visible()): - return - - axes_children = [ - *self.__view_axes.collections, - *self.__view_axes.patches, - *self.__view_axes.lines, - *self.__view_axes.texts, - *self.__view_axes.artists, - *self.__view_axes.images, - *self.__view_axes.child_axes - ] - - # Sort all rendered items by their z-order so they render in layers - # correctly... - axes_children.sort(key=lambda obj: obj.get_zorder()) - - artist_boxes = [] - # We need to temporarily disable the clip boxes of all of the - # artists, in order to allow us to continue rendering them it even - # if it is outside of the parent axes (they might still be visible - # in this zoom axes). - for a in axes_children: - artist_boxes.append(a.get_clip_box()) - a.set_clip_box(a.get_window_extent(renderer)) - - # Construct mock renderer and draw all artists to it. - mock_renderer = _TransformRenderer( - renderer, self.__view_axes.transData, self.transData, self, - self.__image_interpolation, self.__scale_lines - ) - x1, x2 = self.get_xlim() - y1, y2 = self.get_ylim() - axes_box = Bbox.from_extents(x1, y1, x2, y2).transformed( - self.__view_axes.transData - ) - - for artist in axes_children: - if( - (artist is not self) - and ( - Bbox.intersection( - artist.get_window_extent(renderer), axes_box - ) is not None - ) - ): - artist.draw(mock_renderer) - - # Reset all of the artist clip boxes... - for a, box in zip(axes_children, artist_boxes): - a.set_clip_box(box) - - # We need to redraw the splines if enabled, as we have finally - # drawn everything... This avoids other objects being drawn over - # the splines. - if(self.axison and self._frameon): - for spine in self.spines.values(): - spine.draw(renderer) - + # Get rid of the renderer... + self.__renderer = None self._render_depth -= 1 - def get_linescaling(self): + def get_linescaling(self) -> bool: """ Get if line width scaling is enabled. @@ -164,7 +179,7 @@ def get_linescaling(self): """ return self.__scale_lines - def set_linescaling(self, value): + def set_linescaling(self, value: bool): """ Set whether line widths should be scaled when rendering a view of an axes. @@ -178,7 +193,12 @@ def set_linescaling(self, value): self.__scale_lines = value @classmethod - def from_axes(cls, axes, axes_to_view, image_interpolation="nearest"): + def from_axes( + cls, + axes: Axes, + axes_to_view: Axes, + image_interpolation: str = "nearest" + ): axes.__class__ = cls axes._init_vars(axes_to_view, image_interpolation) return axes diff --git a/matplotview/tests/test_inset_zoom.py b/matplotview/tests/test_inset_zoom.py index 172c100..a92122e 100644 --- a/matplotview/tests/test_inset_zoom.py +++ b/matplotview/tests/test_inset_zoom.py @@ -3,7 +3,7 @@ from matplotlib.testing.decorators import check_figures_equal from matplotview import view, inset_zoom_axes -@check_figures_equal(tol=3) +@check_figures_equal(tol=6) def test_double_plot(fig_test, fig_ref): np.random.seed(1) im_data = np.random.rand(30, 30) @@ -13,6 +13,7 @@ def test_double_plot(fig_test, fig_ref): ax_test1.plot([i for i in range(10)], "r") ax_test1.add_patch(plt.Circle((3, 3), 1, ec="black", fc="blue")) + ax_test1.text(10, 10, "Hello World!", size=14) ax_test1.imshow(im_data, origin="lower", cmap="Blues", alpha=0.5, interpolation="nearest") ax_test2 = view(ax_test2, ax_test1) @@ -25,10 +26,12 @@ def test_double_plot(fig_test, fig_ref): ax_ref1.plot([i for i in range(10)], "r") ax_ref1.add_patch(plt.Circle((3, 3), 1, ec="black", fc="blue")) + ax_ref1.text(10, 10, "Hello World!", size=14) ax_ref1.imshow(im_data, origin="lower", cmap="Blues", alpha=0.5, interpolation="nearest") ax_ref2.plot([i for i in range(10)], "r") ax_ref2.add_patch(plt.Circle((3, 3), 1, ec="black", fc="blue")) + ax_ref2.text(10, 10, "Hello World!", size=14) ax_ref2.imshow(im_data, origin="lower", cmap="Blues", alpha=0.5, interpolation="nearest") @@ -65,4 +68,40 @@ def test_auto_zoom_inset(fig_test, fig_ref): axins_ref.add_patch(plt.Circle((3, 3), 1, ec="black", fc="blue")) axins_ref.imshow(im_data, origin="lower", cmap="Blues", alpha=0.5, interpolation="nearest") + ax_ref.indicate_inset_zoom(axins_ref, edgecolor="black") + + +@check_figures_equal(tol=3.5) +def test_plotting_in_view(fig_test, fig_ref): + np.random.seed(1) + im_data = np.random.rand(30, 30) + arrow_s = dict(arrowstyle="->") + + # Test Case... + ax_test = fig_test.gca() + ax_test.imshow(im_data, origin="lower", cmap="Blues", alpha=0.5, + interpolation="nearest") + axins_test = inset_zoom_axes(ax_test, [0.5, 0.5, 0.48, 0.48]) + axins_test.set_linescaling(False) + axins_test.set_xlim(1, 5) + axins_test.set_ylim(1, 5) + axins_test.annotate( + "Interesting", (3, 3), (0, 0), + textcoords="axes fraction", arrowprops=arrow_s + ) + ax_test.indicate_inset_zoom(axins_test, edgecolor="black") + + # Reference + ax_ref = fig_ref.gca() + ax_ref.imshow(im_data, origin="lower", cmap="Blues", alpha=0.5, + interpolation="nearest") + axins_ref = ax_ref.inset_axes([0.5, 0.5, 0.48, 0.48]) + axins_ref.set_xlim(1, 5) + axins_ref.set_ylim(1, 5) + axins_ref.imshow(im_data, origin="lower", cmap="Blues", alpha=0.5, + interpolation="nearest") + axins_ref.annotate( + "Interesting", (3, 3), (0, 0), + textcoords="axes fraction", arrowprops=arrow_s + ) ax_ref.indicate_inset_zoom(axins_ref, edgecolor="black") \ No newline at end of file diff --git a/setup.py b/setup.py index f68860b..b270c10 100644 --- a/setup.py +++ b/setup.py @@ -19,6 +19,7 @@ }, classifiers=[ 'Development Status :: 3 - Alpha', + 'Framework :: Matplotlib', 'License :: OSI Approved :: Python Software Foundation License', 'Programming Language :: Python', 'Programming Language :: Python :: 3',