diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py index b7bd761e..b82b2f82 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -1434,6 +1434,14 @@ def _bar_like( raise ValueError("bar align='edge' requires numeric positions") from None check_unsupported(kwargs, "bar()/barh()") n_bars = int(np.asarray(vals).size) + patch_labels: Optional[list[str]] = None + if label is not None and not isinstance(label, str) and np.iterable(label): + patch_labels = [_plain_text(value) for value in label] + if len(patch_labels) != n_bars: + raise ValueError( + f"number of labels ({len(patch_labels)}) " + f"does not match number of bars ({n_bars})." + ) def paint(value: Any, label_text: str, default: Optional[str] = None) -> Any: if value is None: @@ -1454,7 +1462,13 @@ def paint(value: Any, label_text: str, default: Optional[str] = None) -> Any: "color": paint(color, "bar color", self._next_color()), "width": float(thickness), "opacity": 1.0, - "name": str(label) if label is not None else None, + # A sequence labels the individual Rectangle patches in + # Matplotlib, not the BarContainer. Keep the batched data trace + # unnamed; _chart_children emits empty, named bar proxies for the + # visible legend entries without splitting the bar geometry. + "name": ( + None if patch_labels is not None else str(label) if label is not None else None + ), "orientation": orientation, } if alpha is not None: @@ -1476,7 +1490,15 @@ def paint(value: Any, label_text: str, default: Optional[str] = None) -> Any: if not np.isscalar(linewidth) else scalar_float(linewidth) ) - entry = self._add("bar", {"x": cats, "y": vals, "kwargs": entry_kwargs}) + entry = self._add( + "bar", + { + "x": cats, + "y": vals, + "kwargs": entry_kwargs, + **({"patch_labels": patch_labels} if patch_labels is not None else {}), + }, + ) container = BarContainer(self, entry) if xerr is not None or yerr is not None: positions = np.asarray(cats) @@ -2647,23 +2669,49 @@ def _data_coordinates(self, xy: tuple) -> Optional[tuple[float, float]]: def _iter_entry_arrays(self, axis: str) -> Iterator[tuple[np.ndarray, bool]]: """Yield each (array, needs_finite_filter) an entry contributes to *axis*.""" + from xy.channels import category_label + host = self._y2_of or self y_axis = "y2" if self._y2_of is not None else "y" + category_positions: dict[str, float] = {} + + def numeric_or_categorical(values: Any) -> np.ndarray: + """Mirror the core's first-seen categorical coordinate mapping. + + Pyplot materializes its own auto view before the core Figure is + built. String coordinates therefore cannot simply be skipped: + doing so pins categorical line/scatter axes to the dataless + ``(0, 1)`` view and clips every category after the second. + """ + array = np.asarray(values).reshape(-1) + try: + return np.asarray(unit_converted_values(array), dtype=np.float64).reshape(-1) + except (TypeError, ValueError): + if array.dtype.kind not in {"U", "S", "O", "b"}: + raise + labels = ( + array.tolist() + if array.dtype.kind == "U" + else [category_label(value) for value in array.astype(object)] + ) + positions = np.empty(len(labels), dtype=np.float64) + for index, label in enumerate(labels): + text = str(label) + positions[index] = category_positions.setdefault( + text, float(len(category_positions)) + ) + return positions + for entry in host._entries: if axis == "y" and entry.get("y_axis", "y") != y_axis: continue if entry.get("kind") == "bar": kwargs = entry.get("kwargs", {}) orientation = kwargs.get("orientation", "vertical") - centers = np.asarray(entry.get("x", ())).reshape(-1) try: - centers = np.asarray(unit_converted_values(centers), dtype=np.float64).reshape( - -1 - ) + centers = numeric_or_categorical(entry.get("x", ())) except (TypeError, ValueError): - # The core maps string categories to their ordinal - # positions. Autoscaling must use the same positions - # instead of falling back to the dataless (0, 1) view. + centers = np.asarray(entry.get("x", ())).reshape(-1) centers = np.arange(centers.size, dtype=np.float64) values = np.asarray(entry.get("y", ()), dtype=np.float64).reshape(-1) bases = np.asarray(kwargs.get("base", 0.0), dtype=np.float64) @@ -2681,9 +2729,7 @@ def _iter_entry_arrays(self, axis: str) -> Iterator[tuple[np.ndarray, bool]]: key = "x" if axis == "x" else "y" if key in entry: try: - array = np.asarray(unit_converted_values(entry[key]), dtype=np.float64).reshape( - -1 - ) + array = numeric_or_categorical(entry[key]) except (TypeError, ValueError): continue yield array, True @@ -3187,6 +3233,23 @@ def get_legend_handles_labels(self) -> tuple[list[Artist], list[str]]: handles: list[Artist] = [] labels: list[str] = [] for entry in (self._y2_of or self)._entries: + patch_labels = entry.get("patch_labels") + if patch_labels is not None: + container = next( + ( + item + for item in (self._y2_of or self)._containers + if isinstance(item, BarContainer) and item._entry is entry + ), + None, + ) + for index, label in enumerate(patch_labels): + if label and not str(label).startswith("_"): + handles.append( + container[index] if container is not None else Artist(self, entry) + ) + labels.append(str(label)) + continue label = entry.get("kwargs", {}).get("name") if label and not str(label).startswith("_"): handles.append(Artist(self, entry)) @@ -4389,6 +4452,31 @@ def _chart_children(self) -> list[Any]: children.append(xy.scatter(x=e["x"], y=e["y"], **kw, **axis_kw)) elif kind == "bar": children.append(xy.bar(x=e["x"], y=e["y"], **kw, **axis_kw)) + patch_labels = e.get("patch_labels") + if patch_labels is not None: + colors = resolve_rgba_array( + kw.get("color", "transparent"), + len(patch_labels), + "bar legend color", + ) + orientation = kw.get("orientation", "vertical") + for index, label in enumerate(patch_labels): + if not label or str(label).startswith("_"): + continue + # Named zero-mark proxies give the core legend one + # correctly colored swatch per labeled patch while the + # actual bars remain a single vectorized trace. + children.append( + xy.bar( + x=np.empty(0, dtype=np.float64), + y=np.empty(0, dtype=np.float64), + name=str(label), + color=resolve_color(colors[index]), + opacity=1.0, + orientation=orientation, + **axis_kw, + ) + ) elif kind == "area": children.append(xy.area(x=e["x"], y=e["y"], **kw, **axis_kw)) elif kind == "histogram": diff --git a/tests/pyplot/test_categorical_gallery_regressions.py b/tests/pyplot/test_categorical_gallery_regressions.py new file mode 100644 index 00000000..1aab7931 --- /dev/null +++ b/tests/pyplot/test_categorical_gallery_regressions.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +import numpy as np +import pytest + +import xy.pyplot as plt + + +def _axis_domain(ax, which: str) -> tuple[float, float]: + figure = ax._build_chart(640, 480).figure() + return figure.x_range() if which == "x" else figure.y_range() + + +def test_categorical_variables_trajectories_keep_every_first_seen_category() -> None: + activity = ["combing", "drinking", "feeding", "napping", "playing", "washing"] + dog = ["happy", "happy", "happy", "happy", "bored", "bored"] + cat = ["bored", "happy", "bored", "bored", "happy", "bored"] + + _fig, ax = plt.subplots() + ax.plot(activity, dog, label="dog") + ax.plot(activity, cat, label="cat") + + assert ax.get_xlim() == pytest.approx((-0.25, 5.25)) + assert ax.get_ylim() == pytest.approx((-0.05, 1.05)) + assert _axis_domain(ax, "x") == pytest.approx(ax.get_xlim()) + assert _axis_domain(ax, "y") == pytest.approx(ax.get_ylim()) + + traces = ax._build_chart(640, 480).figure().traces + np.testing.assert_allclose(traces[0].x.values, np.arange(6)) + np.testing.assert_allclose(traces[1].x.values, np.arange(6)) + np.testing.assert_allclose(traces[0].y.values, [0, 0, 0, 0, 1, 1]) + np.testing.assert_allclose(traces[1].y.values, [1, 0, 1, 1, 0, 1]) + + +def test_categorical_scatter_and_line_autoscale_to_the_shared_category_union() -> None: + names = ["apple", "orange", "lemon", "lime"] + values = [10, 15, 5, 20] + + _fig, ax = plt.subplots() + ax.scatter(names, values) + ax.plot(list(reversed(names)), list(reversed(values))) + + assert ax.get_xlim() == pytest.approx((-0.15, 3.15)) + assert _axis_domain(ax, "x") == pytest.approx((-0.15, 3.15)) + assert ax._build_chart(640, 480).figure()._axis_categories["x"] == names + + +def test_bar_patch_labels_make_individual_colored_legend_entries() -> None: + _fig, ax = plt.subplots() + bars = ax.bar( + ["apple", "blueberry", "cherry", "orange"], + [40, 100, 30, 55], + label=["red", "blue", "_red", "orange"], + color=["tab:red", "tab:blue", "tab:red", "tab:orange"], + ) + ax.legend(title="Fruit color") + + handles, labels = ax.get_legend_handles_labels() + assert labels == ["red", "blue", "orange"] + assert handles == [bars[0], bars[1], bars[3]] + + spec, _blob = ax._build_chart(640, 480).figure().build_payload() + assert spec["traces"][0]["n_marks"] == 4 + assert [ + (trace["name"], trace["style"]["color"], trace["n_marks"]) for trace in spec["traces"][1:] + ] == [ + ("red", "rgba(214,39,40,1)", 0), + ("blue", "rgba(31,119,180,1)", 0), + ("orange", "rgba(255,127,14,1)", 0), + ] + + +def test_bar_patch_labels_validate_against_the_bar_count() -> None: + _fig, ax = plt.subplots() + + with pytest.raises(ValueError, match=r"number of labels \(1\).*number of bars \(2\)"): + ax.bar(["apple", "orange"], [1, 2], label=["only one"]) + + +def test_barh_gallery_width_vector_remains_bar_value_geometry() -> None: + people = ("Tom", "Dick", "Harry", "Slim", "Jim") + performance = [5, 7, 6, 4, 9] + + _fig, ax = plt.subplots() + ax.barh(people, performance, xerr=[0.2, 0.4, 0.3, 0.6, 0.2], align="center") + + bar_trace = ax._build_chart(640, 480).figure().traces[0] + np.testing.assert_allclose(bar_trace.x1.values - bar_trace.x0.values, performance) + assert ax.get_xlim() == pytest.approx((0.0, 9.45))