Skip to content
2 changes: 2 additions & 0 deletions doc/_quartodoc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,8 @@ quartodoc:
- scale_y_sqrt
- scale_y_symlog
- scale_y_timedelta
- sec_axis
- dup_axis

- subtitle: Shape Scales
options: *no-members
Expand Down
4 changes: 4 additions & 0 deletions doc/changelog.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ title: Changelog

### New

- You can now add a secondary axis to a plot, either as a one-to-one
transformation of the primary axis with [](:class:`~plotnine.sec_axis`), or as a
mirror of the primary axis with [](:func:`~plotnine.dup_axis`).

- Added [](:class:`~plotnine.composition.plot_layout`) with which you can customise the
layout of plots in composition.

Expand Down
4 changes: 4 additions & 0 deletions plotnine/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@
qplot,
)
from .scales import (
dup_axis,
expand_limits,
lims,
scale_alpha,
Expand Down Expand Up @@ -217,6 +218,7 @@
scale_y_sqrt,
scale_y_symlog,
scale_y_timedelta,
sec_axis,
xlim,
ylim,
)
Expand Down Expand Up @@ -290,6 +292,7 @@
"coord_fixed",
"coord_flip",
"coord_trans",
"dup_axis",
"element_blank",
"element_line",
"element_rect",
Expand Down Expand Up @@ -461,6 +464,7 @@
"scale_y_sqrt",
"scale_y_symlog",
"scale_y_timedelta",
"sec_axis",
"stage",
"stat_bin",
"stat_bin2d",
Expand Down
130 changes: 130 additions & 0 deletions plotnine/_mpl/axes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
from __future__ import annotations

from typing import TYPE_CHECKING, TypeVar

from matplotlib import cbook
from matplotlib.axes import Axes
from matplotlib.axis import XAxis, YAxis
from matplotlib.projections import register_projection

if TYPE_CHECKING:
from plotnine.typing import Side

AxisT = TypeVar("AxisT", XAxis, YAxis)


class p9Axes(Axes):
"""
Axes of a plotnine panel

In addition to the regular x & y axes, a panel can hold one
secondary axis per dimension (`sec_xaxis`/`sec_yaxis`), drawn on
the side opposite the primary with its own fixed breaks and labels.

The panel is the shared substrate (data space, patch, grid, and the
four spines); each `Axis` carries the per-axis state (ticks,
labels, locator/formatter, and its active side); a secondary axis
is just one more `Axis` living in the panel's data space.

Spines start hidden; each axis shows the panel spine of the side it
occupies.
"""

name = "plotnine"

# mpl resolves every Axis operation through the per-name axis
# registries; the secondary axes register under their own names.
_shared_axes = {
**Axes._shared_axes, # pyright: ignore[reportAttributeAccessIssue]
"sec_x": cbook.Grouper(),
"sec_y": cbook.Grouper(),
}

# The secondary axis of each dimension; None until added
sec_xaxis: XAxis | None
sec_yaxis: YAxis | None

# Sides of the panel that carry an axis (primary or secondary);
# setup shows their spines.
sides_with_an_axis: set[Side]

def __init__(self, *args, **kwargs):
self.sec_xaxis = None
self.sec_yaxis = None
self.sides_with_an_axis = set()
super().__init__(*args, **kwargs)
self._sharesec_x = None
self._sharesec_y = None
# Spines are opt-in: each axis (primary or secondary) shows
# the spine of the side it occupies.
self.spines[:].set_visible(False)

def add_sec_axis(self, side: Side) -> XAxis | YAxis:
"""
Return the secondary axis for `side`, creating it if needed

The axis starts bare — no breaks, labels or active side; the
caller configures it like any other axis.

Parameters
----------
side :
Side of the panel the secondary axis will occupy.

Returns
-------
:
The secondary axis of the side's dimension.
"""
if side in ("top", "bottom"):
if self.sec_xaxis is None:
self.sec_xaxis = self._make_sec_axis(XAxis, "sec_x")
return self.sec_xaxis
else:
if self.sec_yaxis is None:
self.sec_yaxis = self._make_sec_axis(YAxis, "sec_y")
return self.sec_yaxis

def _make_sec_axis(self, cls: type[AxisT], name: str) -> AxisT:
axis = cls(self)
# Register the axis so mpl can resolve its name, and add it to
# the draw tree. Panels are never cleared after this point;
# Axes.clear() would detach the artist.
self._axis_map[name] = axis
self.add_artist(axis)
axis.set_clip_on(False)
axis.grid(False)
return axis


