diff --git a/js/src/10_colormaps.ts b/js/src/10_colormaps.ts index 22d44e17..ac04e433 100644 --- a/js/src/10_colormaps.ts +++ b/js/src/10_colormaps.ts @@ -3,8 +3,25 @@ // interpolates a 256-texel LUT texture once per colormap. // --------------------------------------------------------------------------- +function flagStops() { + // Matplotlib's flag colormap is an intentionally high-frequency + // red → white → blue → black cycle. Sample its three canonical channel + // functions at the client LUT's native 256 positions so the oscillations + // are not flattened by the compact-stop interpolation used by other maps. + const clip = (value) => Math.max(0, Math.min(1, value)); + return Array.from({ length: 256 }, (_, index) => { + const x = index / 255; + return [ + Math.trunc(255 * clip(0.75 * Math.sin((x * 31.5 + 0.25) * Math.PI) + 0.5)), + Math.trunc(255 * clip(Math.sin(x * 31.5 * Math.PI))), + Math.trunc(255 * clip(0.75 * Math.sin((x * 31.5 - 0.25) * Math.PI) + 0.5)), + ]; + }); +} + const COLORMAP_STOPS = { binary: [[255, 255, 255], [0, 0, 0]], + flag: flagStops(), reds: [[255, 245, 240], [254, 229, 216], [253, 202, 181], [252, 171, 143], [252, 138, 106], [251, 105, 74], [241, 68, 50], [217, 37, 35], [188, 20, 26], [152, 12, 19], [103, 0, 13]], bone: [[0, 0, 0], [22, 22, 30], [45, 45, 62], [66, 66, 93], [89, 92, 121], [112, 123, 144], [134, 154, 166], [157, 185, 188], [185, 210, 210], [221, 233, 233], [255, 255, 255]], autumn: [[255, 0, 0], [255, 25, 0], [255, 51, 0], [255, 76, 0], [255, 102, 0], [255, 128, 0], [255, 153, 0], [255, 179, 0], [255, 204, 0], [255, 230, 0], [255, 255, 0]], diff --git a/pr-assets/matplotlib-gallery-acceptance/pdsh-text-annotation.png b/pr-assets/matplotlib-gallery-acceptance/pdsh-text-annotation.png new file mode 100644 index 00000000..e42b28f4 Binary files /dev/null and b/pr-assets/matplotlib-gallery-acceptance/pdsh-text-annotation.png differ diff --git a/pr-assets/matplotlib-gallery-acceptance/provenance.json b/pr-assets/matplotlib-gallery-acceptance/provenance.json new file mode 100644 index 00000000..70c5374d --- /dev/null +++ b/pr-assets/matplotlib-gallery-acceptance/provenance.json @@ -0,0 +1,54 @@ +{ + "matplotlib_version": "3.11.0", + "base_code_commit": "10b22b97173e6e58c6aa3fcae58e7e8776903dcf", + "head_code_commit": "97bfeb6b5eb34acea66cc054c9dab6e4a2d31216", + "full_gallery_verified_code_commit": "97bfeb6b5eb34acea66cc054c9dab6e4a2d31216", + "full_gallery_execution": { + "in_scope_examples": 88, + "paired_examples": 81, + "failed_examples": 7, + "paired_figures": 143 + }, + "pdsh_source_commit": "9b080851617c7fe3547a77bbb0726f57c0ac8158", + "pdsh_verified_code_commit": "97bfeb6b5eb34acea66cc054c9dab6e4a2d31216", + "pdsh_execution": { + "included_notebooks": 13, + "excluded_notebooks": [ + "pdsh_04_12_three_dimensional_plotting.ipynb" + ], + "applicable_cells": 154, + "passed_cells": 153, + "failed_cells": 1, + "figure_captures": 289, + "capture_errors": 0 + }, + "full_frame": true, + "source_tree_note": "The exact full-gallery and PDSH reruns executed at 97bfeb6 after Stack B was rebased onto Stack A c196c12. The results remained 81/88 paired gallery examples with 143 paired figures and 153/154 applicable PDSH cells with 289 captures and no capture errors. The comparison sheets were refreshed from those exact render-code boundaries.", + "comparisons": [ + { + "source": "statistics/violinplot.py", + "figure": 0, + "file": "violinplot.png", + "rendered_code_commit": "97bfeb6b5eb34acea66cc054c9dab6e4a2d31216", + "dimensions": [ + 3024, + 540 + ], + "sha256": "26aef7df7896a403311a4af04a63f8ebc2f2fc48e7ce1212979b8a1e800e88ff" + }, + { + "source": "examples/pdsh/pdsh_04_09_text_and_annotation.ipynb", + "physical_cell": 23, + "selected_xy_ordinal": 8, + "source_id": "59bbdb311c014d738909a11f9e486628", + "figure": 4, + "file": "pdsh-text-annotation.png", + "rendered_code_commit": "97bfeb6b5eb34acea66cc054c9dab6e4a2d31216", + "dimensions": [ + 2412, + 530 + ], + "sha256": "328b8e6587dbbde7d69bde294083e3f89a5a7659a4b86000d1277c68deb61056" + } + ] +} diff --git a/pr-assets/matplotlib-gallery-acceptance/violinplot.png b/pr-assets/matplotlib-gallery-acceptance/violinplot.png new file mode 100644 index 00000000..fa8a17e4 Binary files /dev/null and b/pr-assets/matplotlib-gallery-acceptance/violinplot.png differ diff --git a/python/xy/_svg.py b/python/xy/_svg.py index bcda627f..dc78b65c 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -73,9 +73,26 @@ def _stroke_opacity(style: dict[str, Any], default: float = 1.0) -> float: return float(style.get("opacity", default)) * float(style.get("stroke_opacity", 1.0)) +def _flag_stops() -> list[tuple[int, int, int]]: + """Matplotlib's high-frequency ``flag`` map at the native 256 LUT positions.""" + x = np.linspace(0.0, 1.0, 256) + channels = np.column_stack( + ( + 0.75 * np.sin((x * 31.5 + 0.25) * np.pi) + 0.5, + np.sin(x * 31.5 * np.pi), + 0.75 * np.sin((x * 31.5 - 0.25) * np.pi) + 0.5, + ) + ) + # Match Matplotlib's ``bytes=True`` conversion, which truncates rather than + # rounds each clipped channel after scaling it to the uint8 range. + rgb = (np.clip(channels, 0.0, 1.0) * 255.0).astype(np.uint8) + return [tuple(int(channel) for channel in row) for row in rgb] + + # Mirrors js/src/10_colormaps.ts COLORMAP_STOPS (§36) — test-guarded. COLORMAP_STOPS: dict[str, list[tuple[int, int, int]]] = { "binary": [(255, 255, 255), (0, 0, 0)], + "flag": _flag_stops(), "reds": [ (255, 245, 240), (254, 229, 216), diff --git a/python/xy/channels.py b/python/xy/channels.py index e064d13c..50f5c1c5 100644 --- a/python/xy/channels.py +++ b/python/xy/channels.py @@ -48,6 +48,7 @@ "rdbu", "jet", "binary", + "flag", "reds", "bone", "winter", diff --git a/python/xy/pyplot/__init__.py b/python/xy/pyplot/__init__.py index 96008243..7b5c9eef 100644 --- a/python/xy/pyplot/__init__.py +++ b/python/xy/pyplot/__init__.py @@ -2896,6 +2896,7 @@ def __iter__(self) -> Iterator[str]: "RdGy", "bwr", "jet", + "flag", "Blues", "RdYlGn", "rainbow", diff --git a/python/xy/pyplot/_artists.py b/python/xy/pyplot/_artists.py index 84f2a105..eb627f66 100644 --- a/python/xy/pyplot/_artists.py +++ b/python/xy/pyplot/_artists.py @@ -1204,6 +1204,16 @@ def set_clim(self, vmin: Any = None, vmax: Any = None) -> None: class Wedge(PolyCollection): """Pie wedge backed by a grouped subset of one native sector mesh.""" + @property + def theta1(self) -> float: + """Starting angle in degrees, matching Matplotlib's public geometry.""" + return float(self._entry["pie_theta1"]) + + @property + def theta2(self) -> float: + """Ending angle in degrees, matching Matplotlib's public geometry.""" + return float(self._entry["pie_theta2"]) + class PieContainer: """Matplotlib 3.11-compatible pie result with legacy tuple unpacking.""" diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py index 4c78a171..cf7a7ce3 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -1784,6 +1784,33 @@ def _bar_like( orientation: str, kwargs: dict[str, Any], ) -> BarContainer: + def materialize_iterable(value: Any) -> Any: + """Turn one-shot and view iterables into real bar columns. + + ``np.asarray(dict.values())`` is a scalar object array, which made + one apparent bar and left the raw view to fail later during + autoscale. Array-like containers such as Series already expose a + meaningful ndarray and should stay untouched. + """ + # Keep the common bar path allocation-neutral. In particular, + # probing a list of category labels with ``np.asarray`` here and + # again below doubled the string-array conversion cost. + if value is None or isinstance(value, (str, bytes, list, tuple, range, np.ndarray)): + return value + if np.isscalar(value): + return value + array = np.asarray(value) + if array.ndim != 0: + return value + try: + return list(value) + except TypeError: + return value + + cats = materialize_iterable(cats) + vals = materialize_iterable(vals) + thickness = materialize_iterable(thickness) + base = materialize_iterable(base) cat_array = np.asarray(cats) if cat_array.dtype.kind == "U" and cat_array.dtype.isnative: # _plain_text only rewrites labels containing TeX markers; a @@ -6000,6 +6027,11 @@ def axis_values(values: Any, axis: str) -> np.ndarray: return np.asarray(unit_converted_values(values), dtype=np.float64).reshape(-1) except (TypeError, ValueError): array = np.asanyarray(values).reshape(-1) + if self._axis_holds_datetimes(axis): + converted = [self._data_coordinate(value, axis) for value in array] + if not all(value is not None for value in converted): + raise + return np.asarray(converted, dtype=np.float64) if not all(isinstance(value, str) for value in array): raise mapping = category_maps.setdefault(axis, {}) diff --git a/python/xy/pyplot/_colors.py b/python/xy/pyplot/_colors.py index 6d8a4941..899629dd 100644 --- a/python/xy/pyplot/_colors.py +++ b/python/xy/pyplot/_colors.py @@ -92,6 +92,7 @@ def scalar_float(value: Any) -> float: "rdgy": "rdgy", "jet": "jet", "binary": "binary", + "flag": "flag", "reds": "reds", "bone": "bone", "winter": "winter", diff --git a/python/xy/pyplot/_plot_types.py b/python/xy/pyplot/_plot_types.py index 4614326d..20dd6796 100644 --- a/python/xy/pyplot/_plot_types.py +++ b/python/xy/pyplot/_plot_types.py @@ -2498,9 +2498,14 @@ def silverman_factor(self) -> float: } vpstats.append(item) if quantiles is not None: - if len(quantiles) != len(groups): + quantile_groups = list(quantiles) + if quantile_groups and all(np.isscalar(value) for value in quantile_groups): + quantile_groups = [quantile_groups] + elif not quantile_groups: + quantile_groups = [[] for _ in groups] + if len(quantile_groups) != len(groups): raise ValueError("quantiles must contain one sequence per violin") - for item, group, requested in zip(vpstats, groups, quantiles, strict=True): + for item, group, requested in zip(vpstats, groups, quantile_groups, strict=True): item["quantiles"] = np.quantile(group[np.isfinite(group)], requested) return self.violin( vpstats, @@ -2826,11 +2831,13 @@ def _contour(self, filled: bool, args: tuple[Any, ...], kwargs: dict[str, Any]) corner_mask = rcParams["contour.corner_mask"] if not isinstance(corner_mask, (bool, np.bool_)): raise TypeError("corner_mask must be a boolean") - extend = kwargs.pop("extend", None) - if extend is None: - extend = "neither" - if extend not in ("neither", "min", "max", "both"): - raise ValueError("extend must be 'neither', 'min', 'max', or 'both'") + public_extend = kwargs.pop("extend", None) + if public_extend is None: + public_extend = "neither" + # Matplotlib stores unrecognized public values but treats them as an + # unextended contour. Keep that observable value on the ContourSet + # while passing the renderer its normalized four-value contract. + extend = public_extend if public_extend in ("neither", "min", "max", "both") else "neither" hatches = kwargs.pop("hatches", None) locator = kwargs.pop("locator", None) za = np.asarray(z, dtype=np.float64) @@ -2985,7 +2992,7 @@ def _contour(self, filled: bool, args: tuple[Any, ...], kwargs: dict[str, Any]) "corner_mask": bool(corner_mask), "domain": (float(public_levels[0]), float(public_levels[-1])), "hatches": list(hatches) if hatches is not None else None, - "extend": extend, + "extend": public_extend, "levels": public_levels, }, ) @@ -4279,6 +4286,9 @@ def pie( entry["pie_mid"] = float(mids[index]) entry["pie_radius"] = float(radius) entry["pie_explode"] = float(offsets[index]) + theta_start, theta_end = np.rad2deg(boundaries[index : index + 2]) + entry["pie_theta1"] = float(min(theta_start, theta_end)) + entry["pie_theta2"] = float(max(theta_start, theta_end)) wedges.append(Wedge(self, entry)) angle = np.deg2rad(float(startangle)) diff --git a/tests/pyplot/test_best_legend_placement.py b/tests/pyplot/test_best_legend_placement.py index 7d872ed1..6fdfc02d 100644 --- a/tests/pyplot/test_best_legend_placement.py +++ b/tests/pyplot/test_best_legend_placement.py @@ -362,13 +362,16 @@ def test_sparse_scatter_offset_outside_path_budget_disqualifies_the_corner(): assert resolved_loc(ax) == "upper left" -def test_datetime_paths_participate_in_best_placement(): - """Datetime vertices use the same converted millisecond space as drawing.""" +def test_datetime_text_and_annotations_participate_in_best_placement(): + """Each string-datetime text artist disqualifies one top corner.""" _, ax = plt.subplots() dates = np.asarray(["2026-01-01", "2026-01-02"], dtype="datetime64[D]") - ax.plot(dates, [0.0, 1.0], label="dated") + ax.plot(dates, [0.5, 0.5], label="dated") + ax.set_ylim(0, 1) + ax.text("2026-1-2", 0.95, "text") + ax.annotate("annotation", xy=("2026-1-1", 0.95)) ax.legend(loc="best") - assert resolved_loc(ax) == "upper left" + assert resolved_loc(ax) == "lower left" def test_best_is_resolved_before_the_wire(): diff --git a/tests/pyplot/test_categorical_gallery_regressions.py b/tests/pyplot/test_categorical_gallery_regressions.py index ce7f93f1..b0a737cc 100644 --- a/tests/pyplot/test_categorical_gallery_regressions.py +++ b/tests/pyplot/test_categorical_gallery_regressions.py @@ -77,6 +77,29 @@ def test_bar_patch_labels_validate_against_the_bar_count() -> None: ax.bar(["apple", "orange"], [1, 2], label=["only one"]) +def test_bar_materializes_dictionary_views_before_shape_and_autoscale() -> None: + data = {"January": 2.0, "February": 5.0, "March": 3.0} + widths = [0.4, 0.6, 0.8] + bottoms = [1.0, 2.0, 4.0] + _fig, ax = plt.subplots() + + bars = ax.bar( + data.keys(), + data.values(), + width=(value for value in widths), + bottom=(value for value in bottoms), + ) + + entry = ax._entries[0] + assert list(entry["x"]) == list(data) + assert list(entry["y"]) == list(data.values()) + np.testing.assert_allclose(entry["kwargs"]["width"], widths) + np.testing.assert_allclose(entry["kwargs"]["base"], bottoms) + np.testing.assert_allclose(bars.tops, np.asarray(bottoms) + list(data.values())) + assert ax.get_xlim()[1] > 1.0 + assert ax.get_ylim()[1] >= 7.0 + + def test_barh_gallery_width_vector_remains_bar_value_geometry() -> None: people = ("Tom", "Dick", "Harry", "Slim", "Jim") performance = [5, 7, 6, 4, 9] diff --git a/tests/pyplot/test_p3_option_contracts.py b/tests/pyplot/test_p3_option_contracts.py index aaf48ee6..10fd8671 100644 --- a/tests/pyplot/test_p3_option_contracts.py +++ b/tests/pyplot/test_p3_option_contracts.py @@ -483,6 +483,27 @@ def test_contourf_legend_elements_keep_per_band_hatches_and_handleheight() -> No assert all(item["kind"] == "bar" for item in spec["legend"]["items"]) +def test_contourf_preserves_unknown_public_extend_as_unextended_geometry() -> None: + _fig, ax = plt.subplots() + contour = ax.contourf(_Z, levels=4, extend="lower") + + assert contour._entry["extend"] == "lower" + assert contour._entry["kwargs"]["extend"] == "neither" + + +def test_violinplot_flat_quantiles_are_one_single_violin_group() -> None: + _fig, ax = plt.subplots() + result = ax.violinplot( + [1.0, 2.0, 3.0, 4.0], + quantiles=[0.25, 0.5, 0.75], + showextrema=False, + ) + + quantile_segments = result["cquantiles"]._entry["args"] + assert len(quantile_segments[0]) == 3 + np.testing.assert_allclose(quantile_segments[1], [1.75, 2.5, 3.25]) + + def test_plot_masked_integer_coordinates_use_nan_gaps() -> None: _fig, ax = plt.subplots() line = ax.plot( diff --git a/tests/test_svg_export.py b/tests/test_svg_export.py index c9381d5f..4cecd9c9 100644 --- a/tests/test_svg_export.py +++ b/tests/test_svg_export.py @@ -4,7 +4,11 @@ from __future__ import annotations +import base64 +import hashlib +import json import re +import subprocess import xml.etree.ElementTree as ET from pathlib import Path @@ -761,9 +765,14 @@ def test_colormap_stops_stay_in_sync_with_js_client() -> None: verbatim in the JS source, and the map names must match.""" js = (ROOT / "js" / "src" / "10_colormaps.ts").read_text(encoding="utf-8") body = js.split("COLORMAP_STOPS = {", 1)[1].split("};", 1)[0] - js_names = set(re.findall(r"^\s*(\w+): \[", body, re.MULTILINE)) + js_names = set(re.findall(r"^\s*(\w+): (?:\[|flagStops\(\))", body, re.MULTILINE)) assert js_names == set(COLORMAP_STOPS), "colormap names diverged from 10_colormaps.ts" for name, stops in COLORMAP_STOPS.items(): + if name == "flag": + assert "function flagStops()" in js + assert "x * 31.5 + 0.25" in js + assert "x * 31.5 - 0.25" in js + continue for r, g, b in stops: assert f"[{r}, {g}, {b}]" in body, ( f"{name} stop ({r},{g},{b}) missing in 10_colormaps.ts" @@ -848,6 +857,53 @@ def test_matplotlib_gallery_colormap_stops_and_reversal() -> None: assert _colormap_stops(f"{name}_r") == list(reversed(stops)) +def test_flag_colormap_matches_matplotlib_lut_and_gray_aliases() -> None: + """Gallery cmap names resolve without flattening flag's rapid color cycle.""" + from xy.pyplot._colors import resolve_cmap + + flag = COLORMAP_STOPS["flag"] + assert len(flag) == 256 + + # Full Matplotlib 3.11 ``colormaps["flag"](linspace(...), bytes=True)`` + # parity. The digest guards every channel of all 256 entries, including + # the truncation behavior that sparse samples previously missed. + flag_bytes = np.asarray(flag, dtype=np.uint8).tobytes() + assert hashlib.sha256(flag_bytes).hexdigest() == ( + "c84ac1f0b2edd3a53ed05fc90dcf6a41e78c2cf52adf1b2288b2d03777505443" + ) + + js_path = ROOT / "js" / "src" / "10_colormaps.ts" + encoded_source = base64.b64encode(js_path.read_bytes()).decode("ascii") + completed = subprocess.run( + [ + "node", + "--input-type=module", + "--eval", + ( + f'const module = await import("data:text/javascript;base64,{encoded_source}");' + 'console.log(JSON.stringify(module.colormapStops("flag")));' + ), + ], + cwd=ROOT, + capture_output=True, + text=True, + timeout=30, + check=True, + ) + assert json.loads(completed.stdout) == [list(rgb) for rgb in flag] + + assert channels.is_colormap("flag") + assert channels.is_colormap("flag_r") + assert _colormap_stops("flag_r") == list(reversed(flag)) + assert resolve_cmap("flag") == "flag" + assert resolve_cmap("flag_r") == "flag_r" + + assert resolve_cmap("gray") == "gray" + assert resolve_cmap("grey") == "gray" + assert resolve_cmap("gray_r") == "gray_r" + assert resolve_cmap("grey_r") == "gray_r" + + def test_scalar_stroke_color_survives_vectorized_style_path() -> None: """Scalar CSS stroke= on rect-family and mesh marks must not collapse to the face paint (regression: the per-item style refactor resolved strokes