Plot Insets (inset_element)#1058
Merged
Merged
Conversation
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #1058 +/- ##
==========================================
+ Coverage 86.88% 86.92% +0.03%
==========================================
Files 203 206 +3
Lines 13768 14031 +263
Branches 1689 1723 +34
==========================================
+ Hits 11963 12197 +234
- Misses 1256 1277 +21
- Partials 549 557 +8 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
Introduces the inset_element dataclass for placing a plot or composition as an overlay on a host plot at NPC coordinates, and the ggplot._insets list that stores them. Wiring into the draw and layout pipeline comes in later commits.
Adds ggplot._draw_insets to render each attached inset into the host's figure as the final step of draw(). Refactors _create_figure on both ggplot and Compose into independent guards for the figure and the gridspec so a pre-assigned figure (an inset reusing its host's) still flows through to gridspec creation. Adds a _zorder class default on ggplot and Compose that the inset path raises above the host.
Adds PlotSideSpaces._arrange_insets, called as the final step of arrange once the host's panel/plot regions are finalised. Each inset's fractional bounding box is scaled into figure coordinates relative to its align_to region and applied to the inset's own gridspec, then a PlotSideSpaces or CompositionSideSpaces is built and arranged to lay out the inset's internal content within those bounds. Also updates inset_element's docstring to use 'fractional coordinates' instead of 'normalised parent coordinates' for terminology familiar to matplotlib users.
Pass the owning plot's _zorder to add_subplot in facet._make_axes so every panel axes is born at the right layer. Compose.draw propagates the composition's _zorder to its direct children before drawing, and the recursion carries the value down sub-compositions. Combined with ggplot._draw_insets raising _zorder by 1 per inset boundary, insets (including Compose insets and nested insets) end up above the host without any post-hoc subtree walk.
Every figure-level artist owned by a plot — panel borders, titles, strip text, legends, watermarks, plus the plot/composition background and footer rects — now goes through a single _add_figure_artist helper on ggplot and Compose that offsets its zorder by the owning plot's _zorder. This guarantees an inset's whole stack paints above the host's whole stack, including the previously-missed strip backgrounds, titles, and legends. INSET_ZORDER_STEP is set to 1000 (well above the largest within-plot zorder, watermarks at 99.9) so host and inset bands never overlap. Watermark zorder is now managed entirely by plotnine; a user-supplied value is dropped with a PlotnineWarning, and watermark.draw reads its host's _zorder via the parent reference set in __radd__. plot_title and the other figure-level texts are constructed as matplotlib.text.Text and added through the same helper, dropping figure.text indirection.
The previous docstring placed titles/captions/legends in `"full"` and plot margins in `"plot"`. The actual region semantics are the other way around — titles/captions/legends are part of the plot region, and `"full"` adds the plot margin on top. Update the docstring to match.
Sibling insets all received the same _zorder (host._zorder + INSET_ZORDER_STEP), so their figure-level artists collided inside one band: an earlier inset's panel_border, titles, axis titles and legend sat above a later inset's panel because they had larger numeric zorders than the panel itself. The "later inset fully covers earlier" intent only held for the panel area, where equal-zorder ties broke by insertion order. inset_element._setup now takes the 1-based index among its siblings and assigns _zorder = parent._zorder + index * INSET_ZORDER_STEP, so each inset occupies its own band. Insets._setup enumerates and passes the index through. INSET_ZORDER_STEP shrinks from 1000 to 10 now that nothing inside a single plot reaches 99.9 anymore: watermark drops from 99.9 to 9, and the explicit zorder=99.1 on the legend's FlexibleAnchoredOffsetbox is removed (the mpl default of 5 is already above all decorations and below the watermark, and ggplot._add_figure_artist offsets it into the right band). The 0.5 gap between an inset's watermark (band top) and the next inset's plot_background (band bottom, -0.5) is the tightest the geometry permits and is safe — no other artist falls in it.
An inset_element's `obj` and a Compose's items share the parent's matplotlib figure, but their own theme.figure_size and dpi default independently. That caused the inset's `figure_size` themeable to resize the host figure, the inset's dpi to leak into rcParams during its draw context, and layout sites (`_plot_side_space`, `_composition_side_space`, `margin.setup`) to read inconsistent values. A new `theme._inherit_figure_props` method copies these figure-owner-only values from a parent theme. inset_element._setup calls it on the inset's theme; Compose.draw calls it on every item alongside the existing zorder propagation. Nested cases compose by recursion.
Replace `theme._setup(figure, axs, title, subtitle)` with `theme._setup(owner)` where owner is the ggplot, Compose, or guide the theme is attached to. `theme.figure` and `theme.axs` become properties that resolve to the owner's current state at access time, so theme setup no longer requires axes to exist beforehand — they're read lazily at apply time. This removes the ordering constraint that coupled theme setup to facet.setup, and drops the vestigial `theme.plot` annotation that was never assigned or read.
Add a `Figure` subclass that, when `_stamping` is enabled, stamps each figure-level artist with a strictly increasing zorder so insertion order dictates paint order. The override covers `add_artist`, `add_subplot`, `add_axes`, `figimage`, and `text` — the entry points plotnine actually uses. Stamping defaults to off, so plotnine's existing zorder constants keep controlling layering. This is groundwork for the upcoming restructure of `ggplot.draw()` and `Compose._draw_composition` into semantic insertion order, after which the explicit zorder bookkeeping (INSET_ZORDER_STEP, _zorder fields, plot_background_offset, _BASE_ZORDER, etc.) can be removed. Wire `p9Figure` in as the `FigureClass` at every plotnine figure creation site: `ggplot._create_figure`, `Compose._create_figure`, and `PlotnineAnimation`.
`Insets.draw()` now requires `which="above"` or `which="below"` and renders only that band's insets. Below-insets render last-declared first (closest to the host), above-insets in declaration order — both filters that were previously implicit in the per-inset zorder offsets. Update the single call site in `ggplot.draw()` to issue both calls back-to-back. Visual output is unchanged: explicit zorders still control paint order, the new method only changes the *sequence* of `add_artist` calls within each band. Groundwork for the upcoming `ggplot.draw()` restructure, where `draw(which="below")` will move to right after `_draw_plot_background` and `draw(which="above")` to the very end of the pipeline, so insertion order matches paint order.
Move `_draw_plot_background` and `_insets.draw(which="below")` to the top of the draw pipeline, ahead of `facet.setup` (axes creation). Above-insets stay at the very end. The new order is bg → below-insets → axes → host stack → above-insets, matching the order each artist should paint. Visual output is unchanged because explicit zorders still control layering; this reorder is the no-op step that lets Step 5 swap zorder bookkeeping for insertion-order stamping. Theme and guides setup are axes-lazy (`theme._setup` since 274ce91; `guides._setup` always was), so deferring `facet.setup` past them is safe.
Pull `_setup`, `theme._setup`, and `_draw_composition_background` to the head of the draw pipeline, before items render. Items (plots and nested sub-compositions) draw next, then the annotation overlay, then `theme.apply` populates final styling. The inner recursion is renamed to `_draw_items` and only walks plots and sub-compositions — the outer composition's bg / annotation / theme are now handled at the top level. Visual output unchanged; explicit zorders still control layering. This is the Compose-side mirror of the ggplot.draw() reorder so both pipelines insert artists in semantic order before Step 5 flips on insertion-order stamping.
Turn on `p9Figure`'s zorder stamping unconditionally and delete the band-allocation machinery the explicit zorders used to implement. Every figure-level artist now gets the next stamp on insertion, so paint order = the order plotnine adds artists to the figure. Removed: - `INSET_ZORDER_STEP` and the per-band offset math in `Insets._setup` and `inset_element._setup`. Insets just inherit the host's figure now; the band placement is what insertion order produces. - `Insets.plot_background_offset` and the host-bg offset that used to lower the host beneath every below-inset. - `_zorder` field on `ggplot` and `Compose`, and the `_add_figure_artist` helpers that shifted artist zorders by it. Callers go through `figure.add_artist` directly. - `_BASE_ZORDER` in `watermark.py` and the `zorder=` arithmetic on the `figimage` call. - All `bg_z` / `bg_z + 0.1` / `bg_z + 0.2` constants in `_draw_plot_background` and `_draw_composition_background`. - `zorder=self.plot._zorder` on the facet's `add_subplot`. This unblocks below-insets attached to ggplots inside an inset Compose: the case the previous scheme couldn't represent because its single `STEP=10` band had no slack below the panel. Insertion order has no such cap; arbitrary nesting works. `p9Figure.add_artist` is now typed as `(artist: TArtist) -> TArtist` so call sites preserve the precise artist type. `ggplot.figure` and `Compose.figure` are annotated as `p9Figure` so pyright picks up the narrower signature; one `cast` at each `plt.figure(...)` site bridges matplotlib's typing. Existing baselines pass unchanged: steps 3 and 4 already established the correct insertion order, so flipping stamping on is a visual no-op.
Standalone `inset_element(obj, ...)` now displays via an implicit blank ggplot host, mirroring ggplot's `draw` / `show` / `save` / `_repr_mimebundle_` surface. The existing per-host draw entry point is preserved as `_draw_in_host` and called by `Insets.draw`.
Move the validate() check into __post_init__ and rename it to _validate so that every GridSpecParams instance is guaranteed valid at construction. Switch _reduce_height/_reduce_width to build a new instance via dataclasses.replace instead of copy+mutate so the invariant survives aspect-ratio adjustment.
`(host + inset_element(p, ...)) & theme(...)` now themes both the host and the inset; `*` is host-only. Both operators are guarded on `_insets` and return `NotImplemented` on a bare ggplot, so the existing `+` remains the answer there. The broadcast lives on `Insets.__and__`, mirroring how `Compose.__and__` iterates `self.items`. `Compose.__and__` now descends into ggplot children that carry insets so themes broadcast on a composition reach every attached inset.
`inset_element(..., anchor=...)` chooses where an aspect-fitted image sits inside the user's bbox. Default `"center"` reproduces the previous behaviour; named anchors cover the eight corners and edges, and a `(h, v)` tuple in [0, 1]² sets the anchor point directly. `_fit_aspect` now splits the letterbox padding by the anchor's fraction instead of always 50/50. The anchor is resolved once at `_InsetImage` construction so the per-render path stays cheap and typed as a plain tuple. The parameter is ignored for ggplot / Compose insets, which fill the bbox via gridspec sizing and have no letterbox to anchor.
The background rectangle on an image inset now tracks the full user bbox (the letterbox envelope) instead of the fitted image. A themed fill or border now surrounds the whole requested region, with the fill visible in the letterbox padding bands. The patch is now added before the image artist so its fill renders behind the image; previously it sat on top because it hugged the image and only the border was meant to be visible.
Covers the inset_element work that's accumulated over the branch: - `TestInsetAlignTo` — panel / plot / full host regions. - `TestNestedOnTop` — `on_top` toggled on a deeply nested inset. - `TestQuadrantInsets` — four-in-a-grid via separate insets vs. one composition inset. - `TestPropagateTheme` — `&` / `*` broadcast from a host into its insets (including Compose and nested ggplot insets) and the TypeError guards on bare-ggplot operands. - `TestImageInset` — aspect-fit + anchor placement, themed background wrapping the user bbox, ndarray/PIL parity, standalone render, and construction-time validation.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
closes #38