Skip to content

Releases: Ultraplot/UltraPlot

UltraPlot 2.2.0: Precision Placement β€” colorbars that span, norms that flex, labels that stay

20 Apr 08:53
8bf6ccb

Choose a tag to compare

UltraPlot v2.2.0

What's New

Spanning colorbars across subplot slots

Colorbars can now span a specific range of columns or rows using the span parameter, rather than stretching across the entire figure edge. This gives much finer control over colorbar placement in multi-panel figures.

release_v2 2 0_span_colorbar
Example
import ultraplot as uplt
import numpy as np

rng = np.random.default_rng(42)
data = rng.random((20, 20))

fig, axs = uplt.subplots(nrows=2, ncols=3, share=False)

for ax in axs:
    m = ax.pcolormesh(data, cmap="batlow")

# A single colorbar spanning only the first two columns
fig.colorbar(m, loc="bottom", span=(1, 2), label="Shared metric")

axs.format(
    suptitle="Spanning colorbar across selected columns",
    abc="[a.]",
    grid=False,
)

Flexible normalization inputs

Norms can now be specified as strings alongside vmin/vmax kwargs, or as compact tuple/list specs like ('linear', 0.1, 0.9). Previously, passing a string norm with explicit vmin/vmax raised an error.

release_v2 2 0_norm_inputs
Example
import ultraplot as uplt
import numpy as np

rng = np.random.default_rng(0)
data = rng.random((30, 30))

fig, axs = uplt.subplots(ncols=3, share=False)

# String norm with explicit vmin/vmax kwargs
axs[0].pcolormesh(data, norm="linear", vmin=0.2, vmax=0.8, cmap="fire")
axs[0].format(title="String + vmin/vmax")

# Tuple form bundles everything together
axs[1].pcolormesh(data, norm=("linear", 0.2, 0.8), cmap="fire")
axs[1].format(title="Tuple form")

# Works with log norms too
axs[2].pcolormesh(data + 0.01, norm=("log", 0.01, 1), cmap="fire")
axs[2].format(title="Log tuple form")

axs.format(suptitle="Flexible norm specifications", abc="[a.]", grid=False)

Bug Fixes

Title border path effects properly cleared

Disabling titleborder=False now correctly removes the stroke effect from title text. Previously, calling ax.format(titleborder=False) after a title border had been applied would leave the border visible.

release_v2 2 0_titleborder
Example
import ultraplot as uplt
import numpy as np

rng = np.random.default_rng(0)

fig, axs = uplt.subplots(ncols=2)

for ax in axs:
    ax.pcolormesh(rng.random((20, 20)), cmap="batlow")

# Left: border on (default for inset titles)
axs[0].format(title="With border", titleloc="upper left", titleborder=True)

# Right: border explicitly off β€” now correctly removed
axs[1].format(title="Without border", titleloc="upper left", titleborder=False)

axs.format(suptitle="Title border toggle fix", grid=False)

Outer legends no longer hide shared tick labels

Adding an outer legend (loc='r') no longer suppresses y-tick labels on neighboring axes when using sharey='labs'. The hidden panel backing the legend was incorrectly being counted as a sharing participant.

release_v2 2 0_sharey_legend
Example
import ultraplot as uplt
import numpy as np

x = np.linspace(0, 4 * np.pi, 200)

fig, axs = uplt.subplots(ncols=3, sharey="labs")

for i, ax in enumerate(axs):
    for j in range(3):
        ax.plot(x, np.sin(x + j) * (i + 1), label=f"Wave {j+1}")

# Outer legend on the middle panel β€” y-tick labels stay visible on all axes
axs[1].legend(loc="r")

axs.format(
    suptitle="Outer legend with shared y-labels",
    xlabel="Phase",
    ylabel="Amplitude",
    abc="[a.]",
)

