Skip to content
Closed
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
5 changes: 5 additions & 0 deletions python/xy/_figure.py
Original file line number Diff line number Diff line change
Expand Up @@ -1067,6 +1067,11 @@ def _zero_baseline_anchor(self, axis_id: str) -> Optional[str]:
"""
axis = self._axis_dim(axis_id)
for t in self.traces:
# Only rectangle families have a sticky zero edge. Segment-based
# marks such as stem/errorbar also carry x0/x1/y0/y1 columns, but
# Matplotlib pads their baseline like ordinary line data.
if t.kind not in {"bar", "histogram"}:
continue
if axis == "x" and t.x_axis != axis_id:
continue
if axis == "y" and t.y_axis != axis_id:
Expand Down
12 changes: 6 additions & 6 deletions python/xy/_raster.py
Original file line number Diff line number Diff line change
Expand Up @@ -1320,16 +1320,16 @@ def read(index: int) -> np.ndarray:
and (t.get("stroke") is None or t["stroke"].get("mode") == "match_fill")
and (color_mode in {"continuous", "categorical"} or size_mode == "continuous")
):
alpha = max(0, min(255, int(round(fill_op * 255))))
rgb = _parse_color(_css(ch.get("color"), color))[:3]
paint = _parse_color(_css(ch.get("color"), color))
alpha = max(0, min(255, int(round(fill_op * paint[3]))))
cmd.affine_channel_points(
cols[t["x"]],
cols[t["y"]],
sx,
sy,
ch,
size_ch,
(rgb[0], rgb[1], rgb[2], alpha),
(paint[0], paint[1], paint[2], alpha),
sym,
sw,
stroke,
Expand All @@ -1349,9 +1349,9 @@ def read(index: int) -> np.ndarray:
and not t.get("channels")
and (t.get("stroke") is None or t["stroke"].get("mode") == "match_fill")
):
alpha = max(0, min(255, int(round(fill_op * 255))))
rgb = _parse_color(_css(ch.get("color"), color))[:3]
fill = (rgb[0], rgb[1], rgb[2], alpha)
paint = _parse_color(_css(ch.get("color"), color))
alpha = max(0, min(255, int(round(fill_op * paint[3]))))
fill = (paint[0], paint[1], paint[2], alpha)
radius = float(size_ch.get("size", 4.0)) / 2
cmd.affine_points(cols[t["x"]], cols[t["y"]], sx, sy, radius, fill, sym, sw, stroke)
return
Expand Down
28 changes: 21 additions & 7 deletions python/xy/pyplot/_artists.py
Original file line number Diff line number Diff line change
Expand Up @@ -835,18 +835,32 @@ def get_data(self) -> tuple[Any, Any, Any]:
class StemContainer:
"""Small tuple-compatible analogue of matplotlib's StemContainer."""

def __init__(self, artist: Artist) -> None:
self.markerline = artist
self.stemlines = artist
self.baseline = artist
artist._axes._register_container(self)
def __init__(
self,
markerline: Artist,
stemlines: Optional[Artist] = None,
baseline: Optional[Artist] = None,
) -> None:
# Older callers supplied one compact artist for all three handles.
# Keep that form working while allowing the pyplot stem adapter to
# expose independently mutable marker, stem, and baseline artists.
self.markerline = markerline
self.stemlines = markerline if stemlines is None else stemlines
self.baseline = markerline if baseline is None else baseline
markerline._axes._register_container(self)

def __iter__(self) -> Iterator[Any]:
return iter((self.markerline, self.stemlines, self.baseline))

def remove(self) -> None:
self.stemlines.remove()
self.stemlines._axes._unregister_container(self)
axes = self.markerline._axes
seen: set[int] = set()
for artist in (self.markerline, self.stemlines, self.baseline):
if id(artist) in seen:
continue
seen.add(id(artist))
artist.remove()
axes._unregister_container(self)


