Skip to content

Add axis sharing to tiled figures - #593

Draft
SimonHeybrock wants to merge 2 commits into
mainfrom
tiled-shared-axes
Draft

Add axis sharing to tiled figures#593
SimonHeybrock wants to merge 2 commits into
mainfrom
tiled-shared-axes

Conversation

@SimonHeybrock

@SimonHeybrock SimonHeybrock commented Jul 29, 2026

Copy link
Copy Markdown
Member

Draft — the layout work is complete and tested for static figures, but two architectural questions below need a decision before this is ready. Opening it now so @nvaytet can weigh in.

What

Adds sharex and sharey to pp.tiled, using Matplotlib's subplots vocabulary: 'all' (or True), 'row', 'col', 'none' (or False).

tiled = pp.tiled(6, 4, sharex=True, sharey=True)

Why

Tiles showing the same dimension repeat their axis labels and ticks on every panel, and each panel autoscales independently. On anything larger than a 2x2 that is mostly wasted space, and independent ranges make the panels impossible to compare by eye. A 6x4 grid, before and after:

before after
before after

Note the per-tile plot area grows despite the identical figure size. Same effect at 3x3:

before after
before after

What sharing means here

Axes.sharex validates nothing, and it makes the joined axes adopt the reference's range, scale and tick locators. Sharing a tile spanning 100-120 m with one spanning 0-20 m silently leaves the second showing (-1, 21), with its data entirely off-screen. So Tiled does three things Matplotlib does not:

  • Validates dimension, unit and scale per direction, and raises before joining. For 1-D figures the vertical axis carries data rather than a coordinate, so a 1-D/2-D sharey mismatch is an error rather than a silent mess.
  • Unions the ranges, rather than letting the reference win.
  • Drops tick labels only where they are genuinely redundant. This is directional: x labels repeat down a column, so they may only be dropped under 'all'/'col'; y labels repeat across a row, so 'all'/'row'. With sharey='col' every column keeps its labels, since each column is an independent axis:

per-column sharing

Where labels are dropped, the tiles are placed flush and the ticks point inwards. Getting to a genuinely zero gap needed one more thing: constrained layout pads every tile by w_pad/h_pad inches, which survives wspace=hspace=0, so that padding is dropped in the flush direction.

Titles and colorbars

A title sits between two rows, and a colorbar between two columns, so both are starved by the padding removal above. A tile carrying one takes the padding back; tiles with neither stay flush.

titles, unshared titles, shared
titles before titles after

Titles necessarily reopen the vertical gap — a title needs the space. Columns stay flush. The same for 2-D tiles, where the colorbar keeps its distance from the next column's frame:

colorbars

Two architectural questions

Neither is caused by this branch; both are reachable from it and neither felt like my call to make unilaterally.

1. Autoscale ignores the share group. GraphicalView.autoscale computes a bbox from its own artists and assigns canvas.xrange. On shared axes that assignment propagates to the whole group, so any later autoscale collapses the common range to one tile's data — measured (-2.19, 2.20) becoming (-1.10, 1.10) for both tiles. Static figures are unaffected, because sharing is established after construction and nothing autoscales afterwards. Interactive figures driven by widgets are. A correct fix means autoscale has to union across the share group, which is a change to the view/canvas layer, not to Tiled.

2. A tile's canvas runs tight_layout on the parent figure. Canvas.to_widget calls self.fig.tight_layout(). For a tile, self.fig is the shared tiled figure, so adding one tile re-lays-out the whole grid and replaces the constrained layout engine with a PlaceHolderLayoutEngine. Consequences on the widget backend: flush tiles still work (grid spacing survives; measured 0.0 row and column gaps), but the title/colorbar padding does not — a title overlaps the axes above by ~22 pt, worse than the static case this PR fixes. Tiled._set_pads currently just skips when the engine is not configurable, which is why the interactive tests do not crash.

The obvious fix is for a tile's canvas not to tight_layout its parent figure, but that is shared code touching every widget figure, and a naive "more than one axes" guard would also catch 2-D figures with colorbars. Worth discussing what the right condition is.

Composition

