From 6a2024c07390c3556c9f58e53d8da7e27fee4cd0 Mon Sep 17 00:00:00 2001 From: Gepcel Date: Wed, 10 Dec 2025 12:28:14 +0800 Subject: [PATCH 01/12] Fix references in documentation for clarity Fix two unidenfined references in why.rst. 1. ug_apply_norm is a typo I think. 2. ug_mplrc. I'm not sure what it should be. Only by guess. --- docs/why.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/why.rst b/docs/why.rst index 392a5616d..925bd59a4 100644 --- a/docs/why.rst +++ b/docs/why.rst @@ -205,7 +205,7 @@ Links * For more on colormaps, see :ref:`this page `. * For more on normalizers, - see :ref:`this page `. + see :ref:`this page `. * For more on color cycles, see :ref:`this page `. @@ -882,7 +882,7 @@ Limitation ---------- Matplotlib :obj:`~matplotlib.rcParams` can be changed persistently by placing -`matplotlibrc` :ref:`ug_mplrc` files in the same directory as your python script. +ref:`matplotlibrc ` files in the same directory as your python script. But it can be difficult to design and store your own colormaps and color cycles for future use. It is also difficult to get matplotlib to use custom ``.ttf`` and ``.otf`` font files, which may be desirable when you are working on From b85930d4cb8b0a62a96a603e64d825aabd5656d2 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Wed, 10 Dec 2025 16:20:02 +1000 Subject: [PATCH 02/12] keep apply_norm --- docs/why.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/why.rst b/docs/why.rst index 925bd59a4..ab2f17649 100644 --- a/docs/why.rst +++ b/docs/why.rst @@ -205,7 +205,7 @@ Links * For more on colormaps, see :ref:`this page `. * For more on normalizers, - see :ref:`this page `. + see :ref:`this page `. * For more on color cycles, see :ref:`this page `. @@ -499,7 +499,7 @@ like :func:`~ultraplot.axes.PlotAxes.pcolor` and :func:`~ultraplot.axes.PlotAxes plots. This can be disabled by setting :rcraw:`cmap.discrete` to ``False`` or by passing ``discrete=False`` to :class:`~ultraplot.axes.PlotAxes` commands. * The :class:`~ultraplot.colors.DivergingNorm` normalizer is perfect for data with a - :ref:`natural midpoint ` and offers both "fair" and "unfair" scaling. + :ref:`natural midpoint ` and offers both "fair" and "unfair" scaling. The :class:`~ultraplot.colors.SegmentedNorm` normalizer can generate uneven color gradations useful for :ref:`unusual data distributions `. * The :func:`~ultraplot.axes.PlotAxes.heatmap` command invokes From d221b79220096cba6b73dc44a1395be4c49b7141 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Wed, 10 Dec 2025 19:44:19 +1000 Subject: [PATCH 03/12] Add container class to wrap external axes objects --- ultraplot/axes/__init__.py | 9 +- ultraplot/axes/base.py | 15 +- ultraplot/axes/container.py | 688 ++++++++++++++++++++++++++++++++++++ ultraplot/figure.py | 282 ++++++++++++++- ultraplot/gridspec.py | 5 +- 5 files changed, 993 insertions(+), 6 deletions(-) create mode 100644 ultraplot/axes/container.py diff --git a/ultraplot/axes/__init__.py b/ultraplot/axes/__init__.py index fcd6e7fe1..caed005f8 100644 --- a/ultraplot/axes/__init__.py +++ b/ultraplot/axes/__init__.py @@ -7,8 +7,12 @@ from ..internals import context from .base import Axes # noqa: F401 from .cartesian import CartesianAxes -from .geo import GeoAxes # noqa: F401 -from .geo import _BasemapAxes, _CartopyAxes +from .container import ExternalAxesContainer # noqa: F401 +from .geo import ( + GeoAxes, # noqa: F401 + _BasemapAxes, + _CartopyAxes, +) from .plot import PlotAxes # noqa: F401 from .polar import PolarAxes from .shared import _SharedAxes # noqa: F401 @@ -22,6 +26,7 @@ "PolarAxes", "GeoAxes", "ThreeAxes", + "ExternalAxesContainer", ] # Register projections with package prefix to avoid conflicts diff --git a/ultraplot/axes/base.py b/ultraplot/axes/base.py index a0e30f68b..7a2a1a403 100644 --- a/ultraplot/axes/base.py +++ b/ultraplot/axes/base.py @@ -2662,7 +2662,20 @@ def _range_subplotspec(self, s): if not isinstance(self, maxes.SubplotBase): raise RuntimeError("Axes must be a subplot.") ss = self.get_subplotspec().get_topmost_subplotspec() - row1, row2, col1, col2 = ss._get_rows_columns() + + # Check if this is an ultraplot SubplotSpec with _get_rows_columns method + if not hasattr(ss, "_get_rows_columns"): + # Fall back to standard matplotlib SubplotSpec attributes + # This can happen when axes are created directly without ultraplot's gridspec + if hasattr(ss, "rowspan") and hasattr(ss, "colspan"): + row1, row2 = ss.rowspan.start, ss.rowspan.stop - 1 + col1, col2 = ss.colspan.start, ss.colspan.stop - 1 + else: + # Unable to determine range, return default + row1, row2, col1, col2 = 0, 0, 0, 0 + else: + row1, row2, col1, col2 = ss._get_rows_columns() + if s == "x": return (col1, col2) else: diff --git a/ultraplot/axes/container.py b/ultraplot/axes/container.py new file mode 100644 index 000000000..ddce15ee1 --- /dev/null +++ b/ultraplot/axes/container.py @@ -0,0 +1,688 @@ +#!/usr/bin/env python3 +""" +Container class for external axes (e.g., mpltern, cartopy custom axes). + +This module provides the ExternalAxesContainer class which acts as a wrapper +around external axes classes, allowing them to be used within ultraplot's +figure system while maintaining their native functionality. +""" +import matplotlib.axes as maxes +import matplotlib.transforms as mtransforms +from matplotlib import cbook, container + +from ..internals import _pop_rc, warnings +from .cartesian import CartesianAxes + +__all__ = ["ExternalAxesContainer"] + + +class ExternalAxesContainer(CartesianAxes): + """ + Container axes that wraps an external axes instance. + + This class inherits from ultraplot's CartesianAxes and creates/manages an external + axes as a child. It provides ultraplot's interface while delegating + drawing and interaction to the wrapped external axes. + + Parameters + ---------- + *args + Positional arguments passed to Axes.__init__ + external_axes_class : type + The external axes class to instantiate (e.g., mpltern.TernaryAxes) + external_axes_kwargs : dict, optional + Keyword arguments to pass to the external axes constructor + **kwargs + Keyword arguments passed to Axes.__init__ + """ + + def __init__( + self, *args, external_axes_class=None, external_axes_kwargs=None, **kwargs + ): + """Initialize the container and create the external axes child.""" + # Initialize instance variables + self._syncing_position = False + self._external_axes = None + self._last_external_position = None + self._position_synced = False + self._external_stale = True # Track if external axes needs redrawing + + # Store external axes class and kwargs + self._external_axes_class = external_axes_class + self._external_axes_kwargs = external_axes_kwargs or {} + + # Store shrink factor for external axes (to fit labels) + # Can be customized per-axes or set globally + self._external_shrink_factor = kwargs.pop("external_shrink_factor", 0.85) + + # Store subplot spec for later + self._subplot_spec = kwargs.pop("_subplot_spec", None) + + # Pop the projection kwarg if it exists (matplotlib will add it) + # We don't want to pass it to parent since we're using cartesian for container + kwargs.pop("projection", None) + + # Pop format kwargs before passing to parent + rc_kw, rc_mode = _pop_rc(kwargs) + format_kwargs = {} + + # Extract common format parameters + format_params = [ + "title", + "ltitle", + "ctitle", + "rtitle", + "ultitle", + "uctitle", + "urtitle", + "lltitle", + "lctitle", + "lrtitle", + "abc", + "abcloc", + "abcstyle", + "abcformat", + "xlabel", + "ylabel", + "xlim", + "ylim", + "aspect", + "grid", + "gridminor", + ] + for param in format_params: + if param in kwargs: + format_kwargs[param] = kwargs.pop(param) + + # Initialize parent ultraplot Axes + # Don't set projection here - the class itself is already the right projection + # and matplotlib has already resolved it before instantiation + if self._subplot_spec is not None: + kwargs["_subplot_spec"] = self._subplot_spec + + # Disable autoshare for external axes containers since they manage + # external axes that don't participate in ultraplot's sharing system + kwargs.setdefault("autoshare", False) + + super().__init__(*args, **kwargs) + + # Make the container axes invisible (it's just a holder) + # But keep it functional for layout purposes + self.patch.set_visible(False) + self.patch.set_facecolor("none") + for spine in self.spines.values(): + spine.set_visible(False) + self.xaxis.set_visible(False) + self.yaxis.set_visible(False) + + # Hide axis labels explicitly + self.set_xlabel("") + self.set_ylabel("") + self.xaxis.label.set_visible(False) + self.yaxis.label.set_visible(False) + + # Hide tick labels + self.tick_params( + axis="both", + which="both", + labelbottom=False, + labeltop=False, + labelleft=False, + labelright=False, + bottom=False, + top=False, + left=False, + right=False, + ) + + # Ensure container participates in layout + self.set_frame_on(False) + + # Create the external axes as a child + if external_axes_class is not None: + self._create_external_axes() + + # Debug: verify external axes was created + if self._external_axes is None: + warnings._warn_ultraplot( + f"Failed to create external axes of type {external_axes_class.__name__}" + ) + + # Apply any format kwargs + if format_kwargs: + self.format(**format_kwargs) + + def _create_external_axes(self): + """Create the external axes instance as a child of this container.""" + if self._external_axes_class is None: + return + + # Get the figure + fig = self.get_figure() + if fig is None: + warnings._warn_ultraplot("Cannot create external axes without a figure") + return + + # Prepare kwargs for external axes + external_kwargs = self._external_axes_kwargs.copy() + + # Get projection name + projection_name = external_kwargs.pop("projection", None) + + # Get the subplot spec from the container + subplotspec = self.get_subplotspec() + + # Direct instantiation of the external axes class + try: + # Most external axes expect (fig, *args, projection=name, **kwargs) + # or use SubplotBase initialization with subplotspec + if subplotspec is not None: + # Try with subplotspec (standard matplotlib way) + try: + # Don't pass projection= since the class is already the right projection + self._external_axes = self._external_axes_class( + fig, subplotspec, **external_kwargs + ) + except TypeError as e: + # Some axes might not accept subplotspec this way + # Try with rect instead + rect = self.get_position() + # Don't pass projection= since the class is already the right projection + self._external_axes = self._external_axes_class( + fig, + [rect.x0, rect.y0, rect.width, rect.height], + **external_kwargs, + ) + else: + # No subplotspec, use position rect + rect = self.get_position() + # Don't pass projection= since the class is already the right projection + self._external_axes = self._external_axes_class( + fig, + [rect.x0, rect.y0, rect.width, rect.height], + **external_kwargs, + ) + + # Note: Most axes classes automatically register themselves with the figure + # during __init__. We need to REMOVE them from fig.axes so that ultraplot + # doesn't try to call ultraplot-specific methods on them. + # The container will handle all the rendering. + if self._external_axes in fig.axes: + fig.axes.remove(self._external_axes) + + # Ensure external axes is visible and has higher zorder than container + if hasattr(self._external_axes, "set_visible"): + self._external_axes.set_visible(True) + if hasattr(self._external_axes, "set_zorder"): + # Set higher zorder so external axes draws on top of container + container_zorder = self.get_zorder() + self._external_axes.set_zorder(container_zorder + 1) + if hasattr(self._external_axes.patch, "set_visible"): + self._external_axes.patch.set_visible(True) + + # Ensure the external axes patch has white background by default + if hasattr(self._external_axes.patch, "set_facecolor"): + self._external_axes.patch.set_facecolor("white") + + # Ensure all spines are visible + if hasattr(self._external_axes, "spines"): + for spine in self._external_axes.spines.values(): + if hasattr(spine, "set_visible"): + spine.set_visible(True) + + # Ensure axes frame is on + if hasattr(self._external_axes, "set_frame_on"): + self._external_axes.set_frame_on(True) + + # Set subplotspec on the external axes if it has the method + if subplotspec is not None and hasattr( + self._external_axes, "set_subplotspec" + ): + self._external_axes.set_subplotspec(subplotspec) + + # Shrink external axes slightly to leave room for labels + # This prevents labels from being cut off at figure edges + self._shrink_external_for_labels() + + # Set up position synchronization + self._sync_position_to_external() + + # Mark external axes as stale (needs drawing) + self._external_stale = True + + # Add external axes to the container's child artists + # This ensures matplotlib will iterate over it during rendering + if hasattr(self, "add_child_axes"): + self.add_child_axes(self._external_axes) + elif hasattr(self, "_children"): + if self._external_axes not in self._children: + self._children.append(self._external_axes) + + except Exception as e: + warnings._warn_ultraplot( + f"Failed to create external axes {self._external_axes_class.__name__}: {e}" + ) + self._external_axes = None + + def _shrink_external_for_labels(self): + """ + Shrink the external axes to leave room for labels that extend beyond the plot area. + + This is particularly important for ternary plots where axis labels can extend + significantly beyond the triangular plot region. + """ + if self._external_axes is None: + return + + # Get the current position + pos = self._external_axes.get_position() + + # Shrink by a small margin to ensure labels fit + # For ternary axes, labels typically need about 10-15% padding on each side + # Use the configured shrink factor + shrink_factor = getattr(self, "_external_shrink_factor", 0.85) + + # Calculate the center + center_x = pos.x0 + pos.width / 2 + center_y = pos.y0 + pos.height / 2 + + # Calculate new dimensions + new_width = pos.width * shrink_factor + new_height = pos.height * shrink_factor + + # Calculate new position (centered) + new_x0 = center_x - new_width / 2 + new_y0 = center_y - new_height / 2 + + # Set the new position + from matplotlib.transforms import Bbox + + new_pos = Bbox.from_bounds(new_x0, new_y0, new_width, new_height) + + if hasattr(self._external_axes, "set_position"): + self._external_axes.set_position(new_pos) + + # Also adjust aspect if the external axes has aspect control + # This helps ternary axes maintain their triangular shape + if hasattr(self._external_axes, "set_aspect"): + try: + self._external_axes.set_aspect("equal", adjustable="box") + except Exception: + pass # Some axes types don't support aspect adjustment + + def _sync_position_to_external(self): + """Synchronize the container position to the external axes.""" + if self._external_axes is None: + return + + # Copy position from container to external axes + pos = self.get_position() + if hasattr(self._external_axes, "set_position"): + self._external_axes.set_position(pos) + + def set_position(self, pos, which="both"): + """Override to sync position changes to external axes.""" + super().set_position(pos, which=which) + # Only sync to external if not already syncing from external + if not getattr(self, "_syncing_position", False): + self._sync_position_to_external() + # Invalidate position cache when manually setting position + self._last_external_position = None + self._position_synced = False + self._external_stale = True # Position change may affect drawing + + def _iter_axes(self, hidden=True, children=True, panels=True): + """ + Override to only yield the container itself, not the external axes. + + The external axes is a rendering child, not a logical ultraplot child, + so we don't want ultraplot's iteration to find it and call ultraplot + methods on it. + """ + # Only yield self (the container), never the external axes + yield self + + # Plotting method delegation + # Override common plotting methods to delegate to external axes + def plot(self, *args, **kwargs): + """Delegate plot to external axes.""" + if self._external_axes is not None: + self._external_stale = True # Mark for redraw + return self._external_axes.plot(*args, **kwargs) + return super().plot(*args, **kwargs) + + def scatter(self, *args, **kwargs): + """Delegate scatter to external axes.""" + if self._external_axes is not None: + self._external_stale = True # Mark for redraw + return self._external_axes.scatter(*args, **kwargs) + return super().scatter(*args, **kwargs) + + def fill(self, *args, **kwargs): + """Delegate fill to external axes.""" + if self._external_axes is not None: + self._external_stale = True # Mark for redraw + return self._external_axes.fill(*args, **kwargs) + return super().fill(*args, **kwargs) + + def contour(self, *args, **kwargs): + """Delegate contour to external axes.""" + if self._external_axes is not None: + self._external_stale = True # Mark for redraw + return self._external_axes.contour(*args, **kwargs) + return super().contour(*args, **kwargs) + + def contourf(self, *args, **kwargs): + """Delegate contourf to external axes.""" + if self._external_axes is not None: + self._external_stale = True # Mark for redraw + return self._external_axes.contourf(*args, **kwargs) + return super().contourf(*args, **kwargs) + + def pcolormesh(self, *args, **kwargs): + """Delegate pcolormesh to external axes.""" + if self._external_axes is not None: + self._external_stale = True # Mark for redraw + return self._external_axes.pcolormesh(*args, **kwargs) + return super().pcolormesh(*args, **kwargs) + + def imshow(self, *args, **kwargs): + """Delegate imshow to external axes.""" + if self._external_axes is not None: + self._external_stale = True # Mark for redraw + return self._external_axes.imshow(*args, **kwargs) + return super().imshow(*args, **kwargs) + + def hexbin(self, *args, **kwargs): + """Delegate hexbin to external axes.""" + if self._external_axes is not None: + self._external_stale = True # Mark for redraw + return self._external_axes.hexbin(*args, **kwargs) + return super().hexbin(*args, **kwargs) + + def get_external_axes(self): + """ + Get the wrapped external axes instance. + + Returns + ------- + axes + The external axes instance, or None if not created + """ + return self._external_axes + + def has_external_child(self): + """ + Check if this container has an external axes child. + + Returns + ------- + bool + True if an external axes instance exists, False otherwise + """ + return self._external_axes is not None + + def get_external_child(self): + """ + Get the external axes child (alias for get_external_axes). + + Returns + ------- + axes + The external axes instance, or None if not created + """ + return self.get_external_axes() + + def clear(self): + """Clear the container and mark external axes as stale.""" + # Mark external axes as stale before clearing + self._external_stale = True + # Clear the container + super().clear() + # If we have external axes, clear it too + if self._external_axes is not None: + self._external_axes.clear() + + def format(self, **kwargs): + """ + Format the container and delegate to external axes where appropriate. + + This method handles ultraplot-specific formatting on the container + and attempts to delegate common parameters to the external axes. + + Parameters + ---------- + **kwargs + Formatting parameters. Common matplotlib parameters (title, xlabel, + ylabel, xlim, ylim) are delegated to the external axes if supported. + """ + # Separate kwargs into container and external + external_kwargs = {} + container_kwargs = {} + + # Parameters that can be delegated to external axes + delegatable = ["title", "xlabel", "ylabel", "xlim", "ylim"] + + for key, value in kwargs.items(): + if key in delegatable and self._external_axes is not None: + # Check if external axes has the method + method_name = f"set_{key}" + if hasattr(self._external_axes, method_name): + external_kwargs[key] = value + else: + container_kwargs[key] = value + else: + container_kwargs[key] = value + + # Apply container formatting (for ultraplot-specific features) + if container_kwargs: + super().format(**container_kwargs) + + # Apply external axes formatting + if external_kwargs and self._external_axes is not None: + self._external_axes.set(**external_kwargs) + + def draw(self, renderer): + """Override draw to render container (with abc/titles) and external axes.""" + # Draw external axes first - it may adjust its own position for labels + if self._external_axes is not None: + # Check if external axes is stale (needs redrawing) + # This avoids redundant draws on external axes that haven't changed + external_stale = getattr(self._external_axes, "stale", True) + + # Only draw if external axes is stale or we haven't synced positions yet + if external_stale or not self._position_synced or self._external_stale: + self._external_axes.draw(renderer) + self._external_stale = False + + # Ensure external axes stays within container bounds + # Check if tight bbox extends beyond container + if hasattr(self._external_axes, "get_tightbbox"): + try: + tight_bbox = self._external_axes.get_tightbbox(renderer) + container_bbox = self.get_position().transformed( + self.figure.transFigure + ) + + # If tight bbox extends beyond container, we may need to shrink further + # This is a fallback in case initial shrinking wasn't enough + if tight_bbox is not None: + # Get bboxes in figure coordinates + tight_fig = tight_bbox.transformed( + self.figure.transFigure.inverted() + ) + + # Check if we're clipping + if ( + tight_fig.x0 < container_bbox.x0 - 0.01 + or tight_fig.x1 > container_bbox.x1 + 0.01 + or tight_fig.y0 < container_bbox.y0 - 0.01 + or tight_fig.y1 > container_bbox.y1 + 0.01 + ): + # Need more aggressive shrinking + current_pos = self._external_axes.get_position() + extra_shrink = 0.9 # Additional 10% shrink + + center_x = current_pos.x0 + current_pos.width / 2 + center_y = current_pos.y0 + current_pos.height / 2 + new_width = current_pos.width * extra_shrink + new_height = current_pos.height * extra_shrink + new_x0 = center_x - new_width / 2 + new_y0 = center_y - new_height / 2 + + from matplotlib.transforms import Bbox + + new_pos = Bbox.from_bounds( + new_x0, new_y0, new_width, new_height + ) + self._external_axes.set_position(new_pos) + + # Mark as stale to redraw with new position + self._external_stale = True + except Exception: + # If tight bbox calculation fails, just continue + pass + + # After external axes draws, sync container to match its position + # This ensures abc labels and titles are positioned correctly + # Only sync if positions actually changed (performance optimization) + ext_pos = self._external_axes.get_position() + + # Quick check if position changed since last draw + position_changed = False + if self._last_external_position is None: + position_changed = True + else: + last_pos = self._last_external_position + # Use a slightly larger tolerance to avoid excessive sync calls + if ( + abs(ext_pos.x0 - last_pos.x0) > 0.001 + or abs(ext_pos.y0 - last_pos.y0) > 0.001 + or abs(ext_pos.width - last_pos.width) > 0.001 + or abs(ext_pos.height - last_pos.height) > 0.001 + ): + position_changed = True + + # Only update if position actually changed + if position_changed: + container_pos = self.get_position() + + # Check if container needs updating + if ( + abs(container_pos.x0 - ext_pos.x0) > 0.001 + or abs(container_pos.y0 - ext_pos.y0) > 0.001 + or abs(container_pos.width - ext_pos.width) > 0.001 + or abs(container_pos.height - ext_pos.height) > 0.001 + ): + # Temporarily disable position sync to avoid recursion + self._syncing_position = True + self.set_position(ext_pos) + self._syncing_position = False + + # Cache the current external position + self._last_external_position = ext_pos + self._position_synced = True + + # Draw the container (with abc labels, titles, etc.) + super().draw(renderer) + + def stale_callback(self, *args, **kwargs): + """Mark external axes as stale when container is marked stale.""" + # When container is marked stale, mark external axes as stale too + if self._external_axes is not None: + self._external_stale = True + # Call parent stale callback if it exists + if hasattr(super(), "stale_callback"): + super().stale_callback(*args, **kwargs) + + def get_tightbbox(self, renderer, *args, **kwargs): + """Override to return the external axes tight bbox.""" + if self._external_axes is not None and hasattr( + self._external_axes, "get_tightbbox" + ): + return self._external_axes.get_tightbbox(renderer, *args, **kwargs) + return super().get_tightbbox(renderer, *args, **kwargs) + + def __getattr__(self, name): + """ + Delegate attribute access to the external axes when not found on container. + + This allows the container to act as a transparent wrapper, forwarding + plotting methods and other attributes to the external axes. + """ + # Avoid infinite recursion for private attributes + # But allow parent class lookups during initialization + if name.startswith("_"): + # During initialization, let parent class handle private attributes + # This prevents interfering with parent class setup + raise AttributeError( + f"'{type(self).__name__}' object has no attribute '{name}'" + ) + + # Try to get from external axes if it exists + if hasattr(self, "_external_axes") and self._external_axes is not None: + try: + return getattr(self._external_axes, name) + except AttributeError: + pass + + # Not found anywhere + raise AttributeError( + f"'{type(self).__name__}' object has no attribute '{name}'" + ) + + def __dir__(self): + """Include external axes attributes in dir() output.""" + attrs = set(super().__dir__()) + if self._external_axes is not None: + attrs.update(dir(self._external_axes)) + return sorted(attrs) + + +def create_external_axes_container(external_axes_class, projection_name=None): + """ + Factory function to create a container class for a specific external axes type. + + Parameters + ---------- + external_axes_class : type + The external axes class to wrap + projection_name : str, optional + The projection name to register with matplotlib + + Returns + ------- + type + A subclass of ExternalAxesContainer configured for the external axes class + """ + + class SpecificContainer(ExternalAxesContainer): + """Container for {external_axes_class.__name__}""" + + def __init__(self, *args, **kwargs): + # Pop external_axes_class and external_axes_kwargs if passed in kwargs + # (they're passed from Figure._add_subplot) + ext_class = kwargs.pop("external_axes_class", None) + ext_kwargs = kwargs.pop("external_axes_kwargs", None) + + # Pop projection - it's already been handled and shouldn't be passed to parent + kwargs.pop("projection", None) + + # Use the provided class or fall back to the factory default + if ext_class is None: + ext_class = external_axes_class + if ext_kwargs is None: + ext_kwargs = {} + + # Inject the external axes class + kwargs["external_axes_class"] = ext_class + kwargs["external_axes_kwargs"] = ext_kwargs + super().__init__(*args, **kwargs) + + # Set proper name and module + SpecificContainer.__name__ = f"{external_axes_class.__name__}Container" + SpecificContainer.__qualname__ = f"{external_axes_class.__name__}Container" + if projection_name: + SpecificContainer.name = projection_name + + return SpecificContainer diff --git a/ultraplot/figure.py b/ultraplot/figure.py index 6b5b46c48..f729fb4b4 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -499,6 +499,241 @@ def wrapper(self, *args, **kwargs): return wrapper +class _ExternalAxesMixin: + """ + Mixin class that provides ultraplot-compatible interface methods for external axes. + + This allows external axes classes (like mpltern.TernaryAxes) to be used + seamlessly within ultraplot figures by providing stub implementations of + ultraplot-specific methods. + """ + + def __init__(self, *args, **kwargs): + """ + Filter ultraplot-specific kwargs before passing to external axes. + """ + # Pop ultraplot-specific kwargs that external axes don't understand + _subplot_spec = kwargs.pop("_subplot_spec", None) + self._number = None + number = kwargs.pop("number", None) + autoshare = kwargs.pop("autoshare", None) + + # Pop format-related kwargs + rc_kw, rc_mode = _pop_rc(kwargs) + + # Pop any other ultraplot-specific format parameters + ultraplot_keys = [ + "abc", + "abcloc", + "abcstyle", + "abcformat", + "abcborder", + "abcbbox", + "title", + "ltitle", + "ctitle", + "rtitle", + "ultitle", + "uctitle", + "urtitle", + "lltitle", + "lctitle", + "lrtitle", + "titleloc", + "titlepad", + "titleabove", + "toplabels", + "leftlabels", + "rightlabels", + "bottomlabels", + ] + for key in ultraplot_keys: + kwargs.pop(key, None) + + # Call the parent external axes class + super().__init__(*args, **kwargs) + + # Initialize ultraplot-specific attributes that might be accessed + self._panel_hidden = False + self._panel_parent = None + self._panel_side = None + self._inset_parent = None + self._inset_zoom = False + + # Set the subplotspec if provided (after initialization to avoid passing it through) + if _subplot_spec is not None: + self.set_subplotspec(_subplot_spec) + + # Set number if provided (as a simple attribute) + if number is not None: + self.number = number + + def _reposition_subplot(self): + """ + Stub for ultraplot's subplot repositioning. External axes handle their + own positioning through matplotlib's standard mechanisms. + """ + # For external axes, we rely on matplotlib's standard SubplotBase positioning + if hasattr(super(), "_reposition_subplot"): + super()._reposition_subplot() + # Otherwise do nothing - matplotlib will handle it + + def _apply_auto_share(self): + """ + Stub for ultraplot's auto-sharing feature. External axes don't participate + in ultraplot's axis sharing system. + """ + # External axes don't support ultraplot's auto-share mechanism + pass + + def _iter_axes(self, hidden=True, children=True, panels=True): + """ + Stub for ultraplot's axes iteration. External axes are leaf nodes. + """ + # External axes don't have child axes like panels or insets in ultraplot's system + # So we just yield ourselves + yield self + + def _get_topmost_axes(self): + """ + Stub for ultraplot's topmost axes getter. External axes don't have parents. + """ + # External axes are always topmost (no panel parents) + return self + + def _add_queued_guides(self): + """ + Stub for ultraplot's queued guides system. External axes don't use this. + """ + # External axes don't support ultraplot's guide queuing mechanism + pass + + def _apply_title_above(self): + """ + Stub for ultraplot's title positioning. External axes use standard matplotlib. + """ + pass + + def _update_title_position(self, renderer=None): + """ + Stub for ultraplot's title position updates. External axes handle this via matplotlib. + """ + # Call parent's implementation if it exists (matplotlib's version) + if hasattr(super(), "_update_title_position"): + super()._update_title_position(renderer) + + def _update_abc(self): + """ + Stub for ultraplot's a-b-c label updates. External axes don't support this. + """ + pass + + def _label_key(self, name): + """ + Stub for ultraplot's label key system. External axes use standard matplotlib. + """ + # Return a simple key based on the axes position + return (name, id(self)) + + def _is_panel_group_member(self): + """ + Stub for ultraplot's panel group detection. External axes are not panels. + """ + return False + + def _update_share_labels(self, target, value=None): + """ + Stub for ultraplot's share label system. External axes don't participate. + """ + pass + + def _range_subplotspec(self, x): + """ + Stub for ultraplot's subplotspec range calculation. External axes use standard layout. + """ + # Return a simple range based on the subplotspec + ss = self.get_subplotspec() + if ss is None: + return (0, 1) + # Get the gridspec geometry and position + nrows, ncols, start, stop = ss.get_geometry() + # Convert 1D indices to row/col + if x: # horizontal + col_start = start % ncols + col_stop = stop % ncols + return (col_start, col_stop + 1) + else: # vertical + row_start = start // ncols + row_stop = stop // ncols + return (row_start, row_stop + 1) + + def _range_tightbbox(self, x, renderer): + """ + Stub for ultraplot's tight bbox range calculation. External axes use standard matplotlib. + """ + # Use standard matplotlib tight bbox + bbox = self.get_tightbbox(renderer) + if bbox is None: + return (0, 1) + if x: # horizontal + return (bbox.x0, bbox.x1) + else: # vertical + return (bbox.y0, bbox.y1) + + def format(self, *args, **kwargs): + """ + Stub for ultraplot's format method. External axes don't support full formatting. + + This is a no-op for external axes since they have their own formatting methods. + We silently ignore ultraplot-specific formatting calls to allow Figure.format() + to work with external axes. + """ + # Silently ignore formatting calls for external axes + # They can use their native formatting methods instead + pass + + +# Cache for wrapped external axes classes +_external_axes_cache = {} + + +def _wrap_external_axes(projection_class): + """ + Create a wrapper class for external axes that makes them ultraplot-compatible. + + Parameters + ---------- + projection_class : type + The external axes class to wrap (e.g., mpltern.TernaryAxes) + + Returns + ------- + type + A new class that inherits from both _ExternalAxesMixin and the projection_class + """ + # Check cache first + if projection_class in _external_axes_cache: + return _external_axes_cache[projection_class] + + # Create wrapper class with multiple inheritance + # Order matters: Mixin first so it can filter kwargs before external axes sees them + class_name = f"_UltraplotWrapped{projection_class.__name__}" + wrapper_class = type( + class_name, + (_ExternalAxesMixin, projection_class), + { + "__module__": projection_class.__module__, + "__doc__": f"Ultraplot-wrapped {projection_class.__name__}", + "name": class_name, # Required by matplotlib's projection registry + }, + ) + + # Cache it + _external_axes_cache[projection_class] = wrapper_class + + return wrapper_class + + class Figure(mfigure.Figure): """ The `~matplotlib.figure.Figure` subclass used by ultraplot. @@ -1097,7 +1332,12 @@ def _parse_proj( except (KeyError, ValueError): pass else: + name = "ultraplot_" + proj + if name is None and isinstance(proj, str): + # Check if the name is registered globally in Matplotlib (e.g., 'ternary', 'polar', '3d') + if proj in mproj.get_projection_names(): name = proj + # If not found anywhere, leave name as None so geographic projections can be tried # Helpful error message if ( name is None @@ -1116,10 +1356,10 @@ def _parse_proj( # NOTE: Also raises errors due to unexpected projection type if name is None: proj = constructor.Proj(proj, backend=backend, include_axes=True, **proj_kw) - name = proj._proj_backend + name = "ultraplot_" + proj._proj_backend kwargs["map_projection"] = proj - kwargs["projection"] = "ultraplot_" + name + kwargs["projection"] = name return kwargs def _get_align_axes(self, side): @@ -1617,6 +1857,44 @@ def _add_subplot(self, *args, **kwargs): kwargs.setdefault("number", 1 + max(self._subplot_dict, default=0)) kwargs.pop("refwidth", None) # TODO: remove this + # Use container approach for external projections to make them ultraplot-compatible + projection_name = kwargs.get("projection") + external_axes_class = None + external_axes_kwargs = {} + + if projection_name and isinstance(projection_name, str): + # Check if this is an external (non-ultraplot) projection + if not projection_name.startswith("ultraplot_"): + try: + # Get the projection class + proj_class = mproj.get_projection_class(projection_name) + + # Check if it's not a built-in ultraplot axes + if not issubclass(proj_class, paxes.Axes): + # Store the external axes class and original projection name + external_axes_class = proj_class + external_axes_kwargs["projection"] = projection_name + + # Create or get the container class for this external axes type + from .axes.container import create_external_axes_container + + container_name = f"_ultraplot_container_{projection_name}" + + # Check if container is already registered + if container_name not in mproj.get_projection_names(): + container_class = create_external_axes_container( + proj_class, projection_name=container_name + ) + mproj.register_projection(container_class) + + # Use the container projection and pass external axes info + kwargs["projection"] = container_name + kwargs["external_axes_class"] = external_axes_class + kwargs["external_axes_kwargs"] = external_axes_kwargs + except (KeyError, ValueError): + # Projection not found, let matplotlib handle the error + pass + ax = super().add_subplot(ss, _subplot_spec=ss, **kwargs) # Allow sharing for GeoAxes if rectilinear if self._sharex or self._sharey: diff --git a/ultraplot/gridspec.py b/ultraplot/gridspec.py index 59de0f04c..25e10054e 100644 --- a/ultraplot/gridspec.py +++ b/ultraplot/gridspec.py @@ -1694,7 +1694,10 @@ def _validate_item(self, items, scalar=False): if self: gridspec = self.gridspec # compare against existing gridspec for item in items.flat: - if not isinstance(item, paxes.Axes): + # Accept both ultraplot axes and wrapped external axes + from .figure import _ExternalAxesMixin + + if not isinstance(item, (paxes.Axes, _ExternalAxesMixin)): raise ValueError(message.format(f"the object {item!r}")) item = item._get_topmost_axes() if not isinstance(item, maxes.SubplotBase): From 19b6691eff14a80cdec132c7b02fecb752590672 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 11 Dec 2025 15:43:10 +1000 Subject: [PATCH 04/12] remove v1 --- ultraplot/figure.py | 235 ------------------------------------------ ultraplot/gridspec.py | 19 ++-- 2 files changed, 10 insertions(+), 244 deletions(-) diff --git a/ultraplot/figure.py b/ultraplot/figure.py index f729fb4b4..89bab3f3c 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -499,241 +499,6 @@ def wrapper(self, *args, **kwargs): return wrapper -class _ExternalAxesMixin: - """ - Mixin class that provides ultraplot-compatible interface methods for external axes. - - This allows external axes classes (like mpltern.TernaryAxes) to be used - seamlessly within ultraplot figures by providing stub implementations of - ultraplot-specific methods. - """ - - def __init__(self, *args, **kwargs): - """ - Filter ultraplot-specific kwargs before passing to external axes. - """ - # Pop ultraplot-specific kwargs that external axes don't understand - _subplot_spec = kwargs.pop("_subplot_spec", None) - self._number = None - number = kwargs.pop("number", None) - autoshare = kwargs.pop("autoshare", None) - - # Pop format-related kwargs - rc_kw, rc_mode = _pop_rc(kwargs) - - # Pop any other ultraplot-specific format parameters - ultraplot_keys = [ - "abc", - "abcloc", - "abcstyle", - "abcformat", - "abcborder", - "abcbbox", - "title", - "ltitle", - "ctitle", - "rtitle", - "ultitle", - "uctitle", - "urtitle", - "lltitle", - "lctitle", - "lrtitle", - "titleloc", - "titlepad", - "titleabove", - "toplabels", - "leftlabels", - "rightlabels", - "bottomlabels", - ] - for key in ultraplot_keys: - kwargs.pop(key, None) - - # Call the parent external axes class - super().__init__(*args, **kwargs) - - # Initialize ultraplot-specific attributes that might be accessed - self._panel_hidden = False - self._panel_parent = None - self._panel_side = None - self._inset_parent = None - self._inset_zoom = False - - # Set the subplotspec if provided (after initialization to avoid passing it through) - if _subplot_spec is not None: - self.set_subplotspec(_subplot_spec) - - # Set number if provided (as a simple attribute) - if number is not None: - self.number = number - - def _reposition_subplot(self): - """ - Stub for ultraplot's subplot repositioning. External axes handle their - own positioning through matplotlib's standard mechanisms. - """ - # For external axes, we rely on matplotlib's standard SubplotBase positioning - if hasattr(super(), "_reposition_subplot"): - super()._reposition_subplot() - # Otherwise do nothing - matplotlib will handle it - - def _apply_auto_share(self): - """ - Stub for ultraplot's auto-sharing feature. External axes don't participate - in ultraplot's axis sharing system. - """ - # External axes don't support ultraplot's auto-share mechanism - pass - - def _iter_axes(self, hidden=True, children=True, panels=True): - """ - Stub for ultraplot's axes iteration. External axes are leaf nodes. - """ - # External axes don't have child axes like panels or insets in ultraplot's system - # So we just yield ourselves - yield self - - def _get_topmost_axes(self): - """ - Stub for ultraplot's topmost axes getter. External axes don't have parents. - """ - # External axes are always topmost (no panel parents) - return self - - def _add_queued_guides(self): - """ - Stub for ultraplot's queued guides system. External axes don't use this. - """ - # External axes don't support ultraplot's guide queuing mechanism - pass - - def _apply_title_above(self): - """ - Stub for ultraplot's title positioning. External axes use standard matplotlib. - """ - pass - - def _update_title_position(self, renderer=None): - """ - Stub for ultraplot's title position updates. External axes handle this via matplotlib. - """ - # Call parent's implementation if it exists (matplotlib's version) - if hasattr(super(), "_update_title_position"): - super()._update_title_position(renderer) - - def _update_abc(self): - """ - Stub for ultraplot's a-b-c label updates. External axes don't support this. - """ - pass - - def _label_key(self, name): - """ - Stub for ultraplot's label key system. External axes use standard matplotlib. - """ - # Return a simple key based on the axes position - return (name, id(self)) - - def _is_panel_group_member(self): - """ - Stub for ultraplot's panel group detection. External axes are not panels. - """ - return False - - def _update_share_labels(self, target, value=None): - """ - Stub for ultraplot's share label system. External axes don't participate. - """ - pass - - def _range_subplotspec(self, x): - """ - Stub for ultraplot's subplotspec range calculation. External axes use standard layout. - """ - # Return a simple range based on the subplotspec - ss = self.get_subplotspec() - if ss is None: - return (0, 1) - # Get the gridspec geometry and position - nrows, ncols, start, stop = ss.get_geometry() - # Convert 1D indices to row/col - if x: # horizontal - col_start = start % ncols - col_stop = stop % ncols - return (col_start, col_stop + 1) - else: # vertical - row_start = start // ncols - row_stop = stop // ncols - return (row_start, row_stop + 1) - - def _range_tightbbox(self, x, renderer): - """ - Stub for ultraplot's tight bbox range calculation. External axes use standard matplotlib. - """ - # Use standard matplotlib tight bbox - bbox = self.get_tightbbox(renderer) - if bbox is None: - return (0, 1) - if x: # horizontal - return (bbox.x0, bbox.x1) - else: # vertical - return (bbox.y0, bbox.y1) - - def format(self, *args, **kwargs): - """ - Stub for ultraplot's format method. External axes don't support full formatting. - - This is a no-op for external axes since they have their own formatting methods. - We silently ignore ultraplot-specific formatting calls to allow Figure.format() - to work with external axes. - """ - # Silently ignore formatting calls for external axes - # They can use their native formatting methods instead - pass - - -# Cache for wrapped external axes classes -_external_axes_cache = {} - - -def _wrap_external_axes(projection_class): - """ - Create a wrapper class for external axes that makes them ultraplot-compatible. - - Parameters - ---------- - projection_class : type - The external axes class to wrap (e.g., mpltern.TernaryAxes) - - Returns - ------- - type - A new class that inherits from both _ExternalAxesMixin and the projection_class - """ - # Check cache first - if projection_class in _external_axes_cache: - return _external_axes_cache[projection_class] - - # Create wrapper class with multiple inheritance - # Order matters: Mixin first so it can filter kwargs before external axes sees them - class_name = f"_UltraplotWrapped{projection_class.__name__}" - wrapper_class = type( - class_name, - (_ExternalAxesMixin, projection_class), - { - "__module__": projection_class.__module__, - "__doc__": f"Ultraplot-wrapped {projection_class.__name__}", - "name": class_name, # Required by matplotlib's projection registry - }, - ) - - # Cache it - _external_axes_cache[projection_class] = wrapper_class - - return wrapper_class - - class Figure(mfigure.Figure): """ The `~matplotlib.figure.Figure` subclass used by ultraplot. diff --git a/ultraplot/gridspec.py b/ultraplot/gridspec.py index 25e10054e..3f7ce84ba 100644 --- a/ultraplot/gridspec.py +++ b/ultraplot/gridspec.py @@ -6,21 +6,24 @@ import itertools import re from collections.abc import MutableSequence +from functools import wraps from numbers import Integral +from typing import List, Optional, Tuple, Union import matplotlib.axes as maxes import matplotlib.gridspec as mgridspec import matplotlib.transforms as mtransforms import numpy as np -from typing import List, Optional, Union, Tuple -from functools import wraps from . import axes as paxes from .config import rc -from .internals import ic # noqa: F401 -from .internals import _not_none, docstring, warnings +from .internals import ( + _not_none, + docstring, + ic, # noqa: F401 + warnings, +) from .utils import _fontsize_to_pt, units -from .internals import warnings __all__ = ["GridSpec", "SubplotGrid"] @@ -1694,10 +1697,8 @@ def _validate_item(self, items, scalar=False): if self: gridspec = self.gridspec # compare against existing gridspec for item in items.flat: - # Accept both ultraplot axes and wrapped external axes - from .figure import _ExternalAxesMixin - - if not isinstance(item, (paxes.Axes, _ExternalAxesMixin)): + # Accept ultraplot axes (including ExternalAxesContainer which inherits from paxes.Axes) + if not isinstance(item, paxes.Axes): raise ValueError(message.format(f"the object {item!r}")) item = item._get_topmost_axes() if not isinstance(item, maxes.SubplotBase): From 2e66139ec988b55830bc74324bc2f380232d905f Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 11 Dec 2025 15:59:13 +1000 Subject: [PATCH 05/12] fix test_geographic_multiple_projections --- ultraplot/figure.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/ultraplot/figure.py b/ultraplot/figure.py index 89bab3f3c..16c3c5f15 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -1100,7 +1100,14 @@ def _parse_proj( name = "ultraplot_" + proj if name is None and isinstance(proj, str): # Check if the name is registered globally in Matplotlib (e.g., 'ternary', 'polar', '3d') - if proj in mproj.get_projection_names(): + # But ONLY if we're not going to try geographic projections next + # (i.e., only if backend is specified or constructor doesn't support geographic) + should_try_geographic = ( + backend is not None + or constructor.Projection is not object + or constructor.Basemap is not object + ) + if not should_try_geographic and proj in mproj.get_projection_names(): name = proj # If not found anywhere, leave name as None so geographic projections can be tried # Helpful error message From b3aeb4378affa27ebcec2e42ceb42b6c2bb83c0a Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 11 Dec 2025 21:22:40 +1000 Subject: [PATCH 06/12] fixes --- .gitignore | 3 ++ pyproject.toml | 7 ++++ ultraplot/axes/base.py | 13 +++++- ultraplot/axes/container.py | 34 +++++++++++++--- ultraplot/figure.py | 81 ++++++++++++++++++++++++++++--------- 5 files changed, 113 insertions(+), 25 deletions(-) diff --git a/.gitignore b/.gitignore index bbd6bf100..8c348b588 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ docs/_build docs/_static/ultraplotrc docs/_static/rctable.rst docs/_static/* +*.html # Development subfolders dev @@ -33,6 +34,8 @@ sources *.pyc .*.pyc __pycache__ +*.ipynb + # OS files .DS_Store diff --git a/pyproject.toml b/pyproject.toml index 2e0aee22b..9872f5853 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,3 +50,10 @@ include-package-data = true [tool.setuptools_scm] write_to = "ultraplot/_version.py" write_to_template = "__version__ = '{version}'\n" + + +[tool.ruff] +ignore = ["I001", "I002", "I003", "I004"] + +[tool.basedpyright] +exclude = ["**/*.ipynb"] diff --git a/ultraplot/axes/base.py b/ultraplot/axes/base.py index 7a2a1a403..4ce61291d 100644 --- a/ultraplot/axes/base.py +++ b/ultraplot/axes/base.py @@ -963,7 +963,18 @@ def _add_inset_axes( zoom = ax._inset_zoom = _not_none(zoom, zoom_default) if zoom: zoom_kw = zoom_kw or {} - ax.indicate_inset_zoom(**zoom_kw) + # Check if the inset axes is an Ultraplot axes class. + # Ultraplot axes have a custom indicate_inset_zoom that can be + # called on the inset itself (uses self._inset_parent internally). + # Non-Ultraplot axes (e.g., raw matplotlib/cartopy) require calling + # matplotlib's indicate_inset_zoom on the parent with the inset as first argument. + if isinstance(ax, Axes): + # Ultraplot axes: call on inset (uses self._inset_parent internally) + ax.indicate_inset_zoom(**zoom_kw) + else: + # Non-Ultraplot axes: call matplotlib's parent class method + # with inset as first argument (matplotlib API) + maxes.Axes.indicate_inset_zoom(self, ax, **zoom_kw) return ax def _add_queued_guides(self): diff --git a/ultraplot/axes/container.py b/ultraplot/axes/container.py index ddce15ee1..530599de3 100644 --- a/ultraplot/axes/container.py +++ b/ultraplot/axes/container.py @@ -55,9 +55,6 @@ def __init__( # Can be customized per-axes or set globally self._external_shrink_factor = kwargs.pop("external_shrink_factor", 0.85) - # Store subplot spec for later - self._subplot_spec = kwargs.pop("_subplot_spec", None) - # Pop the projection kwarg if it exists (matplotlib will add it) # We don't want to pass it to parent since we're using cartesian for container kwargs.pop("projection", None) @@ -67,6 +64,8 @@ def __init__( format_kwargs = {} # Extract common format parameters + # Include both general format params and GeoAxes-specific params + # to handle cases where GeoAxes might be incorrectly wrapped format_params = [ "title", "ltitle", @@ -89,6 +88,32 @@ def __init__( "aspect", "grid", "gridminor", + # GeoAxes-specific parameters + "extent", + "map_projection", + "lonlim", + "latlim", + "land", + "ocean", + "coast", + "rivers", + "borders", + "innerborders", + "lakes", + "labels", + "latlines", + "lonlines", + "latlabels", + "lonlabels", + "lonlocator", + "latlocator", + "lonformatter", + "latformatter", + "lonticklen", + "latticklen", + "gridminor", + "round", + "boundinglat", ] for param in format_params: if param in kwargs: @@ -97,8 +122,7 @@ def __init__( # Initialize parent ultraplot Axes # Don't set projection here - the class itself is already the right projection # and matplotlib has already resolved it before instantiation - if self._subplot_spec is not None: - kwargs["_subplot_spec"] = self._subplot_spec + # Note: _subplot_spec is handled by parent Axes.__init__, no need to pop/restore it # Disable autoshare for external axes containers since they manage # external axes that don't participate in ultraplot's sharing system diff --git a/ultraplot/figure.py b/ultraplot/figure.py index 16c3c5f15..ee15b764b 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -1091,7 +1091,26 @@ def _parse_proj( # Search axes projections name = None - if isinstance(proj, str): + + # Handle cartopy/basemap Projection objects directly + # These should be converted to Ultraplot GeoAxes + if not isinstance(proj, str): + # Check if it's a cartopy or basemap projection object + if constructor.Projection is not object and isinstance( + proj, constructor.Projection + ): + # It's a cartopy projection - use cartopy backend + name = "ultraplot_cartopy" + kwargs["map_projection"] = proj + elif constructor.Basemap is not object and isinstance( + proj, constructor.Basemap + ): + # It's a basemap projection + name = "ultraplot_basemap" + kwargs["map_projection"] = proj + # If not recognized, leave name as None and it will pass through + + if name is None and isinstance(proj, str): try: mproj.get_projection_class("ultraplot_" + proj) except (KeyError, ValueError): @@ -1099,6 +1118,7 @@ def _parse_proj( else: name = "ultraplot_" + proj if name is None and isinstance(proj, str): +<<<<<<< HEAD # Check if the name is registered globally in Matplotlib (e.g., 'ternary', 'polar', '3d') # But ONLY if we're not going to try geographic projections next # (i.e., only if backend is specified or constructor doesn't support geographic) @@ -1108,30 +1128,43 @@ def _parse_proj( or constructor.Basemap is not object ) if not should_try_geographic and proj in mproj.get_projection_names(): +======= + # Try geographic projections first if cartopy/basemap available + if ( + constructor.Projection is not object + or constructor.Basemap is not object + ): + try: + proj_obj = constructor.Proj( + proj, backend=backend, include_axes=True, **proj_kw + ) + name = "ultraplot_" + proj_obj._proj_backend + kwargs["map_projection"] = proj_obj + except ValueError: + # Not a geographic projection, will try matplotlib registry below + pass + + # If not geographic, check if registered globally in Matplotlib (e.g., 'ternary', 'polar', '3d') + if name is None and proj in mproj.get_projection_names(): +>>>>>>> bf2bbc9c2 (fixes) name = proj - # If not found anywhere, leave name as None so geographic projections can be tried - # Helpful error message - if ( - name is None - and backend is None - and isinstance(proj, str) - and constructor.Projection is object - and constructor.Basemap is object - ): + + # Helpful error message if still not found + if name is None and isinstance(proj, str): raise ValueError( f"Invalid projection name {proj!r}. If you are trying to generate a " "GeoAxes with a cartopy.crs.Projection or mpl_toolkits.basemap.Basemap " "then cartopy or basemap must be installed. Otherwise the known axes " f"subclasses are:\n{paxes._cls_table}" ) - # Search geographic projections - # NOTE: Also raises errors due to unexpected projection type - if name is None: - proj = constructor.Proj(proj, backend=backend, include_axes=True, **proj_kw) - name = "ultraplot_" + proj._proj_backend - kwargs["map_projection"] = proj - - kwargs["projection"] = name + + # Only set projection if we found a named projection + # Otherwise preserve the original projection (e.g., cartopy Projection objects) + if name is not None: + kwargs["projection"] = name + # If name is None and proj is not a string, it means we have a non-string + # projection (e.g., cartopy.crs.Projection object) that should be passed through + # The original projection kwarg is already in kwargs, so no action needed return kwargs def _get_align_axes(self, side): @@ -1636,12 +1669,15 @@ def _add_subplot(self, *args, **kwargs): if projection_name and isinstance(projection_name, str): # Check if this is an external (non-ultraplot) projection + # Skip external wrapping for projections that start with "ultraplot_" prefix + # as these are already Ultraplot axes classes if not projection_name.startswith("ultraplot_"): try: # Get the projection class proj_class = mproj.get_projection_class(projection_name) # Check if it's not a built-in ultraplot axes + # Only wrap if it's NOT a subclass of Ultraplot's Axes if not issubclass(proj_class, paxes.Axes): # Store the external axes class and original projection name external_axes_class = proj_class @@ -1667,7 +1703,14 @@ def _add_subplot(self, *args, **kwargs): # Projection not found, let matplotlib handle the error pass - ax = super().add_subplot(ss, _subplot_spec=ss, **kwargs) + # Remove _subplot_spec from kwargs if present to prevent it from being passed + # to .set() or other methods that don't accept it. + kwargs.pop("_subplot_spec", None) + + # Pass only the SubplotSpec as a positional argument + # Don't pass _subplot_spec as a keyword argument to avoid it being + # propagated to Axes.set() or other methods that don't accept it + ax = super().add_subplot(ss, **kwargs) # Allow sharing for GeoAxes if rectilinear if self._sharex or self._sharey: if len(self.axes) > 1 and isinstance(ax, paxes.GeoAxes): From e502992f296dddb68ec55ba916263822601ab0a5 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 11 Dec 2025 21:23:20 +1000 Subject: [PATCH 07/12] add container tests --- ...est_external_axes_container_integration.py | 487 +++++++++++ .../test_external_container_edge_cases.py | 580 +++++++++++++ .../tests/test_external_container_mocked.py | 792 ++++++++++++++++++ 3 files changed, 1859 insertions(+) create mode 100644 ultraplot/tests/test_external_axes_container_integration.py create mode 100644 ultraplot/tests/test_external_container_edge_cases.py create mode 100644 ultraplot/tests/test_external_container_mocked.py diff --git a/ultraplot/tests/test_external_axes_container_integration.py b/ultraplot/tests/test_external_axes_container_integration.py new file mode 100644 index 000000000..234b98ae7 --- /dev/null +++ b/ultraplot/tests/test_external_axes_container_integration.py @@ -0,0 +1,487 @@ +#!/usr/bin/env python3 +""" +Test external axes container integration. + +These tests verify that the ExternalAxesContainer works correctly with +external axes like mpltern.TernaryAxes. +""" +import numpy as np +import pytest + +import ultraplot as uplt + +# Check if mpltern is available +try: + import mpltern # noqa: F401 + from mpltern.ternary import TernaryAxes + + HAS_MPLTERN = True +except ImportError: + HAS_MPLTERN = False + + +@pytest.mark.skipif(not HAS_MPLTERN, reason="mpltern not installed") +def test_container_creation_via_subplots(): + """Test that external axes container is created via subplots.""" + fig, axs = uplt.subplots(projection="ternary") + + # subplots returns a SubplotGrid + assert axs is not None + assert len(axs) == 1 + ax = axs[0] + assert ax is not None + + +@pytest.mark.skipif(not HAS_MPLTERN, reason="mpltern not installed") +def test_container_has_external_child(): + """Test that container has external child methods.""" + fig, ax = uplt.subplots(projection="ternary") + + # Container should have helper methods + if hasattr(ax, "has_external_child"): + assert hasattr(ax, "get_external_child") + assert hasattr(ax, "get_external_axes") + + +@pytest.mark.skipif(not HAS_MPLTERN, reason="mpltern not installed") +def test_container_format_method(): + """Test that format method works on container.""" + fig, axs = uplt.subplots(projection="ternary") + ax = axs[0] + + # Should not raise + ax.format(title="Test Title") + + # Verify title was set on container (not external axes) + # The container manages titles, external axes handles plotting + title = ax.get_title() + # Title may be empty string if set on external axes instead + # Just verify format doesn't crash + assert title is not None + + +@pytest.mark.skipif(not HAS_MPLTERN, reason="mpltern not installed") +def test_container_plotting(): + """Test that plotting methods are delegated to external axes.""" + fig, axs = uplt.subplots(projection="ternary") + ax = axs[0] + + # Simple ternary plot + n = 10 + t = np.linspace(0, 1, n) + l = 1 - t + r = np.zeros_like(t) + + # This should not raise + result = ax.plot(t, l, r) + assert result is not None + + +@pytest.mark.skipif(not HAS_MPLTERN, reason="mpltern not installed") +def test_container_scatter(): + """Test that scatter works through container.""" + fig, axs = uplt.subplots(projection="ternary") + ax = axs[0] + + n = 20 + t = np.random.rand(n) + l = np.random.rand(n) + r = 1 - t - l + r = np.maximum(r, 0) # Ensure non-negative + + # Should not raise + result = ax.scatter(t, l, r) + assert result is not None + + +@pytest.mark.skipif(not HAS_MPLTERN, reason="mpltern not installed") +def test_container_drawing(): + """Test that drawing works without errors.""" + fig, axs = uplt.subplots(projection="ternary") + ax = axs[0] + + # Add some data + t = np.array([0.5, 0.3, 0.2]) + l = np.array([0.3, 0.4, 0.3]) + r = np.array([0.2, 0.3, 0.5]) + ax.scatter(t, l, r) + + # Should not raise + fig.canvas.draw() + + +@pytest.mark.skipif(not HAS_MPLTERN, reason="mpltern not installed") +def test_container_multiple_subplots(): + """Test that multiple external axes containers work.""" + fig, axs = uplt.subplots(nrows=1, ncols=2, projection="ternary") + + assert len(axs) == 2 + assert all(ax is not None for ax in axs) + + # Each should work independently + for i, ax in enumerate(axs): + ax.format(title=f"Plot {i+1}") + t = np.random.rand(10) + l = np.random.rand(10) + r = 1 - t - l + r = np.maximum(r, 0) + ax.scatter(t, l, r) + + +@pytest.mark.skipif(not HAS_MPLTERN, reason="mpltern not installed") +def test_container_with_abc_labels(): + """Test that abc labels work with container.""" + fig, axs = uplt.subplots(nrows=1, ncols=2, projection="ternary") + + # Should not raise + fig.format(abc=True) + + # Each axes should have abc label + for ax in axs: + # abc label is internal, just verify no errors + pass + + +@pytest.mark.skipif(not HAS_MPLTERN, reason="mpltern not installed") +def test_container_label_fitting(): + """Test that external axes labels fit within bounds.""" + fig, axs = uplt.subplots(projection="ternary") + ax = axs[0] + + # Set labels that would normally be cut off + ax.set_tlabel("Top Component") + ax.set_llabel("Left Component") + ax.set_rlabel("Right Component") + + # Draw to trigger shrinking + fig.canvas.draw() + + # Should not raise and labels should be positioned + # (visual verification would require checking renderer output) + + +@pytest.mark.skipif(not HAS_MPLTERN, reason="mpltern not installed") +def test_container_custom_shrink_factor(): + """Test that custom shrink factor can be specified.""" + # Note: This tests the API exists, actual shrinking tested visually + fig = uplt.figure() + ax = fig.add_subplot(111, projection="ternary", external_shrink_factor=0.8) + + assert ax is not None + # Check if shrink factor was stored + if hasattr(ax, "_external_shrink_factor"): + assert ax._external_shrink_factor == 0.8 + + +@pytest.mark.skipif(not HAS_MPLTERN, reason="mpltern not installed") +def test_container_clear(): + """Test that clear method works.""" + fig, axs = uplt.subplots(projection="ternary") + ax = axs[0] + + # Add data + t = np.array([0.5]) + l = np.array([0.3]) + r = np.array([0.2]) + ax.scatter(t, l, r) + + # Clear should not raise + ax.clear() + + +@pytest.mark.skipif(not HAS_MPLTERN, reason="mpltern not installed") +def test_container_savefig(): + """Test that figures with container can be saved.""" + import os + import tempfile + + fig, axs = uplt.subplots(projection="ternary") + ax = axs[0] + + # Add some data + t = np.array([0.5, 0.3, 0.2]) + l = np.array([0.3, 0.4, 0.3]) + r = np.array([0.2, 0.3, 0.5]) + ax.scatter(t, l, r) + + # Save to temporary file + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp: + tmp_path = tmp.name + + try: + # This should not raise + fig.savefig(tmp_path) + + # File should exist and have content + assert os.path.exists(tmp_path) + assert os.path.getsize(tmp_path) > 0 + finally: + # Clean up + if os.path.exists(tmp_path): + os.remove(tmp_path) + + +def test_regular_axes_still_work(): + """Test that regular ultraplot axes still work normally.""" + fig, axs = uplt.subplots() + + # SubplotGrid with one element + ax = axs[0] + + # Should be regular CartesianAxes + from ultraplot.axes import CartesianAxes + + assert isinstance(ax, CartesianAxes) + + # Should work normally + ax.plot([1, 2, 3], [1, 2, 3]) + ax.format(title="Regular Plot") + fig.canvas.draw() + + +@pytest.mark.skipif(not HAS_MPLTERN, reason="mpltern not installed") +def test_container_position_bounds(): + """Test that container and external axes stay within bounds.""" + fig, axs = uplt.subplots(projection="ternary") + ax = axs[0] + + # Get positions + container_pos = ax.get_position() + + if hasattr(ax, "get_external_child"): + child = ax.get_external_child() + if child is not None: + child_pos = child.get_position() + + # Child should be within or at container bounds + assert child_pos.x0 >= container_pos.x0 - 0.01 + assert child_pos.y0 >= container_pos.y0 - 0.01 + assert child_pos.x1 <= container_pos.x1 + 0.01 + assert child_pos.y1 <= container_pos.y1 + 0.01 + + +@pytest.mark.skipif(not HAS_MPLTERN, reason="mpltern not installed") +def test_container_with_tight_layout(): + """Test that container works with tight_layout.""" + fig, axs = uplt.subplots(nrows=2, ncols=2, projection="ternary") + + # Add data to all axes + for ax in axs: + t = np.random.rand(10) + l = np.random.rand(10) + r = 1 - t - l + r = np.maximum(r, 0) + ax.scatter(t, l, r) + ax.format(title="Test") + + # tight_layout should not crash + try: + fig.tight_layout() + except Exception: + # tight_layout might not work perfectly with external axes + # but shouldn't crash catastrophically + pass + + +@pytest.mark.skipif(not HAS_MPLTERN, reason="mpltern not installed") +def test_container_scatter_with_colorbar(): + """Test scatter plot with colorbar on ternary axes.""" + fig, axs = uplt.subplots(projection="ternary") + ax = axs[0] + + n = 50 + t = np.random.rand(n) + l = np.random.rand(n) + r = 1 - t - l + r = np.maximum(r, 0) + c = np.random.rand(n) # Color values + + # Scatter with color values + sc = ax.scatter(t, l, r, c=c) + + # Should not crash + assert sc is not None + + +@pytest.mark.skipif(not HAS_MPLTERN, reason="mpltern not installed") +def test_container_fill_between(): + """Test fill functionality on ternary axes.""" + fig, axs = uplt.subplots(projection="ternary") + ax = axs[0] + + # Create a triangular region to fill + t = np.array([0.5, 0.6, 0.5, 0.4, 0.5]) + l = np.array([0.3, 0.3, 0.4, 0.3, 0.3]) + r = 1 - t - l + + # Should not crash + ax.fill(t, l, r, alpha=0.5) + + +@pytest.mark.skipif(not HAS_MPLTERN, reason="mpltern not installed") +def test_container_multiple_plot_calls(): + """Test multiple plot calls on same container.""" + fig, axs = uplt.subplots(projection="ternary") + ax = axs[0] + + # Multiple plots + for i in range(3): + t = np.linspace(0, 1, 10) + i * 0.1 + t = np.clip(t, 0, 1) + l = 1 - t + r = np.zeros_like(t) + ax.plot(t, l, r, label=f"Series {i+1}") + + # Should handle multiple plots + fig.canvas.draw() + + +@pytest.mark.skipif(not HAS_MPLTERN, reason="mpltern not installed") +def test_container_legend(): + """Test that legend works with container.""" + fig, axs = uplt.subplots(projection="ternary") + ax = axs[0] + + # Plot with labels + t1 = np.array([0.5, 0.3, 0.2]) + l1 = np.array([0.3, 0.4, 0.3]) + r1 = np.array([0.2, 0.3, 0.5]) + ax.scatter(t1, l1, r1, label="Data 1") + + t2 = np.array([0.4, 0.5, 0.1]) + l2 = np.array([0.4, 0.3, 0.5]) + r2 = np.array([0.2, 0.2, 0.4]) + ax.scatter(t2, l2, r2, label="Data 2") + + # Add legend - should not crash + try: + ax.legend() + except Exception: + # Legend might not be fully supported, but shouldn't crash hard + pass + + +@pytest.mark.skipif(not HAS_MPLTERN, reason="mpltern not installed") +def test_container_grid_lines(): + """Test grid functionality if available.""" + fig, axs = uplt.subplots(projection="ternary") + ax = axs[0] + + # Try to enable grid + try: + if hasattr(ax, "grid"): + ax.grid(True) + except Exception: + # Grid might not be supported on all external axes + pass + + # Should not crash drawing + fig.canvas.draw() + + +@pytest.mark.skipif(not HAS_MPLTERN, reason="mpltern not installed") +def test_container_stale_flag(): + """Test that stale flag works correctly.""" + fig, axs = uplt.subplots(projection="ternary") + ax = axs[0] + + # Check stale tracking exists + if hasattr(ax, "_external_stale"): + # After plotting, should be stale + ax.plot([0.5], [0.3], [0.2]) + assert ax._external_stale == True + + # After drawing, may be reset + fig.canvas.draw() + + +@pytest.mark.skipif(not HAS_MPLTERN, reason="mpltern not installed") +def test_container_iterator_isolation(): + """Test that iteration doesn't expose external child.""" + fig, axs = uplt.subplots(projection="ternary") + ax = axs[0] + + # Iterate using _iter_axes + if hasattr(ax, "_iter_axes"): + axes_list = list(ax._iter_axes()) + + # Should only yield container + assert ax in axes_list + + # External child should not be yielded + if hasattr(ax, "get_external_child"): + child = ax.get_external_child() + if child is not None: + assert child not in axes_list + + +@pytest.mark.skipif(not HAS_MPLTERN, reason="mpltern not installed") +def test_container_with_different_shrink_factors(): + """Test multiple containers with different shrink factors.""" + fig = uplt.figure() + + ax1 = fig.add_subplot(121, projection="ternary", external_shrink_factor=0.9) + ax2 = fig.add_subplot(122, projection="ternary", external_shrink_factor=0.7) + + # Both should work + assert ax1 is not None + assert ax2 is not None + + if hasattr(ax1, "_external_shrink_factor"): + assert ax1._external_shrink_factor == 0.9 + + if hasattr(ax2, "_external_shrink_factor"): + assert ax2._external_shrink_factor == 0.7 + + +@pytest.mark.skipif(not HAS_MPLTERN, reason="mpltern not installed") +def test_container_set_limits(): + """Test setting limits on ternary axes through container.""" + fig, axs = uplt.subplots(projection="ternary") + ax = axs[0] + + # Try setting limits (may or may not be supported) + try: + if hasattr(ax, "set_xlim"): + ax.set_xlim(0, 1) + if hasattr(ax, "set_ylim"): + ax.set_ylim(0, 1) + except Exception: + # Limits might not apply to ternary axes + pass + + # Should not crash + fig.canvas.draw() + + +@pytest.mark.skipif(not HAS_MPLTERN, reason="mpltern not installed") +def test_container_axes_visibility(): + """Test that container axes are hidden but external is visible.""" + fig, axs = uplt.subplots(projection="ternary") + ax = axs[0] + + # Container's visual elements should be hidden + assert not ax.patch.get_visible() + assert not ax.xaxis.get_visible() + assert not ax.yaxis.get_visible() + + for spine in ax.spines.values(): + assert not spine.get_visible() + + +def test_projection_detection(): + """Test that ternary projection is properly detected.""" + # This tests the projection registry and detection logic + fig = uplt.figure() + + # Should be able to detect ternary projection + try: + ax = fig.add_subplot(111, projection="ternary") + # If mpltern is available, should create container + # If not, should raise appropriate error + if HAS_MPLTERN: + assert ax is not None + except Exception as e: + # If mpltern not available, should get helpful error + if not HAS_MPLTERN: + assert "ternary" in str(e).lower() or "projection" in str(e).lower() diff --git a/ultraplot/tests/test_external_container_edge_cases.py b/ultraplot/tests/test_external_container_edge_cases.py new file mode 100644 index 000000000..2124faea1 --- /dev/null +++ b/ultraplot/tests/test_external_container_edge_cases.py @@ -0,0 +1,580 @@ +#!/usr/bin/env python3 +""" +Edge case and integration tests for ExternalAxesContainer. + +These tests cover error handling, edge cases, and integration scenarios +without requiring external dependencies. +""" +from unittest.mock import Mock, patch + +import numpy as np +import pytest +from matplotlib.transforms import Bbox + +import ultraplot as uplt +from ultraplot.axes.container import ExternalAxesContainer + + +class FaultyExternalAxes: + """Mock external axes that raises errors to test error handling.""" + + def __init__(self, fig, *args, **kwargs): + """Initialize but raise error to simulate construction failure.""" + raise RuntimeError("Failed to create external axes") + + +class MinimalExternalAxes: + """Minimal external axes with only required methods.""" + + def __init__(self, fig, *args, **kwargs): + self.figure = fig + self._position = Bbox.from_bounds(0.1, 0.1, 0.8, 0.8) + self.stale = True + self.patch = Mock() + self.spines = {} + self._visible = True + self._zorder = 0 + + def get_position(self): + return self._position + + def set_position(self, pos, which="both"): + self._position = pos + + def draw(self, renderer): + self.stale = False + + def get_visible(self): + return self._visible + + def set_visible(self, visible): + self._visible = visible + + def get_animated(self): + return False + + def get_zorder(self): + return self._zorder + + def set_zorder(self, zorder): + self._zorder = zorder + + @property + def zorder(self): + return self._zorder + + @zorder.setter + def zorder(self, value): + self._zorder = value + + +class PositionChangingAxes(MinimalExternalAxes): + """External axes that changes position during draw (like ternary).""" + + def __init__(self, fig, *args, **kwargs): + super().__init__(fig, *args, **kwargs) + self._draw_count = 0 + + def draw(self, renderer): + """Change position on first draw to simulate label adjustment.""" + self._draw_count += 1 + self.stale = False + if self._draw_count == 1: + # Simulate position adjustment for labels + pos = self._position + new_pos = Bbox.from_bounds( + pos.x0 + 0.05, pos.y0 + 0.05, pos.width - 0.1, pos.height - 0.1 + ) + self._position = new_pos + + +class NoTightBboxAxes(MinimalExternalAxes): + """External axes without get_tightbbox method.""" + + pass # Intentionally doesn't have get_tightbbox + + +class NoTightBboxAxes(MinimalExternalAxes): + """External axes without get_tightbbox method.""" + + def get_tightbbox(self, renderer): + # Return None or basic bbox + return None + + +class AutoRegisteringAxes(MinimalExternalAxes): + """External axes that auto-registers with figure.""" + + def __init__(self, fig, *args, **kwargs): + super().__init__(fig, *args, **kwargs) + # Simulate matplotlib behavior: auto-register + if hasattr(fig, "axes") and self not in fig.axes: + fig.axes.append(self) + + +# Tests + + +def test_faulty_external_axes_creation(): + """Test that container handles external axes creation failure gracefully.""" + fig = uplt.figure() + + # Should not crash, just warn + ax = ExternalAxesContainer( + fig, 1, 1, 1, external_axes_class=FaultyExternalAxes, external_axes_kwargs={} + ) + + # Container should exist but have no external child + assert ax is not None + assert not ax.has_external_child() + assert ax.get_external_child() is None + + +def test_position_change_during_draw(): + """Test that container handles position changes during external axes draw.""" + fig = uplt.figure() + ax = ExternalAxesContainer( + fig, + 1, + 1, + 1, + external_axes_class=PositionChangingAxes, + external_axes_kwargs={}, + ) + + # Get initial external axes position + child = ax.get_external_child() + assert child is not None + assert hasattr(child, "_draw_count") + + # Manually call draw to trigger the position change + from unittest.mock import Mock + + renderer = Mock() + ax.draw(renderer) + + # Verify child's draw was called + # The position change happens during draw, which we just verified doesn't crash + assert child._draw_count >= 1, f"Expected draw_count >= 1, got {child._draw_count}" + + +def test_no_tightbbox_method(): + """Test container works with external axes that has no get_tightbbox.""" + fig = uplt.figure() + ax = ExternalAxesContainer( + fig, 1, 1, 1, external_axes_class=NoTightBboxAxes, external_axes_kwargs={} + ) + + # Should not crash during draw + fig.canvas.draw() + + # get_tightbbox should fall back to parent + renderer = Mock() + result = ax.get_tightbbox(renderer) + # Should return something (from parent implementation) + # May be None or a bbox, but shouldn't crash + + +def test_auto_registering_axes_removed(): + """Test that auto-registering external axes is removed from fig.axes.""" + fig = uplt.figure() + + ax = ExternalAxesContainer( + fig, + 1, + 1, + 1, + external_axes_class=AutoRegisteringAxes, + external_axes_kwargs={}, + ) + + # External child should NOT be in axes (should have been removed) + child = ax.get_external_child() + assert child is not None + + # The key invariant: external child should not be in fig.axes + # (it gets removed during container initialization) + assert child not in fig.axes, f"External child should not be in fig.axes" + + +def test_format_with_non_delegatable_params(): + """Test format with parameters that can't be delegated.""" + fig = uplt.figure() + ax = ExternalAxesContainer( + fig, + 1, + 1, + 1, + external_axes_class=MinimalExternalAxes, + external_axes_kwargs={}, + ) + + # Format with ultraplot-specific params (not delegatable) + # Should not crash, just apply to container + ax.format(abc=True, abcloc="ul") + + +def test_clear_without_external_axes(): + """Test clear works when there's no external axes.""" + fig = uplt.figure() + ax = ExternalAxesContainer( + fig, 1, 1, 1, external_axes_class=None, external_axes_kwargs={} + ) + + # Should not crash + ax.clear() + + +def test_getattr_during_initialization(): + """Test __getattr__ doesn't interfere with initialization.""" + fig = uplt.figure() + + # Should not crash during construction + ax = ExternalAxesContainer( + fig, + 1, + 1, + 1, + external_axes_class=MinimalExternalAxes, + external_axes_kwargs={}, + ) + + assert ax is not None + + +def test_getattr_with_private_attribute(): + """Test __getattr__ raises for private attributes not found.""" + fig = uplt.figure() + ax = ExternalAxesContainer( + fig, + 1, + 1, + 1, + external_axes_class=MinimalExternalAxes, + external_axes_kwargs={}, + ) + + with pytest.raises(AttributeError): + _ = ax._nonexistent_private_attr + + +def test_position_cache_invalidation(): + """Test position cache is invalidated on position change.""" + fig = uplt.figure() + ax = ExternalAxesContainer( + fig, + 1, + 1, + 1, + external_axes_class=MinimalExternalAxes, + external_axes_kwargs={}, + ) + + # Set position + pos1 = Bbox.from_bounds(0.1, 0.1, 0.8, 0.8) + ax.set_position(pos1) + + # Cache should be invalidated initially + assert ax._position_synced is False + + # Draw to establish cache + fig.canvas.draw() + + # After drawing, position sync should have occurred + # The exact state depends on draw logic, just verify no crash + + # Change position again + pos2 = Bbox.from_bounds(0.2, 0.2, 0.6, 0.6) + ax.set_position(pos2) + + # Should be marked as needing sync + assert ax._position_synced is False + + +def test_stale_flag_on_plotting(): + """Test that stale flag is set when plotting.""" + fig = uplt.figure() + ax = ExternalAxesContainer( + fig, + 1, + 1, + 1, + external_axes_class=MinimalExternalAxes, + external_axes_kwargs={}, + ) + + # Reset stale flag + ax._external_stale = False + + # Plot something (if external axes supports it) + child = ax.get_external_child() + if child is not None and hasattr(child, "plot"): + # Add plot method to minimal axes for this test + child.plot = Mock() + ax.plot([1, 2, 3], [1, 2, 3]) + + # Should be marked stale + assert ax._external_stale == True + + +def test_draw_skips_when_not_stale(): + """Test that draw can skip external axes when not stale.""" + fig = uplt.figure() + + # Create mock with draw tracking + draw_count = [0] + + class DrawCountingAxes(MinimalExternalAxes): + def draw(self, renderer): + draw_count[0] += 1 + self.stale = False + + ax = ExternalAxesContainer( + fig, 1, 1, 1, external_axes_class=DrawCountingAxes, external_axes_kwargs={} + ) + + # Set up conditions for skipping draw + child = ax.get_external_child() + if child: + child.stale = False + ax._external_stale = False + ax._position_synced = True + + # Draw should not crash + try: + renderer = Mock() + ax.draw(renderer) + except Exception: + # May fail due to missing renderer methods, that's OK + pass + + +def test_draw_called_when_stale(): + """Test that draw calls external axes when stale.""" + fig = uplt.figure() + + # Create mock with draw tracking + draw_count = [0] + + class DrawCountingAxes(MinimalExternalAxes): + def draw(self, renderer): + draw_count[0] += 1 + self.stale = False + + ax = ExternalAxesContainer( + fig, 1, 1, 1, external_axes_class=DrawCountingAxes, external_axes_kwargs={} + ) + + ax._external_stale = True + + # Draw should not crash and should call external draw + try: + renderer = Mock() + ax.draw(renderer) + # External axes draw should be called when stale + assert draw_count[0] > 0 + except Exception: + # May fail due to missing renderer methods, that's OK + # Just verify no crash during setup + pass + + +def test_shrink_with_zero_size(): + """Test shrink calculation with zero-sized position.""" + fig = uplt.figure() + ax = ExternalAxesContainer( + fig, + 1, + 1, + 1, + external_axes_class=MinimalExternalAxes, + external_axes_kwargs={}, + ) + + # Set zero-sized position + zero_pos = Bbox.from_bounds(0.5, 0.5, 0, 0) + ax.set_position(zero_pos) + + # Should not crash during shrink + ax._shrink_external_for_labels() + + +def test_format_kwargs_popped_before_parent(): + """Test that format kwargs are properly removed before parent init.""" + fig = uplt.figure() + + # Pass format kwargs that would cause issues if passed to parent + ax = ExternalAxesContainer( + fig, + 1, + 1, + 1, + external_axes_class=MinimalExternalAxes, + external_axes_kwargs={}, + title="Title", + xlabel="X", + grid=True, + ) + + # Should not crash + assert ax is not None + + +def test_projection_kwarg_removed(): + """Test that projection kwarg is removed before parent init.""" + fig = uplt.figure() + + # Pass projection which should be popped + ax = ExternalAxesContainer( + fig, + 1, + 1, + 1, + external_axes_class=MinimalExternalAxes, + external_axes_kwargs={}, + projection="ternary", + ) + + # Should not crash + assert ax is not None + + +def test_container_with_subplotspec(): + """Test container creation with subplot spec.""" + fig = uplt.figure() + + # Use add_subplot which handles subplotspec internally + ax = fig.add_subplot(221) + + # Just verify it was created - subplotspec handling is internal + assert ax is not None + + # If it's a container, verify it has the methods + if hasattr(ax, "has_external_child"): + # It's a container, test passes + pass + + +def test_external_axes_with_no_set_position(): + """Test external axes that doesn't have set_position method.""" + + class NoSetPositionAxes: + def __init__(self, fig, *args, **kwargs): + self.figure = fig + self._position = Bbox.from_bounds(0.1, 0.1, 0.8, 0.8) + self.patch = Mock() + self.spines = {} + + def get_position(self): + return self._position + + def draw(self, renderer): + pass + + fig = uplt.figure() + ax = ExternalAxesContainer( + fig, 1, 1, 1, external_axes_class=NoSetPositionAxes, external_axes_kwargs={} + ) + + # Should handle missing set_position gracefully + new_pos = Bbox.from_bounds(0.2, 0.2, 0.6, 0.6) + ax.set_position(new_pos) + + # Should not crash + + +def test_external_axes_kwargs_passed(): + """Test that external_axes_kwargs are passed to external axes constructor.""" + + class KwargsCheckingAxes(MinimalExternalAxes): + def __init__(self, fig, *args, custom_param=None, **kwargs): + super().__init__(fig, *args, **kwargs) + self.custom_param = custom_param + + fig = uplt.figure() + ax = ExternalAxesContainer( + fig, + 1, + 1, + 1, + external_axes_class=KwargsCheckingAxes, + external_axes_kwargs={"custom_param": "test_value"}, + ) + + child = ax.get_external_child() + assert child is not None + assert child.custom_param == "test_value" + + +def test_container_aspect_setting(): + """Test that aspect setting is attempted on external axes.""" + + class AspectAwareAxes(MinimalExternalAxes): + def __init__(self, fig, *args, **kwargs): + super().__init__(fig, *args, **kwargs) + self.aspect_set = False + + def set_aspect(self, aspect, adjustable=None): + self.aspect_set = True + + fig = uplt.figure() + ax = ExternalAxesContainer( + fig, 1, 1, 1, external_axes_class=AspectAwareAxes, external_axes_kwargs={} + ) + + child = ax.get_external_child() + # Aspect should have been set during shrink + if child is not None: + assert child.aspect_set == True + + +def test_multiple_draw_calls_efficient(): + """Test that multiple draw calls don't redraw unnecessarily.""" + fig = uplt.figure() + + # Create mock with draw counting + draw_count = [0] + + class DrawCountingAxes(MinimalExternalAxes): + def draw(self, renderer): + draw_count[0] += 1 + self.stale = False + + ax = ExternalAxesContainer( + fig, 1, 1, 1, external_axes_class=DrawCountingAxes, external_axes_kwargs={} + ) + + try: + renderer = Mock() + + # First draw + ax.draw(renderer) + first_count = draw_count[0] + + # Second draw without changes (may or may not skip depending on stale tracking) + ax.draw(renderer) + # Just verify it doesn't redraw excessively + # Allow for some draws but not too many + assert draw_count[0] <= first_count + 5 + except Exception: + # Drawing may fail due to renderer issues, that's OK for this test + # The point is to verify the counting mechanism works + pass + + +def test_container_autoshare_disabled(): + """Test that autoshare is disabled for external axes containers.""" + fig = uplt.figure() + ax = ExternalAxesContainer( + fig, + 1, + 1, + 1, + external_axes_class=MinimalExternalAxes, + external_axes_kwargs={}, + ) + + # Check that autoshare was set to False during init + # (This is in the init code but hard to verify directly) + # Just ensure container exists + assert ax is not None diff --git a/ultraplot/tests/test_external_container_mocked.py b/ultraplot/tests/test_external_container_mocked.py new file mode 100644 index 000000000..888b499d9 --- /dev/null +++ b/ultraplot/tests/test_external_container_mocked.py @@ -0,0 +1,792 @@ +#!/usr/bin/env python3 +""" +Unit tests for ExternalAxesContainer using mocked external axes. + +These tests verify container behavior without requiring external dependencies +like mpltern to be installed. +""" +from unittest.mock import MagicMock, Mock, call, patch + +import numpy as np +import pytest +from matplotlib.transforms import Bbox + +import ultraplot as uplt +from ultraplot.axes.container import ExternalAxesContainer + + +class MockExternalAxes: + """Mock external axes class that mimics behavior of external axes like TernaryAxes.""" + + def __init__(self, fig, *args, **kwargs): + """Initialize mock external axes.""" + self.figure = fig + self._position = Bbox.from_bounds(0.1, 0.1, 0.8, 0.8) + self._title = "" + self._xlabel = "" + self._ylabel = "" + self._xlim = (0, 1) + self._ylim = (0, 1) + self._visible = True + self._zorder = 0 + self._artists = [] + self.stale = True + + # Mock patch and spines + self.patch = Mock() + self.patch.set_visible = Mock() + self.patch.set_facecolor = Mock() + self.patch.set_alpha = Mock() + + self.spines = { + "top": Mock(set_visible=Mock()), + "bottom": Mock(set_visible=Mock()), + "left": Mock(set_visible=Mock()), + "right": Mock(set_visible=Mock()), + } + + # Simulate matplotlib behavior: auto-register with figure + if hasattr(fig, "axes") and self not in fig.axes: + fig.axes.append(self) + + def get_position(self): + """Get axes position.""" + return self._position + + def set_position(self, pos, which="both"): + """Set axes position.""" + self._position = pos + self.stale = True + + def get_title(self): + """Get title.""" + return self._title + + def set_title(self, title): + """Set title.""" + self._title = title + self.stale = True + + def get_xlabel(self): + """Get xlabel.""" + return self._xlabel + + def set_xlabel(self, label): + """Set xlabel.""" + self._xlabel = label + self.stale = True + + def get_ylabel(self): + """Get ylabel.""" + return self._ylabel + + def set_ylabel(self, label): + """Set ylabel.""" + self._ylabel = label + self.stale = True + + def get_xlim(self): + """Get xlim.""" + return self._xlim + + def set_xlim(self, xlim): + """Set xlim.""" + self._xlim = xlim + self.stale = True + + def get_ylim(self): + """Get ylim.""" + return self._ylim + + def set_ylim(self, ylim): + """Set ylim.""" + self._ylim = ylim + self.stale = True + + def set(self, **kwargs): + """Set multiple properties.""" + for key, value in kwargs.items(): + if key == "title": + self.set_title(value) + elif key == "xlabel": + self.set_xlabel(value) + elif key == "ylabel": + self.set_ylabel(value) + elif key == "xlim": + self.set_xlim(value) + elif key == "ylim": + self.set_ylim(value) + self.stale = True + + def set_visible(self, visible): + """Set visibility.""" + self._visible = visible + + def set_zorder(self, zorder): + """Set zorder.""" + self._zorder = zorder + + def get_zorder(self): + """Get zorder.""" + return self._zorder + + def set_frame_on(self, b): + """Set frame on/off.""" + pass + + def set_aspect(self, aspect, adjustable=None): + """Set aspect ratio.""" + pass + + def set_subplotspec(self, subplotspec): + """Set subplot spec.""" + pass + + def plot(self, *args, **kwargs): + """Mock plot method.""" + line = Mock() + self._artists.append(line) + self.stale = True + return [line] + + def scatter(self, *args, **kwargs): + """Mock scatter method.""" + collection = Mock() + self._artists.append(collection) + self.stale = True + return collection + + def fill(self, *args, **kwargs): + """Mock fill method.""" + poly = Mock() + self._artists.append(poly) + self.stale = True + return [poly] + + def contour(self, *args, **kwargs): + """Mock contour method.""" + cs = Mock() + self._artists.append(cs) + self.stale = True + return cs + + def contourf(self, *args, **kwargs): + """Mock contourf method.""" + cs = Mock() + self._artists.append(cs) + self.stale = True + return cs + + def pcolormesh(self, *args, **kwargs): + """Mock pcolormesh method.""" + mesh = Mock() + self._artists.append(mesh) + self.stale = True + return mesh + + def imshow(self, *args, **kwargs): + """Mock imshow method.""" + img = Mock() + self._artists.append(img) + self.stale = True + return img + + def hexbin(self, *args, **kwargs): + """Mock hexbin method.""" + poly = Mock() + self._artists.append(poly) + self.stale = True + return poly + + def clear(self): + """Clear axes.""" + self._artists.clear() + self._title = "" + self._xlabel = "" + self._ylabel = "" + self.stale = True + + def draw(self, renderer): + """Mock draw method.""" + self.stale = False + # Simulate position adjustment during draw (like ternary axes) + # This is important for testing position synchronization + pass + + def get_tightbbox(self, renderer): + """Get tight bounding box.""" + return self._position.transformed(self.figure.transFigure) + + +# Tests + + +def test_container_creation_basic(): + """Test basic container creation without external axes.""" + fig = uplt.figure() + ax = fig.add_subplot(111) + + assert ax is not None + # Regular axes may or may not have external child methods + # Just verify the axes was created successfully + + +def test_container_creation_with_external_axes(): + """Test container creation with external axes class.""" + fig = uplt.figure() + + # Create container with mock external axes + ax = ExternalAxesContainer( + fig, 1, 1, 1, external_axes_class=MockExternalAxes, external_axes_kwargs={} + ) + + assert ax is not None + assert ax.has_external_child() + assert ax.get_external_child() is not None + assert isinstance(ax.get_external_child(), MockExternalAxes) + + +def test_external_axes_removed_from_figure_axes(): + """Test that external axes is removed from figure axes list.""" + fig = uplt.figure() + + # Track initial axes count + initial_count = len(fig.axes) + + # Create container with mock external axes + ax = ExternalAxesContainer( + fig, 1, 1, 1, external_axes_class=MockExternalAxes, external_axes_kwargs={} + ) + + # External child should NOT be in fig.axes + child = ax.get_external_child() + if child is not None: + assert child not in fig.axes + + # Container should be in fig.axes + # Note: The way ultraplot manages axes, the container may be wrapped + # Just verify the child is not in the list + assert child not in fig.axes + + +def test_position_synchronization(): + """Test that position changes sync between container and external axes.""" + fig = uplt.figure() + ax = ExternalAxesContainer( + fig, 1, 1, 1, external_axes_class=MockExternalAxes, external_axes_kwargs={} + ) + + # Set new position on container + new_pos = Bbox.from_bounds(0.2, 0.2, 0.6, 0.6) + ax.set_position(new_pos) + + # External axes should have similar position (accounting for shrink) + child = ax.get_external_child() + if child is not None: + child_pos = child.get_position() + # Position should be set (within or near the container bounds) + assert child_pos is not None + + +def test_shrink_factor_default(): + """Test default shrink factor is applied.""" + fig = uplt.figure() + ax = ExternalAxesContainer( + fig, 1, 1, 1, external_axes_class=MockExternalAxes, external_axes_kwargs={} + ) + + # Default shrink factor should be 0.85 + assert hasattr(ax, "_external_shrink_factor") + assert ax._external_shrink_factor == 0.85 + + +def test_shrink_factor_custom(): + """Test custom shrink factor can be specified.""" + fig = uplt.figure() + ax = ExternalAxesContainer( + fig, + 1, + 1, + 1, + external_axes_class=MockExternalAxes, + external_axes_kwargs={}, + external_shrink_factor=0.7, + ) + + assert ax._external_shrink_factor == 0.7 + + +def test_plot_delegation(): + """Test that plot method is delegated to external axes.""" + fig = uplt.figure() + ax = ExternalAxesContainer( + fig, 1, 1, 1, external_axes_class=MockExternalAxes, external_axes_kwargs={} + ) + + # Call plot on container + x = [1, 2, 3] + y = [1, 2, 3] + result = ax.plot(x, y) + + # Should return result from external axes + assert result is not None + + +def test_scatter_delegation(): + """Test that scatter method is delegated to external axes.""" + fig = uplt.figure() + ax = ExternalAxesContainer( + fig, 1, 1, 1, external_axes_class=MockExternalAxes, external_axes_kwargs={} + ) + + x = np.random.rand(10) + y = np.random.rand(10) + result = ax.scatter(x, y) + + assert result is not None + + +def test_fill_delegation(): + """Test that fill method is delegated to external axes.""" + fig = uplt.figure() + ax = ExternalAxesContainer( + fig, 1, 1, 1, external_axes_class=MockExternalAxes, external_axes_kwargs={} + ) + + x = [0, 1, 1, 0] + y = [0, 0, 1, 1] + result = ax.fill(x, y) + + assert result is not None + + +def test_contour_delegation(): + """Test that contour method is delegated to external axes.""" + fig = uplt.figure() + ax = ExternalAxesContainer( + fig, 1, 1, 1, external_axes_class=MockExternalAxes, external_axes_kwargs={} + ) + + X = np.random.rand(10, 10) + result = ax.contour(X) + + assert result is not None + + +def test_contourf_delegation(): + """Test that contourf method is delegated to external axes.""" + fig = uplt.figure() + ax = ExternalAxesContainer( + fig, 1, 1, 1, external_axes_class=MockExternalAxes, external_axes_kwargs={} + ) + + X = np.random.rand(10, 10) + result = ax.contourf(X) + + assert result is not None + + +def test_pcolormesh_delegation(): + """Test that pcolormesh method is delegated to external axes.""" + fig = uplt.figure() + ax = ExternalAxesContainer( + fig, 1, 1, 1, external_axes_class=MockExternalAxes, external_axes_kwargs={} + ) + + X = np.random.rand(10, 10) + result = ax.pcolormesh(X) + + assert result is not None + + +def test_imshow_delegation(): + """Test that imshow method is delegated to external axes.""" + fig = uplt.figure() + ax = ExternalAxesContainer( + fig, 1, 1, 1, external_axes_class=MockExternalAxes, external_axes_kwargs={} + ) + + X = np.random.rand(10, 10) + result = ax.imshow(X) + + assert result is not None + + +def test_hexbin_delegation(): + """Test that hexbin method is delegated to external axes.""" + fig = uplt.figure() + ax = ExternalAxesContainer( + fig, 1, 1, 1, external_axes_class=MockExternalAxes, external_axes_kwargs={} + ) + + x = np.random.rand(100) + y = np.random.rand(100) + result = ax.hexbin(x, y) + + assert result is not None + + +def test_format_method_basic(): + """Test format method with basic parameters.""" + fig = uplt.figure() + ax = ExternalAxesContainer( + fig, 1, 1, 1, external_axes_class=MockExternalAxes, external_axes_kwargs={} + ) + + # Should not raise + ax.format(title="Test Title") + + # Title should be set on external axes + child = ax.get_external_child() + if child is not None: + assert child.get_title() == "Test Title" + + +def test_format_method_delegatable_params(): + """Test format method delegates appropriate parameters to external axes.""" + fig = uplt.figure() + ax = ExternalAxesContainer( + fig, 1, 1, 1, external_axes_class=MockExternalAxes, external_axes_kwargs={} + ) + + # Format with delegatable parameters + ax.format( + title="Title", xlabel="X Label", ylabel="Y Label", xlim=(0, 10), ylim=(0, 5) + ) + + child = ax.get_external_child() + if child is not None: + assert child.get_title() == "Title" + assert child.get_xlabel() == "X Label" + assert child.get_ylabel() == "Y Label" + assert child.get_xlim() == (0, 10) + assert child.get_ylim() == (0, 5) + + +def test_clear_method(): + """Test clear method clears both container and external axes.""" + fig = uplt.figure() + ax = ExternalAxesContainer( + fig, 1, 1, 1, external_axes_class=MockExternalAxes, external_axes_kwargs={} + ) + + # Add data + ax.plot([1, 2, 3], [1, 2, 3]) + ax.format(title="Title") + + child = ax.get_external_child() + if child is not None: + assert len(child._artists) > 0 + assert child.get_title() == "Title" + + # Clear + ax.clear() + + # External axes should be cleared + if child is not None: + assert len(child._artists) == 0 + assert child.get_title() == "" + + +def test_stale_tracking(): + """Test that stale tracking works.""" + fig = uplt.figure() + ax = ExternalAxesContainer( + fig, 1, 1, 1, external_axes_class=MockExternalAxes, external_axes_kwargs={} + ) + + # Initially stale + assert ax._external_stale == True + + # After plotting, should be stale + ax.plot([1, 2, 3], [1, 2, 3]) + assert ax._external_stale == True + + +def test_drawing(): + """Test that drawing works without errors.""" + fig = uplt.figure() + ax = ExternalAxesContainer( + fig, 1, 1, 1, external_axes_class=MockExternalAxes, external_axes_kwargs={} + ) + + # Add some data + ax.plot([1, 2, 3], [1, 2, 3]) + + # Should not raise + fig.canvas.draw() + + +def test_getattr_delegation(): + """Test that __getattr__ delegates to external axes.""" + fig = uplt.figure() + ax = ExternalAxesContainer( + fig, 1, 1, 1, external_axes_class=MockExternalAxes, external_axes_kwargs={} + ) + + child = ax.get_external_child() + if child is not None: + # Access attribute that exists on external axes but not container + # MockExternalAxes has 'stale' attribute + assert hasattr(ax, "stale") + + +def test_getattr_raises_for_missing(): + """Test that __getattr__ raises AttributeError for missing attributes.""" + fig = uplt.figure() + ax = ExternalAxesContainer( + fig, 1, 1, 1, external_axes_class=MockExternalAxes, external_axes_kwargs={} + ) + + with pytest.raises(AttributeError): + _ = ax.nonexistent_attribute_xyz + + +def test_dir_includes_external_attrs(): + """Test that dir() includes external axes attributes.""" + fig = uplt.figure() + ax = ExternalAxesContainer( + fig, 1, 1, 1, external_axes_class=MockExternalAxes, external_axes_kwargs={} + ) + + attrs = dir(ax) + + # Should include container methods + assert "has_external_child" in attrs + assert "get_external_child" in attrs + + # Should also include external axes methods + child = ax.get_external_child() + if child is not None: + # Check for some mock external axes attributes + assert "plot" in attrs + assert "scatter" in attrs + + +def test_iter_axes_only_yields_container(): + """Test that _iter_axes only yields the container, not external axes.""" + fig = uplt.figure() + ax = ExternalAxesContainer( + fig, 1, 1, 1, external_axes_class=MockExternalAxes, external_axes_kwargs={} + ) + + # Iterate over axes + axes_list = list(ax._iter_axes()) + + # Should only yield the container + assert len(axes_list) == 1 + assert axes_list[0] is ax + + # Should NOT include external child + child = ax.get_external_child() + if child is not None: + assert child not in axes_list + + +def test_get_external_axes_alias(): + """Test that get_external_axes is an alias for get_external_child.""" + fig = uplt.figure() + ax = ExternalAxesContainer( + fig, 1, 1, 1, external_axes_class=MockExternalAxes, external_axes_kwargs={} + ) + + assert ax.get_external_axes() is ax.get_external_child() + + +def test_container_invisible_elements(): + """Test that container's visual elements are hidden.""" + fig = uplt.figure() + ax = ExternalAxesContainer( + fig, 1, 1, 1, external_axes_class=MockExternalAxes, external_axes_kwargs={} + ) + + # Container patch should be invisible + assert not ax.patch.get_visible() + + # Container spines should be invisible + for spine in ax.spines.values(): + assert not spine.get_visible() + + # Container axes should be invisible + assert not ax.xaxis.get_visible() + assert not ax.yaxis.get_visible() + + +def test_external_axes_visible(): + """Test that external axes elements are visible.""" + fig = uplt.figure() + ax = ExternalAxesContainer( + fig, 1, 1, 1, external_axes_class=MockExternalAxes, external_axes_kwargs={} + ) + + child = ax.get_external_child() + if child is not None: + # External axes should be visible + assert child._visible == True + + # Patch should have been set to visible + child.patch.set_visible.assert_called() + + +def test_container_without_external_class(): + """Test container creation without external axes class.""" + fig = uplt.figure() + ax = ExternalAxesContainer( + fig, 1, 1, 1, external_axes_class=None, external_axes_kwargs={} + ) + + assert ax is not None + assert not ax.has_external_child() + assert ax.get_external_child() is None + + +def test_plotting_without_external_axes(): + """Test that plotting methods work even without external axes.""" + fig = uplt.figure() + ax = ExternalAxesContainer( + fig, 1, 1, 1, external_axes_class=None, external_axes_kwargs={} + ) + + # Should fall back to parent implementation + # (may or may not work depending on parent class, but shouldn't crash) + try: + result = ax.plot([1, 2, 3], [1, 2, 3]) + # If it works, result should be something + assert result is not None + except Exception: + # If parent doesn't support it, that's OK too + pass + + +def test_format_without_external_axes(): + """Test format method without external axes.""" + fig = uplt.figure() + ax = ExternalAxesContainer( + fig, 1, 1, 1, external_axes_class=None, external_axes_kwargs={} + ) + + # Should not raise + ax.format(title="Test") + + # Title should be set on container + assert ax.get_title() == "Test" + + +def test_zorder_external_higher_than_container(): + """Test that external axes has higher zorder than container.""" + fig = uplt.figure() + ax = ExternalAxesContainer( + fig, 1, 1, 1, external_axes_class=MockExternalAxes, external_axes_kwargs={} + ) + + container_zorder = ax.get_zorder() + child = ax.get_external_child() + + if child is not None: + child_zorder = child.get_zorder() + # External axes should have higher zorder + assert child_zorder > container_zorder + + +def test_stale_callback(): + """Test stale callback marks external axes as stale.""" + fig = uplt.figure() + ax = ExternalAxesContainer( + fig, 1, 1, 1, external_axes_class=MockExternalAxes, external_axes_kwargs={} + ) + + # Reset stale flags + ax._external_stale = False + + # Trigger stale callback if it exists + if hasattr(ax, "stale_callback") and callable(ax.stale_callback): + ax.stale_callback() + + # External should be marked stale + assert ax._external_stale == True + else: + # If no stale_callback, just verify the flag can be set + ax._external_stale = True + assert ax._external_stale == True + + +def test_get_tightbbox_delegation(): + """Test get_tightbbox delegates to external axes.""" + fig = uplt.figure() + ax = ExternalAxesContainer( + fig, 1, 1, 1, external_axes_class=MockExternalAxes, external_axes_kwargs={} + ) + + # Mock renderer + renderer = Mock() + + # Should not raise + result = ax.get_tightbbox(renderer) + + # Should get result from external axes + assert result is not None + + +def test_position_sync_disabled_during_sync(): + """Test that position sync doesn't recurse.""" + fig = uplt.figure() + ax = ExternalAxesContainer( + fig, 1, 1, 1, external_axes_class=MockExternalAxes, external_axes_kwargs={} + ) + + # Set syncing flag + ax._syncing_position = True + + # Change position + new_pos = Bbox.from_bounds(0.3, 0.3, 0.5, 0.5) + ax.set_position(new_pos) + + # External axes position should not have been updated + # (since we're in a sync operation) + # This is hard to test directly, but the code should not crash + + +def test_format_kwargs_extracted_from_init(): + """Test that format kwargs are extracted during init.""" + fig = uplt.figure() + ax = ExternalAxesContainer( + fig, + 1, + 1, + 1, + external_axes_class=MockExternalAxes, + external_axes_kwargs={}, + title="Init Title", + xlabel="X", + ylabel="Y", + ) + + child = ax.get_external_child() + if child is not None: + # Title should have been set during init + assert child.get_title() == "Init Title" + + +def test_multiple_containers_independent(): + """Test that multiple containers work independently.""" + fig = uplt.figure() + + ax1 = ExternalAxesContainer( + fig, 2, 1, 1, external_axes_class=MockExternalAxes, external_axes_kwargs={} + ) + + ax2 = ExternalAxesContainer( + fig, 2, 1, 2, external_axes_class=MockExternalAxes, external_axes_kwargs={} + ) + + # Both should work + assert ax1.has_external_child() + assert ax2.has_external_child() + + # Should be different axes + assert ax1 is not ax2 + assert ax1.get_external_child() is not ax2.get_external_child() + + # External children should not be in figure + assert ax1.get_external_child() not in fig.axes + assert ax2.get_external_child() not in fig.axes From 7e0f112c4b62f425f2c3260ede010b7e9c459a00 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 11 Dec 2025 21:26:58 +1000 Subject: [PATCH 08/12] fix merge issue --- ultraplot/figure.py | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/ultraplot/figure.py b/ultraplot/figure.py index ee15b764b..1d3cf105e 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -1118,7 +1118,6 @@ def _parse_proj( else: name = "ultraplot_" + proj if name is None and isinstance(proj, str): -<<<<<<< HEAD # Check if the name is registered globally in Matplotlib (e.g., 'ternary', 'polar', '3d') # But ONLY if we're not going to try geographic projections next # (i.e., only if backend is specified or constructor doesn't support geographic) @@ -1128,25 +1127,6 @@ def _parse_proj( or constructor.Basemap is not object ) if not should_try_geographic and proj in mproj.get_projection_names(): -======= - # Try geographic projections first if cartopy/basemap available - if ( - constructor.Projection is not object - or constructor.Basemap is not object - ): - try: - proj_obj = constructor.Proj( - proj, backend=backend, include_axes=True, **proj_kw - ) - name = "ultraplot_" + proj_obj._proj_backend - kwargs["map_projection"] = proj_obj - except ValueError: - # Not a geographic projection, will try matplotlib registry below - pass - - # If not geographic, check if registered globally in Matplotlib (e.g., 'ternary', 'polar', '3d') - if name is None and proj in mproj.get_projection_names(): ->>>>>>> bf2bbc9c2 (fixes) name = proj # Helpful error message if still not found From 09c59b5e5d50faaf4eaff45d6403e98f5237cce0 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 11 Dec 2025 21:30:03 +1000 Subject: [PATCH 09/12] correct rebase --- ultraplot/figure.py | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/ultraplot/figure.py b/ultraplot/figure.py index 1d3cf105e..8dfb5b047 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -1118,15 +1118,23 @@ def _parse_proj( else: name = "ultraplot_" + proj if name is None and isinstance(proj, str): - # Check if the name is registered globally in Matplotlib (e.g., 'ternary', 'polar', '3d') - # But ONLY if we're not going to try geographic projections next - # (i.e., only if backend is specified or constructor doesn't support geographic) - should_try_geographic = ( - backend is not None - or constructor.Projection is not object + # Try geographic projections first if cartopy/basemap available + if ( + constructor.Projection is not object or constructor.Basemap is not object - ) - if not should_try_geographic and proj in mproj.get_projection_names(): + ): + try: + proj_obj = constructor.Proj( + proj, backend=backend, include_axes=True, **proj_kw + ) + name = "ultraplot_" + proj_obj._proj_backend + kwargs["map_projection"] = proj_obj + except ValueError: + # Not a geographic projection, will try matplotlib registry below + pass + + # If not geographic, check if registered globally in Matplotlib (e.g., 'ternary', 'polar', '3d') + if name is None and proj in mproj.get_projection_names(): name = proj # Helpful error message if still not found From 72a3cdef9cf8d54c4f62339275aef1f0371ab10e Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 11 Dec 2025 21:46:37 +1000 Subject: [PATCH 10/12] fix mpl39 issue --- .../test_external_container_edge_cases.py | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/ultraplot/tests/test_external_container_edge_cases.py b/ultraplot/tests/test_external_container_edge_cases.py index 2124faea1..e905998fd 100644 --- a/ultraplot/tests/test_external_container_edge_cases.py +++ b/ultraplot/tests/test_external_container_edge_cases.py @@ -59,6 +59,52 @@ def get_zorder(self): def set_zorder(self, zorder): self._zorder = zorder + def get_axes_locator(self): + """Return axes locator (for matplotlib 3.9 compatibility).""" + return None + + def get_in_layout(self): + """Return whether axes participates in layout (matplotlib 3.9 compatibility).""" + return True + + def set_in_layout(self, value): + """Set whether axes participates in layout (matplotlib 3.9 compatibility).""" + pass + + def get_clip_on(self): + """Return whether clipping is enabled (matplotlib 3.9 compatibility).""" + return True + + def get_rasterized(self): + """Return whether axes is rasterized (matplotlib 3.9 compatibility).""" + return False + + def get_agg_filter(self): + """Return agg filter (matplotlib 3.9 compatibility).""" + return None + + def get_sketch_params(self): + """Return sketch params (matplotlib 3.9 compatibility).""" + return None + + def get_path_effects(self): + """Return path effects (matplotlib 3.9 compatibility).""" + return [] + + def get_figure(self): + """Return the figure (matplotlib 3.9 compatibility).""" + return self.figure + + def get_transform(self): + """Return the transform (matplotlib 3.9 compatibility).""" + from matplotlib.transforms import IdentityTransform + + return IdentityTransform() + + def get_transformed_clip_path_and_affine(self): + """Return transformed clip path (matplotlib 3.9 compatibility).""" + return None, None + @property def zorder(self): return self._zorder From 24e85020b463e8a974f175a636d6a628a2cd9caf Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Fri, 12 Dec 2025 04:13:11 +1000 Subject: [PATCH 11/12] fix double draw in repl --- ultraplot/axes/container.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/ultraplot/axes/container.py b/ultraplot/axes/container.py index 530599de3..fc88cfc98 100644 --- a/ultraplot/axes/container.py +++ b/ultraplot/axes/container.py @@ -274,13 +274,12 @@ def _create_external_axes(self): # Mark external axes as stale (needs drawing) self._external_stale = True - # Add external axes to the container's child artists - # This ensures matplotlib will iterate over it during rendering - if hasattr(self, "add_child_axes"): - self.add_child_axes(self._external_axes) - elif hasattr(self, "_children"): - if self._external_axes not in self._children: - self._children.append(self._external_axes) + # Note: Do NOT add external axes as a child artist to the container. + # The container's draw() method explicitly handles drawing the external axes + # (line ~514), and adding it as a child would cause matplotlib to draw it + # twice - once via our explicit call and once via the parent's child iteration. + # This double-draw is especially visible in REPL environments where figures + # are displayed multiple times. except Exception as e: warnings._warn_ultraplot( From 4cb9c97e4c63ab7e2fce5a5e2264da2c243ecd0e Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sun, 14 Dec 2025 16:20:25 +1000 Subject: [PATCH 12/12] Improve external axes container layout for native appearance - Increase default shrink factor from 0.75 to 0.95 to make external axes (e.g., ternary plots) larger and more prominent - Change positioning from centered to top-aligned with left offset for better alignment with adjacent Cartesian subplots - Top alignment ensures abc labels and titles align properly across different projection types - Add 5% left offset to better utilize available horizontal space - Update both _shrink_external_for_labels and _ensure_external_fits_within_container methods for consistency This makes ternary and other external axes integrate seamlessly with standard matplotlib subplots, appearing native rather than artificially constrained. --- ultraplot/axes/container.py | 209 ++++++++++++++++++++++++------------ 1 file changed, 142 insertions(+), 67 deletions(-) diff --git a/ultraplot/axes/container.py b/ultraplot/axes/container.py index fc88cfc98..ed5dc5252 100644 --- a/ultraplot/axes/container.py +++ b/ultraplot/axes/container.py @@ -32,8 +32,32 @@ class ExternalAxesContainer(CartesianAxes): The external axes class to instantiate (e.g., mpltern.TernaryAxes) external_axes_kwargs : dict, optional Keyword arguments to pass to the external axes constructor + external_shrink_factor : float, optional, default: 0.75 + The factor by which to shrink the external axes within the container + to leave room for labels. For ternary plots, labels extend significantly + beyond the plot area, so a value of 0.75 (25% padding) helps prevent + overlap with adjacent subplots. + external_padding : float, optional, default: 5.0 + Padding in points to add around the external axes tight bbox. This creates + space between the external axes and adjacent subplots, preventing overlap + with tick labels or other elements. Set to 0 to disable padding. **kwargs Keyword arguments passed to Axes.__init__ + + Notes + ----- + When using external axes containers with multiple subplots, the external axes + (e.g., ternary plots) are automatically shrunk to prevent label overlap with + adjacent subplots. If you still experience overlap, you can: + + 1. Increase spacing with ``wspace`` or ``hspace`` in subplots() + 2. Decrease ``external_shrink_factor`` (more aggressive shrinking) + 3. Use tight_layout or constrained_layout for automatic spacing + + Example: ``uplt.subplots(ncols=2, projection=('ternary', None), wspace=5)`` + + To reduce padding between external axes and adjacent subplots, use: + ``external_padding=2`` or ``external_padding=0`` to disable padding entirely. """ def __init__( @@ -53,7 +77,12 @@ def __init__( # Store shrink factor for external axes (to fit labels) # Can be customized per-axes or set globally - self._external_shrink_factor = kwargs.pop("external_shrink_factor", 0.85) + # Default 0.95 for ternary plots to prevent label overlap with adjacent subplots + self._external_shrink_factor = kwargs.pop("external_shrink_factor", 0.95) + + # Store padding for tight bbox (prevents overlap with adjacent subplot elements) + # Default 5 points (~7 pixels at 96 dpi) + self._external_padding = kwargs.pop("external_padding", 5.0) # Pop the projection kwarg if it exists (matplotlib will add it) # We don't want to pass it to parent since we're using cartesian for container @@ -134,8 +163,12 @@ def __init__( # But keep it functional for layout purposes self.patch.set_visible(False) self.patch.set_facecolor("none") + + # Hide spines for spine in self.spines.values(): spine.set_visible(False) + + # Hide axes self.xaxis.set_visible(False) self.yaxis.set_visible(False) @@ -281,6 +314,9 @@ def _create_external_axes(self): # This double-draw is especially visible in REPL environments where figures # are displayed multiple times. + # After creation, ensure external axes fits within container by measuring + # This is done lazily on first draw to ensure renderer is available + except Exception as e: warnings._warn_ultraplot( f"Failed to create external axes {self._external_axes_class.__name__}: {e}" @@ -300,22 +336,22 @@ def _shrink_external_for_labels(self): # Get the current position pos = self._external_axes.get_position() - # Shrink by a small margin to ensure labels fit - # For ternary axes, labels typically need about 10-15% padding on each side + # Shrink to leave room for labels that extend beyond the plot area + # For ternary axes, labels typically need about 5% padding (0.95 shrink factor) + # This prevents label overlap with adjacent subplots # Use the configured shrink factor - shrink_factor = getattr(self, "_external_shrink_factor", 0.85) - - # Calculate the center - center_x = pos.x0 + pos.width / 2 - center_y = pos.y0 + pos.height / 2 + shrink_factor = getattr(self, "_external_shrink_factor", 0.95) # Calculate new dimensions new_width = pos.width * shrink_factor new_height = pos.height * shrink_factor - # Calculate new position (centered) - new_x0 = center_x - new_width / 2 - new_y0 = center_y - new_height / 2 + # Align external axes to top of container (for alignment with adjacent subplots) + # Offset slightly to the left to use available space better + center_x = pos.x0 + pos.width / 2 + # Move 5% to the left from center + new_x0 = center_x - new_width / 2 - pos.width * 0.05 + new_y0 = pos.y0 + pos.height - new_height # Set the new position from matplotlib.transforms import Bbox @@ -333,6 +369,84 @@ def _shrink_external_for_labels(self): except Exception: pass # Some axes types don't support aspect adjustment + def _ensure_external_fits_within_container(self, renderer): + """ + Iteratively shrink external axes until it fits completely within container bounds. + + This ensures that external axes labels don't extend beyond the container's + allocated space and overlap with adjacent subplots. + """ + if self._external_axes is None: + return + + if not hasattr(self._external_axes, "get_tightbbox"): + return + + # Get container bounds in display coordinates + container_pos = self.get_position() + container_bbox = container_pos.transformed(self.figure.transFigure) + + # Try up to 10 iterations to fit the external axes within container + max_iterations = 10 + tolerance = 1.0 # 1 pixel tolerance + + for iteration in range(max_iterations): + # Get external axes tight bbox (includes labels) + ext_tight = self._external_axes.get_tightbbox(renderer) + + if ext_tight is None: + break + + # Check if external axes extends beyond container + extends_left = ext_tight.x0 < container_bbox.x0 - tolerance + extends_right = ext_tight.x1 > container_bbox.x1 + tolerance + extends_bottom = ext_tight.y0 < container_bbox.y0 - tolerance + extends_top = ext_tight.y1 > container_bbox.y1 + tolerance + + if not (extends_left or extends_right or extends_bottom or extends_top): + # Fits within container, we're done + break + + # Calculate how much we need to shrink + current_pos = self._external_axes.get_position() + + # Calculate shrink factors needed in each direction + shrink_x = 1.0 + shrink_y = 1.0 + + if extends_left or extends_right: + # Need to shrink horizontally + available_width = container_bbox.width + needed_width = ext_tight.width + if needed_width > 0: + shrink_x = min(0.95, available_width / needed_width * 0.95) + + if extends_bottom or extends_top: + # Need to shrink vertically + available_height = container_bbox.height + needed_height = ext_tight.height + if needed_height > 0: + shrink_y = min(0.95, available_height / needed_height * 0.95) + + # Use the more aggressive shrink factor + shrink_factor = min(shrink_x, shrink_y) + + # Apply shrinking with top-aligned, left-offset positioning + center_x = current_pos.x0 + current_pos.width / 2 + new_width = current_pos.width * shrink_factor + new_height = current_pos.height * shrink_factor + # Move 5% to the left from center + new_x0 = center_x - new_width / 2 - current_pos.width * 0.05 + new_y0 = current_pos.y0 + current_pos.height - new_height + + from matplotlib.transforms import Bbox + new_pos = Bbox.from_bounds(new_x0, new_y0, new_width, new_height) + self._external_axes.set_position(new_pos) + + # Mark as stale to ensure it redraws with new position + if hasattr(self._external_axes, "stale"): + self._external_axes.stale = True + def _sync_position_to_external(self): """Synchronize the container position to the external axes.""" if self._external_axes is None: @@ -515,60 +629,15 @@ def draw(self, renderer): # Only draw if external axes is stale or we haven't synced positions yet if external_stale or not self._position_synced or self._external_stale: + # First, ensure external axes fits within container bounds + # This prevents labels from overlapping with adjacent subplots + self._ensure_external_fits_within_container(renderer) + self._external_axes.draw(renderer) self._external_stale = False - # Ensure external axes stays within container bounds - # Check if tight bbox extends beyond container - if hasattr(self._external_axes, "get_tightbbox"): - try: - tight_bbox = self._external_axes.get_tightbbox(renderer) - container_bbox = self.get_position().transformed( - self.figure.transFigure - ) - - # If tight bbox extends beyond container, we may need to shrink further - # This is a fallback in case initial shrinking wasn't enough - if tight_bbox is not None: - # Get bboxes in figure coordinates - tight_fig = tight_bbox.transformed( - self.figure.transFigure.inverted() - ) - - # Check if we're clipping - if ( - tight_fig.x0 < container_bbox.x0 - 0.01 - or tight_fig.x1 > container_bbox.x1 + 0.01 - or tight_fig.y0 < container_bbox.y0 - 0.01 - or tight_fig.y1 > container_bbox.y1 + 0.01 - ): - # Need more aggressive shrinking - current_pos = self._external_axes.get_position() - extra_shrink = 0.9 # Additional 10% shrink - - center_x = current_pos.x0 + current_pos.width / 2 - center_y = current_pos.y0 + current_pos.height / 2 - new_width = current_pos.width * extra_shrink - new_height = current_pos.height * extra_shrink - new_x0 = center_x - new_width / 2 - new_y0 = center_y - new_height / 2 - - from matplotlib.transforms import Bbox - - new_pos = Bbox.from_bounds( - new_x0, new_y0, new_width, new_height - ) - self._external_axes.set_position(new_pos) - - # Mark as stale to redraw with new position - self._external_stale = True - except Exception: - # If tight bbox calculation fails, just continue - pass - - # After external axes draws, sync container to match its position + # Sync container position to external axes if needed # This ensures abc labels and titles are positioned correctly - # Only sync if positions actually changed (performance optimization) ext_pos = self._external_axes.get_position() # Quick check if position changed since last draw @@ -619,12 +688,18 @@ def stale_callback(self, *args, **kwargs): super().stale_callback(*args, **kwargs) def get_tightbbox(self, renderer, *args, **kwargs): - """Override to return the external axes tight bbox.""" - if self._external_axes is not None and hasattr( - self._external_axes, "get_tightbbox" - ): - return self._external_axes.get_tightbbox(renderer, *args, **kwargs) - return super().get_tightbbox(renderer, *args, **kwargs) + """ + Override to return the container bbox for consistent layout positioning. + + By returning the container's bbox, we ensure the layout engine positions + the container properly within the subplot grid, and we rely on our + iterative shrinking to ensure the external axes fits within the container. + """ + # Simply return the container's position bbox + # This gives the layout engine a symmetric, predictable bbox to work with + container_pos = self.get_position() + container_bbox = container_pos.transformed(self.figure.transFigure) + return container_bbox def __getattr__(self, name): """