Skip to content
Open
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
3 changes: 3 additions & 0 deletions news/532.bugfix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Fixed `xy.pyplot.fill_between` raising a `TypeError` when passed `datetime`, `datetime64`, or masked date sequences. Standalone datetime fills now render calendar tick labels without requiring a line overlay.
Shared x axes also retain calendar ticks when only one panel contains dates and refresh after date fills are added or removed. Removing a split fill now removes every polygon, including masked and interpolated regions.
Date-axis inference no longer retains the original date container or changes when the caller later mutates it.
27 changes: 26 additions & 1 deletion python/xy/pyplot/_artists.py
Original file line number Diff line number Diff line change
Expand Up @@ -1335,6 +1335,30 @@ def _rgba_css(value: Any) -> str:
class PolyCollection(Artist):
"""Generic collection handle used by adapter-composed chart families."""

def __init__(
self,
axes: Any,
entry: dict[str, Any],
*,
companion_entries: Optional[list[dict[str, Any]]] = None,
) -> None:
super().__init__(axes, entry)
self._polygon_entries = list(companion_entries or [])

def _companion_entries(self) -> list[dict[str, Any]]:
return self._polygon_entries

def remove(self) -> None:
for entry in self._polygon_entries:
self._axes._remove_entry(entry)
self._polygon_entries.clear()
super().remove()

def set_zorder(self, level: float) -> None:
for entry in self._polygon_entries:
entry["_zorder"] = float(level)
super().set_zorder(level)

def set_clim(self, vmin: Any = None, vmax: Any = None) -> None:
_set_entry_clim(self, vmin, vmax)

Expand All @@ -1361,7 +1385,8 @@ def get_facecolors(self) -> np.ndarray:
def set_facecolors(self, colors: Any) -> None:
"""Set a constant face paint for a filled polygon collection."""
rgba = resolve_rgba_array(colors, 1, "collection facecolors")[0]
self._entry.setdefault("kwargs", {})["color"] = _rgba_css(rgba)
for entry in self._owned_entries():
entry.setdefault("kwargs", {})["color"] = _rgba_css(rgba)
self._touch()

set_facecolor = set_facecolors
Expand Down
131 changes: 86 additions & 45 deletions python/xy/pyplot/_axes.py
Original file line number Diff line number Diff line change
Expand Up @@ -1653,11 +1653,14 @@ def _load_rc_chrome(self) -> None:

def _invalidate(self) -> None:
host = self._y2_of or self
host._chart = None
# Shared render domains are a snapshot from the preceding Figure
# materialization pass. Any artist/limit mutation must make the next
# pass discover the group's new final view before clipping axlines.
host.__dict__.pop("_shared_render_domains", None)
# Date units are inferred across shared x axes too: a sibling cached
# before a date artist was added or removed must refresh its ticks.
for ax in _SharedAxesGroup("x").get_siblings(host):
ax._chart = None
ax.__dict__.pop("_shared_render_domains", None)
if host.figure is not None:
host.figure._invalidate()

