Skip to content

Commit 8f66423

Browse files
authored
Update axis sharing to allow for top and right sharing.
Sharing is one of the core features of scientific plotting as it at the heart of UltraPlot. Sharing, unfortunately, is sometimes complex and is sometimes not handled correctly. This PR updates the sharing to allow for sharing top and right labels without needing to turn of the sharing feature.
1 parent 9e9223d commit 8f66423

File tree

11 files changed

+750
-208
lines changed

11 files changed

+750
-208
lines changed

docs/subplots.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -376,8 +376,14 @@
376376
# are defined as follows:
377377
#
378378
# * ``False`` or ``0``: Axis sharing is disabled.
379-
# * ``'labels'``, ``'labs'``, or ``1``: Axis labels are shared, but
380-
# nothing else. Labels will appear on the leftmost and bottommost subplots.
379+
# * ``'labels'``, ``'labs'``, or ``1``: Axis labels are shared, but nothing else. Labels will appear on the outermost
380+
# plots. This implies that for left, and bottom labels (default)
381+
# the labels will appear on the leftmost and bottommost subplots
382+
# bottommost subplots. Note that labels will be shared only for
383+
# plots that are immediately adjacent in the same row or column
384+
# of the :class:`~ultraplot.gridspec.GridSpec`; a space or
385+
# empty plot will add the labels, but not break the limit
386+
# sharing. See below for a more complex example.
381387
# * ``'limits'``, ``'lims'``, or ``2``: Same as ``1``, but axis limits, axis
382388
# scales, and major and minor tick locations and formatting are also shared.
383389
# * ``True`` or ``3`` (default): Same as ``2``, but axis tick labels are also
@@ -446,6 +452,25 @@
446452
yticks=5,
447453
)
448454

455+
# %% [raw] raw_mimetype="text/restructuredtext"
456+
# When subplots are arranged on a grid, UltraPlot will
457+
# automatically share axis labels where appropriate. For more
458+
# complex layouts, UltraPlot will add the labels when the subplot
459+
# is facing and "edge" which is defined as not immediately having a subplot next to it. For example:
460+
import ultraplot as uplt, numpy as np
461+
462+
layout = [[1, 0, 2], [0, 3, 0], [4, 0, 6]]
463+
fig, ax = uplt.subplots(layout)
464+
ax.format(xticklabelloc="top", yticklabelloc="right")
465+
# plot data to indicate that limits are still shared
466+
x = y = np.linspace(0, 1, 10)
467+
for axi in ax:
468+
axi.plot(axi.number * x, axi.number * y)
469+
fig.show()
470+
471+
# %% [raw] raw_mimetype="text/restructuredtext"
472+
# Notice how the top and right labels here are added since no
473+
# subplot is immediately adjacent to another, the limits however, are shared.
449474

450475
# %% [raw] raw_mimetype="text/restructuredtext"
451476
# .. _ug_units:

ultraplot/axes/base.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1532,7 +1532,7 @@ def shared(paxs):
15321532
return [pax for pax in paxs if not pax._panel_hidden and pax._panel_share]
15331533

15341534
# Internal axis sharing, share stacks of panels and main axes with each other
1535-
# NOTE: This is called on the main axes whenver a panel is created.
1535+
# NOTE: This is called on the main axes whenever a panel is created.
15361536
# NOTE: This block is why, even though we have figure-wide share[xy], we
15371537
# still need the axes-specific _share[xy]_override attribute.
15381538
if not self._panel_side: # this is a main axes
@@ -3259,7 +3259,6 @@ def _is_panel_group_member(self, other: "Axes") -> bool:
32593259
and self._panel_parent is other._panel_parent
32603260
):
32613261
return True
3262-
32633262
# Not in the same panel group
32643263
return False
32653264

ultraplot/axes/cartesian.py

Lines changed: 150 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,16 @@
99
import matplotlib.ticker as mticker
1010
import numpy as np
1111

12+
from packaging import version
13+
1214
from .. import constructor
1315
from .. import scale as pscale
1416
from .. import ticker as pticker
1517
from ..config import rc
1618
from ..internals import ic # noqa: F401
1719
from ..internals import _not_none, _pop_rc, _version_mpl, docstring, labels, warnings
1820
from . import plot, shared
21+
import matplotlib.axis as maxis
1922

