Legend Placement

publiplots offers three complementary legend-placement knobs:

  1. Per-axis inside: legend_kws={'inside': True, 'loc': 'upper right'} drops the legend inside the axes using matplotlib’s corner-based placement. No reactor, no layout reservation — just a local legend.

  2. Figure-anchored group: pp.legend(side=...) (no anchor) spans the full subplot grid on the chosen side. The figure grows on that side to accommodate the legend; panel sizes stay inviolate.

  3. Axes-anchored group: pp.legend(anchor=axes[r,c], side=...) pins the band to a single cell. The corresponding per-cell reservation (right[c] for side='right', xlabel_space[r] for side='bottom', …) absorbs the band width, pushing just that axes’ column/row larger.

pp.legend_group also chooses its orientation and alignment per side by default:

  • side='top' / 'bottom'orientation='horizontal' (entries run along the edge, ncol defaults to len(handles)) and align='center' (centered along the anchor edge).

  • side='left' / 'right'orientation='vertical' and align='start' (legend begins at the anchor’s top corner).

Override either with orientation='vertical'|'horizontal' or align='start'|'center'|'end' when the default isn’t what you want.

This gallery walks through each mode.

import publiplots as pp
import pandas as pd
import numpy as np

# Shared fixture --------------------------------------------------------------

np.random.seed(42)
_df = pd.DataFrame({
    "x": np.random.randn(240),
    "y": np.random.randn(240),
    "group": np.tile(["Control", "Low", "High"], 80),
    "panel": np.repeat(["A", "B", "C", "D"], 60),
})

1. Default outside-right (no legend_group)

A single pp.scatterplot with a categorical hue renders its legend just past the axes’ right edge — the publication-ready default.

pp.scatterplot(
    data=_df, x="x", y="y", hue="group", palette="pastel",
    title="Default outside-right",
)
pp.show()
Default outside-right

2. Per-axis inside legend

legend_kws={'inside': True, 'loc': ...} keeps the legend local to the axes. Useful when vertical real estate is precious or the data leaves a natural empty corner.