def axis_at(ax: Axes, side: Side) -> XAxis | YAxis | None:
"""
Return the axis of `ax` whose ticks occupy `side`, if any

Considers the primary axis of the side's dimension and, on a
`p9Axes`, the secondary one.

Parameters
----------
ax :
Panel axes.
side :
Side of the panel.

Returns
-------
:
The axis with active ticks on `side`, or `None` when that side
shows no ticks (e.g. an interior facet panel).
"""
if side in ("top", "bottom"):
candidates = (ax.xaxis, getattr(ax, "sec_xaxis", None))
else:
candidates = (ax.yaxis, getattr(ax, "sec_yaxis", None))
for axis in candidates:
if axis and axis.get_tick_params(which="major").get(side):
return axis
return None


register_projection(p9Axes)
69 changes: 36 additions & 33 deletions plotnine/_mpl/layout_manager/_plot_layout_items.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from plotnine.composition._compose import Compose
from plotnine.exceptions import PlotnineError

from ..axes import axis_at
from ..utils import (
ArtistGeometry,
TextJustifier,
Expand Down Expand Up @@ -44,6 +45,7 @@
from plotnine.themes.theme import theme
from plotnine.typing import (
HorizontalJustification,
Side,
StripPosition,
VerticalJustification,
)
Expand Down Expand Up @@ -206,15 +208,15 @@ def _filter_axes(self, location: AxesLocation = "all") -> list[Axes]:
if getattr(spec, pred_method)()
]

def axis_text_x(self, ax: Axes, side: str) -> Iterator[Text]:
def axis_text_x(self, ax: Axes, side: Side) -> Iterator[Text]:
"""
Return the visible x-axis labels on one side of an axes
"""
major, minor = [], []

if not self._is_blank("axis_text_x"):
major = ax.xaxis.get_major_ticks()
minor = ax.xaxis.get_minor_ticks()
if not self._is_blank("axis_text_x") and (axis := axis_at(ax, side)):
major = axis.get_major_ticks()
minor = axis.get_minor_ticks()

label_attr = side_artists(side)[1]
return (
Expand All @@ -223,15 +225,15 @@ def axis_text_x(self, ax: Axes, side: str) -> Iterator[Text]:
if _text_is_visible(getattr(tick, label_attr))
)

def axis_text_y(self, ax: Axes, side: str) -> Iterator[Text]:
def axis_text_y(self, ax: Axes, side: Side) -> Iterator[Text]:
"""
Return the visible y-axis labels on one side of an axes
"""
major, minor = [], []

if not self._is_blank("axis_text_y"):
major = ax.yaxis.get_major_ticks()
minor = ax.yaxis.get_minor_ticks()
if not self._is_blank("axis_text_y") and (axis := axis_at(ax, side)):
major = axis.get_major_ticks()
minor = axis.get_minor_ticks()

label_attr = side_artists(side)[1]
return (
Expand All @@ -240,31 +242,33 @@ def axis_text_y(self, ax: Axes, side: str) -> Iterator[Text]:
if _text_is_visible(getattr(tick, label_attr))
)

def axis_ticks_x(self, ax: Axes) -> Iterator[Tick]:
def axis_ticks_x(self, ax: Axes, side: Side) -> Iterator[Tick]:
"""
Return all XTicks that will be shown
Return all XTicks on `side` that will be shown
"""
major, minor = [], []

if not self._is_blank("axis_ticks_major_x"):
major = ax.xaxis.get_major_ticks()
if (axis := axis_at(ax, side)) is not None:
if not self._is_blank("axis_ticks_major_x"):
major = axis.get_major_ticks()

if not self._is_blank("axis_ticks_minor_x"):
minor = ax.xaxis.get_minor_ticks()
if not self._is_blank("axis_ticks_minor_x"):
minor = axis.get_minor_ticks()

return chain(major, minor)

def axis_ticks_y(self, ax: Axes) -> Iterator[Tick]:
def axis_ticks_y(self, ax: Axes, side: Side) -> Iterator[Tick]:
"""
Return all YTicks that will be shown
Return all YTicks on `side` that will be shown
"""
major, minor = [], []

if not self._is_blank("axis_ticks_major_y"):
major = ax.yaxis.get_major_ticks()
if (axis := axis_at(ax, side)) is not None:
if not self._is_blank("axis_ticks_major_y"):
major = axis.get_major_ticks()

