From 8dd433ac56b623dd339c3ed133ecfd660139ea57 Mon Sep 17 00:00:00 2001 From: Sriman Selvakumaran Date: Sun, 26 Jul 2026 22:15:40 -0700 Subject: [PATCH 01/10] Fix gallery legend option compatibility --- js/src/20_theme.ts | 2 +- js/src/50_chartview.ts | 14 +++++-- python/xy/_svg.py | 4 +- python/xy/pyplot/_artists.py | 12 +++++- python/xy/pyplot/_axes.py | 26 +++++++++++-- python/xy/pyplot/_rc.py | 2 + spec/matplotlib/compat.md | 2 +- .../test_gallery_statistics_semantics.py | 39 +++++++++++++++++++ tests/pyplot/test_grid_legend_contracts.py | 13 +++++++ .../pyplot/test_line_legend_gallery_compat.py | 18 +++++++++ tests/test_text_weight_defaults.py | 14 +++++++ 11 files changed, 132 insertions(+), 14 deletions(-) diff --git a/js/src/20_theme.ts b/js/src/20_theme.ts index 8b8df963..5d7b523b 100644 --- a/js/src/20_theme.ts +++ b/js/src/20_theme.ts @@ -127,7 +127,7 @@ export const XY_CHROME_CSS = ` :where(.xy [data-xy-slot="tooltip_label"])::after{content:": "} :where(.xy [data-xy-slot="legend"]){left:var(--xy-legend-left,auto);right:var(--xy-legend-right,auto);top:var(--xy-legend-top,auto);bottom:var(--xy-legend-bottom,auto);transform:var(--xy-legend-transform,none);max-width:var(--xy-legend-max-width);max-height:var(--xy-legend-max-height);gap:2px;font-size:11px;background:var(--chart-legend-bg,rgba(128,128,128,.08));border-radius:4px;padding:4px 8px;color:var(--chart-text,inherit)} :where(.xy [data-xy-slot="legend_title"]){font-weight:400;text-align:center} -:where(.xy [data-xy-slot="legend_swatch"]){display:inline-block;width:var(--xy-legend-swatch-width,12px);height:var(--xy-legend-swatch-height,10px);vertical-align:-1px;background:var(--xy-legend-swatch-paint,transparent);border-radius:2px;margin-right:5px;fill:var(--xy-legend-swatch-fill);stroke:var(--xy-legend-swatch-stroke);stroke-width:var(--xy-legend-swatch-stroke-width);stroke-dasharray:var(--xy-legend-swatch-dasharray)} +:where(.xy [data-xy-slot="legend_swatch"]){display:inline-block;width:var(--xy-legend-swatch-width,12px);height:var(--xy-legend-swatch-height,10px);vertical-align:-1px;background:var(--xy-legend-swatch-paint,transparent);border-radius:2px;margin-right:var(--xy-legend-swatch-margin-right,5px);fill:var(--xy-legend-swatch-fill);stroke:var(--xy-legend-swatch-stroke);stroke-width:var(--xy-legend-swatch-stroke-width);stroke-dasharray:var(--xy-legend-swatch-dasharray)} :where(.xy [data-xy-slot="legend_swatch"] > svg){display:block;width:100%;height:100%} :where(.xy [data-xy-slot="colorbar"]){color:var(--chart-text,inherit);font-size:10px} :where(.xy [data-xy-slot="colorbar_bar"]){background:var(--xy-colorbar-gradient);border:1px solid currentColor;box-sizing:border-box} diff --git a/js/src/50_chartview.ts b/js/src/50_chartview.ts index 3b261b0c..4aa960a7 100644 --- a/js/src/50_chartview.ts +++ b/js/src/50_chartview.ts @@ -1891,6 +1891,12 @@ export class ChartView { const handleHeight = options.handleheight == null ? null : Math.max(8, 11 * Number(options.handleheight)); + const handleLength = Number.isFinite(Number(options.handlelength)) + ? Math.max(0, Number(options.handlelength)) + : 2; + const handleTextPad = Number.isFinite(Number(options.handletextpad)) + ? Math.max(0, Number(options.handletextpad)) + : 0.8; lg.style.cssText = "position:absolute;" + `display:grid;grid-template-columns:repeat(${horizontal ? ncols : 1},max-content);` + "column-gap:2em;row-gap:.5em;overflow:auto;"; @@ -1921,6 +1927,8 @@ export class ChartView { // base-layer slot rule. SVG paint lives on the wrapper and inherits into // its path/line, so Tailwind fill-*/stroke-* utilities on this public slot // can override it without copying layout classes onto SVG paint nodes. + sw.style.setProperty("--xy-legend-swatch-width", `${handleLength}em`); + sw.style.setProperty("--xy-legend-swatch-margin-right", `${handleTextPad}em`); let bg = it.swatch; // A continuous encoding paints the swatch with the colormap ramp, but // the swatch keeps the mark's identity: a gradient-filled symbol for @@ -1938,7 +1946,7 @@ export class ChartView { const ns = "http://www.w3.org/2000/svg"; const svg = document.createElementNS(ns, "svg"); svg.setAttribute("viewBox", "0 0 18 14"); - svg.setAttribute("width", "18"); + svg.setAttribute("width", "100%"); svg.setAttribute("height", "14"); const path = document.createElementNS(ns, "path"); const paths = { @@ -1969,13 +1977,12 @@ export class ChartView { ); svg.appendChild(path); sw.appendChild(svg); - sw.style.setProperty("--xy-legend-swatch-width", "18px"); sw.style.setProperty("--xy-legend-swatch-height", "14px"); } else if (it.line) { const ns = "http://www.w3.org/2000/svg"; const svg = document.createElementNS(ns, "svg"); svg.setAttribute("viewBox", "0 0 22 12"); - svg.setAttribute("width", "22"); + svg.setAttribute("width", "100%"); svg.setAttribute("height", "12"); const ln = document.createElementNS(ns, "line"); ln.setAttribute("x1", "1"); @@ -1998,7 +2005,6 @@ export class ChartView { } svg.appendChild(ln); sw.appendChild(svg); - sw.style.setProperty("--xy-legend-swatch-width", "22px"); sw.style.setProperty("--xy-legend-swatch-height", "12px"); } else if (it.swatch !== "gradient") { // Keep the dynamic base paint on the security-audited safe sink, but diff --git a/python/xy/_svg.py b/python/xy/_svg.py index dc78b65c..ba253da6 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -3585,8 +3585,8 @@ def _legend_layout(named: list[dict], plot: dict, options: dict) -> dict[str, An # borderpad is applied on both sides, handlelength=2, handletextpad=.8, # columnspacing=2, and labelspacing=.5 by default. pad = 2.0 * borderpad * font_size - handle = 2.0 * font_size - gap = 0.8 * font_size + handle = max(0.0, float(options.get("handlelength", 2.0))) * font_size + gap = max(0.0, float(options.get("handletextpad", 0.8))) * font_size column_gap = 2.0 * font_size row_gap = labelspacing * font_size line_h = text_h + row_gap diff --git a/python/xy/pyplot/_artists.py b/python/xy/pyplot/_artists.py index eb627f66..31558159 100644 --- a/python/xy/pyplot/_artists.py +++ b/python/xy/pyplot/_artists.py @@ -1351,8 +1351,16 @@ def _legend_item_from_entry( if isinstance(color, str): style["color"] = color width = kw.get("width") - if width is not None: - style["width"] = float(width) * point_scale + if width is not None and kind in {"line", "segments", "step", "stairs", "errorbar"}: + # Bar ``width`` is data-space rectangle geometry, not a stroke width. + # Histograms with non-uniform bins therefore carry one width per bar. + # Matplotlib's legend handler draws those as a filled patch and never + # interprets the bin widths as line styling. Generic line collections + # can also expose vector widths; their legend sample uses the first + # linewidth, matching Matplotlib's collection handlers. + widths = np.asarray(width, dtype=np.float64).reshape(-1) + if widths.size: + style["width"] = float(widths[0]) * point_scale opacity = kw.get("opacity") if opacity is not None: style["opacity"] = float(opacity) diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py index cf7a7ce3..0652a3b5 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -5336,15 +5336,15 @@ def _compose_legend_options(self, kwargs: dict[str, Any]) -> dict[str, Any]: labelspacing = kwargs.pop("labelspacing", rcParams["legend.labelspacing"]) borderaxespad = kwargs.pop("borderaxespad", rcParams["legend.borderaxespad"]) handleheight = kwargs.pop("handleheight", None) - # Remaining handle/title geometry is not expressible yet and stays - # loud; the frame and row-layout options above map directly to CSS and + handlelength = kwargs.pop("handlelength", rcParams["legend.handlelength"]) + handletextpad = kwargs.pop("handletextpad", rcParams["legend.handletextpad"]) + # Remaining title geometry is not expressible yet and stays loud; the + # frame, handle, and row-layout options above map directly to CSS and # the static exporters. layout_options = { key: kwargs.pop(key) for key in ( "title_fontsize", - "handlelength", - "handletextpad", ) if key in kwargs } @@ -5414,6 +5414,14 @@ def _compose_legend_options(self, kwargs: dict[str, Any]) -> dict[str, Any]: if not np.isfinite(handleheight_value) or handleheight_value <= 0: raise ValueError("legend handleheight must be a positive finite number") options["handleheight"] = handleheight_value + handlelength_value = float(handlelength) + if not np.isfinite(handlelength_value) or handlelength_value < 0: + raise ValueError("legend handlelength must be a non-negative finite number") + options["handlelength"] = handlelength_value + handletextpad_value = float(handletextpad) + if not np.isfinite(handletextpad_value) or handletextpad_value < 0: + raise ValueError("legend handletextpad must be a non-negative finite number") + options["handletextpad"] = handletextpad_value if title is not None: options["title"] = _plain_text(title) if style: @@ -5602,6 +5610,10 @@ def _chart_children(self) -> list[Any]: kw["width"] = ( float(kw.get("width", rcParams["lines.linewidth"])) * self._point_scale() ) + # Every renderer already uses round caps for dashed lines. + # ``Line2D.set_dash_capstyle("round")`` records the public + # Matplotlib state, but it is not a core ``xy.line`` keyword. + kw.pop("dash_capstyle", None) gapcolor = kw.pop("_gapcolor", None) if gapcolor is not None and kw.get("dash"): children.append( @@ -5620,6 +5632,7 @@ def _chart_children(self) -> list[Any]: kw["width"] = ( float(kw.get("width", rcParams["lines.linewidth"])) * self._point_scale() ) + kw.pop("dash_capstyle", None) gapcolor = kw.pop("_gapcolor", None) x, y = self._axline_data(e) if gapcolor is not None and kw.get("dash"): @@ -6530,6 +6543,8 @@ def _build_chart(self, width: int, height: int) -> Any: component_legend_options = dict(legend_options) component_legend_options.pop("border_pad", None) component_legend_options.pop("handleheight", None) + component_legend_options.pop("handlelength", None) + component_legend_options.pop("handletextpad", None) children.append(xy.legend(**component_legend_options)) elif not any(entry.get("kwargs", {}).get("name") for entry in self._entries): # Core XY can auto-create a continuous-color "value" legend. @@ -6582,6 +6597,9 @@ def _build_chart(self, width: int, height: int) -> Any: ) if "handleheight" in self._legend_options: core_figure.legend_options["handleheight"] = self._legend_options["handleheight"] + for key in ("handlelength", "handletextpad"): + if key in self._legend_options: + core_figure.legend_options[key] = self._legend_options[key] core_figure.frame_sides = [ side for side in ("left", "bottom", "top", "right") if side not in self._hidden_spines ] diff --git a/python/xy/pyplot/_rc.py b/python/xy/pyplot/_rc.py index 7de5017e..00209cf7 100644 --- a/python/xy/pyplot/_rc.py +++ b/python/xy/pyplot/_rc.py @@ -79,6 +79,8 @@ def by_key(self) -> dict[str, list[str]]: "legend.borderpad": 0.4, "legend.labelspacing": 0.5, "legend.borderaxespad": 0.5, + "legend.handlelength": 2.0, + "legend.handletextpad": 0.8, "text.usetex": False, "image.cmap": "viridis", # Matplotlib's "antialiased" default resolves to nearest-neighbor when a diff --git a/spec/matplotlib/compat.md b/spec/matplotlib/compat.md index 0d5f39aa..0c482eea 100644 --- a/spec/matplotlib/compat.md +++ b/spec/matplotlib/compat.md @@ -67,7 +67,7 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent | `axhline` / `axvline` / `axhspan` / `axvspan`, `text`, `annotate`, `table` | Fractional span bounds plus data/axes/figure text coordinates are supported. `annotate(arrowprops=)` draws real arrows in every output: offset-point text becomes an engine callout (arrow pinned from label to point across zoom), data-coordinate text an arrow annotation; date-string coordinates convert on datetime axes. Arrowstyles map to head/tail shapes (`->` open V, `-\|>` filled, `\|-\|`/brackets bar caps, `fancy`/`simple`/`wedge` filled tapered shafts sized by the text's mutation scale) and `connectionstyle` arc3/angle3/angle become quadratic curves (corner rounding approximated); `alpha` dims the arrow only. `bbox=` becomes label box styles (fill/edge/round corners/`pad`) in browser and static exports; its `alpha` is the *patch* alpha and dims face and edge together, as Matplotlib's element `opacity` does, and `boxstyle="round"`/`round4` corners are rounded in SVG (`rx`) and native PNG as well as in the browser. An arrow-less `text`/`annotate` label is painted with `rcParams["text.color"]` in all three renderers rather than each renderer's own annotation-label default. Text is unclipped like Matplotlib (`clip_on=False`), and axes-fraction text right of the axes box (x > 1, e.g. seaborn-style row titles) reserves right margin in every exporter. `rotation=90/270` renders vertical text in browser, PNG, and SVG with Matplotlib's rotate-then-align box semantics; other angles rotate in browser and SVG output only (native PNG draws them horizontally) | | `from xy.pyplot import FacetGrid` (seaborn-shaped) | Row/column small multiples with seaborn's `map` contract (subset → activate panel → call the pyplot function), shared domains, edge-only axis labels, top-row column titles, and `margin_titles=True` rotated row titles. `hue=`/`palette=`, `col_wrap=`, `map_dataframe`, and `add_legend` fail loudly | | `xlabel` / `ylabel` / `title` / `suptitle` | Suptitles are retained in HTML and multi-panel PNG/SVG. `ylabel` sits clear of the y tick labels in every renderer: the left gutter is reserved from the measured advances of the tick labels and the rotated title rather than from a fixed constant, leaving Matplotlib's `0.4 em` (5.6 px at the 10 pt/100 dpi default) title-to-tick gap — see *Measured left gutter and the rotated y-axis title* in `spec/api/styling.md` for the formula and its two documented asymmetries | -| `legend()` | `loc`, columns, title/font size/colors, frame styling, `borderpad`, `labelspacing`, `borderaxespad`, `fancybox`, `framealpha`, and `shadow` are retained across browser and static output. `loc='best'` scores the measured displayed legend box in Matplotlib location-code order using path vertices/crossings, collection offsets, and bar-rectangle overlaps. It resolves before the wire; pyplot rejects misspelled Matplotlib locations without narrowing core `xy.legend()`'s independent location vocabulary. Text-box scoring and bounded long-path sampling are documented in `spec/api/styling.md` § Legend placement | +| `legend()` | `loc`, columns, title/font size/colors, frame styling, `borderpad`, `labelspacing`, `borderaxespad`, `handlelength`, `handletextpad`, `fancybox`, `framealpha`, and `shadow` are retained across browser and static output. Handle geometry is measured in legend-font units, matching Matplotlib. `loc='best'` scores the measured displayed legend box in Matplotlib location-code order using path vertices/crossings, collection offsets, and bar-rectangle overlaps. It resolves before the wire; pyplot rejects misspelled Matplotlib locations without narrowing core `xy.legend()`'s independent location vocabulary. Text-box scoring and bounded long-path sampling are documented in `spec/api/styling.md` § Legend placement | | `grid(True/False)` | toggles the grid via the theme | | `xlim` / `ylim`, `set_xmargin` / `set_ymargin`, axis scales, `invert_xaxis/yaxis` | linear/log are native; symlog/logit/asinh use dependency-free monotone data transforms with inverse limit/tick semantics. Automatic linear ticks include Matplotlib's 2.5 step and use uniform decimal padding across a tick set; locations refresh as data arrives. `axes.autolimit_mode="round_numbers"` expands automatic linear limits to the first and last AutoLocator ticks after applying the configured margins. Artist `get_data()` reflects the transformed space; logit masks values at/outside (0, 1) | | `set_major_locator` / `set_major_formatter`, `plt.NullLocator/FixedLocator/MultipleLocator/MaxNLocator/LinearLocator/LogLocator`, `plt.NullFormatter/FixedFormatter/FuncFormatter/FormatStrFormatter/StrMethodFormatter/ScalarFormatter` | xy-owned re-implementations resolved at build time against live data limits (Null/Fixed/Multiple/Linear are position-exact; MaxN/Auto port Matplotlib's `MaxNLocator._raw_ticks` — same step tables, edge extension, and offset handling — with `nbins="auto"` budgeted from the estimated plot rect like `Axis.get_tick_space()`; Log remains approximate). Third-party locator objects work if they implement `tick_values(vmin, vmax)`; minor locators/formatters are retained for round-tripping but minor ticks do not render, except that a labeled minor pair under a blanked major formatter (the centered date-label idiom) is promoted to the drawn tick set | diff --git a/tests/pyplot/test_gallery_statistics_semantics.py b/tests/pyplot/test_gallery_statistics_semantics.py index 997a7126..228a1733 100644 --- a/tests/pyplot/test_gallery_statistics_semantics.py +++ b/tests/pyplot/test_gallery_statistics_semantics.py @@ -39,6 +39,45 @@ def test_hist_rwidth_scales_each_nonuniform_bin() -> None: np.testing.assert_allclose(bars._entry["x"], [0.5, 1.5, 3.0]) +def test_hist_legend_does_not_treat_bin_widths_as_line_widths() -> None: + _fig, ax = plt.subplots() + ax.hist( + [0.2, 1.5, 3.0], + bins=[0.0, 1.0, 2.0, 4.0], + label="samples", + ) + + legend = ax.legend() + ax._build_chart(640, 480).figure().build_payload() + + assert legend is ax.get_legend() + assert legend._items[0]["name"] == "samples" + assert legend._items[0]["kind"] == "bar" + assert "width" not in legend._items[0]["style"] + + +def test_multiseries_histogram_legend_builds_with_per_bin_width_arrays() -> None: + _fig, ax = plt.subplots() + data = np.arange(18, dtype=np.float64).reshape(6, 3) + ax.hist( + data, + bins=[0.0, 3.0, 9.0, 18.0], + color=["red", "tan", "lime"], + label=["red", "tan", "lime"], + ) + legend = ax.legend(prop={"size": 10}) + + ax._build_chart(640, 480).figure().build_payload() + + assert [item["name"] for item in legend._items] == [ + "red", + "tan", + "lime", + ] + assert all(item["kind"] == "bar" for item in legend._items) + assert all("width" not in item["style"] for item in legend._items) + + def test_hist_hatch_families_emit_visible_overlay_geometry() -> None: _fig, ax = plt.subplots() ax.hist( diff --git a/tests/pyplot/test_grid_legend_contracts.py b/tests/pyplot/test_grid_legend_contracts.py index be2ef8b4..bb57adff 100644 --- a/tests/pyplot/test_grid_legend_contracts.py +++ b/tests/pyplot/test_grid_legend_contracts.py @@ -49,6 +49,8 @@ def test_legend_maps_supported_style_and_rejects_unknown_options(): assert ax._legend_options["loc"] == "upper right" assert ax._legend_options["ncols"] == 2 assert ax._legend_options["title"] == "Legend" + assert ax._legend_options["handlelength"] == 2.0 + assert ax._legend_options["handletextpad"] == 0.8 assert ax._legend_options["border_pad"] == pytest.approx(0.5 * 13 * 100 / 72) assert ax._legend_options["style"] == { "fontSize": "18.0556px", @@ -74,6 +76,17 @@ def test_legend_maps_supported_style_and_rejects_unknown_options(): with pytest.raises(ValueError, match="borderaxespad"): ax.legend(borderaxespad=-0.1) + ax.legend(handlelength=4, handletextpad=1.25) + assert ax._legend_options["handlelength"] == 4.0 + assert ax._legend_options["handletextpad"] == 1.25 + spec, _ = ax._build_chart(640, 480).figure().build_payload() + assert spec["legend"]["handlelength"] == 4.0 + assert spec["legend"]["handletextpad"] == 1.25 + with pytest.raises(ValueError, match="handlelength"): + ax.legend(handlelength=-1) + with pytest.raises(ValueError, match="handletextpad"): + ax.legend(handletextpad=float("nan")) + def test_legend_frameoff_maps_to_transparent_style(): _, ax = plt.subplots() diff --git a/tests/pyplot/test_line_legend_gallery_compat.py b/tests/pyplot/test_line_legend_gallery_compat.py index 84d77f37..605c830d 100644 --- a/tests/pyplot/test_line_legend_gallery_compat.py +++ b/tests/pyplot/test_line_legend_gallery_compat.py @@ -295,6 +295,8 @@ def test_round_dash_capstyle_mutation_matches_fixed_round_renderers(): assert line.get_dash_capstyle() == "round" assert line._entry["kwargs"]["dash_capstyle"] == "round" + spec, _ = ax._build_chart(640, 480).figure().build_payload() + assert spec["traces"][0]["kind"] == "line" def test_legend_frame_is_wide_enough_for_its_measured_labels(): @@ -350,6 +352,22 @@ def test_legend_column_width_measures_glyphs_not_character_count(): assert text_x + _legend_text_width("gamma") <= result["box_w"] +def test_legend_handle_geometry_uses_legend_font_units(): + plot = {"x": 0.0, "y": 0.0, "w": 560.0, "h": 400.0} + default = _legend_layout([{"name": "line"}], plot, {"loc": "upper right"}) + custom = _legend_layout( + [{"name": "line"}], + plot, + {"loc": "upper right", "handlelength": 4, "handletextpad": 1.25}, + ) + + assert default["handle"] == pytest.approx(2 * default["font_size"]) + assert default["gap"] == pytest.approx(0.8 * default["font_size"]) + assert custom["handle"] == pytest.approx(4 * custom["font_size"]) + assert custom["gap"] == pytest.approx(1.25 * custom["font_size"]) + assert custom["box_w"] > default["box_w"] + + def test_ellipsized_legend_label_fits_the_column_it_was_sized_for(): """Truncation is measured too, so a shortened label cannot overrun either.""" plot = {"x": 0.0, "y": 0.0, "w": 150.0, "h": 400.0} diff --git a/tests/test_text_weight_defaults.py b/tests/test_text_weight_defaults.py index 1f8d9748..0e5757d6 100644 --- a/tests/test_text_weight_defaults.py +++ b/tests/test_text_weight_defaults.py @@ -226,3 +226,17 @@ def test_chartview_does_not_pin_default_text_weights_inline() -> None: "author utilities must remain able to override the default" ) assert '"font-weight": this._axisStyleValue(axis, "label_font_weight")' in source + + +def test_browser_legend_handle_geometry_uses_font_relative_options() -> None: + source = (_JS / "50_chartview.ts").read_text(encoding="utf-8") + + assert ( + 'sw.style.setProperty("--xy-legend-swatch-width", `${handleLength}em`)' + in source + ) + assert ( + 'sw.style.setProperty("--xy-legend-swatch-margin-right", `${handleTextPad}em`)' + in source + ) + assert 'svg.setAttribute("width", "100%")' in source From a916022a7ed90005df09222ad5ac79cb00bb6d73 Mon Sep 17 00:00:00 2001 From: Sriman Selvakumaran Date: Sun, 26 Jul 2026 22:19:02 -0700 Subject: [PATCH 02/10] Support adaptive image interpolation --- python/xy/pyplot/_axes.py | 98 ++++++++++++- python/xy/pyplot/_colors.py | 12 +- spec/matplotlib/compat.md | 2 +- .../test_matplotlib_mismatch_regressions.py | 135 ++++++++++++++++++ 4 files changed, 234 insertions(+), 13 deletions(-) diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py index 0652a3b5..be3fcaad 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -2476,6 +2476,7 @@ def imshow(self, z: ArrayLike, cmap: Any = None, **kwargs: Any) -> AxesImage: norm = kwargs.pop("norm", None) supported_interpolation = { None, + "auto", "none", "nearest", "bilinear", @@ -2515,6 +2516,25 @@ def imshow(self, z: ArrayLike, cmap: Any = None, **kwargs: Any) -> AxesImage: truecolor = grid.ndim == 3 and grid.shape[-1] in (3, 4) if not truecolor and grid.ndim != 2: raise ValueError(f"imshow image data must be 2-D or RGB(A), got shape {grid.shape}") + truecolor_ceiling = ( + 255.0 + if truecolor and np.issubdtype(np.asanyarray(z).dtype, np.integer) + else 1.0 + ) + source_rows, source_cols = grid.shape[:2] + effective_interpolation = ( + rcParams["image.interpolation"] if interpolation is None else interpolation + ) + ( + effective_interpolation, + effective_interpolation_stage, + interpolation_width, + interpolation_height, + ) = _resolve_imshow_sampling( + grid.shape[:2], + effective_interpolation, + interpolation_stage, + ) if norm is not None: norm_vmin, norm_vmax = getattr(norm, "vmin", None), getattr(norm, "vmax", None) if norm_vmin is not None and norm_vmax is not None: @@ -2615,12 +2635,9 @@ def extreme(name: str, default: tuple[float, float, float, float]) -> np.ndarray ) grid = np.dstack((rgb.reshape(grid.shape + (3,)) / 255.0, alpha_array)) truecolor = True - effective_interpolation = ( - rcParams["image.interpolation"] if interpolation is None else interpolation - ) if ( not truecolor - and interpolation_stage == "rgba" + and effective_interpolation_stage == "rgba" and effective_interpolation not in ("none", "nearest") ): grid = _scalar_grid_rgba( @@ -2640,10 +2657,17 @@ def extreme(name: str, default: tuple[float, float, float, float]) -> np.ndarray # resampling matrices. Nearest retains the original source cells. grid = _resample_grid( grid, - min(1024, max(512, grid.shape[1])), - min(1024, max(512, grid.shape[0])), + interpolation_width, + interpolation_height, effective_interpolation, ) + if truecolor: + # Ringing filters legitimately overshoot their input range. + # Scalar interpolation keeps that overshoot so normalization + # can expose under/over colors, but RGBA output is bounded + # channel data in Matplotlib and must be saturated before + # static uint conversion instead of wrapping to black. + grid = np.clip(grid, 0.0, truecolor_ceiling) if transform == self.transAxes and extent is not None: xlo, xhi = self._axis_props("x").get("domain", self._entry_extent("x")) ylo, yhi = self._axis_props("y").get("domain", self._entry_extent("y")) @@ -2705,7 +2729,23 @@ def extreme(name: str, default: tuple[float, float, float, float]) -> np.ndarray bounds = (left, right, bottom, top) else: rows, cols = grid.shape[:2] - bounds = (-0.5, cols - 0.5, -0.5, rows - 0.5) + bounds = (-0.5, source_cols - 0.5, -0.5, source_rows - 0.5) + # Resampling changes texture resolution, never the image's data + # coordinates. Give the intermediate samples explicit centers so + # the core infers Matplotlib's original MxN half-cell extent + # instead of exposing the implementation's 512--1024 grid. + if (rows, cols) != (source_rows, source_cols): + left, right, bottom, top = bounds + entry_kwargs["x"] = np.linspace( + left + (right - left) / (2 * cols), + right - (right - left) / (2 * cols), + cols, + ) + entry_kwargs["y"] = np.linspace( + bottom + (top - bottom) / (2 * rows), + top - (top - bottom) / (2 * rows), + rows, + ) if self._aspect_bounds is None: self._aspect_bounds = bounds else: @@ -7271,6 +7311,50 @@ def _masked_float(value: Any) -> np.ndarray: return np.ma.asarray(value, dtype=np.float64).filled(np.nan) +def _resolve_imshow_sampling( + source_shape: tuple[int, int], + interpolation: str, + interpolation_stage: Any, +) -> tuple[str, str, int, int]: + """Resolve Matplotlib's adaptive image defaults against our bounded surface. + + Matplotlib 3.11 selects ``nearest`` for an image enlarged by more than + three times in both dimensions, or by exactly one or two times, and uses + ``hanning`` otherwise. Its automatic stage is RGBA for downsampling or + enlargement below three times in either dimension, and data otherwise. + + Matplotlib makes that choice against the final display transform. Pyplot + pre-renders smooth images into a bounded 512--1024 px intermediate, so that + surface is the closest available display-resolution proxy and is also the + exact target passed to :func:`_resample_grid`. + """ + source_height, source_width = source_shape + target_width = min(1024, max(512, source_width)) + target_height = min(1024, max(512, source_height)) + + if interpolation in {"auto", "antialiased"}: + nearest_x = ( + target_width > 3 * source_width + or target_width == source_width + or target_width == 2 * source_width + ) + nearest_y = ( + target_height > 3 * source_height + or target_height == source_height + or target_height == 2 * source_height + ) + interpolation = "nearest" if nearest_x and nearest_y else "hanning" + + if interpolation_stage in (None, "auto"): + interpolation_stage = ( + "rgba" + if target_width < 3 * source_width or target_height < 3 * source_height + else "data" + ) + + return interpolation, interpolation_stage, target_width, target_height + + def _interpolation_taps(source: int, target: int, method: str) -> tuple[np.ndarray, np.ndarray]: """Return source indices and normalized local taps for a separable filter.""" positions = np.linspace(0.0, source - 1.0, target)[:, None] diff --git a/python/xy/pyplot/_colors.py b/python/xy/pyplot/_colors.py index 899629dd..ad99023c 100644 --- a/python/xy/pyplot/_colors.py +++ b/python/xy/pyplot/_colors.py @@ -273,11 +273,13 @@ def _rgba_floats(value: object) -> tuple[float, float, float, float]: "cyan": (0.0, 1.0, 1.0, 1.0), "magenta": (1.0, 0.0, 1.0, 1.0), } - if resolved.lower() not in named: - raise ValueError( - f"colormap extremes require a CSS hex/rgb or basic named color, got {color!r}" - ) - result = named[resolved.lower()] + result = named.get(resolved.lower()) + if result is None: + # Ordinary plotting colors can be any CSS named color because the + # engine validates them natively. Colormap extremes are baked to + # RGBA in Python, so route the same vocabulary through the native + # CSS parser rather than narrowing it to eight basic names. + result = resolve_rgba(resolved) if isinstance(alpha, numbers.Real) and not isinstance(alpha, (bool, np.bool_)): result = (result[0], result[1], result[2], float(alpha)) if not all(np.isfinite(result)) or any(channel < 0.0 or channel > 1.0 for channel in result): diff --git a/spec/matplotlib/compat.md b/spec/matplotlib/compat.md index 0c482eea..b91d6630 100644 --- a/spec/matplotlib/compat.md +++ b/spec/matplotlib/compat.md @@ -58,7 +58,7 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent | `boxplot`, `violinplot`, `bxp`, `violin`, `errorbar` | Boxplots support notches, bootstrap/user confidence intervals, median overrides (drawn median only; notch CIs stay data-derived like Matplotlib), percentile/custom whiskers, cap widths, `sym`, and component colors/widths/alpha — dashed component linestyles fail loudly. Violins support Scott/Silverman/scalar/callable Gaussian-KDE bandwidths, quantiles, and low/high sides; the default (bw_method omitted) uses the native histogram violin mark, whose shape differs from the explicit KDE path. `boxplot` autoscales its value axis over the Tukey whiskers plus, when `showfliers` is on, the flier points. Its default category positions are 1-based and `manage_ticks=True` reserves half a unit around the outer positions, matching Matplotlib | | `fill_between(x, y1, y2, where=, step=)` / `fill_betweenx` | Masks are split into finite contiguous polygons; step geometry is expanded exactly | | `stackplot` | All four baselines are computed by the native stacked-bounds kernel | -| `imshow` / `pcolormesh` (`cmap=`, `vmin=`/`vmax=`, `origin=`) | `imshow` defaults to `rcParams['image.origin']`; nearest stays cell-exact, while named smoothing modes use dependency-free per-kernel approximations over a bounded 512–1024 px intermediate for both scalar and RGB(A) data. Filter choice and intermediate size do not yet depend on final display resolution, and explicit `interpolation="auto"` remains unsupported. Unsupported stages/transforms fail loudly. Uniform meshes retain the texture fast path; nonuniform and curvilinear grids use native quad-to-triangle expansion. Both hug their outer cell edge with no margin, as Matplotlib's sticky image/mesh edges do | +| `imshow` / `pcolormesh` (`cmap=`, `vmin=`/`vmax=`, `origin=`) | `imshow` defaults to `rcParams['image.origin']`; nearest stays cell-exact, while named smoothing modes use dependency-free per-kernel approximations over a bounded 512–1024 px intermediate for both scalar and RGB(A) data. `interpolation="auto"` and `"antialiased"` apply Matplotlib 3.11's adaptive rule against that bounded surface: exact 1×/2× or enlargement above 3× in both dimensions selects nearest, otherwise Hanning; automatic interpolation stage similarly selects data for large enlargement and RGBA for modest enlargement or downsampling. Filter choice does not yet depend on final display resolution. Colormap under/over/bad colors accept the same CSS named-color vocabulary as ordinary marks. Unsupported stages/transforms fail loudly. Uniform meshes retain the texture fast path; nonuniform and curvilinear grids use native quad-to-triangle expansion. Both hug their outer cell edge with no margin, as Matplotlib's sticky image/mesh edges do | | `step`, `stairs`, `stem`, `eventplot` | Compact step/stem/segment marks; no Python-side vertex expansion | | `contour` / `contourf` / `clabel` | Native marching squares over rectilinear grids; warped grids route through native Delaunay/marching-triangle kernels; automatic labels repeat at bounded, separated positions along each level (line knockout for `inline=True` remains a visual approximation) | | `quiver`, `barbs`, `streamplot` | Quiver supports Matplotlib's width-unit vocabulary independently from length scaling. Barbs use fixed-length staffs and Matplotlib's flag/full/half decomposition, including increments, rounding, empty glyphs, colors, sizes, flipping, and pivots. Streamplot uses a dependency-free adaptive Heun integrator with occupancy-aware seeding; `start_points`, `integration_direction`, `broken_streamlines`, integration step/error scales, array widths/colors, and `num_arrows` are honored | diff --git a/tests/pyplot/test_matplotlib_mismatch_regressions.py b/tests/pyplot/test_matplotlib_mismatch_regressions.py index f1a73403..04439e75 100644 --- a/tests/pyplot/test_matplotlib_mismatch_regressions.py +++ b/tests/pyplot/test_matplotlib_mismatch_regressions.py @@ -6,6 +6,7 @@ import xy.pyplot as plt from xy._figure import Figure from xy.pyplot import _axes as axes_module +from xy.pyplot._colors import _rgba_floats def test_axis_equal_before_fill_keeps_data_autoscaling() -> None: @@ -74,6 +75,140 @@ def test_imshow_interpolates_truecolor_and_honors_rgba_stage() -> None: assert np.asarray(rgba_image._entry["z"]).shape == (512, 512, 4) +@pytest.mark.parametrize("interpolation", ["auto", "antialiased"]) +def test_imshow_adaptive_interpolation_uses_matplotlib_sampling_rules( + monkeypatch: pytest.MonkeyPatch, + interpolation: str, +) -> None: + calls: list[tuple[tuple[int, ...], int, int, str]] = [] + + def record_target( + grid: np.ndarray, + width: int, + height: int, + method: str, + ) -> np.ndarray: + calls.append((grid.shape, width, height, method)) + return grid + + monkeypatch.setattr(axes_module, "_resample_grid", record_target) + + # The 450px source in Matplotlib's image-antialiasing gallery is enlarged + # modestly by xy's 512px intermediate. Matplotlib therefore uses Hanning + # after color mapping (RGBA stage). + _fig, ax = plt.subplots() + ax.imshow(np.zeros((450, 450)), interpolation=interpolation) + assert calls == [((450, 450, 4), 512, 512, "hanning")] + + # Exact 1x/2x and enlargement above 3x use nearest, so no Python + # resampling occurs and the source cells stay intact. + calls.clear() + _fig, axes = plt.subplots(1, 3) + one_x = axes[0].imshow(np.zeros((512, 512)), interpolation=interpolation) + two_x = axes[1].imshow(np.zeros((256, 256)), interpolation=interpolation) + over_three_x = axes[2].imshow(np.zeros((170, 170)), interpolation=interpolation) + assert calls == [] + assert np.asarray(one_x._entry["z"]).shape == (512, 512) + assert np.asarray(two_x._entry["z"]).shape == (256, 256) + assert np.asarray(over_three_x._entry["z"]).shape == (170, 170) + + +def test_imshow_auto_respects_explicit_interpolation_stage( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[tuple[tuple[int, ...], str]] = [] + + def record_target( + grid: np.ndarray, + width: int, + height: int, + method: str, + ) -> np.ndarray: + del width, height + calls.append((grid.shape, method)) + return grid + + monkeypatch.setattr(axes_module, "_resample_grid", record_target) + _fig, (data_ax, rgba_ax) = plt.subplots(1, 2) + data_ax.imshow( + np.zeros((450, 450)), + interpolation="auto", + interpolation_stage="data", + ) + rgba_ax.imshow( + np.zeros((450, 450)), + interpolation="auto", + interpolation_stage="rgba", + ) + + assert calls == [ + ((450, 450), "hanning"), + ((450, 450, 4), "hanning"), + ] + + +def test_imshow_resampling_preserves_the_source_data_extent() -> None: + _fig, ax = plt.subplots() + image = ax.imshow(np.zeros((3, 5)), interpolation="bilinear") + + assert np.asarray(image._entry["z"]).shape == (512, 512) + assert image.get_extent() == pytest.approx((-0.5, 4.5, -0.5, 2.5)) + assert ax._entry_extent("x") == pytest.approx((-0.5, 4.5)) + assert ax._entry_extent("y") == pytest.approx((-0.5, 2.5)) + x = np.asarray(image._entry["kwargs"]["x"]) + y = np.asarray(image._entry["kwargs"]["y"]) + assert x[[0, -1]] == pytest.approx((-0.5 + 5 / 1024, 4.5 - 5 / 1024)) + assert y[[0, -1]] == pytest.approx((-0.5 + 3 / 1024, 2.5 - 3 / 1024)) + + +@pytest.mark.parametrize("interpolation", ["lanczos", "sinc"]) +def test_rgba_stage_ringing_filters_saturate_channel_overshoot(interpolation: str) -> None: + values = np.array( + [ + [0.0, 0.2, 0.8, 1.0], + [1.0, 0.8, 0.2, 0.0], + [0.0, 0.2, 0.8, 1.0], + [1.0, 0.8, 0.2, 0.0], + ] + ) + _fig, ax = plt.subplots() + image = ax.imshow( + values, + cmap="viridis", + interpolation=interpolation, + interpolation_stage="rgba", + ) + pixels = np.asarray(image._entry["z"]) + + assert pixels.shape == (512, 512, 4) + assert np.isfinite(pixels).all() + assert float(pixels.min()) >= 0.0 + assert float(pixels.max()) <= 1.0 + assert np.ptp(pixels[..., :3]) > 0.5 + + +def test_imshow_colormap_extremes_accept_css_named_colors() -> None: + assert _rgba_floats("limegreen") == pytest.approx((50 / 255, 205 / 255, 50 / 255, 1)) + assert _rgba_floats(("limegreen", 0.25)) == pytest.approx((50 / 255, 205 / 255, 50 / 255, 0.25)) + + cmap = plt.get_cmap("RdBu_r") + cmap.set_under("yellow") + cmap.set_over("limegreen") + _fig, ax = plt.subplots() + image = ax.imshow( + np.array([[-1.0, 3.0]]), + cmap=cmap, + vmin=0.0, + vmax=2.0, + interpolation="nearest", + origin="lower", + ) + pixels = np.asarray(image._entry["z"]) + + np.testing.assert_allclose(pixels[0, 0], (1.0, 1.0, 0.0, 1.0)) + np.testing.assert_allclose(pixels[0, 1], (50 / 255, 205 / 255, 50 / 255, 1.0)) + + def test_named_imshow_filters_are_not_all_bilinear_aliases() -> None: values = np.array([[0.0, 0.0, 0.0, 0.0], [0.0, 1.0, 1.0, 0.0]] * 2) _fig, (linear_ax, lanczos_ax) = plt.subplots(1, 2) From a16b946f250855774d7518179443a64b7bc9d403 Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Sun, 26 Jul 2026 22:38:57 -0700 Subject: [PATCH 03/10] Fix step capstyle dispatch and formatting --- python/xy/pyplot/_axes.py | 18 ++++++++---------- .../pyplot/test_line_legend_gallery_compat.py | 14 ++++++++++++++ tests/test_text_weight_defaults.py | 10 ++-------- 3 files changed, 24 insertions(+), 18 deletions(-) diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py index be3fcaad..533548eb 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -2517,9 +2517,7 @@ def imshow(self, z: ArrayLike, cmap: Any = None, **kwargs: Any) -> AxesImage: if not truecolor and grid.ndim != 2: raise ValueError(f"imshow image data must be 2-D or RGB(A), got shape {grid.shape}") truecolor_ceiling = ( - 255.0 - if truecolor and np.issubdtype(np.asanyarray(z).dtype, np.integer) - else 1.0 + 255.0 if truecolor and np.issubdtype(np.asanyarray(z).dtype, np.integer) else 1.0 ) source_rows, source_cols = grid.shape[:2] effective_interpolation = ( @@ -5381,13 +5379,7 @@ def _compose_legend_options(self, kwargs: dict[str, Any]) -> dict[str, Any]: # Remaining title geometry is not expressible yet and stays loud; the # frame, handle, and row-layout options above map directly to CSS and # the static exporters. - layout_options = { - key: kwargs.pop(key) - for key in ( - "title_fontsize", - ) - if key in kwargs - } + layout_options = {key: kwargs.pop(key) for key in ("title_fontsize",) if key in kwargs} if layout_options: raise not_implemented( f"legend({sorted(layout_options)[0]}=...)", @@ -5760,6 +5752,12 @@ def _chart_children(self) -> list[Any]: kw["domain"] = (float(dom[0]), float(dom[1])) children.append(xy.heatmap(z=z, **kw, **axis_kw)) elif kind == "@mark": + if e["factory"] == "step": + kw = dict(kw) + # ``Line2D.set_dash_capstyle()`` mutates the deferred + # pyplot entry, but core ``xy.step`` has fixed round caps + # and does not accept the Matplotlib-only keyword. + kw.pop("dash_capstyle", None) children.append(getattr(xy, e["factory"])(*e["args"], **kw, **axis_kw)) elif kind == "@hline": children.append(xy.hline(*e["args"], **kw)) diff --git a/tests/pyplot/test_line_legend_gallery_compat.py b/tests/pyplot/test_line_legend_gallery_compat.py index 605c830d..bda11620 100644 --- a/tests/pyplot/test_line_legend_gallery_compat.py +++ b/tests/pyplot/test_line_legend_gallery_compat.py @@ -299,6 +299,20 @@ def test_round_dash_capstyle_mutation_matches_fixed_round_renderers(): assert spec["traces"][0]["kind"] == "line" +def test_round_dash_capstyle_mutation_is_filtered_for_step_lines(): + _, ax = plt.subplots() + line = ax.plot([0, 1, 2], [0, 1, 0], "--", drawstyle="steps-post")[0] + + line.set_dash_capstyle("round") + + assert line.get_dash_capstyle() == "round" + assert line._entry["kwargs"]["dash_capstyle"] == "round" + assert line._entry["kind"] == "@mark" + assert line._entry["factory"] == "step" + spec, _ = ax._build_chart(640, 480).figure().build_payload() + assert spec["traces"][0]["kind"] == "line" + + def test_legend_frame_is_wide_enough_for_its_measured_labels(): """The frame must contain its own labels, wide glyphs included. diff --git a/tests/test_text_weight_defaults.py b/tests/test_text_weight_defaults.py index 0e5757d6..b4e2bdd9 100644 --- a/tests/test_text_weight_defaults.py +++ b/tests/test_text_weight_defaults.py @@ -231,12 +231,6 @@ def test_chartview_does_not_pin_default_text_weights_inline() -> None: def test_browser_legend_handle_geometry_uses_font_relative_options() -> None: source = (_JS / "50_chartview.ts").read_text(encoding="utf-8") - assert ( - 'sw.style.setProperty("--xy-legend-swatch-width", `${handleLength}em`)' - in source - ) - assert ( - 'sw.style.setProperty("--xy-legend-swatch-margin-right", `${handleTextPad}em`)' - in source - ) + assert 'sw.style.setProperty("--xy-legend-swatch-width", `${handleLength}em`)' in source + assert 'sw.style.setProperty("--xy-legend-swatch-margin-right", `${handleTextPad}em`)' in source assert 'svg.setAttribute("width", "100%")' in source From 46e1035831e4c0debb383ef267f24517045d9af6 Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Sun, 26 Jul 2026 23:19:12 -0700 Subject: [PATCH 04/10] Strengthen dash capstyle renderer regression --- tests/pyplot/test_line_legend_gallery_compat.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/pyplot/test_line_legend_gallery_compat.py b/tests/pyplot/test_line_legend_gallery_compat.py index bda11620..f6a94fff 100644 --- a/tests/pyplot/test_line_legend_gallery_compat.py +++ b/tests/pyplot/test_line_legend_gallery_compat.py @@ -297,6 +297,7 @@ def test_round_dash_capstyle_mutation_matches_fixed_round_renderers(): assert line._entry["kwargs"]["dash_capstyle"] == "round" spec, _ = ax._build_chart(640, 480).figure().build_payload() assert spec["traces"][0]["kind"] == "line" + assert "dash_capstyle" not in spec["traces"][0]["style"] def test_round_dash_capstyle_mutation_is_filtered_for_step_lines(): @@ -311,6 +312,7 @@ def test_round_dash_capstyle_mutation_is_filtered_for_step_lines(): assert line._entry["factory"] == "step" spec, _ = ax._build_chart(640, 480).figure().build_payload() assert spec["traces"][0]["kind"] == "line" + assert "dash_capstyle" not in spec["traces"][0]["style"] def test_legend_frame_is_wide_enough_for_its_measured_labels(): From 74e2a3659f4017543a22a9f1a2e7793fc697837e Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza <62690310+FarhanAliRaza@users.noreply.github.com> Date: Mon, 27 Jul 2026 08:58:10 -0700 Subject: [PATCH 05/10] Match Matplotlib's Kaiser image kernel --- python/xy/pyplot/_axes.py | 11 ++++++-- .../test_matplotlib_mismatch_regressions.py | 25 +++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py index 533548eb..c828276d 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -7379,10 +7379,17 @@ def keys(a: float) -> np.ndarray: elif method == "gaussian": weights = np.where(absolute < 2.0, np.exp(-2.0 * absolute**2), 0.0) elif method == "kaiser": - radius = 3.0 + # Match AGG's image_filter_kaiser, which Matplotlib delegates to: + # a compact one-pixel support with beta=6.33. Treating Kaiser like + # the wider sinc-family filters averages almost the entire 4x4 + # interpolation-methods example at every output location, collapsing + # its localized 2-D structure into a near one-dimensional gradient. + radius = 1.0 + beta = 6.33 weights = np.where( absolute < radius, - np.i0(5.0 * np.sqrt(np.maximum(0.0, 1.0 - (absolute / radius) ** 2))) / np.i0(5.0), + np.i0(beta * np.sqrt(np.maximum(0.0, 1.0 - (absolute / radius) ** 2))) + / np.i0(beta), 0.0, ) elif method == "sinc": diff --git a/tests/pyplot/test_matplotlib_mismatch_regressions.py b/tests/pyplot/test_matplotlib_mismatch_regressions.py index 04439e75..ac5e41d8 100644 --- a/tests/pyplot/test_matplotlib_mismatch_regressions.py +++ b/tests/pyplot/test_matplotlib_mismatch_regressions.py @@ -281,6 +281,31 @@ def dense_weights(source: int, target: int) -> np.ndarray: ) +def test_sparse_imshow_kaiser_matches_matplotlib_agg_kernel() -> None: + values = np.arange(12.0).reshape(3, 4) + + def dense_weights(source: int, target: int) -> np.ndarray: + positions = np.linspace(0.0, source - 1.0, target)[:, None] + distance = np.abs(positions - np.arange(source, dtype=np.float64)[None, :]) + beta = 6.33 + weights = np.where( + distance < 1.0, + np.i0(beta * np.sqrt(np.maximum(0.0, 1.0 - distance**2))) / np.i0(beta), + 0.0, + ) + return weights / weights.sum(axis=1, keepdims=True) + + wy = dense_weights(values.shape[0], 7) + wx = dense_weights(values.shape[1], 9) + expected = wy @ values @ wx.T + + np.testing.assert_allclose( + axes_module._resample_grid(values, 9, 7, "kaiser"), + expected, + atol=1e-12, + ) + + def test_imshow_bounds_large_non_nearest_resampling(monkeypatch: pytest.MonkeyPatch) -> None: requested: list[tuple[int, int]] = [] From a384918eb452dafee4ee9d457e3061ed85fa684f Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Mon, 27 Jul 2026 09:21:32 -0700 Subject: [PATCH 06/10] Fix gallery legend and histogram semantics --- js/src/50_chartview.ts | 7 ++ python/xy/_raster.py | 11 ++- python/xy/_svg.py | 9 +- python/xy/pyplot/_artists.py | 11 +++ python/xy/pyplot/_axes.py | 68 +++++++++++-- python/xy/pyplot/_plot_types.py | 97 +++++++++++-------- spec/api/styling.md | 11 ++- spec/matplotlib/compat-changelog.md | 14 +++ spec/matplotlib/compat.md | 4 +- .../test_gallery_hist_errorbar_compat.py | 50 ++++++++++ .../test_gallery_statistics_semantics.py | 23 ++++- tests/pyplot/test_grid_legend_contracts.py | 51 ++++++++++ .../pyplot/test_line_legend_gallery_compat.py | 87 ++++++++++++++++- tests/test_text_weight_defaults.py | 8 ++ 14 files changed, 394 insertions(+), 57 deletions(-) diff --git a/js/src/50_chartview.ts b/js/src/50_chartview.ts index 4aa960a7..40995bcf 100644 --- a/js/src/50_chartview.ts +++ b/js/src/50_chartview.ts @@ -2014,6 +2014,13 @@ export class ChartView { "--xy-legend-swatch-paint", safeCssPaint(this.root, bg), ); + const strokeWidth = Number(it.style?.stroke_width) || 0; + if (it.style?.stroke && strokeWidth > 0) { + sw.style.boxSizing = "border-box"; + sw.style.borderStyle = "solid"; + sw.style.borderWidth = `${strokeWidth}px`; + sw.style.borderColor = safeCssPaint(this.root, it.style.stroke); + } // Hatch layers are explicit mark semantics and sit over that sanitized // base paint without forcing the base color into an inline background. if (it.style?.hatch) { diff --git a/python/xy/_raster.py b/python/xy/_raster.py index 6f5f3603..72eff58c 100644 --- a/python/xy/_raster.py +++ b/python/xy/_raster.py @@ -2316,7 +2316,16 @@ def _emit_legend( dash=style.get("dash"), ) else: - cmd.fill(_rect_pts(hx0, cy - swatch_h / 2, hx1, cy + swatch_h / 2), c) + swatch_points = _rect_pts(hx0, cy - swatch_h / 2, hx1, cy + swatch_h / 2) + cmd.fill(swatch_points, c) + stroke_width = max(0.0, float(style.get("stroke_width", 0.0))) + if style.get("stroke") is not None and stroke_width > 0.0: + cmd.stroke( + swatch_points, + stroke_width, + _rgba(style.get("stroke"), color_str), + closed=True, + ) hatch = style.get("hatch") if hatch: _emit_legend_hatch( diff --git a/python/xy/_svg.py b/python/xy/_svg.py index ba253da6..ac32679e 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -3838,10 +3838,17 @@ def _legend( f"{_dash_attr(style)}/>" ) else: + stroke_width = max(0.0, float(style.get("stroke_width", 0.0))) + stroke = style.get("stroke") + stroke_attr = ( + f' stroke="{escape(_css(stroke, color))}" stroke-width="{_num(stroke_width)}"' + if stroke is not None and stroke_width > 0.0 + else "" + ) rows.append( f'' + f'rx="2" fill="{escape(color)}"{stroke_attr}/>' ) if style.get("hatch"): rows.append( diff --git a/python/xy/pyplot/_artists.py b/python/xy/pyplot/_artists.py index 31558159..59c50e88 100644 --- a/python/xy/pyplot/_artists.py +++ b/python/xy/pyplot/_artists.py @@ -963,6 +963,13 @@ def __init__(self, artist: Artist, data_line: Optional[Line2D] = None) -> None: def __iter__(self) -> Iterator[Any]: return iter(self.lines) + def get_label(self) -> Any: + return self._artist._entry["kwargs"].get("name") + + def set_label(self, value: Any) -> None: + self._artist._entry["kwargs"]["name"] = value + self._artist._touch() + def remove(self) -> None: self._artist.remove() self._artist._axes._unregister_container(self) @@ -1395,6 +1402,10 @@ def _legend_item_from_entry( for key in ("size", "stroke", "stroke_width"): if kw.get(key) is not None: style[key] = kw[key] + elif kind not in {"line", "segments", "step", "stairs", "errorbar"}: + for key in ("stroke", "stroke_width"): + if kw.get(key) is not None: + style[key] = kw[key] return {"name": str(label), "kind": kind, "style": style} diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py index c828276d..85a2f87a 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -2097,7 +2097,10 @@ def dataset_values(value: Any, name: str, *, color_value: bool = False) -> list[ else (1.0 if (stacked or len(datasets) == 1) else 0.8) ) width = binwidths * rel_width / (1 if stacked else len(datasets)) + histogram_entry_start = len(self._entries) + histogram_entry_groups: list[list[dict[str, Any]]] = [] for index, values in enumerate(counts): + group_start = len(self._entries) positions = centers if stacked else centers + (index - (len(datasets) - 1) / 2) * width current_base = base.copy() if stacked else np.zeros_like(values) series_color = ( @@ -2264,7 +2267,7 @@ def dataset_values(value: Any, name: str, *, color_value: bool = False) -> list[ current_base, orientation, { - "color": resolved_edge or series_color, + "color": resolved_edge or resolve_color(rcParams["patch.edgecolor"]), "facecolor": resolved_color, "opacity": 1.0 if alpha is None else float(alpha), }, @@ -2272,8 +2275,19 @@ def dataset_values(value: Any, name: str, *, color_value: bool = False) -> list[ right_edges=positions + width / 2.0, ) containers.append(BarContainer(self, entry)) + histogram_entry_groups.append(self._entries[group_start:]) if stacked: base += values + if stacked and histtype.startswith("step"): + # Matplotlib draws stacked step polygons from the top dataset + # downward, so the topmost outline cannot be hidden by lower + # layers. Its automatic legend follows that artist insertion order + # (top-to-bottom), while the returned patch containers are restored + # to the caller's original dataset order. Keep those two contracts + # distinct here as well. + self._entries[histogram_entry_start:] = [ + entry for group in reversed(histogram_entry_groups) for entry in group + ] returned = np.vstack(counts) if stacked: returned = np.cumsum(returned, axis=0) @@ -4174,21 +4188,29 @@ def get_legend(self) -> Any: host = self._y2_of or self return host._legend_handle if host._legend else None - def get_legend_handles_labels(self) -> tuple[list[Artist], list[str]]: + def get_legend_handles_labels(self) -> tuple[list[Any], list[str]]: """Handles and labels of the entries that would appear in the legend. Entries whose label starts with ``"_"`` are excluded, matching matplotlib. """ - handles: list[Artist] = [] + from ._artists import ErrorbarContainer + + host = self._y2_of or self + errorbar_containers = { + id(container._artist._entry): container + for container in host._containers + if isinstance(container, ErrorbarContainer) + } + handles: list[Any] = [] labels: list[str] = [] - for entry in (self._y2_of or self)._entries: + for entry in host._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 + for item in host._containers if isinstance(item, BarContainer) and item._entry is entry ), None, @@ -4202,7 +4224,8 @@ def get_legend_handles_labels(self) -> tuple[list[Artist], list[str]]: continue label = entry.get("kwargs", {}).get("name") if label and not str(label).startswith("_"): - handles.append(Artist(self, entry)) + handle = errorbar_containers.get(id(entry)) + handles.append(handle if handle is not None else Artist(self, entry)) labels.append(str(label)) return handles, labels @@ -6167,6 +6190,39 @@ def normalize(values: Any, axis: str) -> np.ndarray: args = entry.get("args") or () if len(args) >= 4: x_values, y_values = (args[0], args[2]), (args[1], args[3]) + elif kind == "@mark" and entry.get("factory") == "stairs": + # ``xy.stairs`` stores ``(values, edges)``, while Matplotlib's + # StepPatch contributes the expanded edge/value path to + # Legend._auto_legend_data(). Feeding the compact arguments to + # the generic (x, y) path reverses the axes and makes the + # histogram effectively invisible to ``loc="best"``. + args = entry.get("args") or () + if len(args) >= 2: + try: + values = axis_values(args[0], "y") + edges = axis_values(args[1], "x") + except (TypeError, ValueError): + continue + if len(edges) != len(values) + 1: + continue + x_values = np.repeat(edges, 2)[1:-1] + y_values = np.repeat(values, 2) + elif kind == "@mark" and entry.get("factory") == "ecdf": + # The compact ECDF mark owns only the source observations; the + # renderer materializes its sorted post-step path. Score that + # same path so a rising CDF occupies the upper-right region + # instead of disappearing from best-placement input. + args = entry.get("args") or () + if args: + try: + values = axis_values(args[0], "x") + except (TypeError, ValueError): + continue + values = np.sort(values[np.isfinite(values)]) + if not len(values): + continue + x_values = np.concatenate((values[:1], values)) + y_values = np.arange(len(values) + 1, dtype=np.float64) / len(values) elif kind == "area" and x_values is not None and y_values is not None: # PolyCollection contributes the complete closed polygon path: # top edge followed by the reversed baseline. diff --git a/python/xy/pyplot/_plot_types.py b/python/xy/pyplot/_plot_types.py index 20dd6796..873d9231 100644 --- a/python/xy/pyplot/_plot_types.py +++ b/python/xy/pyplot/_plot_types.py @@ -13,6 +13,7 @@ # plotting methods must resolve these annotation names (all stdlib or xy-local). from collections.abc import Callable, Mapping, Sequence from datetime import date, datetime +from itertools import pairwise from typing import TYPE_CHECKING, Any, Optional import numpy as np @@ -1967,10 +1968,6 @@ def _stairs_hatch( y0: list[float] = [] x1: list[float] = [] y1: list[float] = [] - dot_x: list[float] = [] - dot_y: list[float] = [] - ring_x: list[float] = [] - ring_y: list[float] = [] left_edges = np.asarray(edges, dtype=np.float64) if right_edges is None: right_values = left_edges[1:] @@ -1979,6 +1976,14 @@ def _stairs_hatch( right_values = np.asarray(right_edges, dtype=np.float64) if not (len(values) == len(bases) == len(left_edges) == len(right_values)): raise ValueError("hatch geometry must have one rectangle per value") + x_extent = np.concatenate((left_edges, right_values)) + y_extent = np.concatenate( + (np.asarray(values, dtype=np.float64), np.asarray(bases, dtype=np.float64)) + ) + finite_x_extent = x_extent[np.isfinite(x_extent)] + finite_y_extent = y_extent[np.isfinite(y_extent)] + x_span = float(np.ptp(finite_x_extent)) if finite_x_extent.size else 0.0 + y_span = float(np.ptp(finite_y_extent)) if finite_y_extent.size else 0.0 pattern = set(hatch) density = max(1, min(3, max((hatch.count(char) for char in pattern), default=1))) line_count = 4 + density * 3 @@ -2027,18 +2032,62 @@ def diagonals(reverse: bool) -> None: if "-" in pattern or "+" in pattern or "*" in pattern: for position in np.linspace(0.1, 0.9, line_count): segment(0.0, float(position), 1.0, float(position)) + + rect_width = abs(float(rx1) - float(rx0)) + rect_height = abs(float(ry1) - float(ry0)) + rectangle = (rect_width, rect_height) + + def ring( + u: float, + v: float, + *, + scale: float, + steps: int, + rectangle: tuple[float, float], + ) -> None: + rect_width, rect_height = rectangle + if rect_width <= 0.0 or rect_height <= 0.0: + return + # Hatch circles are display-sized in Matplotlib and clipped by + # each Rectangle. The shim has no per-bin clip primitive, so + # materialize a small data-space polygon and clamp both radii + # inside this rectangle. That keeps even tail-bin glyphs local + # instead of letting fixed-size scatter markers leak across the + # histogram baseline. + ru = min(0.08 * scale, (x_span / 160.0) * scale / rect_width) + rv = min(0.08 * scale, (y_span / 120.0) * scale / rect_height) + if ru <= 0.0 or rv <= 0.0: + return + angles = np.linspace(0.0, 2.0 * np.pi, steps + 1) + points = [ + (u + ru * float(np.cos(angle)), v + rv * float(np.sin(angle))) + for angle in angles + ] + for start, end in pairwise(points): + segment(*start, *end) + if "." in pattern: grid = np.linspace(0.12, 0.88, 3 + density) for u in grid: for v in grid: - dot_x.append(float(rx0 + u * (rx1 - rx0))) - dot_y.append(float(ry0 + v * (ry1 - ry0))) + ring( + float(u), + float(v), + scale=0.45, + steps=6, + rectangle=rectangle, + ) if "o" in pattern or "O" in pattern: grid = np.linspace(0.14, 0.86, 3 + density) for u in grid: for v in grid: - ring_x.append(float(rx0 + u * (rx1 - rx0))) - ring_y.append(float(ry0 + v * (ry1 - ry0))) + ring( + float(u), + float(v), + scale=1.35 if "O" in pattern else 1.0, + steps=10, + rectangle=rectangle, + ) color = props.get("color") opacity = props.get("opacity", 1.0) @@ -2056,36 +2105,6 @@ def diagonals(reverse: bool) -> None: }, }, ) - if dot_x: - self._add( - "scatter", - { - "x": dot_x, - "y": dot_y, - "kwargs": { - "color": color, - "size": 2.0, - "symbol": "circle", - "opacity": opacity, - }, - }, - ) - if ring_x: - self._add( - "scatter", - { - "x": ring_x, - "y": ring_y, - "kwargs": { - "color": props.get("facecolor", "transparent"), - "stroke": color, - "stroke_width": 0.8, - "size": 13.0 if "O" in pattern else 10.0, - "symbol": "circle", - "opacity": opacity, - }, - }, - ) def ecdf( self, @@ -2680,8 +2699,6 @@ def subset_limit(flag: Any) -> Any: line_kwargs_for_plot["linewidth"] = base["width"] if "opacity" in base: line_kwargs_for_plot["alpha"] = base["opacity"] - if "name" in base: - line_kwargs_for_plot["label"] = base["name"] if "linestyle" in base: line_kwargs_for_plot["linestyle"] = base["linestyle"] if "dash" in base: diff --git a/spec/api/styling.md b/spec/api/styling.md index 6a1ed0c4..2814e5c5 100644 --- a/spec/api/styling.md +++ b/spec/api/styling.md @@ -680,11 +680,12 @@ utilities style the latter. The client pins the lasso path to existing box-oriented `pointer-events-none` class cannot disable handle edits. The `legend_swatch` slot is the chip wrapper for every legend handle. Bar/solid -chips use its background and box dimensions; scatter and line SVG descendants -inherit fill, stroke, stroke-width, and dash paint from that wrapper. The -renderer supplies those values through private base-layer variables rather -than presentation attributes, so normal Tailwind SVG paint utilities on the -slot override them. +chips use its background and box dimensions, and retain the source mark's +stroke paint/width so an unfilled bar still has a visible outlined handle. +Scatter and line SVG descendants inherit fill, stroke, stroke-width, and dash +paint from that wrapper. The renderer supplies those values through private +base-layer variables rather than presentation attributes, so normal Tailwind +SVG paint utilities on the slot override them. Responsive CSS on DOM chrome reevaluates normally. Canvas paint is different: the renderer samples `--chart-bg`, `--chart-grid`, `--chart-axis`, and the diff --git a/spec/matplotlib/compat-changelog.md b/spec/matplotlib/compat-changelog.md index 1778169b..a00ee5f3 100644 --- a/spec/matplotlib/compat-changelog.md +++ b/spec/matplotlib/compat-changelog.md @@ -4,6 +4,20 @@ This changelog records changes to the upstream compatibility target and to the meaning of xy's compatibility levels. It complements the project changelog, which covers user-visible releases across the whole package. +## Legend and histogram gallery corrections — 2026-07-27 + +- Errorbar containers contribute one automatic legend entry instead of + duplicating the bars and their private data line. +- `loc="best"` scores the rendered stairs and compact ECDF paths instead of + treating their compact storage arguments as ordinary x/y vertices. +- Stacked step histograms draw and list their outlines top-to-bottom while + keeping returned containers in input-dataset order. +- Histogram dot/ring hatches use bounded per-bin geometry and the patch-edge + color, so short tail bars no longer leak fixed-size glyphs across the + baseline. +- Hollow patch legend handles preserve their source stroke in browser, SVG, + and native raster output. + ## Vector-field gallery corrections — 2026-07-24 - `quiver(units=...)` now converts Matplotlib's width-unit vocabulary without diff --git a/spec/matplotlib/compat.md b/spec/matplotlib/compat.md index b91d6630..ec09059a 100644 --- a/spec/matplotlib/compat.md +++ b/spec/matplotlib/compat.md @@ -53,7 +53,7 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent | `plt.plot` / `ax.plot` | format strings (`'r--o'`), multiple series per call, implicit x, `label=`, `lw=`, `ls=`, `alpha=`, marker face/edge styling, directional `^`/`v`/`<`/`>` triangles and distinct `+`/`x` glyphs, `markevery`, and dependency-free affine *data* transforms (`Affine2D + ax.transData`); axes/figure-fraction transforms on data artists, partial fill styles, and cap/join policies fail loudly | | `scatter(x, y, s=, c=, cmap=, vmin=, vmax=, alpha=, marker=, edgecolors=, plotnonfinite=)` | `s` (pt², area) maps to pixel diameter; numeric 1-D `c` remains a colormap encoding, while `N×3`/`N×4` face and edge colors, alpha arrays, sizes, and linewidth arrays stay in one collection. Explicit alpha replaces intrinsic RGBA alpha, matching Matplotlib; custom norms/marker paths fail loudly | | `bar`, `barh`, `grouped_bar`, `bar_label` | string categories, stacking bases, per-bar face/edge color-alpha pairs and linewidths, plus iterable/indexable `BarContainer.patches` views whose setters mutate the parent batched trace | -| `hist(bins=, range=, density=, cumulative=, weights=, orientation=, stacked=)` | Returns computed counts/edges; bar, step, and stepfilled families render in both vertical and horizontal orientations | +| `hist(bins=, range=, density=, cumulative=, weights=, orientation=, stacked=)` | Returns computed counts/edges; bar, step, and stepfilled families render in both vertical and horizontal orientations. Per-dataset hatch families use bin-local data geometry (including dots and rings), clamped inside each rectangle rather than fixed-size marker overlays that can leak beyond short bars | | `hist2d`, `hexbin`, `ecdf` | 2D uniform binning uses the native Rust kernel; hexbin uses Matplotlib's two-offset-grid nearest-center assignment and six-triangle data-space cells, supports `C`, arbitrary scalar reducers, and `mincnt`, and retains only the bounded lattice rather than source points. `hist2d` view limits are the outer bin edges with no margin, matching Matplotlib's sticky mesh edges; non-uniform bins delegate to `pcolormesh` and autoscale through the quad-mesh path instead. `ecdf` carries the ordinary margin on the sample axis and is sticky at 0 and 1 on the cumulative axis | | `boxplot`, `violinplot`, `bxp`, `violin`, `errorbar` | Boxplots support notches, bootstrap/user confidence intervals, median overrides (drawn median only; notch CIs stay data-derived like Matplotlib), percentile/custom whiskers, cap widths, `sym`, and component colors/widths/alpha — dashed component linestyles fail loudly. Violins support Scott/Silverman/scalar/callable Gaussian-KDE bandwidths, quantiles, and low/high sides; the default (bw_method omitted) uses the native histogram violin mark, whose shape differs from the explicit KDE path. `boxplot` autoscales its value axis over the Tukey whiskers plus, when `showfliers` is on, the flier points. Its default category positions are 1-based and `manage_ticks=True` reserves half a unit around the outer positions, matching Matplotlib | | `fill_between(x, y1, y2, where=, step=)` / `fill_betweenx` | Masks are split into finite contiguous polygons; step geometry is expanded exactly | @@ -67,7 +67,7 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent | `axhline` / `axvline` / `axhspan` / `axvspan`, `text`, `annotate`, `table` | Fractional span bounds plus data/axes/figure text coordinates are supported. `annotate(arrowprops=)` draws real arrows in every output: offset-point text becomes an engine callout (arrow pinned from label to point across zoom), data-coordinate text an arrow annotation; date-string coordinates convert on datetime axes. Arrowstyles map to head/tail shapes (`->` open V, `-\|>` filled, `\|-\|`/brackets bar caps, `fancy`/`simple`/`wedge` filled tapered shafts sized by the text's mutation scale) and `connectionstyle` arc3/angle3/angle become quadratic curves (corner rounding approximated); `alpha` dims the arrow only. `bbox=` becomes label box styles (fill/edge/round corners/`pad`) in browser and static exports; its `alpha` is the *patch* alpha and dims face and edge together, as Matplotlib's element `opacity` does, and `boxstyle="round"`/`round4` corners are rounded in SVG (`rx`) and native PNG as well as in the browser. An arrow-less `text`/`annotate` label is painted with `rcParams["text.color"]` in all three renderers rather than each renderer's own annotation-label default. Text is unclipped like Matplotlib (`clip_on=False`), and axes-fraction text right of the axes box (x > 1, e.g. seaborn-style row titles) reserves right margin in every exporter. `rotation=90/270` renders vertical text in browser, PNG, and SVG with Matplotlib's rotate-then-align box semantics; other angles rotate in browser and SVG output only (native PNG draws them horizontally) | | `from xy.pyplot import FacetGrid` (seaborn-shaped) | Row/column small multiples with seaborn's `map` contract (subset → activate panel → call the pyplot function), shared domains, edge-only axis labels, top-row column titles, and `margin_titles=True` rotated row titles. `hue=`/`palette=`, `col_wrap=`, `map_dataframe`, and `add_legend` fail loudly | | `xlabel` / `ylabel` / `title` / `suptitle` | Suptitles are retained in HTML and multi-panel PNG/SVG. `ylabel` sits clear of the y tick labels in every renderer: the left gutter is reserved from the measured advances of the tick labels and the rotated title rather than from a fixed constant, leaving Matplotlib's `0.4 em` (5.6 px at the 10 pt/100 dpi default) title-to-tick gap — see *Measured left gutter and the rotated y-axis title* in `spec/api/styling.md` for the formula and its two documented asymmetries | -| `legend()` | `loc`, columns, title/font size/colors, frame styling, `borderpad`, `labelspacing`, `borderaxespad`, `handlelength`, `handletextpad`, `fancybox`, `framealpha`, and `shadow` are retained across browser and static output. Handle geometry is measured in legend-font units, matching Matplotlib. `loc='best'` scores the measured displayed legend box in Matplotlib location-code order using path vertices/crossings, collection offsets, and bar-rectangle overlaps. It resolves before the wire; pyplot rejects misspelled Matplotlib locations without narrowing core `xy.legend()`'s independent location vocabulary. Text-box scoring and bounded long-path sampling are documented in `spec/api/styling.md` § Legend placement | +| `legend()` | `loc`, columns, title/font size/colors, frame styling, `borderpad`, `labelspacing`, `borderaxespad`, `handlelength`, `handletextpad`, `fancybox`, `framealpha`, and `shadow` are retained across browser and static output. Handle geometry is measured in legend-font units, matching Matplotlib; patch handles preserve fill plus outline, including hollow bars. `loc='best'` scores the measured displayed legend box in Matplotlib location-code order using path vertices/crossings, collection offsets, and bar-rectangle overlaps. Compact stairs and ECDF marks contribute their materialized display paths rather than their reversed/implicit storage arguments. It resolves before the wire; pyplot rejects misspelled Matplotlib locations without narrowing core `xy.legend()`'s independent location vocabulary. Text-box scoring and bounded long-path sampling are documented in `spec/api/styling.md` § Legend placement | | `grid(True/False)` | toggles the grid via the theme | | `xlim` / `ylim`, `set_xmargin` / `set_ymargin`, axis scales, `invert_xaxis/yaxis` | linear/log are native; symlog/logit/asinh use dependency-free monotone data transforms with inverse limit/tick semantics. Automatic linear ticks include Matplotlib's 2.5 step and use uniform decimal padding across a tick set; locations refresh as data arrives. `axes.autolimit_mode="round_numbers"` expands automatic linear limits to the first and last AutoLocator ticks after applying the configured margins. Artist `get_data()` reflects the transformed space; logit masks values at/outside (0, 1) | | `set_major_locator` / `set_major_formatter`, `plt.NullLocator/FixedLocator/MultipleLocator/MaxNLocator/LinearLocator/LogLocator`, `plt.NullFormatter/FixedFormatter/FuncFormatter/FormatStrFormatter/StrMethodFormatter/ScalarFormatter` | xy-owned re-implementations resolved at build time against live data limits (Null/Fixed/Multiple/Linear are position-exact; MaxN/Auto port Matplotlib's `MaxNLocator._raw_ticks` — same step tables, edge extension, and offset handling — with `nbins="auto"` budgeted from the estimated plot rect like `Axis.get_tick_space()`; Log remains approximate). Third-party locator objects work if they implement `tick_values(vmin, vmax)`; minor locators/formatters are retained for round-tripping but minor ticks do not render, except that a labeled minor pair under a blanked major formatter (the centered date-label idiom) is promoted to the drawn tick set | diff --git a/tests/pyplot/test_gallery_hist_errorbar_compat.py b/tests/pyplot/test_gallery_hist_errorbar_compat.py index 08d73d62..44cca16e 100644 --- a/tests/pyplot/test_gallery_hist_errorbar_compat.py +++ b/tests/pyplot/test_gallery_hist_errorbar_compat.py @@ -86,6 +86,30 @@ def test_hist_labels_are_padded_or_truncated_like_matplotlib( assert [container.get_label() for container in containers] == expected +def test_stacked_step_histogram_legend_follows_top_to_bottom_draw_order() -> None: + _fig, ax = plt.subplots() + labels = ["green", "red", "blue"] + _counts, _edges, containers = ax.hist( + [[-1.0, 0.0, 1.0], [-0.5, 0.5, 1.5], [0.0, 1.0, 2.0]], + bins=3, + fill=False, + histtype="step", + stacked=True, + edgecolor=labels, + label=labels, + ) + + # Return values stay in dataset order, but Matplotlib inserts the stacked + # step artists (and therefore their automatic legend handles) top-first. + assert [container.get_label() for container in containers] == labels + _handles, legend_labels = ax.get_legend_handles_labels() + assert legend_labels == ["blue", "red", "green"] + + ax.legend() + spec, _ = ax._build_chart(640, 480).figure().build_payload() + assert [trace.get("name") for trace in spec["traces"] if trace.get("name")] == legend_labels + + @pytest.mark.parametrize("histtype", ["step", "stepfilled"]) def test_hist_step_geometry_contributes_to_autoscale(histtype: str) -> None: _fig, ax = plt.subplots() @@ -203,3 +227,29 @@ def test_errorbar_limit_flags_render_directional_endpoint_markers() -> None: assert points["triangle_down"] == ([2], [3.4]) assert points["triangle_right"] == ([2.4], [4]) assert points["triangle_left"] == ([0.8], [3]) + + +def test_errorbar_legend_uses_one_container_handle_per_label() -> None: + _fig, ax = plt.subplots() + first = ax.errorbar( + [0.0, 0.5, 1.0], + [0.0, 0.25, 1.0], + yerr=0.1, + fmt="-", + label="xlolims=True", + ) + second = ax.errorbar( + [0.5, 1.0, 1.5], + [0.0, 0.25, 1.0], + yerr=0.1, + fmt="-", + label="subsets of xuplims and xlolims", + ) + + handles, labels = ax.get_legend_handles_labels() + assert handles == [first, second] + assert labels == ["xlolims=True", "subsets of xuplims and xlolims"] + + ax.legend() + spec, _ = ax._build_chart(640, 480).figure().build_payload() + assert [trace.get("name") for trace in spec["traces"] if trace.get("name")] == labels diff --git a/tests/pyplot/test_gallery_statistics_semantics.py b/tests/pyplot/test_gallery_statistics_semantics.py index 228a1733..ab0e7861 100644 --- a/tests/pyplot/test_gallery_statistics_semantics.py +++ b/tests/pyplot/test_gallery_statistics_semantics.py @@ -89,7 +89,28 @@ def test_hist_hatch_families_emit_visible_overlay_geometry() -> None: factories = [entry.get("factory", entry.get("kind")) for entry in ax._entries] assert "segments" in factories - assert "scatter" in factories + assert "scatter" not in factories + hatch_entries = [entry for entry in ax._entries if entry.get("factory") == "segments"] + assert {entry["kwargs"]["color"] for entry in hatch_entries} == {"black"} + + +def test_hist_ring_hatches_stay_inside_each_bar_rectangle() -> None: + _fig, ax = plt.subplots() + counts, edges, _bars = ax.hist( + [-0.9, -0.8, -0.7, 0.1, 0.2, 0.8], + bins=[-1.0, -0.5, 0.0, 0.5, 1.0], + hatch="o", + ) + + hatch = next(entry for entry in ax._entries if entry.get("factory") == "segments") + endpoint_x = np.concatenate((hatch["args"][0], hatch["args"][2])) + endpoint_y = np.concatenate((hatch["args"][1], hatch["args"][3])) + assert hatch["kwargs"]["color"] == "black" + assert np.all(endpoint_y >= 0.0) + for x_value, y_value in zip(endpoint_x, endpoint_y, strict=True): + containing = np.flatnonzero((edges[:-1] <= x_value) & (x_value <= edges[1:])) + assert len(containing) + assert y_value <= np.max(counts[containing]) + 1e-12 def test_hist_per_dataset_linestyles_emit_distinct_outlines() -> None: diff --git a/tests/pyplot/test_grid_legend_contracts.py b/tests/pyplot/test_grid_legend_contracts.py index bb57adff..828972d0 100644 --- a/tests/pyplot/test_grid_legend_contracts.py +++ b/tests/pyplot/test_grid_legend_contracts.py @@ -194,3 +194,54 @@ def test_center_band_legend_loc_reaches_spec(): ax.legend(["a", "b"]) spec, _ = ax._build_chart(573, 400).figure().build_payload() assert spec["legend"]["loc"] == "center left" + + +def test_best_legend_materializes_cumulative_histogram_and_ecdf_paths(): + import numpy as np + + np.random.seed(19680801) + mean = 200 + sigma = 25 + data = np.random.normal(mean, sigma, size=100) + fig = plt.figure(figsize=(9, 4), layout="constrained") + axes = fig.subplots(1, 2, sharex=True, sharey=True) + + axes[0].ecdf(data, label="CDF") + _counts, bins, _patches = axes[0].hist( + data, + 25, + density=True, + histtype="step", + cumulative=True, + label="Cumulative histogram", + ) + x = np.linspace(data.min(), data.max()) + y = (1 / (np.sqrt(2 * np.pi) * sigma)) * np.exp(-0.5 * (1 / sigma * (x - mean)) ** 2) + y = y.cumsum() + y /= y[-1] + axes[0].plot(x, y, "k--", linewidth=1.5, label="Theory") + + axes[1].ecdf(data, complementary=True, label="CCDF") + axes[1].hist( + data, + bins=bins, + density=True, + histtype="step", + cumulative=-1, + label="Reversed cumulative histogram", + ) + axes[1].plot(x, 1 - y, "k--", linewidth=1.5, label="Theory") + + for ax in axes: + ax.legend() + # These are the gallery's Matplotlib axes dimensions. XY's current + # constrained-layout fallback produces a narrower panel; that independent + # layout discrepancy is deliberately not hidden by the scorer. + locations = [ + ax._best_legend_loc( + legend_options=ax._legend_options, + plot_size=(348.75, 308.0), + ) + for ax in axes + ] + assert locations == ["upper left", "lower left"] diff --git a/tests/pyplot/test_line_legend_gallery_compat.py b/tests/pyplot/test_line_legend_gallery_compat.py index f6a94fff..25f36d76 100644 --- a/tests/pyplot/test_line_legend_gallery_compat.py +++ b/tests/pyplot/test_line_legend_gallery_compat.py @@ -7,7 +7,7 @@ import pytest import xy.pyplot as plt -from xy import _raster +from xy import _raster, _svg from xy._svg import _LEGEND_CHAR_WIDTH, _legend_layout, _legend_text_width, layout from xy.pyplot import Legend @@ -566,3 +566,88 @@ def point(self, _x, _y, _r, symbol, _fill, width, stroke): (_raster._SYMBOLS["plus_line"], 1.0, (18, 52, 86, 255)), (_raster._SYMBOLS["x_line"], 2.5, (255, 0, 0, 255)), ] + + +def test_hollow_patch_legend_swatch_preserves_its_outline_in_every_renderer(): + class Recorder: + def __init__(self): + self.fills = [] + self.strokes = [] + + def fill(self, points, color): + self.fills.append((list(points), color)) + + def stroke(self, points, width, color, closed=False, dash=None, cap="round"): + self.strokes.append((list(points), width, color, closed)) + + def text(self, *_args, **_kwargs): + pass + + named = [ + { + "name": "hollow", + "kind": "bar", + "style": { + "color": "transparent", + "opacity": 1.0, + "stroke": "green", + "stroke_width": 2.0, + }, + } + ] + plot = {"x": 0.0, "y": 0.0, "w": 240.0, "h": 160.0} + + recorder = Recorder() + _raster._emit_legend(recorder, named, plot, {"style": {"background": "transparent"}}) + assert recorder.strokes == [ + ( + recorder.fills[0][0], + 2.0, + (0, 128, 0, 255), + True, + ) + ] + + svg = ElementTree.fromstring( + _svg._legend( + named, + plot, + {"style": {"background": "transparent"}}, + "legend-clip", + "black", + ["#1f77b4"], + ) + ) + swatches = [ + element + for element in svg.iter() + if element.tag.endswith("rect") and element.attrib.get("fill") == "transparent" + ] + assert [swatch.attrib.get("stroke") for swatch in swatches] == ["green"] + assert [swatch.attrib.get("stroke-width") for swatch in swatches] == ["2"] + + +def test_explicit_hollow_bar_legend_item_keeps_patch_stroke(): + _, ax = plt.subplots() + _counts, _edges, container = ax.hist( + [0.0, 1.0, 2.0], + bins=2, + fill=False, + edgecolor="green", + linewidth=2, + label="hollow", + ) + legend = Legend(ax, [container], ["hollow"]) + + assert legend.spec()["items"] == [ + { + "name": "hollow", + "kind": "bar", + "style": { + "color": "transparent", + "opacity": 1.0, + "stroke": "green", + "stroke_width": 2.0, + }, + } + ] diff --git a/tests/test_text_weight_defaults.py b/tests/test_text_weight_defaults.py index b4e2bdd9..5253316b 100644 --- a/tests/test_text_weight_defaults.py +++ b/tests/test_text_weight_defaults.py @@ -234,3 +234,11 @@ def test_browser_legend_handle_geometry_uses_font_relative_options() -> None: assert 'sw.style.setProperty("--xy-legend-swatch-width", `${handleLength}em`)' in source assert 'sw.style.setProperty("--xy-legend-swatch-margin-right", `${handleTextPad}em`)' in source assert 'svg.setAttribute("width", "100%")' in source + + +def test_browser_patch_legend_swatch_preserves_hollow_outlines() -> None: + source = (_JS / "50_chartview.ts").read_text(encoding="utf-8") + + assert "if (it.style?.stroke && strokeWidth > 0)" in source + assert "sw.style.borderWidth = `${strokeWidth}px`" in source + assert "sw.style.borderColor = safeCssPaint(this.root, it.style.stroke)" in source From f079bede14a68434c2729b5dd5bf9fa7fb7e8778 Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Mon, 27 Jul 2026 09:56:36 -0700 Subject: [PATCH 07/10] Format pyplot axes with Ruff --- python/xy/pyplot/_axes.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py index 85a2f87a..81204e99 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -7444,8 +7444,7 @@ def keys(a: float) -> np.ndarray: beta = 6.33 weights = np.where( absolute < radius, - np.i0(beta * np.sqrt(np.maximum(0.0, 1.0 - (absolute / radius) ** 2))) - / np.i0(beta), + np.i0(beta * np.sqrt(np.maximum(0.0, 1.0 - (absolute / radius) ** 2))) / np.i0(beta), 0.0, ) elif method == "sinc": From f963908967b399193b9ce133f6198796130dfeaa Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Mon, 27 Jul 2026 10:20:36 -0700 Subject: [PATCH 08/10] Fix remaining gallery compatibility gates --- python/xy/pyplot/_artists.py | 2 +- python/xy/pyplot/_axes.py | 20 ++++++++++++- python/xy/pyplot/_colors.py | 28 +++++++++++++++++++ python/xy/pyplot/_fmt.py | 7 ++++- python/xy/pyplot/_plot_types.py | 15 ++++++---- spec/matplotlib/compat.md | 4 +-- tests/pyplot/test_axes_charts.py | 23 +++++++++++++++ tests/pyplot/test_fmt.py | 5 ++++ .../test_gallery_hist_errorbar_compat.py | 11 ++++++++ tests/pyplot/test_launch_compat.py | 17 +++++++++++ 10 files changed, 121 insertions(+), 11 deletions(-) diff --git a/python/xy/pyplot/_artists.py b/python/xy/pyplot/_artists.py index 59c50e88..8caa1b3c 100644 --- a/python/xy/pyplot/_artists.py +++ b/python/xy/pyplot/_artists.py @@ -967,7 +967,7 @@ def get_label(self) -> Any: return self._artist._entry["kwargs"].get("name") def set_label(self, value: Any) -> None: - self._artist._entry["kwargs"]["name"] = value + self._artist._entry["kwargs"]["name"] = str(value) self._artist._touch() def remove(self) -> None: diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py index 81204e99..14db393f 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -5307,12 +5307,17 @@ def legend(self, *args: Any, **kwargs: Any) -> Any: ``prop={"size": ...}``), ``labelcolor``, ``frameon``, ``facecolor``, ``edgecolor``, ``framealpha``, ``fancybox``, ``shadow``, ``borderpad``, and ``labelspacing``; unsupported layout keywords - raise loudly. ``loc="best"`` picks the least occupied corner. + raise loudly. ``reverse=True`` reverses the resolved handle/label + order. ``loc="best"`` picks the least occupied corner. """ host = self._y2_of or self + reverse = bool(kwargs.pop("reverse", False)) if len(args) >= 2: handles = list(args[0]) labels = [_plain_text(label) for label in args[1]] + if reverse: + handles.reverse() + labels.reverse() legend_artist = Legend(host, handles, labels, **kwargs) host._legend_handle = legend_artist # An explicit handles/labels call defines the primary legend even @@ -5340,14 +5345,27 @@ def legend(self, *args: Any, **kwargs: Any) -> Any: host._legend_artist = None host._legend_items = None handles, labels = host.get_legend_handles_labels() + if reverse: + handles.reverse() + labels.reverse() host._legend_handle = Legend(host, handles, labels, **kwargs) host._legend_options = dict(host._legend_handle._options) + if reverse: + # Automatic legends ordinarily let the renderer derive items + # from trace order. Freeze the reversed result so this call's + # explicit ordering survives materialization. + host._legend_items = list(host._legend_handle.spec()["items"]) else: host._legend_artist = None host._legend_items = None handles, labels = host.get_legend_handles_labels() + if reverse: + handles.reverse() + labels.reverse() host._legend_handle = Legend(host, handles, labels, **kwargs) host._legend_options = dict(host._legend_handle._options) + if reverse: + host._legend_items = list(host._legend_handle.spec()["items"]) host._legend = True host._invalidate() return host._legend_handle diff --git a/python/xy/pyplot/_colors.py b/python/xy/pyplot/_colors.py index ad99023c..d2a312ea 100644 --- a/python/xy/pyplot/_colors.py +++ b/python/xy/pyplot/_colors.py @@ -65,6 +65,13 @@ def scalar_float(value: Any) -> float: "tab:cyan": "#17becf", } +# The gallery currently exercises this XKCD color as a standalone ``plot`` +# format string. Keep the dependency boundary explicit: pyplot must not import +# Matplotlib just to resolve its named-color table. +_XKCD = { + "xkcd:crimson": "#8c000f", +} + # matplotlib colormap names the engine knows (identity), plus common aliases. CMAPS = { "viridis": "viridis", @@ -326,6 +333,8 @@ def resolve_color(value: object) -> Optional[str]: return _SINGLE_LETTER[value] if value in _TAB: return _TAB[value] + if value.lower() in _XKCD: + return _XKCD[value.lower()] if value.lower() == "none": return "transparent" # matplotlib gray shorthand: a float in a string, "0.0" black - "1.0" white. @@ -359,6 +368,25 @@ def resolve_rgba(value: object) -> tuple[float, float, float, float]: return rgba +def is_color_like(value: object) -> bool: + """Whether *value* is one complete color spec understood by pyplot.""" + if isinstance(value, str): + try: + gray = float(value) + except ValueError: + pass + else: + # Matplotlib gray-string colors are bounded to [0, 1]. Keeping + # that check here is important for fmt strings: "2"/"3"/"4"/"8" + # are marker tokens, not clamped grayscale colors. + return bool(np.isfinite(gray) and 0.0 <= gray <= 1.0) + try: + resolve_rgba(value) + except (TypeError, ValueError): + return False + return True + + def resolve_rgba_array(values: object, n: int, label: str) -> np.ndarray: """Resolve one color or N color specs into an ``(N, 4)`` float array.""" try: diff --git a/python/xy/pyplot/_fmt.py b/python/xy/pyplot/_fmt.py index 042735e0..ce31479e 100644 --- a/python/xy/pyplot/_fmt.py +++ b/python/xy/pyplot/_fmt.py @@ -9,6 +9,8 @@ from typing import Optional +from ._colors import is_color_like + _LINESTYLES = ("--", "-.", "-", ":") # two-char tokens first _MARKERS = set(".,ov^<>12348spP*hH+xXDd|_") @@ -18,7 +20,10 @@ def parse_fmt(fmt: str) -> tuple[Optional[str], Optional[str], Optional[str]]: """Return (color, linestyle, marker); raises on unparseable input.""" - if len(fmt) > 1 and fmt.lower() in {"grey", "gray", "black", "white"}: + # Matplotlib first asks whether the *whole* string is a color, before + # interpreting MATLAB-style marker/line/color tokens. The "0"/"1" + # exceptions preserve their historical marker-token treatment. + if fmt not in {"0", "1"} and is_color_like(fmt): return fmt, None, None color: Optional[str] = None linestyle: Optional[str] = None diff --git a/python/xy/pyplot/_plot_types.py b/python/xy/pyplot/_plot_types.py index 873d9231..d64473e3 100644 --- a/python/xy/pyplot/_plot_types.py +++ b/python/xy/pyplot/_plot_types.py @@ -1976,14 +1976,17 @@ def _stairs_hatch( right_values = np.asarray(right_edges, dtype=np.float64) if not (len(values) == len(bases) == len(left_edges) == len(right_values)): raise ValueError("hatch geometry must have one rectangle per value") - x_extent = np.concatenate((left_edges, right_values)) - y_extent = np.concatenate( + edge_extent = np.concatenate((left_edges, right_values)) + value_extent = np.concatenate( (np.asarray(values, dtype=np.float64), np.asarray(bases, dtype=np.float64)) ) - finite_x_extent = x_extent[np.isfinite(x_extent)] - finite_y_extent = y_extent[np.isfinite(y_extent)] - x_span = float(np.ptp(finite_x_extent)) if finite_x_extent.size else 0.0 - y_span = float(np.ptp(finite_y_extent)) if finite_y_extent.size else 0.0 + finite_edge_extent = edge_extent[np.isfinite(edge_extent)] + finite_value_extent = value_extent[np.isfinite(value_extent)] + edge_span = float(np.ptp(finite_edge_extent)) if finite_edge_extent.size else 0.0 + value_span = float(np.ptp(finite_value_extent)) if finite_value_extent.size else 0.0 + x_span, y_span = ( + (edge_span, value_span) if orientation == "vertical" else (value_span, edge_span) + ) pattern = set(hatch) density = max(1, min(3, max((hatch.count(char) for char in pattern), default=1))) line_count = 4 + density * 3 diff --git a/spec/matplotlib/compat.md b/spec/matplotlib/compat.md index ec09059a..e5d91201 100644 --- a/spec/matplotlib/compat.md +++ b/spec/matplotlib/compat.md @@ -50,7 +50,7 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent | matplotlib | notes | |---|---| -| `plt.plot` / `ax.plot` | format strings (`'r--o'`), multiple series per call, implicit x, `label=`, `lw=`, `ls=`, `alpha=`, marker face/edge styling, directional `^`/`v`/`<`/`>` triangles and distinct `+`/`x` glyphs, `markevery`, and dependency-free affine *data* transforms (`Affine2D + ax.transData`); axes/figure-fraction transforms on data artists, partial fill styles, and cap/join policies fail loudly | +| `plt.plot` / `ax.plot` | format strings (`'r--o'`) and complete standalone color specs (CSS/tab colors plus the gallery's `xkcd:crimson`), multiple series per call, implicit x, `label=`, `lw=`, `ls=`, `alpha=`, marker face/edge styling, directional `^`/`v`/`<`/`>` triangles and distinct `+`/`x` glyphs, `markevery`, and dependency-free affine *data* transforms (`Affine2D + ax.transData`); axes/figure-fraction transforms on data artists, partial fill styles, and cap/join policies fail loudly | | `scatter(x, y, s=, c=, cmap=, vmin=, vmax=, alpha=, marker=, edgecolors=, plotnonfinite=)` | `s` (pt², area) maps to pixel diameter; numeric 1-D `c` remains a colormap encoding, while `N×3`/`N×4` face and edge colors, alpha arrays, sizes, and linewidth arrays stay in one collection. Explicit alpha replaces intrinsic RGBA alpha, matching Matplotlib; custom norms/marker paths fail loudly | | `bar`, `barh`, `grouped_bar`, `bar_label` | string categories, stacking bases, per-bar face/edge color-alpha pairs and linewidths, plus iterable/indexable `BarContainer.patches` views whose setters mutate the parent batched trace | | `hist(bins=, range=, density=, cumulative=, weights=, orientation=, stacked=)` | Returns computed counts/edges; bar, step, and stepfilled families render in both vertical and horizontal orientations. Per-dataset hatch families use bin-local data geometry (including dots and rings), clamped inside each rectangle rather than fixed-size marker overlays that can leak beyond short bars | @@ -67,7 +67,7 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent | `axhline` / `axvline` / `axhspan` / `axvspan`, `text`, `annotate`, `table` | Fractional span bounds plus data/axes/figure text coordinates are supported. `annotate(arrowprops=)` draws real arrows in every output: offset-point text becomes an engine callout (arrow pinned from label to point across zoom), data-coordinate text an arrow annotation; date-string coordinates convert on datetime axes. Arrowstyles map to head/tail shapes (`->` open V, `-\|>` filled, `\|-\|`/brackets bar caps, `fancy`/`simple`/`wedge` filled tapered shafts sized by the text's mutation scale) and `connectionstyle` arc3/angle3/angle become quadratic curves (corner rounding approximated); `alpha` dims the arrow only. `bbox=` becomes label box styles (fill/edge/round corners/`pad`) in browser and static exports; its `alpha` is the *patch* alpha and dims face and edge together, as Matplotlib's element `opacity` does, and `boxstyle="round"`/`round4` corners are rounded in SVG (`rx`) and native PNG as well as in the browser. An arrow-less `text`/`annotate` label is painted with `rcParams["text.color"]` in all three renderers rather than each renderer's own annotation-label default. Text is unclipped like Matplotlib (`clip_on=False`), and axes-fraction text right of the axes box (x > 1, e.g. seaborn-style row titles) reserves right margin in every exporter. `rotation=90/270` renders vertical text in browser, PNG, and SVG with Matplotlib's rotate-then-align box semantics; other angles rotate in browser and SVG output only (native PNG draws them horizontally) | | `from xy.pyplot import FacetGrid` (seaborn-shaped) | Row/column small multiples with seaborn's `map` contract (subset → activate panel → call the pyplot function), shared domains, edge-only axis labels, top-row column titles, and `margin_titles=True` rotated row titles. `hue=`/`palette=`, `col_wrap=`, `map_dataframe`, and `add_legend` fail loudly | | `xlabel` / `ylabel` / `title` / `suptitle` | Suptitles are retained in HTML and multi-panel PNG/SVG. `ylabel` sits clear of the y tick labels in every renderer: the left gutter is reserved from the measured advances of the tick labels and the rotated title rather than from a fixed constant, leaving Matplotlib's `0.4 em` (5.6 px at the 10 pt/100 dpi default) title-to-tick gap — see *Measured left gutter and the rotated y-axis title* in `spec/api/styling.md` for the formula and its two documented asymmetries | -| `legend()` | `loc`, columns, title/font size/colors, frame styling, `borderpad`, `labelspacing`, `borderaxespad`, `handlelength`, `handletextpad`, `fancybox`, `framealpha`, and `shadow` are retained across browser and static output. Handle geometry is measured in legend-font units, matching Matplotlib; patch handles preserve fill plus outline, including hollow bars. `loc='best'` scores the measured displayed legend box in Matplotlib location-code order using path vertices/crossings, collection offsets, and bar-rectangle overlaps. Compact stairs and ECDF marks contribute their materialized display paths rather than their reversed/implicit storage arguments. It resolves before the wire; pyplot rejects misspelled Matplotlib locations without narrowing core `xy.legend()`'s independent location vocabulary. Text-box scoring and bounded long-path sampling are documented in `spec/api/styling.md` § Legend placement | +| `legend()` | `loc`, columns, reversed handle order (`reverse=True`), title/font size/colors, frame styling, `borderpad`, `labelspacing`, `borderaxespad`, `handlelength`, `handletextpad`, `fancybox`, `framealpha`, and `shadow` are retained across browser and static output. Handle geometry is measured in legend-font units, matching Matplotlib; patch handles preserve fill plus outline, including hollow bars. `loc='best'` scores the measured displayed legend box in Matplotlib location-code order using path vertices/crossings, collection offsets, and bar-rectangle overlaps. Compact stairs and ECDF marks contribute their materialized display paths rather than their reversed/implicit storage arguments. It resolves before the wire; pyplot rejects misspelled Matplotlib locations without narrowing core `xy.legend()`'s independent location vocabulary. Text-box scoring and bounded long-path sampling are documented in `spec/api/styling.md` § Legend placement | | `grid(True/False)` | toggles the grid via the theme | | `xlim` / `ylim`, `set_xmargin` / `set_ymargin`, axis scales, `invert_xaxis/yaxis` | linear/log are native; symlog/logit/asinh use dependency-free monotone data transforms with inverse limit/tick semantics. Automatic linear ticks include Matplotlib's 2.5 step and use uniform decimal padding across a tick set; locations refresh as data arrives. `axes.autolimit_mode="round_numbers"` expands automatic linear limits to the first and last AutoLocator ticks after applying the configured margins. Artist `get_data()` reflects the transformed space; logit masks values at/outside (0, 1) | | `set_major_locator` / `set_major_formatter`, `plt.NullLocator/FixedLocator/MultipleLocator/MaxNLocator/LinearLocator/LogLocator`, `plt.NullFormatter/FixedFormatter/FuncFormatter/FormatStrFormatter/StrMethodFormatter/ScalarFormatter` | xy-owned re-implementations resolved at build time against live data limits (Null/Fixed/Multiple/Linear are position-exact; MaxN/Auto port Matplotlib's `MaxNLocator._raw_ticks` — same step tables, edge extension, and offset handling — with `nbins="auto"` budgeted from the estimated plot rect like `Axis.get_tick_space()`; Log remains approximate). Third-party locator objects work if they implement `tick_values(vmin, vmax)`; minor locators/formatters are retained for round-tripping but minor ticks do not render, except that a labeled minor pair under a blanked major formatter (the centered date-label idiom) is promoted to the drawn tick set | diff --git a/tests/pyplot/test_axes_charts.py b/tests/pyplot/test_axes_charts.py index 1ea5de2e..1b4077a1 100644 --- a/tests/pyplot/test_axes_charts.py +++ b/tests/pyplot/test_axes_charts.py @@ -41,6 +41,14 @@ def test_fmt_string_color_dash_marker() -> None: assert traces[1].kind == "scatter" # marker overlay +def test_plot_accepts_a_complete_xkcd_color_as_the_fmt_string() -> None: + _fig, ax = plt.subplots() + line = ax.plot([0, 1], [1, 2], "xkcd:crimson")[0] + + assert line._entry["kwargs"]["color"] == "#8c000f" + assert "dash" not in line._entry["kwargs"] + + def test_markers_only_fmt_is_scatter() -> None: _fig, ax = plt.subplots() ax.plot([0, 1], [1, 2], "go") @@ -243,6 +251,21 @@ def test_stackplot_uses_native_stacked_bounds(baseline) -> None: assert [trace.name for trace in traces] == ["a", "b"] +def test_automatic_stackplot_legend_reverse_freezes_reversed_items() -> None: + _fig, ax = plt.subplots() + ax.stackplot( + [0, 1, 2], + [1, 2, 3], + [3, 2, 1], + labels=["lower", "upper"], + ) + + ax.legend(reverse=True) + spec, _ = ax._build_chart(640, 480).figure().build_payload() + + assert [item["name"] for item in spec["legend"]["items"]] == ["upper", "lower"] + + def test_pcolormesh_accepts_rectilinear_edges() -> None: _fig, ax = plt.subplots() ax.pcolormesh([0, 1, 2], [0, 2, 4], np.array([[1.0, 2.0], [3.0, 4.0]])) diff --git a/tests/pyplot/test_fmt.py b/tests/pyplot/test_fmt.py index bc692c36..b00e3b4f 100644 --- a/tests/pyplot/test_fmt.py +++ b/tests/pyplot/test_fmt.py @@ -22,6 +22,11 @@ ("", (None, None, None)), ("D-", (None, "-", "D")), ("x:", (None, ":", "x")), + ("2", (None, None, "2")), + ("0.5", ("0.5", None, None)), + ("orchid", ("orchid", None, None)), + ("tab:purple", ("tab:purple", None, None)), + ("xkcd:crimson", ("xkcd:crimson", None, None)), ], ) def test_parse(fmt: str, expected: tuple) -> None: diff --git a/tests/pyplot/test_gallery_hist_errorbar_compat.py b/tests/pyplot/test_gallery_hist_errorbar_compat.py index 44cca16e..518a8cd2 100644 --- a/tests/pyplot/test_gallery_hist_errorbar_compat.py +++ b/tests/pyplot/test_gallery_hist_errorbar_compat.py @@ -86,6 +86,17 @@ def test_hist_labels_are_padded_or_truncated_like_matplotlib( assert [container.get_label() for container in containers] == expected +def test_errorbar_container_set_label_coerces_to_text() -> None: + _fig, ax = plt.subplots() + container = ax.errorbar([0, 1], [1, 2], yerr=0.1) + + container.set_label(42) + + assert container.get_label() == "42" + _handles, labels = ax.get_legend_handles_labels() + assert labels == ["42"] + + def test_stacked_step_histogram_legend_follows_top_to_bottom_draw_order() -> None: _fig, ax = plt.subplots() labels = ["green", "red", "blue"] diff --git a/tests/pyplot/test_launch_compat.py b/tests/pyplot/test_launch_compat.py index fa40598e..629a27e1 100644 --- a/tests/pyplot/test_launch_compat.py +++ b/tests/pyplot/test_launch_compat.py @@ -86,6 +86,23 @@ def test_filled_stairs_use_seamless_bins_and_hatches_are_not_dropped() -> None: assert len({len(values) for values in hatch["args"]}) == 1 +def test_horizontal_stairs_ring_hatch_uses_oriented_data_spans() -> None: + _fig, ax = plt.subplots() + ax.stairs([100.0], [0.0, 1.0], orientation="horizontal", hatch="o") + + hatch = [entry for entry in ax._entries if entry.get("factory") == "segments"][-1] + x0, y0, x1, y1 = (np.asarray(values[:10]) for values in hatch["args"]) + ring_x = np.concatenate((x0, x1)) + ring_y = np.concatenate((y0, y1)) + + # The first ten segments are one ring. Its x radius scales from the + # 100-unit value span while its y radius scales from the one-unit edge + # span; using the vertical spans here makes it nearly flat in x and far + # too tall in y. + assert np.ptp(ring_x) == pytest.approx(1.25) + assert np.ptp(ring_y) == pytest.approx(2.0 / 120.0 * np.sin(2.0 * np.pi / 5.0)) + + def test_adding_external_step_patch_does_not_advance_color_cycle() -> None: pytest.importorskip("matplotlib") from matplotlib.patches import StepPatch as MatplotlibStepPatch From 3abba8e1a028b124609a52690a3b98a1f813ea39 Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Mon, 27 Jul 2026 10:59:18 -0700 Subject: [PATCH 09/10] Keep compatibility ledgers out of the gallery PR --- spec/matplotlib/compat-changelog.md | 38 +++++++++++++++++++---------- spec/matplotlib/compat.md | 13 +++++----- 2 files changed, 32 insertions(+), 19 deletions(-) diff --git a/spec/matplotlib/compat-changelog.md b/spec/matplotlib/compat-changelog.md index a00ee5f3..94cb1230 100644 --- a/spec/matplotlib/compat-changelog.md +++ b/spec/matplotlib/compat-changelog.md @@ -4,19 +4,31 @@ This changelog records changes to the upstream compatibility target and to the meaning of xy's compatibility levels. It complements the project changelog, which covers user-visible releases across the whole package. -## Legend and histogram gallery corrections — 2026-07-27 - -- Errorbar containers contribute one automatic legend entry instead of - duplicating the bars and their private data line. -- `loc="best"` scores the rendered stairs and compact ECDF paths instead of - treating their compact storage arguments as ordinary x/y vertices. -- Stacked step histograms draw and list their outlines top-to-bottom while - keeping returned containers in input-dataset order. -- Histogram dot/ring hatches use bounded per-bin geometry and the patch-edge - color, so short tail bars no longer leak fixed-size glyphs across the - baseline. -- Hollow patch legend handles preserve their source stroke in browser, SVG, - and native raster output. +## Box and violin default geometry — 2026-07-26 (Matplotlib 3.11.1 reference) + +- `xy.pyplot.boxplot` no longer routes its default call through the native + opinionated box mark. It now draws Matplotlib's unfilled line geometry and + returns one box, median, and flier handle plus two whisker and cap handles per + group. Fliers stay centered on their group even when several groups are + present, and empty groups of fliers still have the expected handle. +- `xy.pyplot.violinplot` now uses the same Gaussian-KDE path for its default + Scott bandwidth as it does for explicit Scott, Silverman, scalar, and + callable bandwidths. It returns one body per group, with triangle joins + marked as a single fill so browser, PNG, and SVG output suppress internal + seams. +- The public composition API keeps its independent native `box` and `violin` + marks and their opinionated styling; this compatibility correction is + contained inside `xy.pyplot`. + +## Histogram and spectral numeric semantics — 2026-07-26 (Matplotlib 3.11.1 reference) + +- `hist(density=True, stacked=True)` now bins raw per-dataset mass, stacks it, + and normalizes the combined top envelope once. Unequal bin widths, weights, + and both cumulative directions match Matplotlib 3.11.1 numeric outputs. +- The native Welch paths behind `psd`, `csd`, `cohere`, and `specgram` no + longer subtract each segment mean by default. Their omitted/`None` + `detrend` behavior is Matplotlib's `detrend_none`; unsupported explicit + detrending modes continue to fail loudly at the pyplot boundary. ## Vector-field gallery corrections — 2026-07-24 diff --git a/spec/matplotlib/compat.md b/spec/matplotlib/compat.md index e5d91201..82745b09 100644 --- a/spec/matplotlib/compat.md +++ b/spec/matplotlib/compat.md @@ -50,15 +50,16 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent | matplotlib | notes | |---|---| -| `plt.plot` / `ax.plot` | format strings (`'r--o'`) and complete standalone color specs (CSS/tab colors plus the gallery's `xkcd:crimson`), multiple series per call, implicit x, `label=`, `lw=`, `ls=`, `alpha=`, marker face/edge styling, directional `^`/`v`/`<`/`>` triangles and distinct `+`/`x` glyphs, `markevery`, and dependency-free affine *data* transforms (`Affine2D + ax.transData`); axes/figure-fraction transforms on data artists, partial fill styles, and cap/join policies fail loudly | +| `plt.plot` / `ax.plot` | format strings (`'r--o'`), multiple series per call, implicit x, `label=`, `lw=`, `ls=`, `alpha=`, marker face/edge styling, directional `^`/`v`/`<`/`>` triangles and distinct `+`/`x` glyphs, `markevery`, and dependency-free affine *data* transforms (`Affine2D + ax.transData`); axes/figure-fraction transforms on data artists, partial fill styles, and cap/join policies fail loudly | | `scatter(x, y, s=, c=, cmap=, vmin=, vmax=, alpha=, marker=, edgecolors=, plotnonfinite=)` | `s` (pt², area) maps to pixel diameter; numeric 1-D `c` remains a colormap encoding, while `N×3`/`N×4` face and edge colors, alpha arrays, sizes, and linewidth arrays stay in one collection. Explicit alpha replaces intrinsic RGBA alpha, matching Matplotlib; custom norms/marker paths fail loudly | | `bar`, `barh`, `grouped_bar`, `bar_label` | string categories, stacking bases, per-bar face/edge color-alpha pairs and linewidths, plus iterable/indexable `BarContainer.patches` views whose setters mutate the parent batched trace | -| `hist(bins=, range=, density=, cumulative=, weights=, orientation=, stacked=)` | Returns computed counts/edges; bar, step, and stepfilled families render in both vertical and horizontal orientations. Per-dataset hatch families use bin-local data geometry (including dots and rings), clamped inside each rectangle rather than fixed-size marker overlays that can leak beyond short bars | -| `hist2d`, `hexbin`, `ecdf` | 2D uniform binning uses the native Rust kernel; hexbin uses Matplotlib's two-offset-grid nearest-center assignment and six-triangle data-space cells, supports `C`, arbitrary scalar reducers, and `mincnt`, and retains only the bounded lattice rather than source points. `hist2d` view limits are the outer bin edges with no margin, matching Matplotlib's sticky mesh edges; non-uniform bins delegate to `pcolormesh` and autoscale through the quad-mesh path instead. `ecdf` carries the ordinary margin on the sample axis and is sticky at 0 and 1 on the cumulative axis | -| `boxplot`, `violinplot`, `bxp`, `violin`, `errorbar` | Boxplots support notches, bootstrap/user confidence intervals, median overrides (drawn median only; notch CIs stay data-derived like Matplotlib), percentile/custom whiskers, cap widths, `sym`, and component colors/widths/alpha — dashed component linestyles fail loudly. Violins support Scott/Silverman/scalar/callable Gaussian-KDE bandwidths, quantiles, and low/high sides; the default (bw_method omitted) uses the native histogram violin mark, whose shape differs from the explicit KDE path. `boxplot` autoscales its value axis over the Tukey whiskers plus, when `showfliers` is on, the flier points. Its default category positions are 1-based and `manage_ticks=True` reserves half a unit around the outer positions, matching Matplotlib | +| `hist(bins=, range=, density=, cumulative=, weights=, orientation=, stacked=)` | Returns computed counts/edges; stacked density normalizes the combined weighted area once (including unequal bins and either cumulative direction), matching Matplotlib 3.11; bar, step, and stepfilled families render in both vertical and horizontal orientations; unfilled step outlines connect their top envelope to zero or the previous stack at both endpoints | +| `hist2d`, `hexbin`, `ecdf` | 2D uniform binning uses the native Rust kernel. `hist2d` delegates rendering to the pseudocolor-mesh path for both uniform and non-uniform bins, supports linear and logarithmic normalization, defaults to fully opaque cells, and retains the original count domain for logarithmic mappables. Its view limits are the outer bin edges with no margin, matching Matplotlib's sticky mesh edges. Arbitrary custom normalization and `colorizer` remain unsupported. Hexbin uses Matplotlib's two-offset-grid nearest-center assignment and six-triangle data-space cells, supports `C`, arbitrary scalar reducers, and `mincnt`, and retains only the bounded lattice rather than source points. `ecdf` carries the ordinary margin on the sample axis and is sticky at 0 and 1 on the cumulative axis | +| `boxplot`, `violinplot`, `bxp`, `violin`, `errorbar` | Boxplots support notches, bootstrap/user confidence intervals, median overrides (drawn median only; notch CIs stay data-derived like Matplotlib), percentile/custom whiskers, cap widths, `sym`, dashed line-component styles, and component colors/widths/alpha. Default boxes are unfilled outlines and return Matplotlib-shaped per-group component handles (two whiskers/caps and one box/median/flier handle per group). `patch_artist=True` emits mutable filled polygon boxes; statistics labels become category tick labels, while scalar or per-box legend labels bind to boxes for patch plots and medians otherwise. Violins use Gaussian KDE for the default Scott bandwidth and explicit Scott/Silverman/scalar/callable bandwidths, return one seam-free mutable body per group, cycle face and line color sequences, preserve color-alpha pairs, and support quantiles and low/high sides. `boxplot` autoscales its value axis over the Tukey whiskers plus, when `showfliers` is on, the flier points. Its default category positions are 1-based and `manage_ticks=True` reserves half a unit around the outer positions, matching Matplotlib | | `fill_between(x, y1, y2, where=, step=)` / `fill_betweenx` | Masks are split into finite contiguous polygons; step geometry is expanded exactly | | `stackplot` | All four baselines are computed by the native stacked-bounds kernel | -| `imshow` / `pcolormesh` (`cmap=`, `vmin=`/`vmax=`, `origin=`) | `imshow` defaults to `rcParams['image.origin']`; nearest stays cell-exact, while named smoothing modes use dependency-free per-kernel approximations over a bounded 512–1024 px intermediate for both scalar and RGB(A) data. `interpolation="auto"` and `"antialiased"` apply Matplotlib 3.11's adaptive rule against that bounded surface: exact 1×/2× or enlargement above 3× in both dimensions selects nearest, otherwise Hanning; automatic interpolation stage similarly selects data for large enlargement and RGBA for modest enlargement or downsampling. Filter choice does not yet depend on final display resolution. Colormap under/over/bad colors accept the same CSS named-color vocabulary as ordinary marks. Unsupported stages/transforms fail loudly. Uniform meshes retain the texture fast path; nonuniform and curvilinear grids use native quad-to-triangle expansion. Both hug their outer cell edge with no margin, as Matplotlib's sticky image/mesh edges do | +| `psd`, `csd`, `cohere`, `specgram` | Native real-valued Hann-windowed Welch spectra use Matplotlib 3.11's default `detrend_none` semantics. Callable windows/detrending, independent `pad_to`, explicit sides/frequency scaling, and complex/two-sided inputs remain unsupported and fail loudly instead of silently changing the signal; completing these is tracked acceptance debt for `statistics/psd_demo.py` | +| `imshow` / `pcolormesh` (`cmap=`, `vmin=`/`vmax=`, `origin=`) | `imshow` defaults to `rcParams['image.origin']`; nearest stays cell-exact, while named smoothing modes use dependency-free per-kernel approximations over a bounded 512–1024 px intermediate for both scalar and RGB(A) data. Filter choice and intermediate size do not yet depend on final display resolution, and explicit `interpolation="auto"` remains unsupported. Unsupported stages/transforms fail loudly. Uniform meshes retain the texture fast path; nonuniform and curvilinear grids use native quad-to-triangle expansion. Both hug their outer cell edge with no margin, as Matplotlib's sticky image/mesh edges do | | `step`, `stairs`, `stem`, `eventplot` | Compact step/stem/segment marks; no Python-side vertex expansion | | `contour` / `contourf` / `clabel` | Native marching squares over rectilinear grids; warped grids route through native Delaunay/marching-triangle kernels; automatic labels repeat at bounded, separated positions along each level (line knockout for `inline=True` remains a visual approximation) | | `quiver`, `barbs`, `streamplot` | Quiver supports Matplotlib's width-unit vocabulary independently from length scaling. Barbs use fixed-length staffs and Matplotlib's flag/full/half decomposition, including increments, rounding, empty glyphs, colors, sizes, flipping, and pivots. Streamplot uses a dependency-free adaptive Heun integrator with occupancy-aware seeding; `start_points`, `integration_direction`, `broken_streamlines`, integration step/error scales, array widths/colors, and `num_arrows` are honored | @@ -67,7 +68,7 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent | `axhline` / `axvline` / `axhspan` / `axvspan`, `text`, `annotate`, `table` | Fractional span bounds plus data/axes/figure text coordinates are supported. `annotate(arrowprops=)` draws real arrows in every output: offset-point text becomes an engine callout (arrow pinned from label to point across zoom), data-coordinate text an arrow annotation; date-string coordinates convert on datetime axes. Arrowstyles map to head/tail shapes (`->` open V, `-\|>` filled, `\|-\|`/brackets bar caps, `fancy`/`simple`/`wedge` filled tapered shafts sized by the text's mutation scale) and `connectionstyle` arc3/angle3/angle become quadratic curves (corner rounding approximated); `alpha` dims the arrow only. `bbox=` becomes label box styles (fill/edge/round corners/`pad`) in browser and static exports; its `alpha` is the *patch* alpha and dims face and edge together, as Matplotlib's element `opacity` does, and `boxstyle="round"`/`round4` corners are rounded in SVG (`rx`) and native PNG as well as in the browser. An arrow-less `text`/`annotate` label is painted with `rcParams["text.color"]` in all three renderers rather than each renderer's own annotation-label default. Text is unclipped like Matplotlib (`clip_on=False`), and axes-fraction text right of the axes box (x > 1, e.g. seaborn-style row titles) reserves right margin in every exporter. `rotation=90/270` renders vertical text in browser, PNG, and SVG with Matplotlib's rotate-then-align box semantics; other angles rotate in browser and SVG output only (native PNG draws them horizontally) | | `from xy.pyplot import FacetGrid` (seaborn-shaped) | Row/column small multiples with seaborn's `map` contract (subset → activate panel → call the pyplot function), shared domains, edge-only axis labels, top-row column titles, and `margin_titles=True` rotated row titles. `hue=`/`palette=`, `col_wrap=`, `map_dataframe`, and `add_legend` fail loudly | | `xlabel` / `ylabel` / `title` / `suptitle` | Suptitles are retained in HTML and multi-panel PNG/SVG. `ylabel` sits clear of the y tick labels in every renderer: the left gutter is reserved from the measured advances of the tick labels and the rotated title rather than from a fixed constant, leaving Matplotlib's `0.4 em` (5.6 px at the 10 pt/100 dpi default) title-to-tick gap — see *Measured left gutter and the rotated y-axis title* in `spec/api/styling.md` for the formula and its two documented asymmetries | -| `legend()` | `loc`, columns, reversed handle order (`reverse=True`), title/font size/colors, frame styling, `borderpad`, `labelspacing`, `borderaxespad`, `handlelength`, `handletextpad`, `fancybox`, `framealpha`, and `shadow` are retained across browser and static output. Handle geometry is measured in legend-font units, matching Matplotlib; patch handles preserve fill plus outline, including hollow bars. `loc='best'` scores the measured displayed legend box in Matplotlib location-code order using path vertices/crossings, collection offsets, and bar-rectangle overlaps. Compact stairs and ECDF marks contribute their materialized display paths rather than their reversed/implicit storage arguments. It resolves before the wire; pyplot rejects misspelled Matplotlib locations without narrowing core `xy.legend()`'s independent location vocabulary. Text-box scoring and bounded long-path sampling are documented in `spec/api/styling.md` § Legend placement | +| `legend()` | `loc`, columns, title/font size/colors, frame styling, `borderpad`, `labelspacing`, `borderaxespad`, `fancybox`, `framealpha`, and `shadow` are retained across browser and static output. `loc='best'` scores the measured displayed legend box in Matplotlib location-code order using path vertices/crossings, collection offsets, and bar-rectangle overlaps. It resolves before the wire; pyplot rejects misspelled Matplotlib locations without narrowing core `xy.legend()`'s independent location vocabulary. Text-box scoring and bounded long-path sampling are documented in `spec/api/styling.md` § Legend placement | | `grid(True/False)` | toggles the grid via the theme | | `xlim` / `ylim`, `set_xmargin` / `set_ymargin`, axis scales, `invert_xaxis/yaxis` | linear/log are native; symlog/logit/asinh use dependency-free monotone data transforms with inverse limit/tick semantics. Automatic linear ticks include Matplotlib's 2.5 step and use uniform decimal padding across a tick set; locations refresh as data arrives. `axes.autolimit_mode="round_numbers"` expands automatic linear limits to the first and last AutoLocator ticks after applying the configured margins. Artist `get_data()` reflects the transformed space; logit masks values at/outside (0, 1) | | `set_major_locator` / `set_major_formatter`, `plt.NullLocator/FixedLocator/MultipleLocator/MaxNLocator/LinearLocator/LogLocator`, `plt.NullFormatter/FixedFormatter/FuncFormatter/FormatStrFormatter/StrMethodFormatter/ScalarFormatter` | xy-owned re-implementations resolved at build time against live data limits (Null/Fixed/Multiple/Linear are position-exact; MaxN/Auto port Matplotlib's `MaxNLocator._raw_ticks` — same step tables, edge extension, and offset handling — with `nbins="auto"` budgeted from the estimated plot rect like `Axis.get_tick_space()`; Log remains approximate). Third-party locator objects work if they implement `tick_values(vmin, vmax)`; minor locators/formatters are retained for round-tripping but minor ticks do not render, except that a labeled minor pair under a blanked major formatter (the centered date-label idiom) is promoted to the drawn tick set | From fd31f021b0f3c7c15fc7e97d92898870520b52bc Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Mon, 27 Jul 2026 11:10:29 -0700 Subject: [PATCH 10/10] Reject non-finite format colors cleanly --- python/xy/pyplot/_colors.py | 2 +- tests/pyplot/test_fmt.py | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/python/xy/pyplot/_colors.py b/python/xy/pyplot/_colors.py index d2a312ea..292dada8 100644 --- a/python/xy/pyplot/_colors.py +++ b/python/xy/pyplot/_colors.py @@ -382,7 +382,7 @@ def is_color_like(value: object) -> bool: return bool(np.isfinite(gray) and 0.0 <= gray <= 1.0) try: resolve_rgba(value) - except (TypeError, ValueError): + except (TypeError, ValueError, OverflowError): return False return True diff --git a/tests/pyplot/test_fmt.py b/tests/pyplot/test_fmt.py index b00e3b4f..a1f46442 100644 --- a/tests/pyplot/test_fmt.py +++ b/tests/pyplot/test_fmt.py @@ -2,6 +2,7 @@ import pytest +from xy.pyplot._colors import is_color_like from xy.pyplot._fmt import parse_fmt @@ -38,6 +39,18 @@ def test_marker_one_is_not_a_linestyle_dash() -> None: assert parse_fmt("1") == (None, None, "1") +@pytest.mark.parametrize( + "value", + [ + (float("inf"), 0.0, 0.0), + (-float("inf"), 0.0, 0.0, 1.0), + (0.0, float("nan"), 0.0), + ], +) +def test_nonfinite_rgba_is_not_color_like(value: tuple[float, ...]) -> None: + assert is_color_like(value) is False + + @pytest.mark.parametrize("bad", ["z", "r--q", "??"]) def test_rejects_unknown(bad: str) -> None: with pytest.raises(ValueError):