Both ways of building a grid are now covered by tests.

Operators (+, /, hstack, vstack) do not propagate sharing: the combined figure is unshared, labels and ticks come back, and only the ranges carry over via copy. That is deliberate rather than incidental — the operands' sharing modes may disagree ('col' combined with 'row' has no sensible answer), and propagating would make combining grids with incompatible units raise, which would be a regression. Now pinned by tests.

Cell-setters handle tiles spanning several cells; a spanning tile takes the group of the first row/column it spans, and edge detection uses the span, so a tile spanning a whole row keeps its labels only if that row is the last.

Replacing an occupied tile now raises when sharing is on. It cannot be done correctly: Matplotlib provides no way to un-share axes, so the replaced tile stays joined to its neighbours and remains its share group's reference. Before this it silently produced a grid whose share group referred to a discarded tile.

That last point brushes against a pre-existing bug worth a separate issue: __setitem__ always calls add_subplot, so replacing a tile leaves the old axes on the figure, visible and drawn underneath the new one. Reproducible on main with no sharing involved:

t = pp.tiled(1, 1)
t[0, 0] = da.plot()
t[0, 0] = (da * 5).plot()
len(t.fig.get_axes())  # 2, both visible

_history also keeps the superseded entry, so + and / replay both. I have left that alone — it is independent of this branch and fixing it properly means deciding what replacement should mean.

Notes

  • The two layout tests are scoped to the static backend, with the reason recorded in the module docstring, rather than asserting something that is not true on the widget backend.
  • The default figsize is untouched. It is calibrated for tiles carrying full decorations and shared tiles need less, but shrinking it would be a visible change for anyone adopting the flag.

Test plan

  • sharex/sharey accept bool and all four mode strings; invalid values raise.
  • Shared tiles get the union of the ranges, not the reference's.
  • Mismatched dimension, unit or scale raise before joining.
  • Tick labels dropped on inner tiles for 'all'/'col' (x) and 'all'/'row' (y), and kept otherwise.
  • Ticks point inwards only on the axis whose labels were dropped.
  • Tiles without inner decorations are flush; titles and colorbars clear the neighbouring frame.
  • Operators do not propagate sharing, and can still combine shared grids with incompatible ones.
  • Tiles spanning several cells share and drop labels according to their span.
  • Replacing a tile raises when sharing is on, and is still allowed without it.
  • Interactive check in a notebook with %matplotlib widget — see question 2 above; expected to show the title overlap.

🤖 Generated with Claude Code

SimonHeybrock and others added 2 commits July 29, 2026 11:19
Tiles showing the same dimension repeat their axis labels and ticks on
every panel, and each panel autoscales independently, which makes grids
hard to compare and wastes space.

Add `sharex` and `sharey` to `Tiled`, taking Matplotlib's `subplots`
vocabulary (`'all'`/`'row'`/`'col'`/`'none'`, or a bool). Matplotlib's
`Axes.sharex` validates nothing and makes the joined axes adopt the
reference's range and scale, so the dimension, unit and scale are checked
here and the ranges are unioned. Tick labels are dropped only where they
would be repeated: down a column for x, across a row for y. Those tiles
are then placed flush, with ticks pointing inwards.

Constrained layout pads every tile, which keeps tiles apart even at zero
grid spacing, so the padding is dropped in the flush direction. A title
sits between two rows and a colorbar between two columns; a tile carrying
one takes the padding back, so that the decoration does not touch the
frame of its neighbour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replacing a tile of a figure with shared axes cannot be done correctly:
Matplotlib provides no way to un-share axes, so the replaced tile stays
joined to its neighbours and remains the reference of its share group.
Raise instead of silently building a grid that refers to a discarded
tile.

The `+` and `/` operators deliberately do not propagate sharing, as the
sharing modes of the operands may disagree and their axes need not be
compatible. Pin that with tests, along with tiles spanning several cells,
which take the group of the first row/column they span.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@SimonHeybrock

Copy link
Copy Markdown
Member Author

Opened #594 for the pre-existing tile-replacement bug described under Composition, so it can be tracked independently of this branch.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant