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
112 changes: 100 additions & 12 deletions python/xy/pyplot/_axes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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":
Expand Down
89 changes: 89 additions & 0 deletions tests/pyplot/test_categorical_gallery_regressions.py
Original file line number Diff line number Diff line change
@@ -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))
Loading