Skip to content

Releases: Ultraplot/UltraPlot

UltraPlot v1.71: Ridgelines and Smarter Legends ✨

16 Jan 23:15

Choose a tag to compare

This release focuses on two user-facing improvements: a new ridgeline plot type and more flexible figure-level legend placement.

Under the hood, import-time work shifted from eager loading to lazy loading,
cutting startup overhead by about 98%.

Highlights

  • Ridgeline (joyplot) support for stacked distribution comparisons.
  • Figure-level legends now accept ref= for span inference and consistent placement.
  • External context mode for integration-heavy workflows where UltraPlot should
    defer on-the-fly guide creation.
  • New Copernicus journal width presets to standardize publication sizing.
  • Faster startup via lazy-loading of top-level imports.
snippet
from pathlib import Path

import numpy as np
import ultraplot as uplt

outdir = Path("release_assets/v1.71.0")
outdir.mkdir(parents=True, exist_ok=True)

Ridgeline plots

Ridgeline plots (joyplots) are now built-in. This example uses KDE ridges with
a colormap and overlap control.

ridgeline
snippet
rng = np.random.default_rng(12)
data = [rng.normal(loc=mu, scale=0.9, size=1200) for mu in range(5)]
labels = [f"Group {i + 1}" for i in range(len(data))]

fig, ax = uplt.subplots(refwidth="11cm", refaspect=1.6)
ax.ridgeline(
    data,
    labels=labels,
    cmap="viridis",
    overlap=0.65,
    alpha=0.8,
    linewidth=1.1,
)
ax.format(
    xlabel="Value",
    ylabel="Group",
    title="Ridgeline plot with colormap",
)
fig.savefig(outdir / "ridgeline.png", dpi=200)

Figure-level legend placement with ref=

Figure legends can now infer their span from a reference axes or axes group.
This removes the need to manually calculate span, rows, or cols for many
layouts.

legend_ref
snippet
x = np.linspace(0, 2 * np.pi, 256)

layout = [[1, 2, 3], [1, 4, 5]]
fig, axs = uplt.subplots(layout)
cycle = uplt.Cycle("bmh")
for idx, axi in enumerate(axs):
    axi.plot(
        x,
        np.sin((idx + 1) * x),
        color=cycle.get_next()["color"],
        label=f"sin({idx+1}x)",
    )
axs.format(xlabel="x", ylabel=r"sin($\alpha x)")
# Place legend of the first 2 axes on the bottom of the last plot
fig.legend(ax=axs[:2], ref=axs[-1], loc="bottom", ncols=2, frame=False)
# Place legend of the last 2 plots on the bottom of the first column
fig.legend(ax=axs[-2:], ref=axs[:, 1], loc="left", ncols=1, frame=False)
# Collect all labels in a singular legend
fig.legend(ax=axs, loc="bottom", frameon=0)
fig.savefig(outdir / "legend_ref.png", dpi=200)

πŸš€ UltraPlot v1.70.0: Smart Layouts, Better Maps, and Scientific Publishing Support

04 Jan 04:43

Choose a tag to compare

High-Level Overview: This release focuses on intelligent layout management, geographic plotting enhancements, and publication-ready features. Geographic plots receive improved boundary label handling and rotation capabilities, while new Copernicus Publications standard widths support scientific publishing workflows. Various bug fixes and documentation improvements round out this release.

Major Changes:

1. Geographic Plot Enhancements

image
# Improved boundary labels and rotation
fig, ax = uplt.subplots(projection="cyl")
ax.format(
    lonlim=(-180, 180),
    latlim=(-90, 90),
    lonlabelrotation=45, # new parameter
    labels=True,
    land=True,
)
# Boundary labels now remain visible and can be rotated

2. Copernicus Publications Support

# New standard figure widths for scientific publishing
fig = uplt.figure(journal = "cop1")
# Automatically sets appropriate width for Copernicus Publications

3. Legend Placement Improvements

test
import numpy as np

import ultraplot as uplt