Expand Down Expand Up @@ -3177,8 +3180,31 @@ def fill_between(
if transform not in (None, "xaxis transform"):
raise not_implemented("fill_between(transform=...)")
check_unsupported(kwargs, "fill_between()")

from xy.components import _is_datetime_like

# Convert temporal x coordinates to epoch milliseconds while preserving
# masked entries and snapshot metadata for datetime-axis detection.
is_datetime_x = False
if np.ma.isMaskedArray(x):
compressed = x.compressed()
if (len(compressed) > 0 and _is_datetime_like(compressed)) or _is_datetime_like(x.data):
is_datetime_x = True

if is_datetime_x:
# Fill masked points with NaT so unit_converted_values transforms
# dates to float milliseconds without failing on MaskedConstant,
# then reapply the mask to break polygon intervals.
raw_conv = unit_converted_values(x.filled(np.datetime64("NaT", "ms")))
conv_x = np.ma.array(raw_conv, mask=x.mask)
else:
conv_x = x
else:
is_datetime_x = _is_datetime_like(x)
conv_x = unit_converted_values(x) if is_datetime_x else x
Comment thread
FarhanAliRaza marked this conversation as resolved.
Resolved

xv, upper, lower = np.broadcast_arrays(
_masked_float(x),
_masked_float(conv_x),
_masked_float(y1),
_masked_float(y2),
)
Expand Down Expand Up @@ -3233,24 +3259,23 @@ def fill_between(
if step is not None:
sx, su = _step_values(sx, su, step)
_sx, sl = _step_values(xv[start:end], sl, step)
entries.append(
self._add(
"area",
{
"x": sx,
"y": su,
"kwargs": {
"base": sl,
"color": resolved_color,
"opacity": float(alpha) if alpha is not None else 1.0,
"line_width": float(rcParams["patch.linewidth"]) * self._point_scale(),
"line_opacity": float(alpha) if alpha is not None else 1.0,
"stroke_perimeter": True,
"name": str(label) if label is not None and not entries else None,
},
},
)
)
area_dict = {
"x": sx,
"y": su,
"kwargs": {
"base": sl,
"color": resolved_color,
"opacity": float(alpha) if alpha is not None else 1.0,
"line_width": float(rcParams["patch.linewidth"]) * self._point_scale(),
"line_opacity": float(alpha) if alpha is not None else 1.0,
"stroke_perimeter": True,
"name": str(label) if label is not None and not entries else None,
},
}
if is_datetime_x:
area_dict["_datetime_x"] = True

entries.append(self._add("area", area_dict))
if interpolate:
# A single selected point between deselected neighbors spans no
# interval, but matplotlib still draws its interpolated wedge.
Expand Down Expand Up @@ -3279,27 +3304,22 @@ def fill_between(
su.append(cross_y)
sl.append(cross_y)
if len(sx) >= 2:
entries.append(
self._add(
"area",
{
"x": np.asarray(sx),
"y": np.asarray(su),
"kwargs": {
"base": np.asarray(sl),
"color": resolved_color,
"opacity": float(alpha) if alpha is not None else 1.0,
"line_width": float(rcParams["patch.linewidth"])
* self._point_scale(),
"line_opacity": float(alpha) if alpha is not None else 1.0,
"stroke_perimeter": True,
"name": str(label)
if label is not None and not entries
else None,
},
},
)
)
interp_dict = {
"x": np.asarray(sx),
"y": np.asarray(su),
"kwargs": {
"base": np.asarray(sl),
"color": resolved_color,
"opacity": float(alpha) if alpha is not None else 1.0,
"line_width": float(rcParams["patch.linewidth"]) * self._point_scale(),
"line_opacity": float(alpha) if alpha is not None else 1.0,
"stroke_perimeter": True,
"name": str(label) if label is not None and not entries else None,
},
}
if is_datetime_x:
interp_dict["_datetime_x"] = True
entries.append(self._add("area", interp_dict))
if not entries:
# Matplotlib still returns a collection handle, but there is no
# polygon to retain when no pair of adjacent points is selected.
Expand All @@ -3318,7 +3338,7 @@ def fill_between(
},
}
return PolyCollection(self, empty)
return PolyCollection(self, entries[0])
return PolyCollection(self, entries[0], companion_entries=entries[1:])

def imshow(self, z: ArrayLike, cmap: Any = None, **kwargs: Any) -> AxesImage:
"""Display a 2-D scalar array or RGB(A) image.
Expand Down Expand Up @@ -4463,11 +4483,25 @@ def set_position(self, position: Bbox | tuple[float, float, float, float]) -> No
self._figure_rect = _parse_bounds(position, "set_position()")
self._invalidate()

def _axis_holds_datetimes(self, axis: str) -> bool:
def _axis_holds_datetimes(self, axis: str, *, shared: bool = False) -> bool:
from xy.components import _is_datetime_like

key = "x" if axis == "x" else "y"
return any(key in entry and _is_datetime_like(entry[key]) for entry in self._entries)
datetime_key = f"_datetime_{key}"

def _is_datetime_value(val: Any) -> bool:
if np.ma.isMaskedArray(val):
comp = val.compressed()
if len(comp) > 0:
return _is_datetime_like(comp)
return _is_datetime_like(val.data)
return _is_datetime_like(val)

return any(
entry.get(datetime_key, False) or (key in entry and _is_datetime_value(entry[key]))
for ax in (_SharedAxesGroup(axis).get_siblings(self) if shared else [self])
for entry in ax._entries
)

def _data_coordinate(self, value: Any, axis: str) -> Optional[float]:
"""A data-space coordinate as the engine's float — numbers directly,
Expand Down Expand Up @@ -8731,6 +8765,13 @@ def _build_chart_uncached(self, width: int, height: int) -> Any:
y_props = {k: v for k, v in self._axis["y"].items() if v is not None}
self._inherit_shared_axis_state("x", x_props)
self._inherit_shared_axis_state("y", y_props)
# fill_between retains datetime identity after converting its geometry
# to f64 milliseconds. Carry it into the rendered axis (dossier §16),
# without overriding explicit scales or persisting an inferred type.
if self._scale_specs["x"]["name"] == "linear" and self._axis_holds_datetimes(
"x", shared=True
):
x_props.setdefault("type_", "time")
rotation_source = self._shared_ticker_source("x")
if (
rotation_source._tick_rotation_modes.get("x") == "xtick"
Expand Down
8 changes: 4 additions & 4 deletions spec/matplotlib/compat-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ Corpus links are executable examples and are checked for every supported method.

| Family | Level | Methods | Executable corpus |
|---|---|---:|---:|
| Basic | equivalent semantics | 22 | 48 |
| Spans | exact geometry | 5 | 4 |
| Basic | equivalent semantics | 22 | 51 |
| Spans | exact geometry | 5 | 5 |
| Spectral | equivalent semantics | 9 | 2 |
| Statistics | equivalent semantics | 5 | 2 |
| Binned | exact geometry | 4 | 7 |
Expand All @@ -32,7 +32,7 @@ Approximation level: **equivalent semantics**.
- `loglog` — [`51_basic_2d_aliases.py`](../../tests/pyplot/corpus/51_basic_2d_aliases.py)
- `semilogx` — [`54_plotting_method_coverage.py`](../../tests/pyplot/corpus/54_plotting_method_coverage.py)
- `semilogy` — [`54_plotting_method_coverage.py`](../../tests/pyplot/corpus/54_plotting_method_coverage.py)
- `fill_between` — [`20_fill_between_band.py`](../../tests/pyplot/corpus/20_fill_between_band.py)
- `fill_between` — [`20_fill_between_band.py`](../../tests/pyplot/corpus/20_fill_between_band.py), [`56_fill_between_datetime.py`](../../tests/pyplot/corpus/56_fill_between_datetime.py), [`57_fill_between_shared_dates.py`](../../tests/pyplot/corpus/57_fill_between_shared_dates.py), [`58_fill_between_remove_dates.py`](../../tests/pyplot/corpus/58_fill_between_remove_dates.py)
- `fill_betweenx` — [`51_basic_2d_aliases.py`](../../tests/pyplot/corpus/51_basic_2d_aliases.py)
- `bar` — [`14_bar_categories.py`](../../tests/pyplot/corpus/14_bar_categories.py), [`15_bar_stacked_bottom.py`](../../tests/pyplot/corpus/15_bar_stacked_bottom.py), [`27_subplots_2x2_mixed.py`](../../tests/pyplot/corpus/27_subplots_2x2_mixed.py), [`32_xticks_rotation.py`](../../tests/pyplot/corpus/32_xticks_rotation.py), [`42_tick_params_rotation.py`](../../tests/pyplot/corpus/42_tick_params_rotation.py), [`55_polar_projection.py`](../../tests/pyplot/corpus/55_polar_projection.py)
- `barh` — [`16_barh.py`](../../tests/pyplot/corpus/16_barh.py)
Expand All @@ -52,7 +52,7 @@ Approximation level: **equivalent semantics**.

Approximation level: **exact geometry**.

- `axhline` — [`23_axhline_axvline.py`](../../tests/pyplot/corpus/23_axhline_axvline.py), [`34_gray_string_color.py`](../../tests/pyplot/corpus/34_gray_string_color.py)
- `axhline` — [`23_axhline_axvline.py`](../../tests/pyplot/corpus/23_axhline_axvline.py), [`34_gray_string_color.py`](../../tests/pyplot/corpus/34_gray_string_color.py), [`57_fill_between_shared_dates.py`](../../tests/pyplot/corpus/57_fill_between_shared_dates.py)
- `axhspan` — [`24_axvspan_band.py`](../../tests/pyplot/corpus/24_axvspan_band.py)
- `axvline` — [`23_axhline_axvline.py`](../../tests/pyplot/corpus/23_axhline_axvline.py)
- `axvspan` — [`24_axvspan_band.py`](../../tests/pyplot/corpus/24_axvspan_band.py)
Expand Down
Loading
Loading