if not self._is_blank("axis_ticks_minor_y"):
minor = ax.yaxis.get_minor_ticks()
if not self._is_blank("axis_ticks_minor_y"):
minor = axis.get_minor_ticks()

return chain(major, minor)

Expand Down Expand Up @@ -383,20 +387,20 @@ def strip_shift(self, st: StripText) -> float:
pad_pt = theme.getp(f"strip_switch_pad_{g}") or 0
return band + (pad_pt / 72) / dim

def axis_ticks_x_max_height(self, ax: Axes, side: str) -> float:
def axis_ticks_x_max_height(self, ax: Axes, side: Side) -> float:
"""
Return maximum height[figure space] of visible x ticks on a side
"""
attr = side_artists(side)[0]
heights = [
self.geometry.tight_height(getattr(tick, attr))
for tick in self.axis_ticks_x(ax)
for tick in self.axis_ticks_x(ax, side)
if getattr(tick, attr).get_visible()
]
return max(heights) if len(heights) else 0

def axis_ticks_x_max_height_at(
self, location: AxesLocation, side: str
self, location: AxesLocation, side: Side
) -> float:
"""
Return maximum height[figure space] of visible x ticks on a side
Expand All @@ -407,7 +411,7 @@ def axis_ticks_x_max_height_at(
]
return max(heights) if len(heights) else 0

def axis_text_x_max_height(self, ax: Axes, side: str) -> float:
def axis_text_x_max_height(self, ax: Axes, side: Side) -> float:
"""
Return maximum height[figure space] of x tick labels on a side
"""
Expand All @@ -418,7 +422,7 @@ def axis_text_x_max_height(self, ax: Axes, side: str) -> float:
return max(heights) if len(heights) else 0

def axis_text_x_max_height_at(
self, location: AxesLocation, side: str
self, location: AxesLocation, side: Side
) -> float:
"""
Return maximum height[figure space] of x tick labels on a side
Expand All @@ -429,20 +433,20 @@ def axis_text_x_max_height_at(
]
return max(heights) if len(heights) else 0

def axis_ticks_y_max_width(self, ax: Axes, side: str) -> float:
def axis_ticks_y_max_width(self, ax: Axes, side: Side) -> float:
"""
Return maximum width[figure space] of visible y ticks on a side
"""
attr = side_artists(side)[0]
widths = [
self.geometry.tight_width(getattr(tick, attr))
for tick in self.axis_ticks_y(ax)
for tick in self.axis_ticks_y(ax, side)
if getattr(tick, attr).get_visible()
]
return max(widths) if len(widths) else 0

def axis_ticks_y_max_width_at(
self, location: AxesLocation, side: str
self, location: AxesLocation, side: Side
) -> float:
"""
Return maximum width[figure space] of visible y ticks on a side
Expand All @@ -453,7 +457,7 @@ def axis_ticks_y_max_width_at(
]
return max(widths) if len(widths) else 0

def axis_text_y_max_width(self, ax: Axes, side: str) -> float:
def axis_text_y_max_width(self, ax: Axes, side: Side) -> float:
"""
Return maximum width[figure space] of y tick labels on a side
"""
Expand All @@ -464,7 +468,7 @@ def axis_text_y_max_width(self, ax: Axes, side: str) -> float:
return max(widths) if len(widths) else 0

def axis_text_y_max_width_at(
self, location: AxesLocation, side: str
self, location: AxesLocation, side: Side
) -> float:
"""
Return maximum width[figure space] of y tick labels on a side
Expand Down Expand Up @@ -742,17 +746,16 @@ def _position_strip_band_axes(self, spaces: PlotSideSpaces):
fig = self.plot.figure
to_points = 72 / fig.dpi
W, H = fig.bbox.width, fig.bbox.height
offsets = {
offsets: dict[Side, float] = {
"top": spaces.t.strip_band_offset() * H,
"bottom": spaces.b.strip_band_offset() * H,
"left": spaces.l.strip_band_offset() * W,
"right": spaces.r.strip_band_offset() * W,
}
for ax in self.plot.axs:
for side, offset in offsets.items():
if not offset:
if not offset or (axis := axis_at(ax, side)) is None:
continue
axis = ax.xaxis if side in ("top", "bottom") else ax.yaxis
_spine_set_position_outward(
ax.spines[side], axis, offset * to_points
)
Expand Down
Loading
Loading