np.random.seed(0)
fig, ax = uplt.subplots(ncols=2, nrows=2)
handles = []
for idx, axi in enumerate(ax):
    noise = np.random.randn(100) * idx
    angle = np.random.rand() * 2 * np.pi
    t = np.linspace(0, 2 * np.pi, noise.size)
    y = np.sin(t * angle) + noise[1]
    (h,) = axi.plot(t, y, label=f"$f_{idx}$")
    handles.append(h)

# New: spanning legends
fig.legend(handles=handles, ax=ax[0, :], span=(1, 2), loc="b")
fig.show()

What's Changed

New Contributors

Full Changelog: v1.66.0...v1.70.0

New feature: External Contexts, and bug splats πŸ›

22 Nov 00:38

Choose a tag to compare

Release Notes

This release introduces two key improvements to enhance compatibility and consistency.

External Contexts

UltraPlot provides sensible defaults by controlling matplotlib's internal mechanics and applying overrides when needed. While this approach works well in isolation, it can create conflicts when integrating with external libraries.

We've introduced a new external context that disables UltraPlot-specific features when working with third-party libraries. Currently, this context prevents conflicts with internally generated labels in Seaborn plots. We plan to extend this functionality to support broader library compatibility in future releases.

Example usage with Seaborn:

import seaborn as sns
import ultraplot as uplt

# Load example dataset
tips = sns.load_dataset("tips")

# Use external context to avoid label conflicts
fig, ax = uplt.subplots()
with ax.external():
    sns.lineplot(data=tips, x="size", y="total_bill", hue="day", ax = ax)

Standardized Binning Functions

We've standardized the default aggregation function across all binning operations to use sum. This change affects hexbin, which previously defaulted to averaging values. All binning functions now consistently use sum as the default, though you can specify any custom aggregation function via the reduce_C_function parameter.

What's Changed

Full Changelog: v1.65.1...v1.66.0

Hot-fix: add minor issue where boxpct was not parsed properly

02 Nov 11:13

Choose a tag to compare

What's Changed

Full Changelog: v1.65.0...v1.65.1

Enhanced Grid Layouts and Multi-Span Colorbars

31 Oct 14:49

Choose a tag to compare

🎨 UltraPlot v1.65 release notes

This release introduces substantial improvements to subplot layout flexibility and configuration management for scientific visualization.

Key Features

