diff --git a/python/xy/_figure.py b/python/xy/_figure.py index dcad0766..09b6d0e3 100644 --- a/python/xy/_figure.py +++ b/python/xy/_figure.py @@ -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: diff --git a/python/xy/_raster.py b/python/xy/_raster.py index 2514840a..6e72d018 100644 --- a/python/xy/_raster.py +++ b/python/xy/_raster.py @@ -1320,8 +1320,8 @@ 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"]], @@ -1329,7 +1329,7 @@ def read(index: int) -> np.ndarray: sy, ch, size_ch, - (rgb[0], rgb[1], rgb[2], alpha), + (paint[0], paint[1], paint[2], alpha), sym, sw, stroke, @@ -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 diff --git a/python/xy/pyplot/_artists.py b/python/xy/pyplot/_artists.py index cbbb5e6f..c0689aea 100644 --- a/python/xy/pyplot/_artists.py +++ b/python/xy/pyplot/_artists.py @@ -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: diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py index 3b04d605..ca7629ed 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -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) diff --git a/python/xy/pyplot/_plot_types.py b/python/xy/pyplot/_plot_types.py index 5fd23ef2..53bbd2ab 100644 --- a/python/xy/pyplot/_plot_types.py +++ b/python/xy/pyplot/_plot_types.py @@ -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) @@ -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", @@ -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, diff --git a/tests/pyplot/test_gallery_stem_regressions.py b/tests/pyplot/test_gallery_stem_regressions.py new file mode 100644 index 00000000..9f218e7c --- /dev/null +++ b/tests/pyplot/test_gallery_stem_regressions.py @@ -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" diff --git a/tests/pyplot/test_p3_option_contracts.py b/tests/pyplot/test_p3_option_contracts.py index 3b78f346..d806d088 100644 --- a/tests/pyplot/test_p3_option_contracts.py +++ b/tests/pyplot/test_p3_option_contracts.py @@ -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", diff --git a/tests/test_png_export.py b/tests/test_png_export.py index 1dc18f0e..ac5fab60 100644 --- a/tests/test_png_export.py +++ b/tests/test_png_export.py @@ -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(