class ErrorbarContainer:
Expand Down
3 changes: 3 additions & 0 deletions python/xy/pyplot/_axes.py
Original file line number Diff line number Diff line change
Expand Up @@ -2652,9 +2652,12 @@ def _iter_entry_arrays(self, axis: str) -> Iterator[tuple[np.ndarray, bool]]:
indexes = {
"segments": (0, 2) if axis == "x" else (1, 3),
"triangle_mesh": (0, 2, 4) if axis == "x" else (1, 3, 5),
"stem": (0,) if axis == "x" else (1,),
}.get(factory, ())
for index in indexes:
yield np.asarray(entry["args"][index], dtype=np.float64).reshape(-1), True
if factory == "stem" and axis == "y":
yield np.asarray(entry.get("kwargs", {}).get("base", 0.0)).reshape(-1), True
if factory == "contour":
z = np.asarray(entry["args"][0])
coordinates = entry.get("kwargs", {}).get(key)
Expand Down
107 changes: 74 additions & 33 deletions python/xy/pyplot/_plot_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -1501,9 +1501,7 @@ def stem(

Call as ``stem(y)`` or ``stem(x, y)``. ``linefmt``/``markerfmt`` are
``plot``-style fmt strings for the stems and heads, ``bottom`` moves
the baseline, and ``orientation`` may be ``"horizontal"``. Only the
default ``basefmt`` (``"C3-"``) is honored — the shim renders no
baseline rule.
the baseline, and ``orientation`` may be ``"horizontal"``.
"""
if len(args) == 1:
y = _from_data(args[0], data)
Expand All @@ -1519,42 +1517,55 @@ def stem(
color = resolve_color(color_spec) if color_spec else None
dash_pattern = _dash_segment_pattern("stem", linestyle)
symbol = "circle"
marker_color = None
if markerfmt:
marker_color, _linestyle, marker = parse_fmt(str(markerfmt))
color = color or (resolve_color(marker_color) if marker_color else None)
marker_color_spec, _linestyle, marker = parse_fmt(str(markerfmt))
marker_color = resolve_color(marker_color_spec) if marker_color_spec else None
from ._translate import MARKER_TO_SYMBOL

symbol = MARKER_TO_SYMBOL.get(marker or "o", "circle")
# The shim renders no baseline rule, so only the default basefmt passes.
_reject_non_default("stem", "basefmt", basefmt, "C3-")
chosen = color or self._next_color()
if orientation not in ("vertical", "horizontal"):
raise ValueError("stem orientation must be 'vertical' or 'horizontal'")

xv = np.asarray(x, dtype=np.float64)
yv = np.asarray(y, dtype=np.float64)
if xv.ndim != 1 or yv.ndim != 1 or xv.shape != yv.shape:
raise ValueError("stem x and y must be equally sized 1-D arrays")
base = np.full_like(xv, float(bottom))
if orientation == "vertical":
segments = (xv, base, xv, yv)
marker_x, marker_y = xv, yv
baseline_x = np.asarray([xv.min(), xv.max()]) if xv.size else np.asarray([])
baseline_y = np.full(2 if xv.size else 0, float(bottom))
else:
segments = (base, xv, yv, xv)
marker_x, marker_y = yv, xv
baseline_x = np.full(2 if xv.size else 0, float(bottom))
baseline_y = np.asarray([xv.min(), xv.max()]) if xv.size else np.asarray([])
if dash_pattern is not None:
segments = _dashed_segments(*segments, dash_pattern)

if orientation == "vertical" and dash_pattern is None:
entry = self._add(
# Retain the compact native stem primitive (and its public trace
# kind), but render markers separately so the returned markerline
# can be styled independently like Matplotlib's Line2D.
stem_entry = self._add(
"@mark",
{
"factory": "stem",
"args": (x, y),
"args": (xv, yv),
"kwargs": {
"base": bottom,
"marker": False,
"name": str(label) if label is not None else None,
"color": chosen,
"symbol": symbol,
"width": 1.2,
},
},
)
elif orientation in ("vertical", "horizontal"):
xv = np.asarray(x, dtype=np.float64)
yv = np.asarray(y, dtype=np.float64)
base = np.full_like(xv, float(bottom))
if orientation == "vertical":
segments = (xv, base, xv, yv)
marker_x, marker_y = xv, yv
else:
segments = (base, xv, yv, xv)
marker_x, marker_y = yv, xv
if dash_pattern is not None:
segments = _dashed_segments(*segments, dash_pattern)
entry = self._add(
else:
stem_entry = self._add(
"@mark",
{
"factory": "segments",
Expand All @@ -1566,17 +1577,47 @@ def stem(
},
},
)
self._add(
"scatter",
{
"x": marker_x,
"y": marker_y,
"kwargs": {"color": chosen, "symbol": symbol, "size": 5.0},
edge_width = float(rcParams["lines.markeredgewidth"]) * self._point_scale()
marker_size = float(rcParams["lines.markersize"]) * self._point_scale() + edge_width
marker_entry = self._add(
"scatter",
{
"x": marker_x,
"y": marker_y,
"kwargs": {
"color": marker_color or chosen,
"stroke": marker_color or chosen,
"stroke_width": edge_width,
"symbol": symbol,
"size": marker_size,
"opacity": 1.0,
},
)
else:
raise ValueError("stem orientation must be 'vertical' or 'horizontal'")
return StemContainer(Artist(self, entry))
},
)

base_color, base_linestyle, _base_marker = parse_fmt(str(basefmt or "C3-"))
base_dash = _dash_segment_pattern("stem", base_linestyle)
baseline_entry = self._add(
"line",
{
"x": baseline_x,
"y": baseline_y,
"kwargs": {
"color": resolve_color(base_color or "C3"),
# The axes spine is painted after marks. Matplotlib's
# baseline remains visible when it coincides with that
# spine, so give the colored rule enough width to remain
# visible on either side of the 1 px frame.
"width": 1.5,
**({"dash": list(base_dash)} if base_dash is not None else {}),
},
},
)
return StemContainer(
Line2D(self, marker_entry),
Artist(self, stem_entry),
Line2D(self, baseline_entry),
)

def stairs(
self,
Expand Down
74 changes: 74 additions & 0 deletions tests/pyplot/test_gallery_stem_regressions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"""Regressions reduced from Matplotlib's ``stem_plot`` gallery example."""

from __future__ import annotations

import numpy as np

import xy.pyplot as plt
from xy._figure import Figure


def test_stem_contributes_tip_and_baseline_to_autoscale() -> None:
x = np.linspace(0.1, 2 * np.pi, 41)
y = np.exp(np.sin(x))
_fig, ax = plt.subplots()

ax.stem(x, y, bottom=0.0)

assert not ax._axis_is_dataless("x")
assert not ax._axis_is_dataless("y")
np.testing.assert_allclose(ax._entry_extent("x"), (x.min(), x.max()))
np.testing.assert_allclose(ax._entry_extent("y"), (0.0, y.max()))
# Stems are segment marks, not sticky-edge rectangles. The core's normal
# autorange padding must leave the baseline visible inside the frame.
ylo, yhi = ax._build_chart(640, 480).figure().y_range()
assert ylo < 0.0
assert yhi > y.max()


def test_stem_padding_does_not_unstick_rectangle_baselines() -> None:
assert Figure().bar(["a", "b"], [1.0, 2.0]).y_range()[0] == 0.0
assert Figure().hist([1.0, 2.0, 3.0]).y_range()[0] == 0.0


def test_stem_nonzero_bottom_is_part_of_the_value_extent() -> None:
_fig, ax = plt.subplots()

markerline, stemlines, baseline = ax.stem([1.0, 2.0], [1.5, 1.8], bottom=1.1)

np.testing.assert_allclose(ax._entry_extent("x"), (1.0, 2.0))
np.testing.assert_allclose(ax._entry_extent("y"), (1.1, 1.8))
assert markerline is not stemlines
assert baseline is not stemlines
np.testing.assert_allclose(baseline.get_xdata(), (1.0, 2.0))
np.testing.assert_allclose(baseline.get_ydata(), (1.1, 1.1))
assert baseline.get_color() == "#d62728"


def test_stem_marker_face_mutation_does_not_recolor_stems() -> None:
_fig, ax = plt.subplots()
markerline, stemlines, _baseline = ax.stem(
[1.0, 2.0],
[1.5, 1.8],
linefmt="grey",
markerfmt="D",
)

markerline.set_markerfacecolor("none")

assert markerline._entry["kwargs"]["color"] == "transparent"
assert markerline._entry["kwargs"]["opacity"] == 1.0
assert markerline._entry["kwargs"]["stroke"] == "grey"
assert stemlines._entry["kwargs"]["color"] == "grey"


def test_stem_basefmt_controls_the_independent_baseline() -> None:
_fig, ax = plt.subplots()

_markerline, _stemlines, baseline = ax.stem(
[1.0, 2.0],
[1.5, 1.8],
basefmt="k-",
)

assert baseline.get_color() == "#000000"
1 change: 0 additions & 1 deletion tests/pyplot/test_p3_option_contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,6 @@ def _stream_args() -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
(lambda ax: ax.table(cellText=[["a"]], rowLoc="center"), "rowLoc"),
(lambda ax: ax.table(cellText=[["a"]], colLoc="left"), "colLoc"),
(lambda ax: ax.table(cellText=[["a"]], loc="top"), "loc"),
(lambda ax: ax.stem([0, 1], [1, 2], basefmt="k-"), "basefmt"),
(
lambda ax: ax.quiverkey(_quiver(ax), 0.5, 0.5, 1, "k", fontproperties={"size": 9}),
"fontproperties",
Expand Down
9 changes: 9 additions & 0 deletions tests/test_png_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -729,6 +729,15 @@ def test_affine_static_scatter_full_render_matches_expanded(monkeypatch) -> None
stroke="#111827",
stroke_width=0.5,
),
Figure(width=360, height=220).scatter(
x,
y,
color="transparent",
size=9,
symbol="diamond",
stroke="#666666",
stroke_width=1.0,
),
Figure(width=360, height=220).scatter(x, y, color=color_values, colormap="plasma"),
Figure(width=360, height=220).scatter(x, y, color=categories),
Figure(width=360, height=220).scatter(
Expand Down
Loading