Skip to content

Conversation

@cvanelteren
Copy link
Collaborator

@cvanelteren cvanelteren commented Dec 11, 2025

Closes #419

External Axes Integration via Container Pattern

This PR implements robust support for external axes projections (e.g., mpltern's TernaryAxes) in Ultraplot through a container-based architecture. The implementation includes automatic label fitting, performance optimizations, and comprehensive compatibility with Ultraplot's existing features.

Problem Statement

Ultraplot previously struggled with external axes classes that don't inherit from ultraplot.axes.Axes. Users attempting to use projections like "ternary" from mpltern encountered:

  1. AttributeError - External axes missing Ultraplot-specific methods
  2. Import conflicts - Ultraplot couldn't find or properly instantiate external axes
  3. Label clipping - External axes labels (e.g., ternary plot labels) cut off at figure edges
  4. Performance issues - Redundant draws and position calculations
  5. Layout incompatibility - External axes didn't respect Ultraplot's layout system

Proposed Solution: Container Pattern

Architecture

Instead of wrapping external axes via inheritance (which caused method conflicts), we implement a container pattern:

ExternalAxesContainer (CartesianAxes)
  └─> External Axes (e.g., TernaryAxes) as child

Key Design Decisions:

  • Container inherits from CartesianAxes → Full Ultraplot API support
  • External axes created as managed child → No method conflicts
  • Plotting methods explicitly delegated → Native external axes behavior
  • Container handles formatting → Ultraplot features (abc, titles, etc.) work
  • External axes isolated from fig.axes → Ultraplot internals don't break

Core Components

  1. ExternalAxesContainer (ultraplot/axes/container.py)

    • Main container class managing external axes
    • Delegates plotting to external axes
    • Handles Ultraplot formatting on container
    • Manages position synchronization
  2. Auto-detection (ultraplot/figure.py)

    • Detects non-Ultraplot projections automatically
    • Creates container with external axes
    • Registers container classes on-demand
  3. Label Fitting (New Feature)

    • Automatically shrinks external axes to fit labels
    • Configurable via external_shrink_factor parameter
    • Two-stage process: fast initial + precise dynamic adjustment
  4. Performance Optimizations (New Feature)

    • Stale tracking prevents redundant draws
    • Position caching reduces calculations
    • Smart redraw on data changes

Changes

Core Implementation

1. Container Architecture

  • Added: ultraplot/axes/container.py - ExternalAxesContainer class
  • Modified: ultraplot/axes/__init__.py - Export ExternalAxesContainer
  • Modified: ultraplot/figure.py - Auto-detect and create containers

2. Compatibility Fixes

  • Modified: ultraplot/axes/base.py - SubplotSpec fallback for matplotlib compatibility
  • Modified: ultraplot/axes/container.py - Disable autoshare for external axes

3. Label Fitting System

  • Added: _shrink_external_for_labels() - Automatic label space allocation
  • Added: external_shrink_factor parameter - Configurable shrinking (default 0.85)
  • Added: Dynamic adjustment during draw - Ensures labels never clip

4. Performance Optimizations

  • Added: Stale tracking system - Avoid redundant external axes draws
  • Added: Position caching - Skip unnecessary position updates
  • Added: Smart invalidation - Mark stale on plot/scatter/etc.
  • Added: Stale callbacks - Integration with matplotlib's optimization

Tests

Added/Reorganized

  • Added: ultraplot/tests/test_external_axes_container_integration.py - Pytest tests for container
  • Moved: test_minimal.pytests/test_external_axes_minimal.py
  • Moved: test_subplotspec_fix.pytests/test_subplotspec_compatibility.py
  • Moved: test_container_manual.pytests/test_external_axes_container.py (manual test)

Documentation

TBD

Basic Usage

import ultraplot as uplt
import mpltern

# Everything works automatically
fig, ax = uplt.subplots(projection="ternary")
ax.plot(*get_spiral())
ax.set_tlabel("Top")
ax.set_llabel("Left")
ax.set_rlabel("Right")  # No longer clipped!
ax.format(abc=True, title="Ternary Plot")
uplt.show()

Custom Label Fitting

# For longer labels, adjust shrink factor
fig, ax = uplt.subplots(
    projection="ternary",
    external_shrink_factor=0.75  # More space for labels
)

Multiple Subplots

# Works seamlessly with Ultraplot's subplot system
fig, axs = uplt.subplots(nrows=2, ncols=2, projection="ternary")
for ax in axs:
    ax.plot(data)
    ax.format(abc=True)

gepcel and others added 3 commits December 10, 2025 12:28
Fix two unidenfined references in why.rst.
1. ug_apply_norm is a typo I think.
2. ug_mplrc. I'm not sure what it should be. Only by guess.
@cvanelteren cvanelteren marked this pull request as draft December 11, 2025 05:37
@cvanelteren
Copy link
Collaborator Author

cvanelteren commented Dec 11, 2025

Need to untangle the first version, but other than that I think I got the wrapping working in a general sense.

@codecov
Copy link

codecov bot commented Dec 11, 2025

@beckermr
Copy link
Collaborator

How does this one relate to the feature to mark an axis as external that we just merged?

@cvanelteren
Copy link
Collaborator Author

cvanelteren commented Dec 11, 2025

Good question, I can see how this does not seem that different but it is.

This PR fundamentally restructures how Ultraplot integrates external axes classes. Previously, external axes were handled through a mixin-based approach that attempted to modify external classes directly, which proved fragile and caused conflicts with kwargs that external axes didn't understand -- while this is fine for some plots like line and scatters, it can fail for something more complex like ternary plots.

The new approach uses a composition-based ExternalAxesContainer that wraps external axes as children while maintaining a Ultraplot axes as the container for layout purposes. Critically, this PR also clarifies the distinction between two separate mechanisms: (1) external mode marking (ax.external() context manager), which is a behavioral flag for suppressing Ultraplot's automatic features when using libraries like seaborn on Ultraplot axes, and (2) ExternalAxesContainer, which is a structural wrapper for truly external axes classes like mpltern.

Importantly, GeoAxes are no longer treated as external — we now explicitly check for the "ultraplot_" prefix and recognize that Ultraplot's bundled GeoAxes classes are native Ultraplot axes, not external ones. This prevents the incorrect wrapping that was causing kwargs like rc_kw, extent, and land to fail. The new detection logic also properly handles cartopy/basemap Projection objects passed directly (e.g., projection=uplt.Mollweide()), converting them to use Ultraplot's GeoAxes backends rather than creating raw cartopy axes.

So while both approaches aim to improve compatibility with external packages, they achieve this through different means. The ExternalAxesContainer is essential because packaging something like ternary plots would require us to reimplement the entire class and manage all the logic ourselves, which violates DRY principles: why recreate a method that already exists and works great?

@cvanelteren
Copy link
Collaborator Author

So while the external marker turns off the tagging feature, the axis being plotting on is fundamentally still an UltraPlotAxis, while this just hosts the external axis within a container. This still allows for nice composition, but uses the external axis for the rest.

@cvanelteren cvanelteren added this to the v2.0 milestone Dec 12, 2025
@cvanelteren
Copy link
Collaborator Author

Things before sending out review

  • Achieve 80 percent code coverage
  • Sanity check the logic here
  • do some perf testing as the ternary plots were initially rather slow on draw.
  • check a few more "special" axes that we could check for

- Increase default shrink factor from 0.75 to 0.95 to make external axes (e.g., ternary plots) larger and more prominent
- Change positioning from centered to top-aligned with left offset for better alignment with adjacent Cartesian subplots
- Top alignment ensures abc labels and titles align properly across different projection types
- Add 5% left offset to better utilize available horizontal space
- Update both _shrink_external_for_labels and _ensure_external_fits_within_container methods for consistency

This makes ternary and other external axes integrate seamlessly with standard matplotlib subplots, appearing native rather than artificially constrained.
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.

[Feature] Ternary plots

3 participants