Skip to content
554 changes: 554 additions & 0 deletions ultraplot/_sharing.py

Large diffs are not rendered by default.

52 changes: 29 additions & 23 deletions ultraplot/axes/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -1485,7 +1485,7 @@ def shared(paxs):
iax._panel_sharex_group = True
iax._sharex_setup(bottom) # parent is bottom-most
paxs = shared(self._panel_dict["top"])
if paxs and self.figure._sharex > 0:
if paxs and self.figure._axis_sharing_enabled("x"):
self._panel_sharex_group = True
for iax in paxs:
iax._panel_sharex_group = True
Expand All @@ -1501,15 +1501,15 @@ def shared(paxs):
iax._panel_sharey_group = True
iax._sharey_setup(left) # parent is left-most
paxs = shared(self._panel_dict["right"])
if paxs and self.figure._sharey > 0:
if paxs and self.figure._axis_sharing_enabled("y"):
self._panel_sharey_group = True
for iax in paxs:
iax._panel_sharey_group = True
iax._sharey_setup(left)

# External axes sharing, sometimes overrides panel axes sharing
# Share x axes within compatible groups
if self.figure._sharex > 0:
if self.figure._axis_sharing_enabled("x"):
axes_x = self._get_share_axes("x")
for group in self.figure._partition_share_axes(axes_x, "x"):
if not group:
Expand All @@ -1519,7 +1519,7 @@ def shared(paxs):
child._sharex_setup(parent)

# Share y axes within compatible groups
if self.figure._sharey > 0:
if self.figure._axis_sharing_enabled("y"):
axes_y = self._get_share_axes("y")
for group in self.figure._partition_share_axes(axes_y, "y"):
if not group:
Expand Down Expand Up @@ -2565,21 +2565,22 @@ def _unshare(self, *, which: str):
self._shared_axes[which].remove(sibling)
if which in "xy":
setattr(sibling, f"_share{which}", None)
this_ax = getattr(self, f"{which}axis")
sib_ax = getattr(sibling, f"{which}axis")
# Reset formatters by creating new Ticker objects.
# A deepcopy can trigger redraws.
new_major = maxis.Ticker()
if this_ax.major:
new_major.locator = copy.copy(this_ax.major.locator)
new_major.formatter = copy.copy(this_ax.major.formatter)
this_ax.major = new_major

new_minor = maxis.Ticker()
if this_ax.minor:
new_minor.locator = copy.copy(this_ax.minor.locator)
new_minor.formatter = copy.copy(this_ax.minor.formatter)
this_ax.minor = new_minor
if which in "xy" and len(siblings) > 1:
# Matplotlib shares the actual Ticker objects, not just their state.
# Give every detached axes independent copies so that subsequent
# limits select tick locations from that axes' own view interval.
# A deepcopy can trigger redraws, so shallow-copy each component.
for sibling in siblings:
axis = getattr(sibling, f"{which}axis")
for name in ("major", "minor"):
ticker = getattr(axis, name)
new_ticker = maxis.Ticker()
if ticker:
new_ticker.locator = copy.copy(ticker.locator)
new_ticker.formatter = copy.copy(ticker.formatter)
new_ticker.locator.set_axis(axis)
new_ticker.formatter.set_axis(axis)
setattr(axis, name, new_ticker)

def _sharex_setup(self, sharex, **kwargs):
"""
Expand Down Expand Up @@ -2966,10 +2967,7 @@ def _update_title(self, loc, title=None, **kwargs):
if self.number is None:
pass
elif self.number > len(title):
raise ValueError(
f"Invalid title list length {len(title)} "
f"for axes with number {self.number}."
)
pass
else:
kw["text"] = title[self.number - 1]
else:
Expand Down Expand Up @@ -3335,6 +3333,14 @@ def format(
change them for specific axes. But many :ref:`other configuration
settings <ug_format>` can be passed to ``format`` too.

Notes
-----
Formatting an indexed axes with parameters that require independent axis
labels, limits, scales, locators, formatters, or tick locations may reduce
the corresponding sharing component for the entire figure. Inspect or
restore sharing with `~ultraplot.figure.Figure.get_axis_sharing` and
`~ultraplot.figure.Figure.set_axis_sharing`.

Other parameters
----------------
%(figure.format)s
Expand Down
69 changes: 28 additions & 41 deletions ultraplot/axes/cartesian.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
"""

