Layout¶
Figures & axes covered the regular subplots grid. This
page covers everything past it: uneven grids, panels that span cells, axes
placed inside other axes, and second axes stacked on the same cell.
"""Layout tools: a mosaic of spanning panels, a twin y-axis, and an inset."""
import math
import pyplotrs as pp
fig, axd = pp.subplot_mosaic(
"""
AB
AC
""",
figsize=(560, 320),
)
xs = [i * 0.1 for i in range(200)]
# A spans both rows: a decaying signal with a second series in other units.
signal = [math.exp(-x / 6) * math.sin(2 * x) for x in xs]
axd["A"].line(xs, signal, label="signal (V)")
axd["A"].set(title="spanning panel", xlabel="t (s)", ylabel="volts")
power = axd["A"].twinx()
power.line(xs, [s * s for s in signal], color="C1", label="power (W)")
power.set(ylabel="watts")
# An inset zooms the first oscillation of the same trace.
zoom = axd["A"].inset_axes((0.55, 0.62, 0.4, 0.33))
zoom.line(xs[:40], signal[:40], linewidth=1.0)
zoom.set(xlim=(0, 4))
axd["B"].scatter([math.sin(x) for x in xs], [math.cos(3 * x) for x in xs], markersize=2)
axd["B"].set(title="B")
axd["C"].hist([math.sin(x) * 2 for x in xs], bins=14, color="C3")
axd["C"].set(title="C")
fig.set(suptitle="subplot_mosaic + twinx + inset_axes")
fig.save("layout.png")

Uneven grids¶
width_ratios and height_ratios weight the columns and rows:
fig, axs = pp.subplots(1, 2, width_ratios=[3, 1]) # wide panel, narrow panel
fig, axs = pp.subplots(2, 1, height_ratios=[3, 1]) # plot over a residual strip
Only the proportions matter — [3, 1] and [0.75, 0.25] are the same — and the
gutters keep their size, so weighting changes the panels rather than the space
between them. A malformed hint (wrong length, zero or negative) falls back to an
even grid instead of raising.
Spanning panels: subplot_mosaic¶
An ASCII drawing of the layout is usually clearer than index arithmetic.
subplot_mosaic takes one and hands back a
dict keyed by the labels:
A spans the top-left 2×2 block, C spans two rows of the right column, and
. (or a space) leaves a cell empty. Each label's cells must form a solid
rectangle. The string is dedented before it is read, so the indented
triple-quoted form above means what it looks like.
Spanning panels: GridSpec¶
The index-based route, for when the geometry is computed rather than drawn.
Figure.add_gridspec returns a
GridSpec you slice NumPy-style, and
add_subplot places an axes on the slice:
fig = pp.figure(figsize=(500, 320))
gs = fig.add_gridspec(2, 3, height_ratios=[2, 1])
main = fig.add_subplot(gs[0, :2]) # top-left, two columns wide
side = fig.add_subplot(gs[:, 2]) # full-height right column
under = fig.add_subplot(gs[1, 0])
polar = fig.add_subplot(gs[1, 1], projection="polar")
pp.figure() creates a figure with no axes, which is
what you want when every panel is placed by hand. add_subplot takes the same
projection argument as subplots, so a single figure can mix 2D, polar and
3D panels.
Shared axes¶
sharex / sharey unify the data range across all panels, so they line up and
are directly comparable:
fig, axs = pp.subplots(1, 3, sharey=True)
for k, ax in enumerate(axs):
ax.line(xs, [f(x, k) for x in xs])
Sharing affects the range, not the chrome: each panel still draws its own ticks and tick labels. On stacked panels that duplication is usually unwanted, and blanking the inner labels is one argument:
Every getter reports the shared result, so axs[0].get_ylim() on a sharey
figure returns the range the whole row settled on.
Twin axes¶
twinx returns a second axes over the same cell,
sharing the x-axis with an independent y-axis drawn on the right — the usual way
to put two quantities in different units on one panel.
twiny is the transpose.
fig, ax = pp.subplots()
ax.line(t, voltage, label="V")
ax.set(xlabel="t (s)", ylabel="volts")
power = ax.twinx()
power.line(t, watts, color="C1", label="W")
power.set(ylabel="watts")
The twin continues the palette rather than restarting it, so the second series
does not silently come out the same color as the first. Plot on the object
twinx() returned; the original ax still owns the left axis.
Insets¶
inset_axes places a child axes inside the
parent's plot area, in fractions of that area ((0, 0) is the lower-left
corner):
zoom = ax.inset_axes((0.55, 0.6, 0.4, 0.35)) # x0, y0, width, height
zoom.line(xs[:40], ys[:40])
zoom.set(xlim=(0, 4))
The inset is an ordinary Axes — its own scales, ticks, theme colors and marks.
Secondary axes¶
A secondary axis is a relabeling of the same data in another unit, not another
set of data. secondary_xaxis /
secondary_yaxis take a
(forward, inverse) pair mapping primary values to secondary ones:
ax.set(xlabel="wavelength (nm)")
ax.secondary_xaxis("top",
functions=(lambda nm: 1239.8 / nm, lambda ev: 1239.8 / ev),
label="photon energy (eV)")
Omit functions for a plain duplicate axis on the far side. Unlike twinx,
this returns the original axes, because there is nothing new to plot on.
Figure-level chrome¶
A Figure can carry a super-title, one shared legend, and colorbars:
Figure.legend collects the labeled marks of
every panel — 2D, polar and 3D — de-duplicated by label, into a reserved column
to the right of the grid. Because the column is part of the layout rather than
an overlay, a figure legend can never cover data, and the panels shrink to make
room for it. An Axes.legend sits inside its
panel instead, and loc="best" scores each corner by how much data the box
would cover.
Colorbars work the same way — see colormaps & images.
What the layout engine guarantees¶
Panels are solved in a single pass in Rust: every band a figure needs — titles,
tick labels, axis labels, colorbar strips, the legend column — is reserved
before anything is drawn, from the measured extent of the text that will go in
it. There is no tight_layout to call and no iterative shrink-to-fit, which is
also why the per-panel cost stays flat as the grid grows (see
performance).