Other Changes

  • Zenodo publishing fix β€” corrected metadata for DOI generation (#686)
  • Figure initialization refactor β€” internal cleanup of figure setup (#687)
  • What's New page generation fix β€” documentation build improvements (#697)

Full Changelog: v2.1.9...v2.2.0

What's Changed

Full Changelog: v2.1.9...v2.2.0

UltraPlot v2.1.9: bugs, nans, and improved title sharing.

14 Apr 08:13
f8fd865

Choose a tag to compare

With v2.1.9 we add nan support for curved_quiver, and allow for using axes slicing to set titles.

Flexible title setting through axes slicing

We intend to enhance capabilities to offer strong and emphatic controls to the user. The format method gives a succinct localized entry point to format matplotlib axes. We extend the functionality that we added to colorbars and legend by now allowing titles to be spannend across subgroupings.

test
snippet
import ultraplot as uplt

fig, ax =uplt.subplots(ncols = 3, nrows = 2)
ax[0, :2].format(title = "Hello world!")
fig.show()

What's Changed

Full Changelog: v2.1.5...v2.1.9

What's Changed

Full Changelog: v2.1.8...v2.1.9

UltraPlot v2.1.5: Choropleth, Custom labels semantic plots, and bug fixes

30 Mar 23:45

Choose a tag to compare

UltraPlot v2.1.5

The biggest additions are richer semantic size legends and first-class
choropleth support for geographic axes, alongside typing, plotting, CI, and
documentation improvements.

Highlights

Custom labels for Axes.sizelegend

sizelegend can now describe marker magnitudes in domain language instead of
just echoing the raw numeric levels.

scatter_polished
Snippet
import numpy as np

import ultraplot as uplt

np.random.seed(42)
cities = [
    "Tokyo",
    "Delhi",
    "Shanghai",
    "Sao Paulo",
    "Mumbai",
    "Cairo",
    "Beijing",
    "Dhaka",
    "Osaka",
    "Lagos",
    "Istanbul",
    "London",
]
population = np.array(
    [37.4, 32.9, 29.2, 22.4, 21.7, 21.3, 20.9, 23.2, 19.1, 16.6, 15.8, 9.5]
)
gdp_pc = np.array([42, 8, 23, 12, 7, 4, 22, 3, 38, 3, 14, 55])
growth = np.array([0.2, 2.8, 0.5, 0.7, 1.1, 1.9, 0.4, 3.1, 0.1, 3.5, 1.4, 0.8])

fig, ax = uplt.subplots(refwidth=4.5, refaspect=1.1)
ax.scatter(
    gdp_pc,
    growth,
    s=population * 12,
    c="cherry red",
    edgecolor="gray8",
    linewidth=0.5,
    alpha=0.85,
    absolute_size=True,
)
for i, city in enumerate(cities):
    offset = (5, 5)
    if city == "Osaka":
        offset = (5, -10)
    elif city == "Beijing":
        offset = (-5, 8)
    ax.annotate(
        city,
        (gdp_pc[i], growth[i]),
        fontsize=6,
        textcoords="offset points",
        xytext=offset,
        color="gray8",
    )
ax.sizelegend(
    [10 * 12, 20 * 12, 35 * 12],
    labels={10 * 12: "10M", 20 * 12: "20M", 35 * 12: "35M"},
    title="Population",
    loc="ur",
    frameon=False,
    color="gray6",
    edgecolor="gray8",
)
ax.format(
    title="Megacities: Wealth vs Growth",
    xlabel="GDP per capita (k USD)",
    ylabel="Annual growth rate (%)",
    xgrid=True,
    ygrid=True,
    xlim=(-2, 62),
    ylim=(-0.3, 4.2),
)
fig.show()

GeoAxes.choropleth for thematic maps

You can now color countries and polygon features directly from numeric values
while keeping the same UltraPlot formatting and colorbar workflow used on
cartesian plots.

choropleth_polished
Snippet
import numpy as np

import ultraplot as uplt

values = {
    "United States of America": 83.6,
    "Canada": 81.7,
    "Mexico": 75.1,
    "Brazil": 75.9,
    "Argentina": 76.7,
    "United Kingdom": 81.0,
    "France": 82.5,
    "Germany": 80.9,
    "Italy": 83.5,
    "Spain": 83.4,
    "Norway": 83.2,
    "Sweden": 83.0,
    "Russia": 73.2,
    "China": 78.2,
    "Japan": 84.8,
    "South Korea": 83.7,
    "India": 70.8,
    "Australia": 83.3,
    "New Zealand": 82.1,
    "South Africa": 64.9,
    "Nigeria": 53.9,
    "Egypt": 72.1,
    "Saudi Arabia": 76.5,
    "Turkey": 76.0,
    "Indonesia": 71.9,
    "Thailand": 78.7,
}

fig, ax = uplt.subplots(proj="merc", proj_kw={"lon0": 10}, refwidth=5.5)
m = ax.choropleth(
    values,
    country=True,
    cmap="Glacial",
    vmin=50,
    vmax=88,
    edgecolor="none",
    linewidth=0,
    colorbar="b",
    colorbar_kw={"label": "Life expectancy (years)", "length": 0.7},
    missing_kw={"facecolor": "gray8", "hatch": "///", "edgecolor": "gray5"},
)
ax.format(
    title="Global Life Expectancy (2023)",
    land=True,
    landcolor="gray2",
    ocean=True,
    oceancolor="gray1",
    coast=True,
    coastcolor="gray4",
    coastlinewidth=0.3,
    borders=True,
    borderscolor="gray4",
    borderslinewidth=0.2,
    longrid=False,
    latgrid=False,
)
fig.show()

Other changes

  • Better static-analysis support for the lazy top-level API.
  • Numeric scatter plots with explicit numeric colors now respect cmap.
  • Shared boxplot tick labels no longer duplicate.
  • SubplotGrid single-item 2D slices now keep returning SubplotGrid.
  • Helper and release-metadata coverage expanded and the CI flow was tightened.

What's Changed

New Contributors

Full Changelog: v2.1.3...v2.1.5

What's Changed

New Contributors

Full Changelog: v2.1.3...v2.1.5

v2.1.3

11 Mar 01:53
69e0001

Choose a tag to compare

This is a small patch release focused on plotting and legend fixes.

Highlights

  • Restored frame / frameon handling for colorbars.
    Outer colorbars now again respect frame as a backwards-compatible alias for outline visibility, and inset colorbars no longer fail during layout reflow when frame=False.

  • Preserved hatching in geometry legend proxies.
    Legends generated from geographic geometry artists now carry hatch styling through to the legend handle, alongside facecolor, edgecolor, linewidth, and alpha.

  • Enabled graph plotting on 3D axes.
    This restores graph plotting support for 3D plots.

Other changes

  • Updated GitHub Actions dependencies in the workflow configuration.

Included pull requests

  • #605 Enable graph plotting on 3D axes
  • #610 Restore colorbar frame handling
  • #612 Preserve hatches in geometry legend proxies
  • #604 GitHub Actions dependency updates

Full Changelog: V2.1.2...v2.1.3

V2.1.2 Fix colorbar framing and extra legend entries on slicing

26 Feb 07:40
3378000

Choose a tag to compare

What's Changed

Full Changelog: V2.1.0...V2.1.2

V2.1.0: Tricontour fix projections

25 Feb 04:02
153df0d

Choose a tag to compare

This release hotfixes two bugs.

  1. It fixes a bug where the dpi would be changed by external packages that create figures using matplotlib axes
  2. It fixes a bug where the projection was assumed to be PlateCaree for tri-related functions

What's Changed

Full Changelog: v2.0.1...V2.1.0

V2.0.1 : New Plot Types, Semantic Legends, More flexible Colorbars and Smarter Layouts

18 Feb 10:30

Choose a tag to compare

UltraPlot v2.0.1

UltraPlot v2.0.1 is our biggest release yet. Since v1.72.0, we have rebuilt core parts of the library around semantic legends, more reliable layout behavior, stronger guide architecture, and a much more stable CI pipeline. We also launched a brand-new documentation site at https://ultraplot.readthedocs.io/ with a gallery that gives a bird’s-eye view of Matplotlib’s key capabilities through UltraPlot. On the performance side, import times are significantly lower thanks to a new lazy-loading system. And for complex figure composition, sharing logic is now smarter about which axes should be linked, so multi-panel layouts behave more predictably with less manual tweaking.

test
snippet
import numpy as np

import ultraplot as uplt

rng = np.random.default_rng(7)
fig, ax = uplt.subplots(refwidth=4, refheight = 2)

t = np.linspace(0.0, 8.0 * np.pi, 700)
signal = 0.50 * np.sin(t) + 0.20 * np.sin(0.35 * t + 0.8)
trend = 0.55 * np.cos(0.50 * t)

ax.plot(t, signal, c="blue7", lw=2.2, label="Signal", zorder=-1)
ax.plot(t, trend, c="gray6", lw=1.4, ls="--", alpha=0.8, label="Trend", zorder=-1)
ax.fill_between(t, signal - 0.11, signal + 0.11, color="blue2", alpha=0.30, lw=0)

cats = np.array(["A", "B", "C"])
cat_markers = {"A": "o", "B": "s", "C": "^"}
cat_colors = {"A": "blue7", "B": "orange7", "C": "green7"}

n = 85
xp = np.sort(rng.choice(t, size=n, replace=False))
cp = rng.choice(cats, size=n, p=[0.35, 0.40, 0.25])
yp = np.interp(xp, t, signal)
amp = np.interp(xp, t, np.abs(signal))
sizes = 24 + 220 * amp
score = np.clip(0.15 + 0.85 * amp + 0.07 * rng.normal(size=n), 0, 1)

for cat in cats:
    mask = cp == cat
    ax.scatter(
        xp[mask],
        yp[mask],
        c=score[mask],
        cmap="viko",
        vmin=0,
        vmax=1,
        s=sizes[mask],
        marker=cat_markers[cat],
        ec="black",
        lw=0.45,
        alpha=0.9,
    )

ax.curvedtext(
    t,
    signal + 0.16,
    "UltraPlot v2.0",
    ha="center",
    va="bottom",
    color="black",
    size=10,
    weight="bold",
)

ax.format(
    title="Semantic Legends + Curved Text + Smart Layout",
    xlabel="Phase",
    ylabel="Amplitude",
    xlim=(0, 8 * np.pi),
    ylim=(-1.2, 1.2),
    grid=True,
    gridalpha=0.22,
)

ax.catlegend(
    cats,
    colors=cat_colors,
    markers=cat_markers,
    line=False,
    loc="l",
    title="Category",
    frameon=False,
    handle_kw={"ms": 8.5, "ec": "black", "mew": 0.8},
    ncols=1,
)
ax.sizelegend(
    [25, 90, 180],
    color="gray7",
    loc="b",
    align="l",
    title="Magnitude",
    frameon=False,
    handle_kw={"ec": "black", "linewidths": 0.8},
)
ax.numlegend(
    vmin=0,
    vmax=1,
    n=5,
    cmap="viko",
    loc="r",
    align="b",
    title="Score",
    frameon=False,
    handle_kw={"edgecolors": "black", "linewidths": 0.4},
    ncols=1,
)
ax.entrylegend(
    [
        ("Reference", {"line": True, "lw": 2.2, "ls": "-", "c": "blue7"}),
        ("Samples", {"line": False, "m": "o", "ms": 7, "fc": "white", "ec": "black"}),
    ],
    loc="r",
    align="t",
    title="Glyph key",
    frameon=False,
    ncols=1,
)
inax = ax.inset((0.75, 0.75, 0.2, 0.2), zoom=0, projection="ortho")
inax.format(land=1, ocean=1, landcolor="mushroom", oceancolor="ocean blue")
fig.show()

Highlights

  • New Layout Solver. We have replaced the layout solver to provide snappier, and better layout handling to make the even tighter.
  • New semantic legend system with categorical, size, numeric, and geographic legend builders (#586).
  • New legend primitives: LegendEntry and improved legend handling for wedge/pie artists (#571).
  • Major legend internals refactor via a dedicated UltraLegend builder (#570).
  • Colorbar architecture refactor: colorbars are now decoupled from axes internals through UltraColorbar and UltraColorbarLayout (#529).

New Features

  • Top-aligned ribbon flow plot type (#559).
  • Curved annotation support (#550).
  • Ridgeline histogram histtype support (#557).
  • Compatibility-aware auto-share defaults (#560).
  • PyCirclize integration for circular/network workflows (#495).

Layout, Rendering, and Geo Improvements

  • Multiple UltraLayout fixes for spanning axes, gaps, and shared labels (#555, #532, #584).
  • Improved inset colorbar frame handling (#554 and related follow-ups).
  • Better suptitle spacing in non-bottom vertical alignments (#574).
  • Polar tight-layout fixes (#534).
  • Geo tick/label robustness improvements (#579, related geo labeling fixes).
  • Opt-in subplot pixel snapping plus follow-up adjustments (#561, #567).

Stability, Tooling, and Compatibility

  • Python 3.14 support (#385).
  • Improved CI matrix coverage and determinism (#587, #580, #577, #545 and related CI fixes).
  • pytest-mpl style/baseline stabilization and improved test selection behavior (#528, #533, #535).
  • Docs and theme updates, including warnings cleanup and presentation improvements (#585, #552).

Upgrade Notes

  • Legend and colorbar internals were significantly refactored. Public usage remains familiar, but extensions relying on internals should be
    reviewed.
  • Semantic legends now have a clearer API surface and are ready for richer per-entry styling workflows.

Full Changelog

v1.72.0...v2.0.1
v1.72.0...v2.0.1

UltraPlot v1.72.0: Sankey diagrams and Ternary plots

27 Jan 10:59

Choose a tag to compare

This release is marked by the addition of Sankey diagrams and ternary plots (powered by mpltern).

Sankey diagrams

Sankey diagrams are flow charts that visualize the movement of quantities (like energy, money, or users) between different stages or categories, where the width of the connecting arrows is proportional to the flow's magnitude, making major transfers visually obvious. Named after Captain Sankey, they effectively show distributions, energy efficiency, material flows, user journeys, and budget breakdowns, helping to identify dominant paths within a system

test

Ternary plots

A ternary plot (also known as a ternary graph, triangle plot, or simplex plot) is a barycentric plot on an equilateral triangle. It is used to represent the relative proportions of three variables that sum to a constantβ€”usually 100% or 1.0.

Because the three variables are interdependent (if you know the value of two, the third is automatically determined), a 3D dataset can be visualized in a 2D space without losing information. The plot is commonly used in field such as (evollutionary) game theory. We are harnessing the power of mpltern by wrapping their axes ax external. This gives the best of both worlds where the functionality of the ternary plot is provided by mpltern while allow thing formatting flexibility of UltraPlot.

Note that this feature is introduced now, but marked as experimental. The underlying changes are embedding a different axes inside a container, and there are likely for bugs to emerge from this -- so any feedback or reports are highly appreciated.

test
snippet
import mpltern


from mpltern.datasets import get_shanon_entropies, get_spiral
import ultraplot as uplt, numpy as np
import networkx as nx

t, l, r, v = get_shanon_entropies()


layout = [[1, 3], [2, 3]]
fig, ax = uplt.subplots(layout, projection=["cartesian", "ternary", "cartesian"], share = 0, hspace = 10)

# Show some noise
ax[0].imshow(np.random.rand(10, 10), cmap = "Fire", colorbar = "r",
    colorbar_kw = dict(title = "Random\nnoise", length = 0.333, align = "t"),)


# Ternary plot mock data
vmin = 0.0
vmax = 1.0
levels = np.linspace(vmin, vmax, 7)
cs = ax[1].tripcolor(t, l, r, v, cmap="lapaz_r", shading="flat", vmin=vmin, vmax=vmax)
ax[1].plot(*get_spiral(), color="white", lw=1.25)
colorbar = ax[1].colorbar(
    cs,
    loc="b",
    align="c",
    title="Entropy",
    length=0.33,
)

# Show a network
g = nx.random_geometric_graph(101, 0.2, seed = 1)
nc = []
min_deg = min(g.degree(), key=lambda x: x[1])[1]
max_deg = max(g.degree(), key=lambda x: x[1])[1]
for node in g.nodes():
    intensity = (g.degree(node) - min_deg)/ (max_deg - min_deg)
    nc.append(uplt.Colormap("plasma")(intensity))

ax[2].graph(g, node_kw = dict(node_size = 32, node_color = nc))
ax.format(title = ["Hello", "there", "world!"], abc = True)
fig.show()

What's Changed

Full Changelog: v1.71.0...v1.72.0

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