2023
__all__ = ["CartesianAxes"]
2124

@@ -373,35 +376,162 @@ def _apply_axis_sharing(self):
373376
Enforce the "shared" axis labels and axis tick labels. If this is not
374377
called at drawtime, "shared" labels can be inadvertantly turned off.
375378
"""
376-
# X axis
377379
# NOTE: Critical to apply labels to *shared* axes attributes rather
378380
# than testing extents or we end up sharing labels with twin axes.
379381
# NOTE: Similar to how _align_super_labels() calls _apply_title_above() this
380382
# is called inside _align_axis_labels() so we align the correct text.
381383
# NOTE: The "panel sharing group" refers to axes and panels *above* the
382384
# bottommost or to the *right* of the leftmost panel. But the sharing level
383385
# used for the leftmost and bottommost is the *figure* sharing level.
384-
axis = self.xaxis
385-
if self._sharex is not None and axis.get_visible():
386-
level = 3 if self._panel_sharex_group else self.figure._sharex
387-
if level > 0:
388-
labels._transfer_label(axis.label, self._sharex.xaxis.label)
389-
axis.label.set_visible(False)
390-
if level > 2:
391-
# WARNING: Cannot set NullFormatter because shared axes share the
392-
# same Ticker(). Instead use approach copied from mpl subplots().
393-
axis.set_tick_params(which="both", labelbottom=False, labeltop=False)
394-
# Y axis
395-
axis = self.yaxis
396-
if self._sharey is not None and axis.get_visible():
397-
level = 3 if self._panel_sharey_group else self.figure._sharey
398-
if level > 0:
399-
labels._transfer_label(axis.label, self._sharey.yaxis.label)
400-
axis.label.set_visible(False)
401-
if level > 2:
402-
axis.set_tick_params(which="both", labelleft=False, labelright=False)
386+
387+
# Get border axes once for efficiency
388+
border_axes = self.figure._get_border_axes()
389+
390+
# Apply X axis sharing
391+
self._apply_axis_sharing_for_axis("x", border_axes)
392+
393+
# Apply Y axis sharing
394+
self._apply_axis_sharing_for_axis("y", border_axes)
395+
396+
def _apply_axis_sharing_for_axis(
397+
self,
398+
axis_name: str,
399+
border_axes: dict[str, plot.PlotAxes],
400+
) -> None:
401+
"""
402+
Apply axis sharing for a specific axis (x or y).
403+
404+
Parameters
405+
----------
406+
axis_name : str
407+
Either 'x' or 'y'
408+
border_axes : dict
409+
Dictionary from _get_border_axes() containing border information
410+
"""
411+
if axis_name == "x":
412+
axis = self.xaxis
413+
shared_axis = self._sharex
414+
panel_group = self._panel_sharex_group
415+
sharing_level = self.figure._sharex
416+
label_params = ["labeltop", "labelbottom"]
417+
border_sides = ["top", "bottom"]
418+
else: # axis_name == 'y'
419+
axis = self.yaxis
420+
shared_axis = self._sharey
421+
panel_group = self._panel_sharey_group
422+
sharing_level = self.figure._sharey
423+
label_params = ["labelleft", "labelright"]
424+
border_sides = ["left", "right"]
425+
426+
if shared_axis is None or not axis.get_visible():
427+
return
428+
429+
level = 3 if panel_group else sharing_level
430+
431+
# Handle axis label sharing (level > 0)
432+
if level > 0:
433+
shared_axis_obj = getattr(shared_axis, f"{axis_name}axis")
434+
labels._transfer_label(axis.label, shared_axis_obj.label)
435+
axis.label.set_visible(False)
436+
437+
# Handle tick label sharing (level > 2)
438+
if level > 2:
439+
label_visibility = self._determine_tick_label_visibility(
440+
axis,
441+
shared_axis,
442+
axis_name,
443+
label_params,
444+
border_sides,
445+
border_axes,
446+
)
447+
axis.set_tick_params(which="both", **label_visibility)
448+
# Turn minor ticks off
403449
axis.set_minor_formatter(mticker.NullFormatter())
404450

451+
def _determine_tick_label_visibility(
452+
self,
453+
axis: maxis.Axis,
454+
shared_axis: maxis.Axis,
455+
axis_name: str,
456+
label_params: list[str],
457+
border_sides: list[str],
458+
border_axes: dict[str, list[plot.PlotAxes]],
459+
) -> dict[str, bool]:
460+
"""
461+
Determine which tick labels should be visible based on sharing rules and borders.
462+
463+
Parameters
464+
----------
465+
axis : matplotlib axis
466+
The current axis object
467+
shared_axis : Axes
468+
The axes this one shares with
469+
axis_name : str
470+
Either 'x' or 'y'
471+
label_params : list
472+
List of label parameter names (e.g., ['labeltop', 'labelbottom'])
473+
border_sides : list
474+
List of border side names (e.g., ['top', 'bottom'])
475+
border_axes : dict
476+
Dictionary from _get_border_axes()
477+
478+
Returns
479+
-------
480+
dict
481+
Dictionary of label visibility parameters
482+
"""
483+
ticks = axis.get_tick_params()
484+
shared_axis_obj = getattr(shared_axis, f"{axis_name}axis")
485+
sharing_ticks = shared_axis_obj.get_tick_params()
486+
487+
label_visibility = {}
488+
489+
def _convert_label_param(label_param: str) -> str:
490+
# Deal with logic not being consistent
491+
# in prior mpl versions
492+
if version.parse(str(_version_mpl)) <= version.parse("3.9"):
493+
if label_param == "labeltop" and axis_name == "x":
494+
label_param = "labelright"
495+
elif label_param == "labelbottom" and axis_name == "x":
496+
label_param = "labelleft"
497+
return label_param
498+
499+
for label_param, border_side in zip(label_params, border_sides):
500+
# Check if user has explicitly set label location via format()
501+
label_visibility[label_param] = False
502+
has_panel = False
503+
for panel in self._panel_dict[border_side]:
504+
# Check if the panel is a colorbar
505+
colorbars = [
506+
values
507+
for key, values in self._colorbar_dict.items()
508+
if border_side in key # key is tuple (side, top | center | lower)
509+
]
510+
if not panel in colorbars:
511+
# Skip colorbar as their
512+
# yaxis is not shared
513+
has_panel = True
514+
break
515+
# When we have a panel, let the panel have
516+
# the labels and turn-off for this axis + side.
517+
if has_panel:
518+
continue
519+
is_border = self in border_axes.get(border_side, [])
520+
is_panel = (
521+
self in shared_axis._panel_dict[border_side]
522+
and self == shared_axis._panel_dict[border_side][-1]
523+
)
524+
# Use automatic border detection logic
525+
# if we are a panel we "push" the labels outwards
526+
label_param_trans = _convert_label_param(label_param)
527+
is_this_tick_on = ticks[label_param_trans]
528+
is_parent_tick_on = sharing_ticks[label_param_trans]
529+
if is_panel:
530+
label_visibility[label_param] = is_parent_tick_on
531+
elif is_border:
532+
label_visibility[label_param] = is_this_tick_on
533+
return label_visibility
534+
405535
def _add_alt(self, sx, **kwargs):
406536
"""
407537
Add an alternate axes.

ultraplot/axes/shared.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,13 @@
1212
from ..utils import _fontsize_to_pt, _not_none, units
1313
from ..axes import Axes
1414

15+
try:
16+
# From python 3.12
17+
from typing import override
18+
except ImportError:
19+
# From Python 3.5
20+
from typing_extensions import override
21+
1522

1623
class _SharedAxes(object):
1724
"""
@@ -186,10 +193,11 @@ def _update_ticks(
186193
for lab in obj.get_ticklabels():
187194
lab.update(kwtext_extra)
188195

189-
# Override matplotlib defaults to handle multiple axis sharing
196+
@override
190197
def sharex(self, other):
191198
return self._share_axis_with(other, which="x")
192199

200+
@override
193201
def sharey(self, other):
194202
self._share_axis_with(other, which="y")
195203

0 commit comments

Comments
 (0)