Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/components/legends.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,13 @@ configuration.
Set `ncols=` on the legend, e.g. `xy.legend(ncols=2, title="Series")`, which
lays the entries out in two columns under an optional legend title.

`ncols=` is a minimum for static output. The browser legend scrolls when it
outgrows the plot, but SVG and PNG cannot, so those exporters add columns
beyond `ncols=` when that is what it takes to draw every entry. If the plot
rect is too small even at full width, the export keeps as many entries as fit
and warns with the row and column count it managed — raise the chart height or
`ncols=` to clear it.

### How do I hide the legend or keep a series out of it?

Use `xy.legend(show=False)` to suppress the legend entirely. Only marks with a
Expand Down
37 changes: 31 additions & 6 deletions python/xy/_svg.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import base64
import math
import re
import warnings
from collections.abc import Callable, Sequence
from datetime import UTC, datetime
from os import PathLike
Expand Down Expand Up @@ -2690,23 +2691,47 @@ def _legend_layout(named: list[dict], plot: dict, options: dict) -> dict[str, An
inset = 6.0
available_w = max(1.0, float(plot["w"]) - 2 * inset)
ncols = requested_cols
# A cell must at least retain its handle and a visible ellipsis, so the
# plot width bounds how many columns can ever be drawn.
min_cell_w = handle + gap + 2 * pad + 4 * _LEGEND_CHAR_WIDTH
max_fit_cols = max(1, int(max(0.0, available_w - pad) // min_cell_w))
if ncols * natural_cell_w + pad > available_w:
# A cell must at least retain its handle and a visible ellipsis. Reduce
# an impossible column count before shortening the individual labels.
min_cell_w = handle + gap + 2 * pad + 4 * _LEGEND_CHAR_WIDTH
max_fit_cols = max(1, int(max(0.0, available_w - pad) // min_cell_w))
# Reduce an impossible column count before shortening the labels.
ncols = min(ncols, max_fit_cols)
cell_w = min(natural_cell_w, max(1.0, (available_w - pad) / ncols))
box_w = min(available_w, ncols * cell_w + pad)

nrows = (len(named) + ncols - 1) // ncols
available_h = max(1.0, float(plot["h"]) - 2 * inset)
max_rows = max(0, int((available_h - pad - title_h) // line_h))

# The browser legend scrolls (overflow:auto); a static export cannot, so
# entries that do not fit are lost. Spend width before losing any: widen
# past `requested_cols` up to whatever the plot rect actually affords, so a
# tall series list reflows into columns instead of being cut (§28 — a drop
# is allowed but never silent, see the warning below).
if max_rows and ncols * max_rows < len(named):
needed_cols = (len(named) + max_rows - 1) // max_rows
ncols = min(max(ncols, needed_cols), max_fit_cols)
cell_w = min(natural_cell_w, max(1.0, (available_w - pad) / ncols))
box_w = min(available_w, ncols * cell_w + pad)

nrows = (len(named) + ncols - 1) // ncols
visible_rows = nrows
natural_box_h = nrows * line_h + pad + title_h
if natural_box_h > available_h:
visible_rows = max(0, int((available_h - pad - title_h) // line_h))
visible_rows = max_rows
visible_count = min(len(named), visible_rows * ncols)
box_h = min(available_h, visible_rows * line_h + pad + title_h)
if visible_count < len(named):
warnings.warn(
f"legend shows {visible_count} of {len(named)} entries in static "
f"export: the plot rect fits {max_rows or 0} row(s) by {ncols} "
"column(s) and a static legend cannot scroll the way the browser "
"one does. Raise the chart height, pass a larger legend(ncols=...), "
"or name fewer series to keep every entry.",
RuntimeWarning,
stacklevel=3,
)

loc = options.get("loc") or "upper right"
if "left" in loc:
Expand Down
11 changes: 11 additions & 0 deletions spec/api/styling.md
Original file line number Diff line number Diff line change
Expand Up @@ -600,6 +600,17 @@ super/subscripts, typographic quotes and dashes). A codepoint outside that set
tick label, legend entry, or annotation containing one renders shortened rather
than raising. Use `engine=xy.Engine.chromium` for full Unicode text.

### Legend fit in static export

The browser legend scrolls (`overflow:auto`), so every entry stays reachable
however small the plot. SVG and the native rasterizer have no scroll, so they
resolve the same overflow by **spending width first**: `legend(ncols=)` is a
floor, and both exporters widen past it — up to whatever the plot rect affords
at the minimum cell width — so a long series list reflows into columns instead
of being cut. Only when the rect cannot hold every entry at full width does
the export drop the remainder, and that drop warns with the row × column count
it achieved (§28 — allowed, never silent). The browser is unaffected.

The atlas bounds the native **raster** formats (PNG, JPEG, WebP) only. The
other two native formats carry their own text contracts: SVG emits real
`<text>` elements in a `system-ui` stack and so resolves against the viewer's
Expand Down
Loading