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
102 changes: 74 additions & 28 deletions js/src/50_chartview.js
Original file line number Diff line number Diff line change
Expand Up @@ -717,10 +717,11 @@ class ChartView {
this.chrome.style.height = this.size.h + "px";
this.chrome.width = this.size.w * this.dpr;
this.chrome.height = this.size.h * this.dpr;
if (this._legend && this._slotStyleValue("legend", "max-height") == null) {
if (this._legends && this._legends.length && this._slotStyleValue("legend", "max-height") == null) {
// _slotStyleValue canonicalizes keys, so this one check honors snake_case
// / camelCase / kebab author styles alike (no separate maxHeight probe).
this._legend.style.maxHeight = p.h - 12 + "px";
// Extra legend boxes share the primary's slot, so all get the refresh.
for (const lg of this._legends) lg.style.maxHeight = p.h - 12 + "px";
}
this._positionReductionBadges();
this._positionColorbar();
Expand Down Expand Up @@ -851,39 +852,63 @@ class ChartView {

_buildLegend(root) {
const s = this.spec;
if (s.show_legend === false) return;
this._legends = [];
const items = [];
for (const t of s.traces) {
if (t.tier === "density") {
items.push({ swatch: "gradient", cmap: t.density.colormap, name: t.name || "density" });
} else if (t.color && t.color.mode === "categorical") {
t.color.categories.forEach((cat, i) =>
items.push({ swatch: t.color.palette[i], name: cat, symbol: t.kind === "scatter" ? (t.style?.symbol || "circle") : null, style: t.style || {} }));
} else if (t.color && t.color.mode === "continuous") {
items.push({ swatch: "gradient", cmap: t.color.colormap, name: t.name || "value" });
} else if (t.name) {
const c = (t.color && t.color.color) || (t.style && t.style.color);
items.push({ swatch: c, name: t.name, symbol: t.kind === "scatter" ? (t.style?.symbol || "circle") : null, style: t.style || {} });
if (s.show_legend !== false) {
for (const t of s.traces) {
if (t.tier === "density") {
items.push({ swatch: "gradient", cmap: t.density.colormap, name: t.name || "density" });
} else if (t.color && t.color.mode === "categorical") {
t.color.categories.forEach((cat, i) =>
items.push({ swatch: t.color.palette[i], name: cat, symbol: t.kind === "scatter" ? (t.style?.symbol || "circle") : null, style: t.style || {} }));
} else if (t.color && t.color.mode === "continuous") {
items.push({ swatch: "gradient", cmap: t.color.colormap, name: t.name || "value" });
} else if (t.name) {
const c = (t.color && t.color.color) || (t.style && t.style.color);
// Line-family kinds get a short line sample (honoring the dash), the
// same handle the raster/SVG exporters draw — not a filled swatch.
const line = ["line", "segments", "step", "stairs", "errorbar"].includes(t.kind);
items.push({ swatch: c, name: t.name, symbol: t.kind === "scatter" ? (t.style?.symbol || "circle") : null, line, style: t.style || {} });
}
}
if (items.length) this._legendBox(root, items, s.legend || {});
}
// Manually added Legend artists ship explicit items + their own loc, so a
// second legend (e.g. one per line group) renders as its own box.
for (const extra of s.extra_legends || []) {
const mapped = (extra.items || []).map((it) => ({
swatch: it.style && it.style.color,
name: it.name,
symbol: it.kind === "scatter" ? (it.style?.symbol || "circle") : null,
line: ["line", "segments", "step", "stairs", "errorbar"].includes(it.kind),
style: it.style || {},
}));
if (mapped.length) this._legendBox(root, mapped, extra);
}
if (!items.length) return;
}

_legendBox(root, items, options) {
const lg = document.createElement("div");
const options = s.legend || {};
const loc = options.loc || "upper right";
const ncols = Math.max(1, Number(options.ncols) || 1);
const rightInset = this.size.w - (this.plot.x + this.plot.w);
const horizontal = ncols > 1;
const xPos = loc.includes("left")
? `left:${this.plot.x + 6}px;`
: loc.includes("center")
? `left:${this.plot.x + this.plot.w / 2}px;transform:translateX(-50%);`
: `right:${rightInset + 6}px;`;
const yPos = loc.includes("lower")
? `bottom:${this.size.h - (this.plot.y + this.plot.h) + 6}px;`
: loc === "center" || loc.includes("center left") || loc.includes("center right")
? `top:${this.plot.y + this.plot.h / 2}px;transform:${loc.includes("center") && !loc.includes("left") && !loc.includes("right") ? "translate(-50%,-50%)" : "translateY(-50%)"};`
: `top:${this.plot.y + 6}px;`;
lg.style.cssText = `position:absolute;${xPos}${yPos}` +
// Parse the loc into independent horizontal/vertical anchors. "center" is
// the fallback on each axis, so "center right" reads as right-edge +
// vertical-center and "upper center" as top + horizontal-center. Both
// translate offsets go into ONE transform (two `transform:` declarations
// would clobber each other, dropping the horizontal recenter on "center").
const h = loc.includes("left") ? "left" : loc.includes("right") ? "right" : "center";
const v = loc.includes("upper") ? "upper" : loc.includes("lower") ? "lower" : "center";
let xPos, yPos, tx = "0", ty = "0";
if (h === "left") xPos = `left:${this.plot.x + 6}px;`;
else if (h === "right") xPos = `right:${rightInset + 6}px;`;
else { xPos = `left:${this.plot.x + this.plot.w / 2}px;`; tx = "-50%"; }
if (v === "upper") yPos = `top:${this.plot.y + 6}px;`;
else if (v === "lower") yPos = `bottom:${this.size.h - (this.plot.y + this.plot.h) + 6}px;`;
else { yPos = `top:${this.plot.y + this.plot.h / 2}px;`; ty = "-50%"; }
const transform = tx === "0" && ty === "0" ? "" : `transform:translate(${tx},${ty});`;
lg.style.cssText = `position:absolute;${xPos}${yPos}${transform}` +
`display:grid;grid-template-columns:repeat(${horizontal ? ncols : 1},max-content);` +
"overflow:auto;" + `max-height:${this.plot.h - 12}px;`;
this._applySlot(lg, "legend");
Expand Down Expand Up @@ -938,6 +963,26 @@ class ChartView {
sw.appendChild(svg);
sw.style.width = "18px";
sw.style.height = "14px";
} else if (it.line) {
const ns = "http://www.w3.org/2000/svg";
const svg = document.createElementNS(ns, "svg");
svg.setAttribute("viewBox", "0 0 22 12");
svg.setAttribute("width", "22");
svg.setAttribute("height", "12");
const ln = document.createElementNS(ns, "line");
ln.setAttribute("x1", "1");
ln.setAttribute("y1", "6");
ln.setAttribute("x2", "21");
ln.setAttribute("y2", "6");
ln.setAttribute("stroke", safeCssPaint(this.root, bg));
// ?? not ||: an explicit lw=0 keeps 0 and draws nothing, like the
// exporters' dict-default and Matplotlib itself.
ln.setAttribute("stroke-width", String(it.style?.width ?? 1.5));
if (it.style?.dash && it.style.dash.length) ln.setAttribute("stroke-dasharray", it.style.dash.join(" "));
svg.appendChild(ln);
sw.appendChild(svg);
sw.style.width = "22px";
sw.style.height = "12px";
} else {
sw.style.background = safeCssPaint(this.root, bg);
}
Expand All @@ -947,7 +992,8 @@ class ChartView {
lg.appendChild(row);
}
root.appendChild(lg);
this._legend = lg; // _resize refreshes its max-height
this._legends.push(lg); // _resize refreshes every box's max-height
return lg;
}

_buildColorbar(root) {
Expand Down
4 changes: 4 additions & 0 deletions python/xy/_figure.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,10 @@ def __init__(
self.traces: list[Trace] = []
self.show_legend = True
self.legend_options: dict[str, Any] = {}
# Additional legend boxes (each with its own explicit items + loc),
# e.g. the pyplot shim's manually added Legend artists. Empty for the
# ordinary single-legend case.
self.extra_legends: list[dict[str, Any]] = []
# None keeps the declarative engine's two-axis baseline convention;
# pyplot sets an explicit Matplotlib-style spine list.
self.frame_sides: Optional[list[str]] = None
Expand Down
3 changes: 3 additions & 0 deletions python/xy/_payload.py
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,9 @@ def axis_range(axis_id: str) -> tuple[float, float]:
}
if self.legend_options:
spec["legend"] = self.legend_options
extra_legends = getattr(self, "extra_legends", None)
if extra_legends:
spec["extra_legends"] = extra_legends
if self.frame_sides is not None:
spec["frame_sides"] = list(self.frame_sides)
if self.colorbar_options:
Expand Down
20 changes: 18 additions & 2 deletions python/xy/_raster.py
Original file line number Diff line number Diff line change
Expand Up @@ -730,6 +730,10 @@ def tick_span(style):
named = [t for t in spec["traces"] if t.get("name")]
if spec.get("show_legend", True) and named:
_emit_legend(cmd, named, plot, spec.get("legend") or {})
for extra in spec.get("extra_legends") or []:
items = extra.get("items") or []
if items:
_emit_legend(cmd, items, plot, extra)
if spec.get("colorbar"):
_emit_colorbar(cmd, spec["colorbar"], plot)

Expand Down Expand Up @@ -1256,8 +1260,20 @@ def _emit_legend(cmd, named, plot, options):
cell_w = max(len(str(t["name"])) for t in named) * 6.2 + handle + gap + 2 * pad
box_w, box_h = ncols * cell_w + pad, nrows * line_h + pad + title_h
loc = options.get("loc") or "upper right"
x = plot["x"] + 6 if "left" in loc else plot["x"] + plot["w"] - box_w - 6
y = plot["y"] + plot["h"] - box_h - 6 if "lower" in loc else plot["y"] + 6
# "center" is the per-axis fallback: "center right" is the right edge at
# vertical center, "upper center" the top edge at horizontal center.
if "left" in loc:
x = plot["x"] + 6
elif "right" in loc:
x = plot["x"] + plot["w"] - box_w - 6
else:
x = plot["x"] + (plot["w"] - box_w) / 2
if "upper" in loc:
y = plot["y"] + 6
elif "lower" in loc:
y = plot["y"] + plot["h"] - box_h - 6
else:
y = plot["y"] + (plot["h"] - box_h) / 2
# frameon=False (background transparent) drops the box entirely (§ mpl parity).
if style_opts.get("background") != "transparent":
if style_opts.get("boxShadow"):
Expand Down
20 changes: 18 additions & 2 deletions python/xy/_svg.py
Original file line number Diff line number Diff line change
Expand Up @@ -1175,6 +1175,10 @@ def line_attrs(style: dict[str, Any], color: str) -> str:
named = [t for t in spec["traces"] if t.get("name")]
if spec.get("show_legend", True) and named:
chrome.append(_legend(named, plot, spec.get("legend") or {}))
for extra in spec.get("extra_legends") or []:
items = extra.get("items") or []
if items:
chrome.append(_legend(items, plot, extra))
if spec.get("colorbar"):
chrome.append(_colorbar(spec["colorbar"], plot))

Expand Down Expand Up @@ -1737,8 +1741,20 @@ def _legend(named: list[dict], plot: dict, options: dict) -> str:
cell_w = max(len(str(t["name"])) for t in named) * 6.2 + handle + gap + 2 * pad
box_w, box_h = ncols * cell_w + pad, nrows * line_h + pad + title_h
loc = options.get("loc") or "upper right"
x = plot["x"] + 6 if "left" in loc else plot["x"] + plot["w"] - box_w - 6
y = plot["y"] + plot["h"] - box_h - 6 if "lower" in loc else plot["y"] + 6
# "center" is the per-axis fallback: "center right" is the right edge at
# vertical center, "upper center" the top edge at horizontal center.
if "left" in loc:
x = plot["x"] + 6
elif "right" in loc:
x = plot["x"] + plot["w"] - box_w - 6
else:
x = plot["x"] + (plot["w"] - box_w) / 2
if "upper" in loc:
y = plot["y"] + 6
elif "lower" in loc:
y = plot["y"] + plot["h"] - box_h - 6
else:
y = plot["y"] + (plot["h"] - box_h) / 2
if style_opts.get("background") != "transparent":
if style_opts.get("boxShadow"):
rows.append(
Expand Down
2 changes: 2 additions & 0 deletions python/xy/pyplot/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@

import numpy as np

from ._artists import Legend
from ._axes import Axes
from ._colors import LinearSegmentedColormap, ListedColormap
from ._mplfig import Figure, GridSpec
Expand Down Expand Up @@ -51,6 +52,7 @@
"FormatStrFormatter",
"FuncFormatter",
"GridSpec",
"Legend",
"LinearLocator",
"LinearSegmentedColormap",
"ListedColormap",
Expand Down
111 changes: 111 additions & 0 deletions python/xy/pyplot/_artists.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from __future__ import annotations

import warnings
from itertools import pairwise
from typing import Any, Optional

Expand Down Expand Up @@ -818,3 +819,113 @@ class StreamplotSet:
def __init__(self, lines: PolyCollection, arrows: PolyCollection) -> None:
self.lines = lines
self.arrows = arrows


def _legend_item_from_entry(
entry: dict[str, Any], label: Any, point_scale: float
) -> dict[str, Any]:
"""Freeze a plotted entry into a standalone legend swatch descriptor.

The primary legend derives its swatches from trace names inside the render
client; a manually built :class:`Legend` instead ships explicit items so it
can show a *subset* of the handles under different labels. The item shape
(``kind`` + ``style`` with color/width/dash/symbol) matches what every
renderer already draws for a named trace, so line dashes and marker glyphs
render identically.
"""
kind = str(entry.get("kind", "line"))
if kind.startswith("@"): # generic marks (errorbar, vlines, …) → a line sample
kind = "line"
kw = entry.get("kwargs", {})
style: dict[str, Any] = {}
color = kw.get("color")
if isinstance(color, str):
style["color"] = color
width = kw.get("width")
if width is not None:
style["width"] = float(width) * point_scale
opacity = kw.get("opacity")
if opacity is not None:
style["opacity"] = float(opacity)
# Rule annotations keep renderer-specific geometry inside ``style`` while
# ordinary line/step entries keep it at the top level. Accept both shapes
# so explicit Legend handles preserve the plotted dash.
dash = kw.get("dash", (kw.get("style") or {}).get("dash"))
if isinstance(dash, str) and "," in dash:
try:
dash = [float(value.strip()) for value in dash.split(",")]
except ValueError:
dash = None
if isinstance(dash, str) and dash not in ("", "none", "solid"):
from .. import _validate

try:
resolved = _validate.dash(dash, "legend dash")
except (ValueError, TypeError):
resolved = None
if resolved:
style["dash"] = resolved
elif isinstance(dash, (list, tuple)):
style["dash"] = [float(v) for v in dash]
if kind == "scatter":
symbol = kw.get("symbol")
if symbol:
style["symbol"] = symbol
for key in ("stroke", "stroke_width"):
if kw.get(key) is not None:
style[key] = kw[key]
return {"name": str(label), "kind": kind, "style": style}


class Legend:
"""A standalone legend artist, as ``matplotlib.legend.Legend``.

Construct it with the parent axes plus explicit handles/labels, then attach
it via ``ax.add_artist(leg)`` to render a *second* legend (e.g. one legend
per group of lines) alongside the axes' own ``ax.legend()``.
"""

def __init__(self, parent: Any, handles: Any, labels: Any, loc: Any = "best", **kwargs: Any):
handles, labels = list(handles), list(labels)
if len(handles) != len(labels):
warnings.warn(
f"Legend: mismatched number of handles ({len(handles)}) and "
f"labels ({len(labels)}); the extras are ignored",
stacklevel=2,
)
self._pairs: list[tuple[dict[str, Any], Any]] = []
for handle, label in zip(handles, labels, strict=False):
entry = getattr(handle, "_entry", None)
if entry is None:
# ErrorbarContainer exposes the bars through its private
# compatibility artist rather than inheriting Artist itself.
entry = getattr(getattr(handle, "_artist", None), "_entry", None)
if entry is None:
warnings.warn(
f"Legend does not support {type(handle).__name__} handles; "
f"dropping the entry for {label!r}",
stacklevel=2,
)
continue
self._pairs.append((entry, label))
self._kwargs = dict(kwargs)
self._kwargs.setdefault("loc", loc)
self._attach(parent)

def _attach(self, parent: Any) -> None:
"""(Re)freeze options and swatch scaling against *parent*'s figure state.

``Axes.add_artist`` calls this so a legend constructed against one axes
but attached to another picks up the host's dpi/rcParams state rather
than keeping the constructor's.
"""
self._parent = parent
self._options = parent._compose_legend_options(dict(self._kwargs))
scale = parent._point_scale()
self._items = [_legend_item_from_entry(entry, label, scale) for entry, label in self._pairs]

def spec(self) -> dict[str, Any]:
"""The option dict plus explicit items, ready for the render payload."""
options = dict(self._options)
options["items"] = self._items
return options
Loading
Loading