Skip to content

Plot Insets (inset_element)#1058

Merged
has2k1 merged 25 commits into
mainfrom
inset-element
May 15, 2026
Merged

Plot Insets (inset_element)#1058
has2k1 merged 25 commits into
mainfrom
inset-element

Conversation

@has2k1
Copy link
Copy Markdown
Owner

@has2k1 has2k1 commented May 4, 2026

closes #38

@codecov
Copy link
Copy Markdown

codecov Bot commented May 4, 2026

Codecov Report

❌ Patch coverage is 90.60773% with 34 lines in your changes missing coverage. Please review.
✅ Project coverage is 86.92%. Comparing base (35e658a) to head (5ec9cfb).
⚠️ Report is 25 commits behind head on main.

Files with missing lines Patch % Lines
plotnine/composition/_inset_element.py 80.85% 12 Missing and 6 partials ⚠️
plotnine/composition/_inset_image.py 92.50% 4 Missing and 2 partials ⚠️
plotnine/watermark.py 62.50% 2 Missing and 1 partial ⚠️
plotnine/_mpl/figure.py 92.00% 2 Missing ⚠️
plotnine/_utils/yippie.py 83.33% 2 Missing ⚠️
plotnine/animation.py 0.00% 2 Missing ⚠️
plotnine/_mpl/layout_manager/_plot_side_space.py 96.55% 0 Missing and 1 partial ⚠️
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

has2k1 added 8 commits May 6, 2026 23:23
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.
has2k1 added 15 commits May 7, 2026 17:59
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.
has2k1 added 2 commits May 14, 2026 18:58
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.
@has2k1 has2k1 merged commit 5ec9cfb into main May 15, 2026
14 checks passed
@has2k1 has2k1 deleted the inset-element branch May 15, 2026 14:07
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.

Insets

1 participant