import copy
import functools
import inspect
from dataclasses import dataclass, field
from typing import Any, Dict, Optional, Tuple, Union
Expand All @@ -15,6 +14,7 @@
import numpy as np
from packaging import version

from .. import _sharing as psharing
from .. import constructor
from .. import scale as pscale
from .. import ticker as pticker
Expand Down Expand Up @@ -545,8 +545,11 @@ def _apply_axis_sharing_for_axis(

level = 3 if panel_group else sharing_level

# Handle axis label sharing (level > 0)
if level > 0:
# Handle axis-title sharing independently from numeric sharing.
share_labels = panel_group or getattr(
self.figure, f"_share{axis_name}_labels", level > 0
)
if share_labels:
if self.figure._is_share_label_group_member(self, axis_name):
pass
elif self.figure._is_share_label_group_member(shared_axis, axis_name):
Expand All @@ -556,8 +559,11 @@ def _apply_axis_sharing_for_axis(
labels._transfer_label(axis.label, shared_axis_obj.label)
axis.label.set_visible(False)

# Handle tick label sharing (level > 2)
if level > 2:
# Handle tick-label suppression independently from numeric sharing.
share_ticklabels = panel_group or getattr(
self.figure, f"_share{axis_name}_ticklabels", level > 2
)
if share_ticklabels:
label_visibility = self._determine_tick_label_visibility(
axis,
shared_axis,
Expand Down Expand Up @@ -712,7 +718,10 @@ def _add_alt(self, sx, **kwargs):
self._twinned_axes.join(self, ax)

# Format parent and child axes
self.format(**{f"{sx}loc": OPPOSITE_SIDE.get(kwargs[f"{sx}loc"], None)})
with psharing.preserve_axis_sharing():
self.format(
**{f"{sx}loc": OPPOSITE_SIDE.get(kwargs[f"{sx}loc"], None)},
)
setattr(ax, f"_alt{sx}_parent", self)
getattr(ax, f"{sy}axis").set_visible(False)
getattr(ax, "patch").set_visible(False)
Expand Down Expand Up @@ -874,25 +883,21 @@ def _sharex_setup(self, sharex, *, labels=True, limits=True):
"""
# Share panels across *different* subplots
super()._sharex_setup(sharex)
# Get the axis sharing level
level = (
3
if self._panel_sharex_group and self._is_panel_group_member(sharex)
else self.figure._sharex
)
if level not in range(5): # must be internal error
raise ValueError(f"Invalid sharing level sharex={level!r}.")
panel_group = self._panel_sharex_group and self._is_panel_group_member(sharex)
if sharex in (None, self) or not isinstance(sharex, CartesianAxes):
return
# Share future axis label changes. Implemented in _apply_axis_sharing().
# Matplotlib only uses these attributes in __init__() and cla() to share
# tickers -- all other builtin sharing features derives from shared x axes
if level > 0 and labels:
share_labels = panel_group or self.figure._sharex_labels
share_limits = panel_group or self.figure._sharex_limits
share_ticklabels = panel_group or self.figure._sharex_ticklabels
if (share_labels and labels) or (share_limits and limits) or share_ticklabels:
self._sharex = sharex
# Share future axis tickers, limits, and scales
# NOTE: Only difference between levels 2 and 3 is level 3 hides tick
# labels. But this is done after the fact -- tickers are still shared.
if level > 1 and limits:
if share_limits and limits:
self._sharex_limits(sharex)

def _sharey_setup(self, sharey, *, labels=True, limits=True):
Expand All @@ -902,18 +907,15 @@ def _sharey_setup(self, sharey, *, labels=True, limits=True):
"""
# NOTE: See _sharex_setup for notes
super()._sharey_setup(sharey)
level = (
3
if self._panel_sharey_group and self._is_panel_group_member(sharey)
else self.figure._sharey
)
if level not in range(5): # must be internal error
raise ValueError(f"Invalid sharing level sharey={level!r}.")
panel_group = self._panel_sharey_group and self._is_panel_group_member(sharey)
if sharey in (None, self) or not isinstance(sharey, CartesianAxes):
return
if level > 0 and labels:
share_labels = panel_group or self.figure._sharey_labels
share_limits = panel_group or self.figure._sharey_limits
share_ticklabels = panel_group or self.figure._sharey_ticklabels
if (share_labels and labels) or (share_limits and limits) or share_ticklabels:
self._sharey = sharey
if level > 1 and limits:
if share_limits and limits:
self._sharey_limits(sharey)

def _apply_log_formatter_on_scale(self, s):
Expand Down Expand Up @@ -1222,7 +1224,6 @@ def _validate_loc(loc, opts, descrip):
labelloc = _validate_loc(labelloc, label_opts, "axis label")
axis.set_label_position(labelloc)
if offsetloc is not None:
offsetloc = _not_none(offsetloc, options[0])
if hasattr(axis, "set_offset_position"): # y axis (and future x axis?)
axis.set_offset_position(offsetloc)
elif s == "x" and _version_mpl >= "3.3": # ugly x axis kludge
Expand Down Expand Up @@ -1581,6 +1582,7 @@ def get(name):

return _AxisFormatConfig(**config_kwargs)

@shared._format_wrapper(capture_explicit=True)
@docstring._snippet_manager
def format(
self,
Expand Down Expand Up @@ -1860,24 +1862,9 @@ def get_tightbbox(self, renderer, *args, **kwargs):
return super().get_tightbbox(renderer, *args, **kwargs)


def _capture_explicit_format_keys(func):
"""
Preserve raw keyword names before Python binds them to the format signature.
"""

@functools.wraps(func)
def wrapper(self, *args, **kwargs):
kwargs.setdefault("_explicit_format_keys", set(kwargs))
return func(self, *args, **kwargs)

return wrapper


# tmp
# Apply signature obfuscation after storing previous signature
# NOTE: This is needed for __init__, altx, and alty
CartesianAxes._format_signatures[CartesianAxes] = inspect.signature(
CartesianAxes.format
) # noqa: E501
CartesianAxes.format = _capture_explicit_format_keys(CartesianAxes.format)
CartesianAxes.format = docstring._obfuscate_kwargs(CartesianAxes.format)
2 changes: 2 additions & 0 deletions ultraplot/axes/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

from ..config import rc
from ..internals import _pop_rc, warnings
from . import shared
from .cartesian import CartesianAxes

__all__ = ["ExternalAxesContainer"]
Expand Down Expand Up @@ -710,6 +711,7 @@ def clear(self):
if self._external_axes is not None:
self._external_axes.clear()

@shared._format_wrapper
def format(self, **kwargs):
"""
Format the container and delegate to external axes where appropriate.
Expand Down
36 changes: 21 additions & 15 deletions ultraplot/axes/geo.py
Original file line number Diff line number Diff line change
Expand Up @@ -1563,6 +1563,10 @@ class GeoAxes(shared._SharedAxes, plot.PlotAxes):
`map_projection` keyword argument.
"""

_format_sharing_exclude = frozenset(
{"labelpad", "labelcolor", "labelsize", "labelweight"}
)

@docstring._snippet_manager
def __init__(self, *args: Any, **kwargs: Any) -> None:
"""
Expand Down Expand Up @@ -1613,12 +1617,12 @@ def _sync_shared_tick_state(
if not any((copy_major_locator, copy_minor_locator, copy_major_formatter)):
return
if which == "x":
if self.figure._sharex < 2:
if not self.figure._sharex_limits:
return
this_axis = self._lonaxis
siblings = list(self._shared_axes["x"].get_siblings(self))
else:
if self.figure._sharey < 2:
if not self.figure._sharey_limits:
return
this_axis = self._lataxis
siblings = list(self._shared_axes["y"].get_siblings(self))
Expand Down Expand Up @@ -1880,24 +1884,25 @@ def __share_axis_setup(
labels: bool,
limits: bool,
) -> None:
level = getattr(self.figure, f"_share{which}")
if getattr(self, f"_panel_share{which}_group") and self._is_panel_group_member(
other
):
level = 3
if level not in range(5): # must be internal error
raise ValueError(f"Invalid sharing level sharex={level!r}.")
panel_group = getattr(
self, f"_panel_share{which}_group"
) and self._is_panel_group_member(other)
if other in (None, self) or not isinstance(other, GeoAxes):
return
# Share future axis label changes. Implemented in _apply_axis_sharing().
# Matplotlib only uses these attributes in __init__() and cla() to share
# tickers -- all other builtin sharing features derives from shared x axes
if level > 0 and labels:
share_labels = panel_group or getattr(self.figure, f"_share{which}_labels")
share_limits = panel_group or getattr(self.figure, f"_share{which}_limits")
share_ticklabels = panel_group or getattr(
self.figure, f"_share{which}_ticklabels"
)
if (share_labels and labels) or (share_limits and limits) or share_ticklabels:
setattr(self, f"_share{which}", other)
# Share future axis tickers, limits, and scales
# NOTE: Only difference between levels 2 and 3 is level 3 hides ticklabels
# labels. But this is done after the fact -- tickers are still shared.
if level > 1 and limits:
if share_limits and limits:
self._share_limits_with(other, which=which)

@override
Expand Down Expand Up @@ -2078,15 +2083,15 @@ def _apply_axis_sharing(self) -> None:
"""

# Share axis labels
if self._sharex and self.figure._sharex >= 1:
if self._sharex and self.figure._sharex_labels:
if self.figure._is_share_label_group_member(self, "x"):
pass
elif self.figure._is_share_label_group_member(self._sharex, "x"):
self.xaxis.label.set_visible(False)
else:
labels._transfer_label(self.xaxis.label, self._sharex.xaxis.label)
self.xaxis.label.set_visible(False)
if self._sharey and self.figure._sharey >= 1:
if self._sharey and self.figure._sharey_labels:
if self.figure._is_share_label_group_member(self, "y"):
pass
elif self.figure._is_share_label_group_member(self._sharey, "y"):
Expand All @@ -2096,12 +2101,12 @@ def _apply_axis_sharing(self) -> None:
self.yaxis.label.set_visible(False)

# Share interval x
if self._sharex and self.figure._sharex >= 2:
if self._sharex and self.figure._sharex_limits:
self._lonaxis.set_view_interval(*self._sharex._lonaxis.get_view_interval())
self._lonaxis.set_minor_locator(self._sharex._lonaxis.get_minor_locator())

# Share interval y
if self._sharey and self.figure._sharey >= 2:
if self._sharey and self.figure._sharey_limits:
self._lataxis.set_view_interval(*self._sharey._lataxis.get_view_interval())
self._lataxis.set_minor_locator(self._sharey._lataxis.get_minor_locator())

Expand Down Expand Up @@ -2950,6 +2955,7 @@ def _format_apply_ticklen(
# 2) enter rc context and resolve label/locator/formatter inputs
# 3) apply extent, features, and gridlines
# 4) apply tick lengths and defer to parent format
@shared._format_wrapper(exclude=_format_sharing_exclude)
@docstring._snippet_manager
def format(
self,
Expand Down
Loading