Non-Rectangular Grid Layouts with Side Labels (#376)
Asymmetric subplot arrangements now support proper axis labeling, enabling complex multi-panel figures without manual positioning workarounds.

Multi-Span Colorbars (#394)
Colorbars can span multiple subplots, eliminating redundant color scales in comparative visualizations.

RC-Configurable Color Cycles (#378)
Cycle objects can be set via rc configuration, enabling consistent color schemes across figures and projects.

Improved Label Sharing (#372, #387)
Enhanced logic for axis label sharing in complex grid configurations with expanded test coverage.

Infrastructure

  • Automatic version checking (#377). Users can now get informed when a new version is available by setting uplt.rc["ultraplot.check_for_latest_version"] = True which will drop a warning if a newer version is available.
  • Demo gallery unit tests (#386)
  • Optimized CI/CD workflow (#388, #389, #390, #391)

Impact

These changes address common pain points in creating publication-quality multi-panel figures, particularly for comparative analyses requiring consistent styling and efficient use of figure space.

What's Changed

Full Changelog: v1.63.0...v1.65.0

πŸŒ€ New Feature: Curved Quiver

14 Oct 09:06

Choose a tag to compare

This release introduces curved_quiver, a new plotting primitive that renders compact, curved arrows following the local direction of a vector field. It’s designed to bridge the gap between quiver (straight, local glyphs) and streamplot (continuous, global trajectories): you retain the discrete arrow semantics of quiver, but you gain local curvature that more faithfully communicates directional change.

streamplot_quiver_curvedquiver

What it does

Under the hood, the implementation follows the same robust foundations as matplotlib’s streamplot, adapted to generate short, curved arrow segments instead of full streamlines. As such it can be seen as in between streamplot and quiver plots, see figure below and above.

curved_quiver_comparison

The core types live in ultraplot/axes/plot_types/curved_quiver.py and are centered on CurvedQuiverSolver, which coordinates grid/coordinate mapping, seed point generation, trajectory integration, and spacing control:

  • _CurvedQuiverGrid validates and models the input grid. It ensures the x grid is rectilinear with equal rows and the y grid with equal columns, computes dx/dy, and exposes grid shape and extent. This means curved_quiver is designed for rectilinear grids where rows/columns of x/y are consistent, matching the expectations of stream/line-based vector plotting.

  • _DomainMap maintains transformations among data-, grid-, and mask-coordinates. Velocity components are rescaled into grid-coordinates for integration, and speed is normalized to axes-coordinates so that step sizes and error metrics align with the visual output (this is important for smooth curves at different figure sizes and grid densities). It also owns bookkeeping for the spacing mask.

  • _StreamMask enforces spacing between trajectories at a coarse mask resolution, much like streamplot spacing. As a trajectory advances, the mask is filled where the curve passes, preventing new trajectories from entering already-occupied cells. This avoids over-plotting and stabilizes density in a way that feels consistent with streamplot output while still generating discrete arrows.

  • Integration is handled by a second-order Runge–Kutta method with adaptive step sizing, implemented in CurvedQuiverSolver.integrate_rk12. This β€œimproved Euler” approach is chosen for a balance of speed and visual smoothness. It uses an error metric in axes-coordinates to adapt the step size ds. A maximum step (maxds) is also enforced to prevent skipping mask cells. The integration proceeds forward from each seed point, terminating when any of the following hold: the curve exits the domain, an intermediate integration step would go out of bounds (in which case a single Euler step to the boundary is taken for neatness), a local zero-speed region is detected, or the path reaches the target arc length set by the visual resolution. Internally, that arc length is bounded by a threshold proportional to the mean of the sampled magnitudes along the curve, which is how scale effectively maps to a β€œhow far to bend” control in physical units.

  • Seed points are generated uniformly over the data extent via CurvedQuiverSolver.gen_starting_points, using grains Γ— grains positions. Increasing grains increases the number of potential arrow locations and produces smoother paths because more micro-steps are used to sample curvature. During integration, the solver marks the mask progressively via _DomainMap.update_trajectory, and very short trajectories are rejected with _DomainMap.undo_trajectory() to avoid clutter.

  • The final artist returned to you is a CurvedQuiverSet (a small dataclass aligned with matplotlib.streamplot.StreamplotSet) exposing lines (the curved paths) and arrows (the arrowheads). This mirrors familiar streamplot ergonomics. For example, you can attach a colorbar to .lines, as shown in the figures.

From a user perspective, you call ax.curved_quiver(X, Y, U, V, ...) just as you would quiver, optionally passing color as a scalar field to map magnitude, cmap for color mapping, arrow_at_end=True and arrowsize to emphasize direction, and the two most impactful shape controls: grains and scale. Use curved_quiver when you want to reveal local turning behaviorβ€”vortices, shear zones, near saddles, or flow deflection around obstaclesβ€”without committing to global streamlines. If your field is highly curved in localized pockets where straight arrows are misleading but streamplot feels too continuous or dense, curved_quiver is the right middle ground.

Performance

Performance-wise, runtime scales with the number of glyphs and the micro-steps (grains). The default values are a good balance for most grids; for very dense fields, you can either reduce grains or down-sample the input grid. The API is fully additive and doesn’t introduce any breaking changes, and it integrates with existing colorbar and colormap workflows.

Parameters

There are two main parameters that affect the plots visually. The grainsparameters controls the density of the grid by interpolating between the input grid. Setting a higher grid will fill the space with more streams. See for a full function description the documentation.

curved_quiver_grains

The size parameter will multiply the magnitude of the stream. Setting this value higher will make it look more similar to streamplot.

curved_quiver_sizes

Acknowledgements

Special thanks to @veenstrajelmer for his implementation (https://github.com/Deltares/dfm_tools) and @Yefee for his suggestion to add this to UltraPlot! And as always @beckermr for his review.

What's Changed

Suggestions or feedback

Do you have suggestion or feedback? Checkout our discussion on this release.

Full Changelog: v1.62.0...v1.63.0

πŸš€ New Release: Configurator Handler Registration

13 Oct 13:38

Choose a tag to compare

This release introduces a powerful extension point to the configuration system β€” Configurator.register_handler() β€” enabling dynamic responses to configuration changes.

✨ New Feature: register_handler

You can now register custom handlers that execute automatically when specific settings are modified.
This is particularly useful for settings that require derived logic or side-effects, such as updating related Matplotlib parameters.

register_handler(name: str, func: Callable[[Any], Dict[str, Any]]) β†’ None

Example (enabled by default):

def _cycle_handler(value):
    # Custom logic to create a cycler object from the value
    return {'axes.prop_cycle': new_cycler}

rc.register_handler('cycle', _cycle_handler)

Each handler function receives the new value of the setting and must return a dictionary mapping valid Matplotlib rc keys to their corresponding values. These updates are applied automatically to the runtime configuration.


🧩 Why It Matters

This addition:

  • Fixes an issue where cycle: entries in ultraplotrc were not properly applied.
  • Decouples configuration logic from Matplotlib internals.
  • Provides a clean mechanism for extending the configuration system with custom logic β€” without circular imports or hard-coded dependencies.

πŸ”§ Internal Improvements

  • Refactored configuration update flow to support handler callbacks.
  • Simplified rc management by delegating side-effectful updates to registered handlers.

πŸ’‘ Developer Note

This API is designed to be extensible. Future handlers may include dynamic color normalization, font synchronization, or interactive theme updates β€” all powered through the same mechanism.

πŸš€ Release: CFTime Support and Integration

08 Oct 02:34

Choose a tag to compare

Highlights

CFTime Axis Support:
We’ve added robust support for CFTime objects throughout ultraplot. This enables accurate plotting and formatting of time axes using non-standard calendars (e.g., noleap, gregorian, standard), which are common in climate and geoscience datasets.

Automatic Formatter and Locator Selection:
ultraplot now automatically detects CFTime axes and applies the appropriate formatters and locators, ensuring correct tick placement and labeling for all supported calendar types.

Seamless Integration with xarray:
These features are designed for direct use with xarray datasets and dataarrays. When plotting data with CFTime indexes, ultraplot will handle all time axis formatting and tick generation automaticallyβ€”no manual configuration required.


Intended Use

This release is aimed at users working with climate, weather, and geoscience data, where time coordinates may use non-standard calendars. The new CFTime functionality ensures that plots generated from xarray and other scientific libraries display time axes correctly, regardless of calendar type.


Example Usage

import xarray as xr
import numpy as np
import cftime
import ultraplot as uplt

# Create a sample xarray DataArray with CFTime index
times = [cftime.DatetimeNoLeap(2001, 1, i+1) for i in range(10)]
data = xr.DataArray(np.random.rand(10), coords=[times], dims=["time"])

fig, ax = uplt.subplots()
data.plot(ax=ax)

# CFTime axes are automatically formatted and labeled
ax.set_title("CFTime-aware plotting with ultraplot")
uplt.show()

Migration and Compatibility

  • No changes are required for existing code using standard datetime axes.
  • For datasets with CFTime indexes (e.g., from xarray), simply plot as usualβ€”ultraplot will handle the rest.

We welcome feedback and bug reports as you explore these new capabilities!

What's Changed

New Contributors

  • @Copilot made their first contribution in #325

Full Changelog: v1.60.2...v1.61.0

Note: v1.61.0 is yanked from pypi as it contained a debug statement. This merely removes the debug.

Hotfix: double depth decorator that affected geoplots

18 Aug 08:44

Choose a tag to compare

What's Changed

New Contributors

Full Changelog: v1.60.1...v1.60.2

Hotfixes for colors and colormaps

08 Aug 07:30

Choose a tag to compare

Minor bug fixes

What's Changed

  • Fix edge case where vcenter is not properly set for diverging norms by @cvanelteren in #314
  • Fix color parsing when color is not string by @cvanelteren in #315

Full Changelog: v1.60.0...v1.60.1