pp.scatterplot(
    data=_df, x="x", y="y", hue="group", palette="pastel",
    legend_kws={"inside": True, "loc": "upper right"},
    title='legend_kws={"inside": True, "loc": "upper right"}',
)
pp.show()
legend_kws={

3. Figure-anchored side='right' on a 2×2 grid

pp.legend() without an anchor= spans the full figure vertically, tucked past the rightmost column. Auto-collects every stashed entry from every panel; dedupes by name.

fig, axes = pp.subplots(2, 2, axes_size=(35, 30))
pp.legend(side="right")
for (r, c), panel in zip([(0, 0), (0, 1), (1, 0), (1, 1)], "ABCD"):
    pp.scatterplot(
        data=_df[_df["panel"] == panel], x="x", y="y",
        hue="group", palette="pastel",
        title=f"Panel {panel}", ax=axes[r, c],
    )
pp.show()
Panel A, Panel B, Panel C, Panel D

4. Figure-anchored side='bottom' on a 2×2 grid

When panels leave spare vertical headroom, a bottom-anchored legend uses that space instead of the figure’s right column. Same call, just side='bottom'. The default orientation flips to horizontal (entries run along the edge) and align='center' keeps the block balanced under the grid.

fig, axes = pp.subplots(2, 2, axes_size=(35, 30))
pp.legend(side="bottom")
for (r, c), panel in zip([(0, 0), (0, 1), (1, 0), (1, 1)], "ABCD"):
    pp.scatterplot(
        data=_df[_df["panel"] == panel], x="x", y="y",
        hue="group", palette="pastel",
        title=f"Panel {panel}", ax=axes[r, c],
    )
pp.show()
Panel A, Panel B, Panel C, Panel D

4b. Bottom with align='start'

Override the default center-alignment to pin the legend to the left edge of the grid — a common choice when the legend should align with the first panel column rather than the figure midline.

fig, axes = pp.subplots(2, 2, axes_size=(35, 30))
pp.legend(side="bottom", align="start")
for (r, c), panel in zip([(0, 0), (0, 1), (1, 0), (1, 1)], "ABCD"):
    pp.scatterplot(
        data=_df[_df["panel"] == panel], x="x", y="y",
        hue="group", palette="pastel",
        title=f"Panel {panel}", ax=axes[r, c],
    )
pp.show()
Panel A, Panel B, Panel C, Panel D

5. Figure-anchored side='top' + side='left'

All four sides are supported. Top/left are less common but useful for figures with a strong vertical hierarchy or right-to-left reading order.

fig, axes = pp.subplots(2, 2, axes_size=(35, 30))
pp.legend(side="top")
for (r, c), panel in zip([(0, 0), (0, 1), (1, 0), (1, 1)], "ABCD"):
    pp.scatterplot(
        data=_df[_df["panel"] == panel], x="x", y="y",
        hue="group", palette="pastel",
        title=f"Panel {panel}", ax=axes[r, c],
    )
pp.show()

fig, axes = pp.subplots(2, 2, axes_size=(35, 30))
pp.legend(side="left")
for (r, c), panel in zip([(0, 0), (0, 1), (1, 0), (1, 1)], "ABCD"):
    pp.scatterplot(
        data=_df[_df["panel"] == panel], x="x", y="y",
        hue="group", palette="pastel",
        title=f"Panel {panel}", ax=axes[r, c],
    )
pp.show()
  • Panel A, Panel B, Panel C, Panel D
  • Panel A, Panel B, Panel C, Panel D

5b. Figure title (pp.suptitle) above a side='top' band

pp.suptitle hooks into the same auto-layout engine as the legend bands: it measures the text’s height and grows the figure to reserve a dedicated suptitle_space band above everything else. No manual y=... nudge, no overlap with the top row of axis titles — and a side='top' legend band slots in cleanly between the suptitle and the axes.

fig, axes = pp.subplots(2, 2, axes_size=(35, 30))
pp.legend(side="top")
for (r, c), panel in zip([(0, 0), (0, 1), (1, 0), (1, 1)], "ABCD"):
    pp.scatterplot(
        data=_df[_df["panel"] == panel], x="x", y="y",
        hue="group", palette="pastel",
        title=f"Panel {panel}", ax=axes[r, c],
    )
pp.suptitle("Experiment 42")
pp.show()
Experiment 42, Panel A, Panel B, Panel C, Panel D

6. Axes-anchored: pin the band to a single cell

Pass anchor=axes[r, c] to pin the band to one cell. The corresponding per-cell reservation (the column’s right for side='right', the row’s xlabel_space for side='bottom', etc.) absorbs the band width. Useful when one panel deserves its own annotation band without growing the rest of the figure.

fig, axes = pp.subplots(2, 2, axes_size=(35, 30))
# Anchor the band to the top-right panel only.
pp.legend(anchor=axes[0, 1], side="right")
for (r, c), panel in zip([(0, 0), (0, 1), (1, 0), (1, 1)], "ABCD"):
    pp.scatterplot(
        data=_df[_df["panel"] == panel], x="x", y="y",
        hue="group", palette="pastel",
        title=f"Panel {panel}", ax=axes[r, c],
    )
pp.show()
Panel A, Panel B, Panel C, Panel D

7. Combining inside + figure-anchored group

The two modes compose: collect one shared dimension into the figure-level group (here group) and render other per-panel legends inside each axes. collect=['group'] filters the group’s auto-collect pass; legend_kws={'inside': True} on each scatter renders the non-collected style (replicate below) as a local legend in each panel.

rng = np.random.default_rng(7)
split_df = pd.DataFrame({
    "x": rng.normal(size=240),
    "y": rng.normal(size=240),
    "group": np.tile(["Control", "Low", "High"], 80),
    "replicate": np.tile(["R1", "R2"], 120),
    "panel": np.repeat(["A", "B", "C", "D"], 60),
})

fig, axes = pp.subplots(2, 2, axes_size=(35, 30))
pp.legend(side="bottom", collect=["group"])
for (r, c), panel in zip([(0, 0), (0, 1), (1, 0), (1, 1)], "ABCD"):
    pp.scatterplot(
        data=split_df[split_df["panel"] == panel], x="x", y="y",
        hue="group", style="replicate", palette="pastel",
        title=f"Panel {panel}", ax=axes[r, c],
        legend_kws={"inside": True, "loc": "upper right"},
    )
pp.show()
Panel A, Panel B, Panel C, Panel D

8. Multi-kind legends: lineplot (hue + linestyle)

When a plot exposes several orthogonal legend kinds — e.g., a lineplot with both hue= (3 colored lines) and style= (2 dash styles) — pp.legend_group collects each kind as its own legend entry. On a bottom/top horizontal band they sit side-by-side along the edge, centered as a block; the two-kind layout is a good stress test for the along-edge cursor advancing between successive legends. markers=False (the default) keeps the lines crisp — markers would overlap awkwardly with this many time points.

rng = np.random.default_rng(11)
t = np.linspace(0, 10, 40)
_line_rows = []
for panel in "ABCD":
    for treatment, offset in [("Control", 0.0), ("Low", 0.8), ("High", 1.6)]:
        for method, jitter in [("raw", 0.0), ("smoothed", 0.3)]:
            for tt in t:
                _line_rows.append({
                    "panel": panel, "time": tt,
                    "value": np.sin(tt) + offset + jitter + rng.normal(0, 0.15),
                    "treatment": treatment, "method": method,
                })
line_df = pd.DataFrame(_line_rows)

# Define the palette as a mapping BEFORE the loop so each treatment
# keeps the same color across every panel — otherwise a panel that
# only saw a subset of treatments would hand out colors positionally
# and the merged legend would render inconsistent colors.
treatment_palette = dict(zip(
    ["Control", "Low", "High"],
    pp.color_palette("pastel", 3),
))

fig, axes = pp.subplots(2, 2, axes_size=(50, 30))
pp.legend(side="bottom")
for (r, c), panel in zip([(0, 0), (0, 1), (1, 0), (1, 1)], "ABCD"):
    pp.lineplot(
        data=line_df[line_df["panel"] == panel], x="time", y="value",
        hue="treatment", style="method", palette=treatment_palette,
        dashes={"raw": (1, 0), "smoothed": (4, 2)},
        title=f"Panel {panel}", ax=axes[r, c],
    )
pp.show()
Panel A, Panel B, Panel C, Panel D

9. Multi-kind legends: barplot (hue + hatch)

Barplots with both hue= (color) and hatch= (pattern) stash two entries per panel — pp.legend_group places them side-by-side on the bottom band. Three hue levels × two hatch levels = five handles across two legends, exercising horizontal layout with non-trivial widths.

rng = np.random.default_rng(17)
bar_df = pd.DataFrame({
    "cat": np.tile(["A", "B", "C"], 160),
    "val": rng.normal(size=480) + np.tile([0, 1, 2], 160),
    "group": np.repeat(["low", "mid", "high"], 160),
    "time": np.tile(np.repeat(["24h", "48h"], 80), 3),
    "panel": np.repeat(list("ABCD"), 120),
})

# Pin each group level to a specific color by passing the palette as a
# mapping. Without this, each panel would resolve its palette from
# whatever subset of levels it contains, producing color drift across
# panels (e.g., 'mid' → second color in a low+mid panel but first
# color in a mid+high panel).
group_palette = dict(zip(
    ["low", "mid", "high"],
    pp.color_palette("pastel", 3),
))

fig, axes = pp.subplots(2, 2, axes_size=(45, 30))
pp.legend(side="bottom")
for (r, c), panel in zip([(0, 0), (0, 1), (1, 0), (1, 1)], "ABCD"):
    pp.barplot(
        data=bar_df[bar_df["panel"] == panel], x="cat", y="val",
        hue="group", hatch="time",
        palette=group_palette, hatch_map={"24h": "", "48h": "///"},
        errorbar="se", title=f"Panel {panel}", ax=axes[r, c],
    )
pp.show()
Panel A, Panel B, Panel C, Panel D

10. Two independent bands on one figure

Multiple pp.legend_group calls can coexist on the same figure. Each uses collect=[...] (and optionally axes=[...]) to claim a disjoint slice of the stashed legend entries, and each renders on its own side. Below, the treatment palette shares a side='top' band while the method linestyles share a side='bottom' band — two figure-anchored groups on the same grid.

fig, axes = pp.subplots(2, 2, axes_size=(45, 30))
pp.legend(side="top", collect=["treatment"])
pp.legend(side="bottom", collect=["method"])
for r, row in enumerate(axes):
    for c, ax in enumerate(row):
        pp.lineplot(
            data=line_df, x="time", y="value",
            hue="treatment", style="method", palette=treatment_palette,
            dashes={"raw": (1, 0), "smoothed": (4, 2)},
            title=f"Panel {(r, c)}", ax=ax,
        )
pp.show()
Panel (0, 0), Panel (0, 1), Panel (1, 0), Panel (1, 1)

10b. Scoping a group to a subset of axes

axes=[...] restricts which subplots a group collects from and evicts per-axis legends from. The top-row band below only looks at the top row of axes; the bottom-row band only at the bottom. Useful when the subplot grid displays two independent stories that share a figure.

fig, axes = pp.subplots(2, 2, axes_size=(45, 30))
top_row = list(axes[0])
bottom_row = list(axes[1])
pp.legend(
    anchor=axes[0, -1], side="top", axes=top_row, collect=["treatment"],
)
pp.legend(
    anchor=axes[1, -1], side="bottom", axes=bottom_row, collect=["method"],
)
for r, row in enumerate(axes):
    for c, ax in enumerate(row):
        pp.lineplot(
            data=line_df, x="time", y="value",
            hue="treatment", style="method", palette=treatment_palette,
            dashes={"raw": (1, 0), "smoothed": (4, 2)},
            title=f"Panel {(r, c)}", ax=ax,
        )
pp.show()
Panel (0, 0), Panel (0, 1), Panel (1, 0), Panel (1, 1)

11. Row bands (with inter-row band)

New in 0.10: passing a row of axes as the positional scope creates a band pinned to that row’s top edge, centered on the row’s width only (not the full figure). Ideal for a 2xN grid where each row carries its own hue that deserves its own legend.

Here we place two row bands on the same figure: one above row 0 (at the top of the figure) and one above row 1 (between the two rows). The second band is the interesting case — it exercises the auto-layout’s ability to negotiate per-row reservations so the inter-row band opens enough vertical space without colliding with row 0’s xlabels below.

Contrast with pp.legend(side='top') (section 5, top case): that variant spans the full grid width as a single band. Here we get two distinct bands, each scoped to its own row’s width.

Migration note: pre-0.10 this pattern required explicitly passing anchor= AND axes= (see section 10b). With 0.10 the single positional arg expresses both — the scope IS the anchor.

fig, axes = pp.subplots(2, 3, axes_size=(35, 25))
pp.legend(axes[0], side="top", collect=["group"])
pp.legend(axes[1], side="top", collect=["group"])
_panel_cycle = ["A", "B", "C", "D", "A", "B"]
for r, row in enumerate(axes):
    for c, ax in enumerate(row):
        panel = _panel_cycle[r * 3 + c]
        pp.scatterplot(
            data=_df[_df["panel"] == panel], x="x", y="y",
            hue="group", palette="pastel",
            title=f"Panel {panel}", ax=ax,
        )
pp.show()
Panel A, Panel B, Panel C, Panel D, Panel A, Panel B

12. Column band — pp.legend(axes[:, 0], side='left')

The same idea, column-oriented. Passing a column slice as the scope creates a band pinned to the left edge of that column, centered on the column’s height. Handy when a left-most column shares a common legend that doesn’t apply to other columns (e.g., a reference-distribution column next to per-condition panels).

Default orientation for side='left' is vertical (entries stack down the edge) and alignment defaults to 'start' (top of the column). Override either via orientation= / align= if the defaults collide with the column’s ylabels.

fig, axes = pp.subplots(3, 2, axes_size=(35, 25))
pp.legend(axes[:, 0], side="left", collect=["group"])
for r, row in enumerate(axes):
    for c, ax in enumerate(row):
        panel = "ABCD"[(r * 2 + c) % 4]
        pp.scatterplot(
            data=_df[_df["panel"] == panel], x="x", y="y",
            hue="group", palette="pastel",
            title=f"Row {r} Col {c}", ax=ax,
        )
pp.show()
Row 0 Col 0, Row 0 Col 1, Row 1 Col 0, Row 1 Col 1, Row 2 Col 0, Row 2 Col 1

13. Internal vs external per-axes legend

The positional and keyword forms of pp.legend have subtly different layout semantics when scoping to a single axes:

  • pp.legend(ax) (positional) — internal per-axes legend. The plot call (pp.scatterplot etc.) already created a per-axes legend group on that axes; pp.legend(ax) adopts that existing group rather than building a second competing one. This makes it the single source of truth — no “scope overlaps with an existing group” warning, no double render, and any side= you pass is honoured. The legend is measured by ax.get_tightbbox(), so it counts as part of the axes’ own decoration and the figure grows to accommodate it just like a tick label or title would.

  • pp.legend(anchor=ax) (kwarg) — external band pinned to that axes’ right edge. The band is measured as an overhang past the axes rectangle and absorbs the per-cell right reservation (see section 6).

The 1x2 figure below shows both modes side by side, one per panel.

fig, axes = pp.subplots(1, 2, axes_size=(45, 35))
# Left panel: internal legend pinned to axes[0].
pp.legend(axes[0])
# Right panel: external band pinned to axes[1]'s right edge.
pp.legend(anchor=axes[1])
pp.scatterplot(
    data=_df[_df["panel"] == "A"], x="x", y="y",
    hue="group", palette="pastel",
    title="pp.legend(ax) — internal", ax=axes[0],
)
pp.scatterplot(
    data=_df[_df["panel"] == "B"], x="x", y="y",
    hue="group", palette="pastel",
    title="pp.legend(anchor=ax) — external", ax=axes[1],
)
pp.show()
pp.legend(ax) — internal, pp.legend(anchor=ax) — external

14. Sub-scope with an explicit anchor= override

Advanced use: the axes= kwarg sets the collection scope (which plots contribute entries) while anchor= independently sets the geometric pin (where the band physically sits). By default pp.legend(axes=top_row, side='top') anchors to the row’s bounding rect and centers the band. Pass anchor= to override the pin — e.g., collect entries from the whole top row but pin the band above the top-right corner cell specifically.

Useful when the collection scope is one geometry (a full row) but the aesthetic target is another (a single corner panel, typically because it carries the richest hue stash).

fig, axes = pp.subplots(2, 3, axes_size=(35, 25))
top_row = list(axes[0])
pp.legend(
    axes=top_row, anchor=axes[0, -1], side="top", collect=["group"],
)
for r, row in enumerate(axes):
    for c, ax in enumerate(row):
        panel = "ABCD"[(r * 3 + c) % 4]
        pp.scatterplot(
            data=_df[_df["panel"] == panel], x="x", y="y",
            hue="group", palette="pastel",
            title=f"Panel {(r, c)}", ax=ax,
        )
pp.show()
Panel (0, 0), Panel (0, 1), Panel (0, 2), Panel (1, 0), Panel (1, 1), Panel (1, 2)

15. In-cell shared legend — pp.legend(anchor=ax, inside=True)

When a grid has an empty cell (3 plots in a 2x2 layout, or any asymmetric arrangement), publiplots can fill that cell with the shared legend instead of overhanging the figure’s edge. The anchor= is the cell to render INTO; collection scope still defaults to the full figure (or pass axes=[...] to narrow). The anchor cell is auto-blanked (no frame, no ticks) so it reads as a clean legend tile — opt out via clear_anchor=False if the cell already holds intentional content.

Default placement: side='left', align='start' → upper-left of the tile. This is the visual continuation of the canonical pp.legend(side='right', align='start') band recipe: the legend hugs the inner-left edge of the legend tile, against the divide between plots and legend. Override side / align for other layouts (empty cell on the LEFT of plots → side='right'; centered tile → side='center').

fig, axes = pp.subplots(2, 2, axes_size=(35, 30))
for (r, c), panel in zip([(0, 0), (0, 1), (1, 0)], "ABC"):
    pp.scatterplot(
        data=_df[_df["panel"] == panel], x="x", y="y",
        hue="group", palette="pastel",
        title=f"Panel {panel}", ax=axes[r, c],
    )
pp.legend(anchor=axes[1, 1], inside=True)
pp.show()
Panel A, Panel B, Panel C

16. Per-axes side= on a single axes (all four sides)

pp.legend(ax, side=...) adopts the per-axes legend group the plot call already created on that axes and applies the side you ask for — no second group, no “scope overlaps with an existing group” warning, and the side= is honoured (previously the auto-created right-side group could win). All four sides work in axes-anchored mode:

  • side='right' / 'left' → vertical band beside the axes. An internal left legend clears the y-tick labels.

  • side='top' / 'bottom' → horizontal band above/below. An internal top legend clears the axes title.

The outward offset defaults are larger for the top and left sides in this axes-anchored mode, so the band clears the reclaimed decoration cleanly. The 2x2 figure below shows one side per panel.

fig, axes = pp.subplots(2, 2, axes_size=(45, 35))
for (r, c), side in zip([(0, 0), (0, 1), (1, 0), (1, 1)],
                        ["top", "bottom", "left", "right"]):
    pp.scatterplot(
        data=_df[_df["panel"] == "A"], x="x", y="y",
        hue="group", palette="pastel",
        title=f'side="{side}"', ax=axes[r, c],
    )
    pp.legend(axes[r, c], side=side)
pp.show()
side=

17. One-call placement via legend_kws

Placement keys passed in legend_kws are forwarded straight to the per-axes legend group, so you can position the legend in a single pp.scatterplot call — no separate pp.legend(ax) needed. The forwarded placement keys are side, orientation, align, x_offset, y_offset, and gap. Here side="left" lands the legend on the axes’ left edge in one call.

(The inside=True path is independent and ignores these placement keys — see section 2.)

pp.scatterplot(
    data=_df[_df["panel"] == "A"], x="x", y="y",
    hue="group", palette="pastel",
    legend_kws={"side": "left"},
    title='legend_kws={"side": "left"}',
)
pp.show()
legend_kws={

Total running time of the script: (0 minutes 53.999 seconds)

Gallery generated by Sphinx-Gallery