From e01866cc500ea62e24eb52a41f990228f27e8b72 Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Sun, 26 Jul 2026 22:53:36 -0700 Subject: [PATCH 1/6] Fix pie wedges and annotation connectors --- js/src/51_annotations.ts | 36 ++- python/xy/_arrowgeom.py | 14 +- python/xy/_raster.py | 7 + python/xy/_svg.py | 50 ++++- python/xy/pyplot/_artists.py | 22 +- python/xy/pyplot/_axes.py | 51 +++-- python/xy/pyplot/_plot_types.py | 100 +++++++-- spec/matplotlib/compat.md | 6 +- tests/pyplot/test_axes_charts.py | 8 +- tests/pyplot/test_gallery_text_pie_compat.py | 9 +- .../test_pie_annotation_grouped_repair.py | 208 ++++++++++++++++++ 11 files changed, 449 insertions(+), 62 deletions(-) create mode 100644 tests/pyplot/test_pie_annotation_grouped_repair.py diff --git a/js/src/51_annotations.ts b/js/src/51_annotations.ts index c3920c91..9d622626 100644 --- a/js/src/51_annotations.ts +++ b/js/src/51_annotations.ts @@ -22,6 +22,7 @@ const XY_ANNOTATION_SHAPE_STYLE_KEYS = new Set([ "curve", "angle_a", "angle_b", + "elbow", "gap_start", "gap_end", "start_offset", @@ -105,7 +106,14 @@ function xyArrowGeometry(x0, y0, x1, y1, style) { // Tangent INTO each endpoint (head/tail orientation). const dir1 = cx === null ? toward(p0[0], p0[1], p1[0], p1[1]) : toward(cx, cy, p1[0], p1[1]); const dir0 = cx === null ? toward(p1[0], p1[1], p0[0], p0[1]) : toward(cx, cy, p0[0], p0[1]); - return { p0, p1, control: cx === null ? null : [cx, cy], dir0, dir1 }; + return { + p0, + p1, + control: cx === null ? null : [cx, cy], + elbow: Boolean(style.elbow), + dir0, + dir1, + }; } // The shaft as a point list (quadratic Bézier sampled when curved). @@ -114,6 +122,7 @@ function xyArrowShaftPoints(geom, samples = 24) { const [x1, y1] = geom.p1; if (!geom.control) return [[x0, y0], [x1, y1]]; const [cx, cy] = geom.control; + if (geom.elbow) return [[x0, y0], [cx, cy], [x1, y1]]; const points = []; for (let i = 0; i <= samples; i++) { const t = i / samples; @@ -292,11 +301,26 @@ Object.assign(ChartView.prototype, { const annotations = Array.isArray(this.spec.annotations) ? this.spec.annotations : []; if (!annotations.length) return; const p = this.plot; - ctx.save(); - ctx.beginPath(); - ctx.rect(p.x, p.y, p.w, p.h); - ctx.clip(); for (const [annotationIndex, ann] of annotations.entries()) { + ctx.save(); + let targetX = NaN; + let targetY = NaN; + if (ann.kind === "arrow") { + targetX = this._dataPxX(Number(ann.x1)); + targetY = this._dataPxY(Number(ann.y1)); + } else if (ann.kind === "callout") { + targetX = this._dataPxX(Number(ann.x)); + targetY = this._dataPxY(Number(ann.y)); + } + const connectorTargetInBounds = + Number.isFinite(targetX) && Number.isFinite(targetY) && + targetX >= p.x && targetX <= p.x + p.w && + targetY >= p.y && targetY <= p.y + p.h; + if (!connectorTargetInBounds) { + ctx.beginPath(); + ctx.rect(p.x, p.y, p.w, p.h); + ctx.clip(); + } const style = ann && typeof ann.style === "object" ? ann.style : {}; if (ann.kind === "band") { const vertical = ann.axis === "x"; @@ -371,8 +395,8 @@ Object.assign(ChartView.prototype, { ann ); } + ctx.restore(); } - ctx.restore(); }, _drawAnnotationLabels(updateLabels) { diff --git a/python/xy/_arrowgeom.py b/python/xy/_arrowgeom.py index 0193622d..865641a7 100644 --- a/python/xy/_arrowgeom.py +++ b/python/xy/_arrowgeom.py @@ -4,7 +4,8 @@ sync. Style keys: ``curve`` (matplotlib arc3 rad — quadratic bulge as a fraction of chord length), ``angle_a``/``angle_b`` (matplotlib angle3/angle departure/arrival angles, degrees, y-up screen space — the control point is -the ray intersection), ``gap_start``/``gap_end`` (px trims along the path +the ray intersection), ``elbow`` (use that intersection as the sharp corner +for ``connectionstyle="angle"``), ``gap_start``/``gap_end`` (px trims along the path tangents for label/point clearance), ``start_offset`` (an "x,y" px shift of the start point — matplotlib's relpos: the arrow leaves the label's box CENTER, not its anchor), ``label_clear`` (a "left,right,up,down" px @@ -85,7 +86,14 @@ def toward(px: float, py: float, qx: float, qy: float) -> tuple[float, float]: # Tangent INTO each endpoint (head/tail orientation). dir1 = toward(*control, *p1) if control else toward(*p0, *p1) dir0 = toward(*control, *p0) if control else toward(*p1, *p0) - return {"p0": p0, "p1": p1, "control": control, "dir0": dir0, "dir1": dir1} + return { + "p0": p0, + "p1": p1, + "control": control, + "elbow": bool(style.get("elbow")), + "dir0": dir0, + "dir1": dir1, + } def shaft_points(geom: dict[str, Any], samples: int = 24) -> list[tuple[float, float]]: @@ -95,6 +103,8 @@ def shaft_points(geom: dict[str, Any], samples: int = 24) -> list[tuple[float, f if control is None: return [(x0, y0), (x1, y1)] cx, cy = control + if geom.get("elbow"): + return [(x0, y0), (cx, cy), (x1, y1)] points = [] for index in range(samples + 1): t = index / samples diff --git a/python/xy/_raster.py b/python/xy/_raster.py index 6f5f3603..29ce7b6e 100644 --- a/python/xy/_raster.py +++ b/python/xy/_raster.py @@ -29,6 +29,7 @@ _STATIC_COLOR_FALLBACK, _TEXT, DEFAULT_PALETTE, + _annotation_connector_unclipped, _axis_label_geometry, _axis_scales, _axis_tick_font_size, @@ -1262,6 +1263,7 @@ def _emit_annotations( # pass; every label draws in the unclipped chrome pass, matching # matplotlib's Text and the client's DOM labels. style = ann.get("style") or {} + restore_plot_clip = False color = _rgba(style.get("color"), "#667085", float(style.get("opacity", 1.0))) start = max(0.0, min(1.0, float(style.get("span_start", 0.0)))) end = max(start, min(1.0, float(style.get("span_end", 1.0)))) @@ -1297,6 +1299,9 @@ def _emit_annotations( _rgba(style.get("color"), "#64748b", float(style.get("opacity", 0.14))), ) elif ann.get("kind") in ("arrow", "callout"): + if _annotation_connector_unclipped(ann, sx, sy, plot): + cmd.clip(0, 0, width, height) + restore_plot_clip = True if ann.get("kind") == "arrow": x0, y0 = float(sx(float(ann["x0"]))), float(sy(float(ann["y0"]))) x1, y1 = float(sx(float(ann["x1"]))), float(sy(float(ann["y1"]))) @@ -1344,6 +1349,8 @@ def _emit_annotations( else (0, 0, 0, 0) ), ) + if restore_plot_clip: + cmd.clip(plot["x"], plot["y"], plot["w"], plot["h"]) if text_phase and ann.get("text"): x, y, label_anchor, vertical_align = annotation_label_placement( ann, style, sx, sy, plot, width, height diff --git a/python/xy/_svg.py b/python/xy/_svg.py index dc78b65c..03931bcf 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -2194,7 +2194,7 @@ def append_axis_title(axis: dict[str, Any], *, is_x: bool) -> None: ) ) - annotation_marks, annotation_labels = _annotation_svg( + annotation_marks, unclipped_annotation_marks, annotation_labels = _annotation_svg( spec.get("annotations") or [], sx, sy, plot, width, height ) marks.extend(annotation_marks) @@ -2366,6 +2366,7 @@ def tick_span(style: dict[str, Any]) -> tuple[float, float, float]: f'', *marks, "", + *unclipped_annotation_marks, baselines, f'', *labels, @@ -2432,6 +2433,37 @@ def annotation_label_placement( return float(sx(x)), float(sy(y)), anchor, vertical_align +def _annotation_connector_unclipped( + ann: dict[str, Any], + sx: Callable[[float], float], + sy: Callable[[float], float], + plot: dict[str, float], +) -> bool: + """Whether an arrow may leave the axes because its target is in bounds. + + Matplotlib's default ``annotation_clip=None`` clips based on the annotated + point, not the text/connector path. A label may therefore sit outside the + axes while its connector remains visible back to an in-bounds target. + """ + kind = ann.get("kind") + if kind == "arrow": + target = ann.get("x1"), ann.get("y1") + elif kind == "callout": + target = ann.get("x"), ann.get("y") + else: + return False + try: + px, py = float(sx(float(target[0]))), float(sy(float(target[1]))) + except (TypeError, ValueError): + return False + return ( + np.isfinite(px) + and np.isfinite(py) + and plot["x"] <= px <= plot["x"] + plot["w"] + and plot["y"] <= py <= plot["y"] + plot["h"] + ) + + def _annotation_svg( annotations: Sequence[dict[str, Any]], sx: Callable[[float], float], @@ -2439,8 +2471,9 @@ def _annotation_svg( plot: dict[str, float], width: float, height: float, -) -> tuple[list[str], list[str]]: +) -> tuple[list[str], list[str], list[str]]: marks: list[str] = [] + unclipped_marks: list[str] = [] labels: list[str] = [] px0, py0 = plot["x"], plot["y"] for ann in annotations: @@ -2476,6 +2509,9 @@ def _annotation_svg( f'height="{_num(y1 - y0)}" fill="{color}" fill-opacity="{_num(float(style.get("opacity", 0.14)))}"/>' ) elif kind in ("arrow", "callout"): + connector_marks = ( + unclipped_marks if _annotation_connector_unclipped(ann, sx, sy, plot) else marks + ) if kind == "arrow": x0, y0 = float(sx(float(ann["x0"]))), float(sy(float(ann["y0"]))) x1, y1 = float(sx(float(ann["x1"]))), float(sy(float(ann["y1"]))) @@ -2487,12 +2523,12 @@ def _annotation_svg( stroke_width = _num(max(0.5, float(style.get("width", 1.5)))) if shapes["taper"] is not None: taper = " ".join(f"{_num(px)},{_num(py)}" for px, py in shapes["taper"]) - marks.append( + connector_marks.append( f'' ) else: shaft = " ".join(f"{_num(px)},{_num(py)}" for px, py in shapes["shaft"]) - marks.append( + connector_marks.append( f'' @@ -2502,12 +2538,12 @@ def _annotation_svg( continue points = " ".join(f"{_num(px)},{_num(py)}" for px, py in decoration["points"]) if decoration["kind"] == "fill": - marks.append( + connector_marks.append( f'' ) else: - marks.append( + connector_marks.append( f'' ) @@ -2621,7 +2657,7 @@ def _annotation_svg( + (f'fill-opacity="{_num(text_opacity)}" ' if text_opacity < 1 else "") + f'fill="{label_color}">{tspans}' ) - return marks, labels + return marks, unclipped_marks, labels def _svg_font_attrs(style: dict[str, Any]) -> str: diff --git a/python/xy/pyplot/_artists.py b/python/xy/pyplot/_artists.py index eb627f66..4661fbf0 100644 --- a/python/xy/pyplot/_artists.py +++ b/python/xy/pyplot/_artists.py @@ -1204,6 +1204,26 @@ def set_clim(self, vmin: Any = None, vmax: Any = None) -> None: class Wedge(PolyCollection): """Pie wedge backed by a grouped subset of one native sector mesh.""" + def __init__( + self, + axes: Any, + entry: dict[str, Any], + outline_entry: dict[str, Any] | None = None, + ) -> None: + super().__init__(axes, entry) + self._outline_entry = outline_entry + + def remove(self) -> None: + if self._outline_entry is not None: + self._axes._remove_entry(self._outline_entry) + self._outline_entry = None + super().remove() + + def set_zorder(self, level: float) -> None: + if self._outline_entry is not None: + self._outline_entry["_zorder"] = float(level) + super().set_zorder(level) + @property def theta1(self) -> float: """Starting angle in degrees, matching Matplotlib's public geometry.""" @@ -1342,7 +1362,7 @@ def _legend_item_from_entry( renderer already draws for a named trace, so line dashes and marker glyphs render identically. """ - kind = str(entry.get("kind", "line")) + kind = str(entry.get("_legend_kind", entry.get("kind", "line"))) if kind.startswith("@"): # generic marks (errorbar, vlines, …) → a line sample kind = "line" kw = entry.get("kwargs", {}) diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py index cf7a7ce3..79a8b33a 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -2940,6 +2940,7 @@ def annotate(self, text: str, xy: tuple, xytext: Optional[tuple] = None, **kwarg weight = kwargs.pop("weight", kwargs.pop("fontweight", None)) rotation = kwargs.pop("rotation", None) bbox = kwargs.pop("bbox", None) + zorder = kwargs.pop("zorder", None) check_unsupported(kwargs, "annotate()") akw: dict[str, Any] = {} if color is not None: @@ -2986,6 +2987,7 @@ def annotate(self, text: str, xy: tuple, xytext: Optional[tuple] = None, **kwarg style["rotation"] = 90.0 if rotation == "vertical" else float(rotation) if style: akw["style"] = style + annotation_entries: list[dict[str, Any]] = [] if arrowprops is not None and text_xy != xy: if style.get("coordinate_space"): raise not_implemented( @@ -3029,23 +3031,29 @@ def annotate(self, text: str, xy: tuple, xytext: Optional[tuple] = None, **kwarg ) if attach is not None and not shrink: arrow_style = {**arrow_style, **attach} - self._add( - "@arrow", - { - "args": (sx0, sy0, ex0, ey0), - "kwargs": { - "color": arrow_color, - "width": arrow_width, - "style": arrow_style, + annotation_entries.append( + self._add( + "@arrow", + { + "args": (sx0, sy0, ex0, ey0), + "kwargs": { + "color": arrow_color, + "width": arrow_width, + "style": arrow_style, + }, }, - }, + ) ) - return Text( - self, - self._add( - "@text", {"args": (text_xy[0], text_xy[1], _plain_text(text)), "kwargs": akw} - ), + text_entry = self._add( + "@text", {"args": (text_xy[0], text_xy[1], _plain_text(text)), "kwargs": akw} ) + annotation_entries.append(text_entry) + if zorder is not None: + for entry in annotation_entries: + entry["_zorder"] = float(zorder) + host = self._y2_of or self + host._entries.sort(key=lambda entry: float(entry.get("_zorder", 0.0))) + return Text(self, text_entry) # -- axis config ----------------------------------------------------------- @@ -6768,10 +6776,7 @@ def _parse_style_options(spec: str) -> dict[str, float]: def _connection_curve(connectionstyle: Any) -> dict[str, float]: - """matplotlib ``connectionstyle`` → quadratic-curve style keys (see - ``_arrowgeom.py``): arc3's rad becomes ``curve``; angle3/angle become the - ``angle_a``/``angle_b`` departure/arrival angles (corner rounding is - approximated by the quadratic).""" + """Matplotlib ``connectionstyle`` → shared arrow-geometry style keys.""" if not isinstance(connectionstyle, str): return {} name = connectionstyle.split(",")[0].strip() @@ -6780,7 +6785,15 @@ def _connection_curve(connectionstyle: Any) -> dict[str, float]: rad = options.get("rad", 0.0) return {"curve": rad} if rad else {} if name in ("angle3", "angle"): - return {"angle_a": options.get("angleA", 90.0), "angle_b": options.get("angleB", 0.0)} + result = { + "angle_a": options.get("angleA", 90.0), + "angle_b": options.get("angleB", 0.0), + } + if name == "angle": + # ``angle`` is a sharp two-segment elbow. ``angle3`` uses the + # same ray intersection as a quadratic Bézier control point. + result["elbow"] = 1.0 + return result return {} diff --git a/python/xy/pyplot/_plot_types.py b/python/xy/pyplot/_plot_types.py index 20dd6796..67d79d76 100644 --- a/python/xy/pyplot/_plot_types.py +++ b/python/xy/pyplot/_plot_types.py @@ -4226,6 +4226,7 @@ def pie( edgecolor = wedge_style.pop("edgecolor", wedge_style.pop("ec", None)) linewidth = wedge_style.pop("linewidth", wedge_style.pop("lw", None)) alpha = wedge_style.pop("alpha", None) + zorder = float(wedge_style.pop("zorder", 1.0)) if wedge_style.pop("hatch", None) is not None: raise not_implemented("pie(wedgeprops={'hatch': ...})") if wedge_style: @@ -4249,39 +4250,39 @@ def pie( ([0.0], np.cumsum(values) / total) ) mids = (boundaries[:-1] + boundaries[1:]) * 0.5 - wedges: list[Wedge] = [] + wedge_entries: list[dict[str, Any]] = [] + outline_args: list[tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray] | None] = [] for index in range(len(values)): selected = sectors == float(index) + vertices = ( + (x0[selected], y0[selected]), + (x1[selected], y1[selected]), + (x2[selected], y2[selected]), + ) face = resolve_color(color_values[index]) mark_kwargs: dict[str, Any] = { "color": face, "name": None if label_values[index] is None else str(label_values[index]), "opacity": 1.0 if alpha is None else float(alpha), + "_joined_fill": True, } - if edgecolor is not None: - mark_kwargs["stroke"] = resolve_color(edgecolor) - mark_kwargs["stroke_width"] = 1.0 if linewidth is None else float(linewidth) - else: - # A sector is a fan of adjacent triangles. Stroke each fan - # triangle with its own face color so anti-aliasing cannot - # expose the figure background as radial hairline spokes. - mark_kwargs["stroke"] = face - mark_kwargs["stroke_width"] = 0.75 entry = self._add( "@mark", { "factory": "triangle_mesh", "args": ( - x0[selected], - y0[selected], - x1[selected], - y1[selected], - x2[selected], - y2[selected], + vertices[0][0], + vertices[0][1], + vertices[1][0], + vertices[1][1], + vertices[2][0], + vertices[2][1], ), "kwargs": mark_kwargs, }, ) + entry["_zorder"] = zorder + entry["_legend_kind"] = "patch" entry["pie_center"] = (float(center[0]), float(center[1])) entry["pie_mid"] = float(mids[index]) entry["pie_radius"] = float(radius) @@ -4289,7 +4290,72 @@ def pie( theta_start, theta_end = np.rad2deg(boundaries[index : index + 2]) entry["pie_theta1"] = float(min(theta_start, theta_end)) entry["pie_theta2"] = float(max(theta_start, theta_end)) - wedges.append(Wedge(self, entry)) + wedge_entries.append(entry) + if edgecolor is not None: + # These are the canonical pre-transport kernel vertices. + # Tolerance-snapped endpoint counting recovers every exterior + # edge, including both rings of a one-slice donut; the snap + # also joins its numerically near-equal 0°/360° seam. + # Interior fan edges occur twice and are deliberately omitted. + coordinate_values = np.concatenate([axis for vertex in vertices for axis in vertex]) + tolerance = max(float(np.ptp(coordinate_values)) * 1e-12, 1e-14) + + def point_key( + point: tuple[float, float], scale: float = tolerance + ) -> tuple[int, int]: + return round(point[0] / scale), round(point[1] / scale) + + edges: dict[ + tuple[tuple[int, int], tuple[int, int]], + tuple[int, tuple[float, float], tuple[float, float]], + ] = {} + for first, second in ((0, 1), (1, 2), (2, 0)): + for start_x, start_y, end_x, end_y in zip( + vertices[first][0], + vertices[first][1], + vertices[second][0], + vertices[second][1], + strict=True, + ): + start = float(start_x), float(start_y) + end = float(end_x), float(end_y) + start_key, end_key = point_key(start), point_key(end) + key = (start_key, end_key) if start_key <= end_key else (end_key, start_key) + count, saved_start, saved_end = edges.get(key, (0, start, end)) + edges[key] = count + 1, saved_start, saved_end + exterior = [(start, end) for count, start, end in edges.values() if count == 1] + outline_args.append( + ( + np.asarray([start[0] for start, _end in exterior]), + np.asarray([start[1] for start, _end in exterior]), + np.asarray([end[0] for _start, end in exterior]), + np.asarray([end[1] for _start, end in exterior]), + ) + ) + else: + outline_args.append(None) + + # Draw every explicit outline after every fill. A later neighboring + # wedge must not overpaint half of an earlier wedge's shared border. + wedges: list[Wedge] = [] + for entry, segment_args in zip(wedge_entries, outline_args, strict=True): + outline_entry = None + if segment_args is not None: + outline_entry = self._add( + "@mark", + { + "factory": "segments", + "args": segment_args, + "kwargs": { + "color": resolve_color(edgecolor), + "width": 1.0 if linewidth is None else float(linewidth), + "opacity": 1.0 if alpha is None else float(alpha), + }, + }, + ) + outline_entry["_zorder"] = zorder + outline_entry["_legend_skip"] = True + wedges.append(Wedge(self, entry, outline_entry)) angle = np.deg2rad(float(startangle)) text_kwargs = _textprops_kwargs(textprops, "pie(textprops=)") diff --git a/spec/matplotlib/compat.md b/spec/matplotlib/compat.md index 0d5f39aa..3480e2af 100644 --- a/spec/matplotlib/compat.md +++ b/spec/matplotlib/compat.md @@ -63,8 +63,8 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent | `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 | | `tripcolor`, `triplot`, `tricontour`, `tricontourf` | Explicit topology or native dependency-free Delaunay triangulation; indexed geometry and isolines stay in Rust | -| `pie` / `pie_label` | Native pie/donut tessellation and the Matplotlib 3.11 `PieContainer` (`values`, `fracs`, grouped text labels), including dtype-preserving value formats, radial label rotation/alignment, and common text properties | -| `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) | +| `pie` / `pie_label` | Native pie/donut tessellation and the Matplotlib 3.11 `PieContainer` (`values`, `fracs`, grouped text labels), including dtype-preserving value formats, radial label rotation/alignment, and common text properties. Each wedge is a seam-free joined fill at Matplotlib's default zorder 1; an explicit edge color adds one exterior wedge outline rather than stroking the internal tessellation, and explicit wedge legend handles remain filled patch swatches | +| `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. Annotation `zorder` is retained on both label and connector entries. 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); `connectionstyle` maps `arc3`/`angle3` to quadratic curves and `angle` to its sharp two-segment elbow. When the annotated target is inside the axes, its connector may extend outside the axes to an exterior label in browser, SVG, and raster output, matching Matplotlib's default annotation clipping rule. `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 | @@ -106,7 +106,7 @@ Unknown keyword arguments on supported calls raise `TypeError` naming the offending keyword. Known material options that the native marks cannot honor raise `NotImplementedError`, with these documented exceptions that are accepted as visual approximations rather than rejected: imshow smoothing collapse above, -`annotate(arrowprops=...)` connection curves and +`annotate(arrowprops=...)` arc/angle3 connection curves and fancy/wedge outlines drawn as quadratic-curve tapered fills rather than Matplotlib's exact patch paths, `bbox=` boxes sized from an estimated text width with a fixed corner radius per box style (5 px for `round`, 8 px for diff --git a/tests/pyplot/test_axes_charts.py b/tests/pyplot/test_axes_charts.py index 1ea5de2e..1bf1a8f9 100644 --- a/tests/pyplot/test_axes_charts.py +++ b/tests/pyplot/test_axes_charts.py @@ -311,8 +311,12 @@ def test_pie_and_donut_use_native_sector_mesh_and_return_text_handles() -> None: assert [text.get_text() for text in texts] == ["a", "b", "c"] assert [text.get_text() for text in autotexts] == ["20%", "30%", "50%"] traces = _traces(ax) - assert [trace.kind for trace in traces[:3]] == ["triangle_mesh"] * 3 - assert all(trace.style["stroke_width"] == 0.5 for trace in traces[:3]) + fills = [trace for trace in traces if trace.kind == "triangle_mesh"] + outlines = [trace for trace in traces if trace.kind == "segments"] + assert len(fills) == len(outlines) == 3 + assert all(trace.style["joined_fill"] is True for trace in fills) + assert all("stroke_width" not in trace.style for trace in fills) + assert all(trace.style["width"] == 0.5 for trace in outlines) def test_additional_basic_and_array_families_map_to_existing_generic_marks() -> None: diff --git a/tests/pyplot/test_gallery_text_pie_compat.py b/tests/pyplot/test_gallery_text_pie_compat.py index 0285bfbe..f45e7fd9 100644 --- a/tests/pyplot/test_gallery_text_pie_compat.py +++ b/tests/pyplot/test_gallery_text_pie_compat.py @@ -344,7 +344,7 @@ def test_pie_container_values_are_defensive_and_fracs_stay_numeric() -> None: np.testing.assert_allclose(pie.fracs, [0.2, 0.3, 0.5]) -def test_pie_uses_equal_aspect_hidden_axes_and_seam_covering_face_strokes() -> None: +def test_pie_uses_equal_aspect_hidden_axes_and_joined_fills() -> None: _fig, ax = plt.subplots() pie = ax.pie([2, 3, 5], startangle=90) @@ -354,8 +354,7 @@ def test_pie_uses_equal_aspect_hidden_axes_and_seam_covering_face_strokes() -> N assert spec["frame_sides"] == [] assert spec["x_axis"]["tick_label_strategy"] == "none" assert spec["y_axis"]["tick_label_strategy"] == "none" - assert all(wedge._entry["kwargs"]["stroke_width"] == 0.75 for wedge in pie.wedges) - assert all( - wedge._entry["kwargs"]["stroke"] == wedge._entry["kwargs"]["color"] for wedge in pie.wedges - ) + assert all(wedge._entry["kwargs"]["_joined_fill"] is True for wedge in pie.wedges) + assert all("stroke" not in wedge._entry["kwargs"] for wedge in pie.wedges) + assert all("stroke_width" not in wedge._entry["kwargs"] for wedge in pie.wedges) assert pie.wedges[0]._entry["pie_mid"] == pytest.approx(np.deg2rad(126)) diff --git a/tests/pyplot/test_pie_annotation_grouped_repair.py b/tests/pyplot/test_pie_annotation_grouped_repair.py new file mode 100644 index 00000000..569fd753 --- /dev/null +++ b/tests/pyplot/test_pie_annotation_grouped_repair.py @@ -0,0 +1,208 @@ +"""Regressions reduced from Matplotlib's pie-and-donut labels gallery.""" + +from __future__ import annotations + +from io import BytesIO +from pathlib import Path + +import numpy as np +import pytest + +import xy.pyplot as plt +from conftest import probe_document, run_browser_probe +from xy._arrowgeom import arrow_geometry, shaft_points +from xy._svg import layout +from xy.export import find_chromium + + +@pytest.fixture(autouse=True) +def _clean_pyplot_state(): + plt.close("all") + yield + plt.close("all") + + +def test_pie_wedges_use_joined_fills_and_exterior_only_strokes() -> None: + _fig, ax = plt.subplots() + + pie = ax.pie( + [2, 3, 5], + wedgeprops={"edgecolor": "black", "linewidth": 2}, + ) + + for wedge in pie.wedges: + assert wedge.get_zorder() == 1.0 + assert wedge._entry["kwargs"]["_joined_fill"] is True + assert "stroke" not in wedge._entry["kwargs"] + assert "stroke_width" not in wedge._entry["kwargs"] + outline = wedge._outline_entry + assert outline is not None + assert outline["factory"] == "segments" + assert outline["kwargs"]["color"] == "black" + assert outline["kwargs"]["width"] == 2.0 + assert outline["_zorder"] == 1.0 + x0, y0, x1, y1 = outline["args"] + assert len(x0) == len(y0) == len(x1) == len(y1) + assert len(x0) < len(wedge._entry["args"][0]) * 3 + + +def test_explicit_pie_legend_handles_stay_filled_patches() -> None: + _fig, ax = plt.subplots() + pie = ax.pie([1, 2], colors=["tab:blue", "tab:orange"]) + + legend = ax.legend(pie.wedges, ["flour", "sugar"]) + + assert [item["kind"] for item in legend.spec()["items"]] == ["patch", "patch"] + assert [item["style"]["color"] for item in legend.spec()["items"]] == [ + "#1f77b4", + "#ff7f0e", + ] + + +def test_one_slice_donut_outline_has_only_outer_and_inner_rings() -> None: + _fig, ax = plt.subplots() + + wedge = ax.pie( + [1], + wedgeprops={"width": 0.5, "edgecolor": "black"}, + ).wedges[0] + + assert wedge._outline_entry is not None + assert len(wedge._outline_entry["args"][0]) == 120 + + +def test_angle_connectionstyle_is_an_elbow_but_angle3_is_quadratic() -> None: + _fig, ax = plt.subplots() + angle = ax.annotate( + "angle", + xy=(1, 1), + xytext=(0, 0), + arrowprops={"arrowstyle": "-", "connectionstyle": "angle,angleA=0,angleB=90"}, + ) + angle3 = ax.annotate( + "angle3", + xy=(1, 1), + xytext=(0, 0), + arrowprops={"arrowstyle": "-", "connectionstyle": "angle3,angleA=0,angleB=90"}, + ) + + arrows = [entry for entry in ax._entries if entry["kind"] == "@arrow"] + assert arrows[0]["kwargs"]["style"]["elbow"] == 1.0 + assert "elbow" not in arrows[1]["kwargs"]["style"] + assert angle.get_text() == "angle" + assert angle3.get_text() == "angle3" + elbow_geometry = arrow_geometry( + 0, + 0, + 10, + 10, + {"angle_a": 0.0, "angle_b": 90.0, "elbow": 1.0}, + ) + quadratic_geometry = arrow_geometry( + 0, + 0, + 10, + 10, + {"angle_a": 0.0, "angle_b": 90.0}, + ) + assert shaft_points(elbow_geometry) == [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)] + assert len(shaft_points(quadratic_geometry)) == 25 + + +def test_annotation_zorder_is_stored_on_label_and_connector() -> None: + _fig, ax = plt.subplots() + pie = ax.pie([1, 1]) + + label = ax.annotate( + "outside", + xy=(1, 0), + xytext=(1.35, 0), + arrowprops={"arrowstyle": "-"}, + zorder=0, + ) + + arrow = next(entry for entry in ax._entries if entry["kind"] == "@arrow") + assert label.get_zorder() == 0.0 + assert arrow["_zorder"] == 0.0 + positions = {id(entry): index for index, entry in enumerate(ax._entries)} + assert positions[id(arrow)] < min(positions[id(wedge._entry)] for wedge in pie.wedges) + + +def _outside_connector_chart(): + fig, ax = plt.subplots(figsize=(6, 3), dpi=100) + ax.set_axis_off() + ax.set_xlim(-1.25, 1.25) + ax.set_ylim(-1.0, 1.0) + ax.annotate( + "", + xy=(1.0, 0.0), + xytext=(1.4, 0.0), + arrowprops={"arrowstyle": "-", "color": "#ff0000", "linewidth": 6}, + ) + return ax._build_chart(*fig._panel_px()).figure() + + +def test_svg_and_raster_keep_connector_outside_axes_when_target_is_inside() -> None: + chart = _outside_connector_chart() + spec, _blob = chart.build_payload() + plot = layout(spec)[3] + plot_right = plot["x"] + plot["w"] + + svg = chart.to_svg() + connector = svg.index('stroke="#ff0000"') + clipped_group = svg.index('", clipped_group) + assert connector > clipped_group_end + + pixels = np.asarray(plt.imread(BytesIO(chart.to_png()))) + outside = pixels[:, int(np.ceil(plot_right)) + 1 :, :3] + red = (outside[..., 0] > 0.8) & (outside[..., 1] < 0.3) & (outside[..., 2] < 0.3) + assert np.any(red) + + +def test_browser_keeps_connector_outside_axes_when_target_is_inside(tmp_path: Path) -> None: + chromium = find_chromium() + if chromium is None: + pytest.skip("Chromium unavailable") + chart = _outside_connector_chart() + probe = """ + +""" + result = run_browser_probe( + chromium, + probe_document(chart, probe), + tmp_path / "outside_annotation.html", + "data-xy-outside-annotation", + label="outside annotation connector", + ) + + assert result["outsideRed"] > 0, result + assert result["plotRight"] < result["canvasWidth"], result From 7278bd792509615a745ae6f378ba2357e3f58300 Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Mon, 27 Jul 2026 10:16:03 -0700 Subject: [PATCH 2/6] Support pie hatches and shadows --- python/xy/pyplot/__init__.py | 7 +- python/xy/pyplot/_artists.py | 19 +- python/xy/pyplot/_plot_types.py | 503 ++++++++++++++++-- spec/matplotlib/compat-changelog.md | 11 + spec/matplotlib/compat.md | 2 +- tests/pyplot/test_p3_option_contracts.py | 3 - .../test_pie_annotation_grouped_repair.py | 134 +++++ 7 files changed, 611 insertions(+), 68 deletions(-) diff --git a/python/xy/pyplot/__init__.py b/python/xy/pyplot/__init__.py index 7b5c9eef..c167d34d 100644 --- a/python/xy/pyplot/__init__.py +++ b/python/xy/pyplot/__init__.py @@ -1790,7 +1790,7 @@ def pie( colors: ColorsLike | None = None, autopct: str | Callable[[float], str] | None = None, pctdistance: float = 0.6, - shadow: bool = False, + shadow: bool | Mapping[str, Any] = False, labeldistance: float | None = 1.1, startangle: float = 0, radius: float = 1, @@ -1810,8 +1810,9 @@ def pie( ``explode`` offsets slices, ``autopct`` labels them with their share (%-format or callable), ``startangle``/``counterclock`` control orientation, and ``wedgeprops``/``textprops`` style slices and - labels. Returns ``(wedges, texts)`` or ``(wedges, texts, autotexts)`` - as matplotlib does. + labels. ``hatch`` cycles patterns over wedges, and ``shadow`` accepts + either a boolean or Matplotlib ``Shadow`` properties. Returns + ``(wedges, texts)`` or ``(wedges, texts, autotexts)`` as matplotlib does. """ return gca().pie( x, diff --git a/python/xy/pyplot/_artists.py b/python/xy/pyplot/_artists.py index 4661fbf0..f40aaf70 100644 --- a/python/xy/pyplot/_artists.py +++ b/python/xy/pyplot/_artists.py @@ -1209,17 +1209,32 @@ def __init__( axes: Any, entry: dict[str, Any], outline_entry: dict[str, Any] | None = None, + *, + hatch_entry: dict[str, Any] | None = None, + shadow_entries: list[dict[str, Any]] | None = None, ) -> None: super().__init__(axes, entry) self._outline_entry = outline_entry + self._hatch_entry = hatch_entry + self._shadow_entries = list(shadow_entries or []) def remove(self) -> None: + for entry in self._shadow_entries: + self._axes._remove_entry(entry) + self._shadow_entries.clear() + if self._hatch_entry is not None: + self._axes._remove_entry(self._hatch_entry) + self._hatch_entry = None if self._outline_entry is not None: self._axes._remove_entry(self._outline_entry) self._outline_entry = None super().remove() def set_zorder(self, level: float) -> None: + for entry in self._shadow_entries: + entry["_zorder"] = float(np.nextafter(float(level), -np.inf)) + if self._hatch_entry is not None: + self._hatch_entry["_zorder"] = float(level) if self._outline_entry is not None: self._outline_entry["_zorder"] = float(level) super().set_zorder(level) @@ -1376,10 +1391,10 @@ def _legend_item_from_entry( opacity = kw.get("opacity") if opacity is not None: style["opacity"] = float(opacity) - hatch = kw.get("hatch") + hatch = kw.get("hatch", entry.get("pie_hatch")) if hatch: style["hatch"] = str(hatch) - style["hatch_color"] = str(kw.get("hatch_color", "#222222")) + style["hatch_color"] = str(kw.get("hatch_color", entry.get("pie_hatch_color", "#222222"))) # Rule annotations keep renderer-specific geometry inside ``style`` while # ordinary line/step entries keep it at the top level. Accept both shapes # so explicit Legend handles preserve the plotted dash. diff --git a/python/xy/pyplot/_plot_types.py b/python/xy/pyplot/_plot_types.py index 67d79d76..1a36fe81 100644 --- a/python/xy/pyplot/_plot_types.py +++ b/python/xy/pyplot/_plot_types.py @@ -382,6 +382,233 @@ def _dashed_segments( ) +def _triangle_mesh_exterior( + vertices: tuple[ + tuple[np.ndarray, np.ndarray], + tuple[np.ndarray, np.ndarray], + tuple[np.ndarray, np.ndarray], + ], +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """Return the boundary edges of a triangle mesh without its fan seams.""" + coordinate_values = np.concatenate([axis for vertex in vertices for axis in vertex]) + tolerance = max(float(np.ptp(coordinate_values)) * 1e-12, 1e-14) + + def point_key(point: tuple[float, float]) -> tuple[int, int]: + return round(point[0] / tolerance), round(point[1] / tolerance) + + edges: dict[ + tuple[tuple[int, int], tuple[int, int]], + tuple[int, tuple[float, float], tuple[float, float]], + ] = {} + for first, second in ((0, 1), (1, 2), (2, 0)): + for start_x, start_y, end_x, end_y in zip( + vertices[first][0], + vertices[first][1], + vertices[second][0], + vertices[second][1], + strict=True, + ): + start = float(start_x), float(start_y) + end = float(end_x), float(end_y) + start_key, end_key = point_key(start), point_key(end) + key = (start_key, end_key) if start_key <= end_key else (end_key, start_key) + count, saved_start, saved_end = edges.get(key, (0, start, end)) + edges[key] = count + 1, saved_start, saved_end + exterior = [(start, end) for count, start, end in edges.values() if count == 1] + return ( + np.asarray([start[0] for start, _end in exterior]), + np.asarray([start[1] for start, _end in exterior]), + np.asarray([end[0] for _start, end in exterior]), + np.asarray([end[1] for _start, end in exterior]), + ) + + +def _clip_segment_to_triangle( + start: tuple[float, float], + end: tuple[float, float], + triangle: np.ndarray, +) -> tuple[tuple[float, float], tuple[float, float]] | None: + """Clip a segment to one triangle with a convex half-plane solve.""" + area = float( + (triangle[1, 0] - triangle[0, 0]) * (triangle[2, 1] - triangle[0, 1]) + - (triangle[1, 1] - triangle[0, 1]) * (triangle[2, 0] - triangle[0, 0]) + ) + if abs(area) <= np.finfo(float).eps: + return None + direction = np.asarray(end, dtype=np.float64) - np.asarray(start, dtype=np.float64) + origin = np.asarray(start, dtype=np.float64) + orientation = 1.0 if area > 0 else -1.0 + lower, upper = 0.0, 1.0 + for index in range(3): + edge_start = triangle[index] + edge = triangle[(index + 1) % 3] - edge_start + at_start = float( + orientation + * (edge[0] * (origin[1] - edge_start[1]) - edge[1] * (origin[0] - edge_start[0])) + ) + at_end = float( + orientation + * ( + edge[0] * (origin[1] + direction[1] - edge_start[1]) + - edge[1] * (origin[0] + direction[0] - edge_start[0]) + ) + ) + slope = at_end - at_start + if abs(slope) <= np.finfo(float).eps: + if at_start < -1e-12: + return None + continue + crossing = -at_start / slope + if slope > 0: + lower = max(lower, crossing) + else: + upper = min(upper, crossing) + if lower > upper: + return None + clipped_start = origin + min(1.0, max(0.0, lower)) * direction + clipped_end = origin + min(1.0, max(0.0, upper)) * direction + if np.linalg.norm(clipped_end - clipped_start) <= 1e-12: + return None + return ( + (float(clipped_start[0]), float(clipped_start[1])), + (float(clipped_end[0]), float(clipped_end[1])), + ) + + +def _pie_hatch_geometry( + vertices: tuple[ + tuple[np.ndarray, np.ndarray], + tuple[np.ndarray, np.ndarray], + tuple[np.ndarray, np.ndarray], + ], + hatch: str, +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """Build hatch strokes clipped to a sector for every xy renderer.""" + triangles = np.stack( + [ + np.column_stack(vertices[0]), + np.column_stack(vertices[1]), + np.column_stack(vertices[2]), + ], + axis=1, + ) + all_x = np.concatenate([vertex[0] for vertex in vertices]) + all_y = np.concatenate([vertex[1] for vertex in vertices]) + xmin, xmax = float(all_x.min()), float(all_x.max()) + ymin, ymax = float(all_y.min()), float(all_y.max()) + diameter = max(xmax - xmin, ymax - ymin) + if diameter <= 0 or not hatch: + empty = np.empty(0, dtype=np.float64) + return empty, empty.copy(), empty.copy(), empty.copy() + + candidates: list[tuple[tuple[float, float], tuple[float, float]]] = [] + + def count(*characters: str) -> int: + return max((hatch.count(character) for character in characters), default=0) + + def spacing(density: int) -> float: + return diameter / (5.0 + 2.0 * max(1, density)) + + def linear_family(kind: str, density: int) -> None: + gap = spacing(density) + margin = diameter + gap + if kind == "vertical": + for position in np.arange(xmin - gap, xmax + gap, gap): + candidates.append( + ((float(position), ymin - margin), (float(position), ymax + margin)) + ) + elif kind == "horizontal": + for position in np.arange(ymin - gap, ymax + gap, gap): + candidates.append( + ((xmin - margin, float(position)), (xmax + margin, float(position))) + ) + else: + low = ymin - xmax - margin + high = ymax - xmin + margin + for intercept in np.arange(low, high, gap): + if kind == "slash": + candidates.append( + ( + (xmin - margin, xmin - margin + intercept), + (xmax + margin, xmax + margin + intercept), + ) + ) + else: + candidates.append( + ( + (xmin - margin, -xmin + margin + intercept), + (xmax + margin, -xmax - margin + intercept), + ) + ) + + slash_density = count("/", "x", "X") + backslash_density = count("\\", "x", "X") + vertical_density = count("|", "+") + horizontal_density = count("-", "+") + if slash_density: + linear_family("slash", slash_density) + if backslash_density: + linear_family("backslash", backslash_density) + if vertical_density: + linear_family("vertical", vertical_density) + if horizontal_density: + linear_family("horizontal", horizontal_density) + + def polygon_family(character: str, points: int, radius_factor: float) -> None: + density = hatch.count(character) + if not density: + return + gap = spacing(density) + radius = gap * radius_factor + xs = np.arange(xmin + gap * 0.5, xmax + gap * 0.5, gap) + ys = np.arange(ymin + gap * 0.5, ymax + gap * 0.5, gap) + for cx in xs: + for cy in ys: + if character == "*": + angles = -np.pi / 2 + np.arange(points * 2) * np.pi / points + radii = np.where( + np.arange(points * 2) % 2 == 0, + radius, + radius * 0.42, + ) + else: + angles = np.arange(points) * 2.0 * np.pi / points + radii = np.full(points, radius) + polygon = np.column_stack( + (cx + radii * np.cos(angles), cy + radii * np.sin(angles)) + ) + closed = np.vstack((polygon, polygon[0])) + candidates.extend( + ( + (float(closed[index, 0]), float(closed[index, 1])), + (float(closed[index + 1, 0]), float(closed[index + 1, 1])), + ) + for index in range(len(closed) - 1) + ) + + polygon_family(".", 4, 0.08) + polygon_family("o", 8, 0.18) + polygon_family("O", 10, 0.30) + polygon_family("*", 5, 0.34) + + x0: list[float] = [] + y0: list[float] = [] + x1: list[float] = [] + y1: list[float] = [] + for start, end in candidates: + for triangle in triangles: + clipped = _clip_segment_to_triangle(start, end, triangle) + if clipped is None: + continue + clipped_start, clipped_end = clipped + x0.append(clipped_start[0]) + y0.append(clipped_start[1]) + x1.append(clipped_end[0]) + y1.append(clipped_end[1]) + arrays = tuple(np.asarray(values, dtype=np.float64) for values in (x0, y0, x1, y1)) + return arrays # type: ignore[return-value] + + def _limit_error(error: Any, lower_limits: Any, upper_limits: Any, size: int) -> Any: """Convert limit flags into Matplotlib's two-sided error-array geometry.""" if error is None or (not np.any(lower_limits) and not np.any(upper_limits)): @@ -4171,7 +4398,7 @@ def pie( colors: Any = None, autopct: Any = None, pctdistance: float = 0.6, - shadow: bool = False, + shadow: bool | Mapping[str, Any] = False, labeldistance: float | None = 1.1, startangle: float = 0, radius: float = 1, @@ -4191,15 +4418,13 @@ def pie( ``explode`` offsets slices, ``autopct`` labels them with their share (%-format or callable), ``startangle``/``counterclock`` control orientation, and ``wedgeprops``/``textprops`` style slices and - labels. ``shadow``, ``frame``, ``rotatelabels``, and ``hatch`` raise - loudly. Returns ``(wedges, texts)`` or ``(wedges, texts, autotexts)`` - as matplotlib does. + labels. Per-wedge hatches and Matplotlib ``Shadow`` dictionaries are + retained as bounded geometry in every renderer. ``frame`` and + ``rotatelabels`` still raise loudly. Returns ``(wedges, texts)`` or + ``(wedges, texts, autotexts)`` as matplotlib does. """ - _reject_non_default("pie", "shadow", shadow, False) _reject_non_default("pie", "frame", frame, False) _reject_non_default("pie", "rotatelabels", rotatelabels, False) - if hatch is not None: - raise not_implemented("pie(hatch=...)") source_values = np.asarray(_from_data(x, data)) values = np.asarray(source_values, dtype=np.float64) if values.ndim != 1 or len(values) == 0: @@ -4227,10 +4452,87 @@ def pie( linewidth = wedge_style.pop("linewidth", wedge_style.pop("lw", None)) alpha = wedge_style.pop("alpha", None) zorder = float(wedge_style.pop("zorder", 1.0)) - if wedge_style.pop("hatch", None) is not None: - raise not_implemented("pie(wedgeprops={'hatch': ...})") + wedge_hatch = wedge_style.pop("hatch", None) + hatch_color = wedge_style.pop( + "hatchcolor", + wedge_style.pop("hatch_color", "#000000"), + ) if wedge_style: check_unsupported(wedge_style, "pie(wedgeprops=)") + if wedge_hatch is not None: + hatch_values = [str(wedge_hatch)] * len(values) + elif hatch is None: + hatch_values = [None] * len(values) + else: + provided_hatches = [hatch] if isinstance(hatch, str) else list(hatch) + if not provided_hatches: + raise ValueError("pie hatch must not be empty") + hatch_values = [ + None + if provided_hatches[index % len(provided_hatches)] is None + else str(provided_hatches[index % len(provided_hatches)]) + for index in range(len(values)) + ] + shadow_options: dict[str, Any] | None = None + if shadow: + if not isinstance(shadow, (bool, Mapping)): + raise TypeError("pie shadow must be a bool or mapping") + shadow_options = { + "ox": -0.02, + "oy": -0.02, + "shade": 0.7, + "alpha": 0.5, + "label": "_nolegend_", + } + if isinstance(shadow, Mapping): + shadow_options.update(shadow) + shade = float(shadow_options.pop("shade")) + if not 0.0 <= shade <= 1.0: + raise ValueError("pie shadow shade must be between 0 and 1") + shadow_options["shade"] = shade + shadow_options["ox"] = float(shadow_options["ox"]) + shadow_options["oy"] = float(shadow_options["oy"]) + shadow_options["zorder"] = float( + shadow_options.get("zorder", np.nextafter(zorder, -np.inf)) + ) + shadow_options["linewidth"] = float( + shadow_options.pop( + "lw", + shadow_options.get("linewidth", rcParams["patch.linewidth"]), + ) + ) + shadow_options["facecolor"] = shadow_options.pop("fc", shadow_options.get("facecolor")) + shadow_options["edgecolor"] = shadow_options.pop("ec", shadow_options.get("edgecolor")) + shadow_color = shadow_options.pop("color", None) + if shadow_color is not None: + shadow_options["facecolor"] = shadow_color + shadow_options["edgecolor"] = shadow_color + visible = shadow_options.pop("visible", True) + if not bool(visible): + shadow_options = None + if shadow_options is not None: + supported_shadow = { + "ox", + "oy", + "shade", + "alpha", + "label", + "zorder", + "linewidth", + "facecolor", + "edgecolor", + } + check_unsupported( + { + key: value + for key, value in shadow_options.items() + if key not in supported_shadow + }, + "pie(shadow=)", + ) + shadow_options = { + key: value for key, value in shadow_options.items() if key in supported_shadow + } inner_radius = 0.0 if width is None else max(0.0, float(radius) - float(width)) from xy import kernels @@ -4250,7 +4552,24 @@ def pie( ([0.0], np.cumsum(values) / total) ) mids = (boundaries[:-1] + boundaries[1:]) * 0.5 - wedge_entries: list[dict[str, Any]] = [] + extent = float(radius) * (1.25 + float(np.max(offsets))) + data_units_per_point = 0.0 + if shadow_options is not None and self.figure is not None: + figure_width, figure_height = self.figure.get_size_inches() + _left, _bottom, axes_width, axes_height = self.get_position(original=True).bounds + active_points = min(axes_width * figure_width, axes_height * figure_height) * 72.0 + data_units_per_point = 2.0 * extent / max(active_points, np.finfo(float).eps) + + wedge_geometry: list[ + tuple[ + tuple[ + tuple[np.ndarray, np.ndarray], + tuple[np.ndarray, np.ndarray], + tuple[np.ndarray, np.ndarray], + ], + str | None, + ] + ] = [] outline_args: list[tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray] | None] = [] for index in range(len(values)): selected = sectors == float(index) @@ -4259,7 +4578,85 @@ def pie( (x1[selected], y1[selected]), (x2[selected], y2[selected]), ) - face = resolve_color(color_values[index]) + wedge_geometry.append((vertices, resolve_color(color_values[index]))) + outline_args.append( + _triangle_mesh_exterior(vertices) if edgecolor is not None else None + ) + + shadow_entries: list[list[dict[str, Any]]] = [[] for _ in values] + if shadow_options is not None: + shift_x = float(shadow_options["ox"]) * data_units_per_point + shift_y = float(shadow_options["oy"]) * data_units_per_point + shade = float(shadow_options["shade"]) + shadow_alpha = shadow_options.get("alpha", 0.5) + shadow_zorder = float(shadow_options["zorder"]) + for index, (vertices, face) in enumerate(wedge_geometry): + face_rgba = resolve_rgba(face) + darkened = tuple((1.0 - shade) * channel for channel in face_rgba[:3]) + explicit_face = shadow_options.get("facecolor") + shadow_face = ( + resolve_color(explicit_face) + if explicit_face is not None + else resolve_color(darkened) + ) + opacity = face_rgba[3] if shadow_alpha is None else float(shadow_alpha) + shifted = tuple( + ( + vertex[0] + shift_x, + vertex[1] + shift_y, + ) + for vertex in vertices + ) + shadow_entry = self._add( + "@mark", + { + "factory": "triangle_mesh", + "args": ( + shifted[0][0], + shifted[0][1], + shifted[1][0], + shifted[1][1], + shifted[2][0], + shifted[2][1], + ), + "kwargs": { + "color": shadow_face, + "name": None, + "opacity": opacity, + "_joined_fill": True, + }, + }, + ) + shadow_entry["_zorder"] = shadow_zorder + shadow_entry["_legend_skip"] = True + shadow_entry["_pie_shadow_offset_points"] = ( + float(shadow_options["ox"]), + float(shadow_options["oy"]), + ) + shadow_entries[index].append(shadow_entry) + shadow_edge = shadow_options.get("edgecolor") + if shadow_edge is None: + shadow_edge = shadow_face + resolved_shadow_edge = resolve_color(shadow_edge) + if resolved_shadow_edge != "transparent": + shadow_outline = self._add( + "@mark", + { + "factory": "segments", + "args": _triangle_mesh_exterior(shifted), + "kwargs": { + "color": resolved_shadow_edge, + "width": float(shadow_options["linewidth"]) * self._point_scale(), + "opacity": opacity, + }, + }, + ) + shadow_outline["_zorder"] = shadow_zorder + shadow_outline["_legend_skip"] = True + shadow_entries[index].append(shadow_outline) + + wedge_entries: list[dict[str, Any]] = [] + for index, (vertices, face) in enumerate(wedge_geometry): mark_kwargs: dict[str, Any] = { "color": face, "name": None if label_values[index] is None else str(label_values[index]), @@ -4290,55 +4687,36 @@ def pie( theta_start, theta_end = np.rad2deg(boundaries[index : index + 2]) entry["pie_theta1"] = float(min(theta_start, theta_end)) entry["pie_theta2"] = float(max(theta_start, theta_end)) + entry["pie_hatch"] = hatch_values[index] + entry["pie_hatch_color"] = resolve_color(hatch_color) wedge_entries.append(entry) - if edgecolor is not None: - # These are the canonical pre-transport kernel vertices. - # Tolerance-snapped endpoint counting recovers every exterior - # edge, including both rings of a one-slice donut; the snap - # also joins its numerically near-equal 0°/360° seam. - # Interior fan edges occur twice and are deliberately omitted. - coordinate_values = np.concatenate([axis for vertex in vertices for axis in vertex]) - tolerance = max(float(np.ptp(coordinate_values)) * 1e-12, 1e-14) - - def point_key( - point: tuple[float, float], scale: float = tolerance - ) -> tuple[int, int]: - return round(point[0] / scale), round(point[1] / scale) - - edges: dict[ - tuple[tuple[int, int], tuple[int, int]], - tuple[int, tuple[float, float], tuple[float, float]], - ] = {} - for first, second in ((0, 1), (1, 2), (2, 0)): - for start_x, start_y, end_x, end_y in zip( - vertices[first][0], - vertices[first][1], - vertices[second][0], - vertices[second][1], - strict=True, - ): - start = float(start_x), float(start_y) - end = float(end_x), float(end_y) - start_key, end_key = point_key(start), point_key(end) - key = (start_key, end_key) if start_key <= end_key else (end_key, start_key) - count, saved_start, saved_end = edges.get(key, (0, start, end)) - edges[key] = count + 1, saved_start, saved_end - exterior = [(start, end) for count, start, end in edges.values() if count == 1] - outline_args.append( - ( - np.asarray([start[0] for start, _end in exterior]), - np.asarray([start[1] for start, _end in exterior]), - np.asarray([end[0] for _start, end in exterior]), - np.asarray([end[1] for _start, end in exterior]), - ) - ) - else: - outline_args.append(None) - # Draw every explicit outline after every fill. A later neighboring - # wedge must not overpaint half of an earlier wedge's shared border. + # Draw clipped hatches and every explicit outline after every fill. A + # later neighboring wedge must not overpaint either decoration. wedges: list[Wedge] = [] - for entry, segment_args in zip(wedge_entries, outline_args, strict=True): + for index, (entry, segment_args) in enumerate( + zip(wedge_entries, outline_args, strict=True) + ): + hatch_entry = None + pattern = hatch_values[index] + if pattern: + hatch_args = _pie_hatch_geometry(wedge_geometry[index][0], pattern) + if len(hatch_args[0]): + hatch_entry = self._add( + "@mark", + { + "factory": "segments", + "args": hatch_args, + "kwargs": { + "color": resolve_color(hatch_color), + "width": 0.8 * self._point_scale(), + "opacity": 1.0 if alpha is None else float(alpha), + }, + }, + ) + hatch_entry["_zorder"] = zorder + hatch_entry["_legend_skip"] = True + hatch_entry["_pie_hatch"] = pattern outline_entry = None if segment_args is not None: outline_entry = self._add( @@ -4355,7 +4733,15 @@ def point_key( ) outline_entry["_zorder"] = zorder outline_entry["_legend_skip"] = True - wedges.append(Wedge(self, entry, outline_entry)) + wedges.append( + Wedge( + self, + entry, + outline_entry, + hatch_entry=hatch_entry, + shadow_entries=shadow_entries[index], + ) + ) angle = np.deg2rad(float(startangle)) text_kwargs = _textprops_kwargs(textprops, "pie(textprops=)") @@ -4397,7 +4783,6 @@ def add_text(distance: float, mid: float, value: str, offset: float) -> Text: add_text(float(pctdistance), mid, str(label), float(offsets[index])) ) angle += sweep - extent = float(radius) * (1.25 + float(np.max(offsets))) self.set_xlim(float(center[0]) - extent, float(center[0]) + extent) self.set_ylim(float(center[1]) - extent, float(center[1]) + extent) self.set_aspect("equal", adjustable="box") diff --git a/spec/matplotlib/compat-changelog.md b/spec/matplotlib/compat-changelog.md index 1778169b..a8b5acf5 100644 --- a/spec/matplotlib/compat-changelog.md +++ b/spec/matplotlib/compat-changelog.md @@ -4,6 +4,17 @@ 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. +## Pie gallery corrections — 2026-07-27 + +- `pie(hatch=...)` now cycles patterns per wedge and emits sector-clipped + line/dot/ring/star strokes through the shared segment mark, so browser, SVG, + and raster output use the same bounded geometry. A hatch supplied through + `wedgeprops` retains Matplotlib's override precedence. +- `pie(shadow=True)` and shadow dictionaries now retain Matplotlib's + point-space offset, shade darkening, alpha, face/edge paint, linewidth, and + z-order semantics. Shadow outlines use the exterior sector boundary rather + than exposing the native wedge's triangle fan. + ## 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 3480e2af..712f8564 100644 --- a/spec/matplotlib/compat.md +++ b/spec/matplotlib/compat.md @@ -63,7 +63,7 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent | `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 | | `tripcolor`, `triplot`, `tricontour`, `tricontourf` | Explicit topology or native dependency-free Delaunay triangulation; indexed geometry and isolines stay in Rust | -| `pie` / `pie_label` | Native pie/donut tessellation and the Matplotlib 3.11 `PieContainer` (`values`, `fracs`, grouped text labels), including dtype-preserving value formats, radial label rotation/alignment, and common text properties. Each wedge is a seam-free joined fill at Matplotlib's default zorder 1; an explicit edge color adds one exterior wedge outline rather than stroking the internal tessellation, and explicit wedge legend handles remain filled patch swatches | +| `pie` / `pie_label` | Native pie/donut tessellation and the Matplotlib 3.11 `PieContainer` (`values`, `fracs`, grouped text labels), including dtype-preserving value formats, radial label rotation/alignment, and common text properties. Each wedge is a seam-free joined fill at Matplotlib's default zorder 1; an explicit edge color adds one exterior wedge outline rather than stroking the internal tessellation, and explicit wedge legend handles remain filled patch swatches. `hatch=` cycles Matplotlib's line, dot, ring, and star families over wedges as sector-clipped segment geometry shared by browser/SVG/raster output; `wedgeprops["hatch"]` overrides that cycle. `shadow=True` and shadow dictionaries retain point-space `ox`/`oy`, shade darkening, face/edge colors, alpha, linewidth, and z-order without copying the wedge's internal triangle seams | | `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. Annotation `zorder` is retained on both label and connector entries. 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); `connectionstyle` maps `arc3`/`angle3` to quadratic curves and `angle` to its sharp two-segment elbow. When the annotated target is inside the axes, its connector may extend outside the axes to an exterior label in browser, SVG, and raster output, matching Matplotlib's default annotation clipping rule. `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 | diff --git a/tests/pyplot/test_p3_option_contracts.py b/tests/pyplot/test_p3_option_contracts.py index 10fd8671..2ec39011 100644 --- a/tests/pyplot/test_p3_option_contracts.py +++ b/tests/pyplot/test_p3_option_contracts.py @@ -167,11 +167,8 @@ def _stream_args() -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: @pytest.mark.parametrize( ("call", "match"), [ - (lambda ax: ax.pie([1, 2], shadow=True), "shadow"), (lambda ax: ax.pie([1, 2], frame=True), "frame"), (lambda ax: ax.pie([1, 2], rotatelabels=True), "rotatelabels"), - (lambda ax: ax.pie([1, 2], hatch="//"), "hatch"), - (lambda ax: ax.pie([1, 2], wedgeprops={"hatch": "x"}), "hatch"), (lambda ax: ax.quiver([0, 1], [0, 1], [1, 0], [0, 1], headwidth=6), "headwidth"), (lambda ax: ax.quiver([0, 1], [0, 1], [1, 0], [0, 1], headlength=2), "headlength"), (lambda ax: ax.quiver([0, 1], [0, 1], [1, 0], [0, 1], headaxislength=2), "headaxislength"), diff --git a/tests/pyplot/test_pie_annotation_grouped_repair.py b/tests/pyplot/test_pie_annotation_grouped_repair.py index 569fd753..56142f21 100644 --- a/tests/pyplot/test_pie_annotation_grouped_repair.py +++ b/tests/pyplot/test_pie_annotation_grouped_repair.py @@ -13,6 +13,7 @@ from xy._arrowgeom import arrow_geometry, shaft_points from xy._svg import layout from xy.export import find_chromium +from xy.pyplot._colors import resolve_color @pytest.fixture(autouse=True) @@ -71,6 +72,139 @@ def test_one_slice_donut_outline_has_only_outer_and_inner_rings() -> None: assert len(wedge._outline_entry["args"][0]) == 120 +def _point_in_triangle(point: np.ndarray, triangle: np.ndarray) -> bool: + edges = np.roll(triangle, -1, axis=0) - triangle + offsets = point - triangle + crosses = edges[:, 0] * offsets[:, 1] - edges[:, 1] * offsets[:, 0] + return bool(np.all(crosses >= -1e-10) or np.all(crosses <= 1e-10)) + + +def test_pie_hatches_cycle_and_every_stroke_is_sector_clipped() -> None: + _fig, ax = plt.subplots() + + pie = ax.pie( + [15, 30, 45, 10], + labels=["Frogs", "Hogs", "Dogs", "Logs"], + hatch=["**O", "oO"], + ) + + assert [wedge._entry["pie_hatch"] for wedge in pie.wedges] == [ + "**O", + "oO", + "**O", + "oO", + ] + for wedge in pie.wedges: + hatch = wedge._hatch_entry + assert hatch is not None + assert hatch["factory"] == "segments" + assert hatch["_legend_skip"] is True + triangles = np.stack( + [ + np.column_stack((wedge._entry["args"][0], wedge._entry["args"][1])), + np.column_stack((wedge._entry["args"][2], wedge._entry["args"][3])), + np.column_stack((wedge._entry["args"][4], wedge._entry["args"][5])), + ], + axis=1, + ) + x0, y0, x1, y1 = hatch["args"] + assert len(x0) == len(y0) == len(x1) == len(y1) > 0 + for start_x, start_y, end_x, end_y in zip(x0, y0, x1, y1, strict=True): + for point in ( + np.asarray((start_x, start_y)), + np.asarray((end_x, end_y)), + np.asarray(((start_x + end_x) / 2, (start_y + end_y) / 2)), + ): + assert any(_point_in_triangle(point, triangle) for triangle in triangles) + + legend = ax.legend(pie.wedges, ["one", "two", "three", "four"]) + assert [item["style"]["hatch"] for item in legend.spec()["items"]] == [ + "**O", + "oO", + "**O", + "oO", + ] + + +def test_wedgeprops_hatch_overrides_the_pie_hatch_cycle() -> None: + _fig, ax = plt.subplots() + + pie = ax.pie([1, 2, 3], hatch=["/", "\\"], wedgeprops={"hatch": "x"}) + + assert [wedge._entry["pie_hatch"] for wedge in pie.wedges] == ["x", "x", "x"] + + +def test_pie_shadow_dict_darkens_offsets_and_applies_alpha() -> None: + _fig, ax = plt.subplots(figsize=(6.4, 4.8), dpi=100) + + pie = ax.pie( + [1, 2], + colors=["#ff0000", "#00ff00"], + shadow={ + "ox": -2, + "oy": 3, + "shade": 0.9, + "alpha": 0.25, + "edgecolor": "none", + }, + ) + + for wedge in pie.wedges: + assert len(wedge._shadow_entries) == 1 + shadow = wedge._shadow_entries[0] + assert shadow["factory"] == "triangle_mesh" + assert shadow["kwargs"]["opacity"] == 0.25 + assert shadow["_pie_shadow_offset_points"] == (-2.0, 3.0) + assert shadow["_zorder"] < wedge.get_zorder() + assert ax._entries.index(shadow) < ax._entries.index(wedge._entry) + dx = np.asarray(shadow["args"][0]) - np.asarray(wedge._entry["args"][0]) + dy = np.asarray(shadow["args"][1]) - np.asarray(wedge._entry["args"][1]) + assert np.all(dx < 0) + assert np.all(dy > 0) + assert np.ptp(dx) < 1e-12 + assert np.ptp(dy) < 1e-12 + + assert pie.wedges[0]._shadow_entries[0]["kwargs"]["color"] == resolve_color((0.1, 0, 0)) + assert pie.wedges[1]._shadow_entries[0]["kwargs"]["color"] == resolve_color((0, 0.1, 0)) + + +def test_pie_shadow_point_offset_is_dpi_independent_in_data_space() -> None: + shifts = [] + for dpi in (72, 144): + fig, ax = plt.subplots(figsize=(4, 4), dpi=dpi) + wedge = ax.pie([1], shadow={"ox": 2, "oy": -3, "edgecolor": "none"}).wedges[0] + shadow = wedge._shadow_entries[0] + shifts.append( + ( + float(shadow["args"][0][0] - wedge._entry["args"][0][0]), + float(shadow["args"][1][0] - wedge._entry["args"][1][0]), + ) + ) + plt.close(fig) + + assert shifts[0] == pytest.approx(shifts[1]) + + +def test_removing_a_wedge_removes_hatch_shadow_and_outline_entries() -> None: + _fig, ax = plt.subplots() + wedge = ax.pie( + [1], + hatch="/", + shadow=True, + wedgeprops={"edgecolor": "black"}, + ).wedges[0] + owned = { + id(wedge._entry), + id(wedge._hatch_entry), + id(wedge._outline_entry), + *(id(entry) for entry in wedge._shadow_entries), + } + + wedge.remove() + + assert not owned.intersection(id(entry) for entry in ax._entries) + + def test_angle_connectionstyle_is_an_elbow_but_angle3_is_quadratic() -> None: _fig, ax = plt.subplots() angle = ax.annotate( From b706c56fdbe7aa755f9291ae419ce7281a074230 Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Mon, 27 Jul 2026 10:21:14 -0700 Subject: [PATCH 3/6] Fix pie shadow regression coverage --- python/xy/pyplot/_plot_types.py | 3 ++- tests/pyplot/test_pie_annotation_grouped_repair.py | 8 +++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/python/xy/pyplot/_plot_types.py b/python/xy/pyplot/_plot_types.py index 1a36fe81..58335c59 100644 --- a/python/xy/pyplot/_plot_types.py +++ b/python/xy/pyplot/_plot_types.py @@ -4592,7 +4592,8 @@ def pie( shadow_zorder = float(shadow_options["zorder"]) for index, (vertices, face) in enumerate(wedge_geometry): face_rgba = resolve_rgba(face) - darkened = tuple((1.0 - shade) * channel for channel in face_rgba[:3]) + shade_factor = round(1.0 - shade, 15) + darkened = tuple(shade_factor * channel for channel in face_rgba[:3]) explicit_face = shadow_options.get("facecolor") shadow_face = ( resolve_color(explicit_face) diff --git a/tests/pyplot/test_pie_annotation_grouped_repair.py b/tests/pyplot/test_pie_annotation_grouped_repair.py index 56142f21..c1298ab1 100644 --- a/tests/pyplot/test_pie_annotation_grouped_repair.py +++ b/tests/pyplot/test_pie_annotation_grouped_repair.py @@ -156,7 +156,13 @@ def test_pie_shadow_dict_darkens_offsets_and_applies_alpha() -> None: assert shadow["kwargs"]["opacity"] == 0.25 assert shadow["_pie_shadow_offset_points"] == (-2.0, 3.0) assert shadow["_zorder"] < wedge.get_zorder() - assert ax._entries.index(shadow) < ax._entries.index(wedge._entry) + shadow_index = next( + index for index, entry in enumerate(ax._entries) if entry is shadow + ) + wedge_index = next( + index for index, entry in enumerate(ax._entries) if entry is wedge._entry + ) + assert shadow_index < wedge_index dx = np.asarray(shadow["args"][0]) - np.asarray(wedge._entry["args"][0]) dy = np.asarray(shadow["args"][1]) - np.asarray(wedge._entry["args"][1]) assert np.all(dx < 0) From e84322d15b1082f4e4e77005d918e5608b2e6ea8 Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Mon, 27 Jul 2026 10:34:09 -0700 Subject: [PATCH 4/6] Format pie compatibility regression tests --- tests/pyplot/test_pie_annotation_grouped_repair.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/pyplot/test_pie_annotation_grouped_repair.py b/tests/pyplot/test_pie_annotation_grouped_repair.py index c1298ab1..e8fe85f9 100644 --- a/tests/pyplot/test_pie_annotation_grouped_repair.py +++ b/tests/pyplot/test_pie_annotation_grouped_repair.py @@ -156,9 +156,7 @@ def test_pie_shadow_dict_darkens_offsets_and_applies_alpha() -> None: assert shadow["kwargs"]["opacity"] == 0.25 assert shadow["_pie_shadow_offset_points"] == (-2.0, 3.0) assert shadow["_zorder"] < wedge.get_zorder() - shadow_index = next( - index for index, entry in enumerate(ax._entries) if entry is shadow - ) + shadow_index = next(index for index, entry in enumerate(ax._entries) if entry is shadow) wedge_index = next( index for index, entry in enumerate(ax._entries) if entry is wedge._entry ) From bb816f07f9b37252bf1e657c9c381e34945cf410 Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Mon, 27 Jul 2026 11:45:11 -0700 Subject: [PATCH 5/6] Keep upstream export docs unchanged --- docs/styling/chrome-slots.md | 4 ++++ spec/api/export.md | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/docs/styling/chrome-slots.md b/docs/styling/chrome-slots.md index 3af04f5e..bdbcb2cd 100644 --- a/docs/styling/chrome-slots.md +++ b/docs/styling/chrome-slots.md @@ -334,7 +334,11 @@ apply it with. Rather than leave that to be discovered, it is a contract: | --- | --- | --- | --- | | mark / axis `style=` | yes | yes | yes | | chart-level `style=` (design tokens) | yes | yes | yes | +<<<<<<< HEAD | `styles={slot: {...}}` | yes, all 29 slots | text subset, 9 slots | text subset, 9 slots | +======= +| `styles={slot: {...}}` | yes, all 29 slots | dropped | dropped | +>>>>>>> origin/main | `class_names={slot: "..."}` | yes, all 29 slots | dropped | dropped | | `custom_css=` | yes | raises | raises | | `xy.legend(style=...)` | yes | 6 keys | 6 keys | diff --git a/spec/api/export.md b/spec/api/export.md index a2b2a208..bfcc48d3 100644 --- a/spec/api/export.md +++ b/spec/api/export.md @@ -245,7 +245,11 @@ vector** (`_svg.to_svg`, and `_pdf.svg_to_pdf` on top of it). | `style={...}` on a mark | yes | yes | yes | validated CSS subset, `styles.compile_mark_style` | | `style={...}` on an axis | yes | yes | yes | validated vocabulary, `styles.compile_axis_style` | | `style={...}` on the chart (token bag) | yes | yes | yes | `spec["dom"]["style"]`, read at `_svg.py:767,1481` and `_raster.py:662` | +<<<<<<< HEAD | `styles={slot: {...}}` (per-slot inline) | yes, all 29 slots | text subset, 9 slots | text subset, 9 slots | `_svg.STATIC_STYLED_SLOTS`; the rest is live-only chrome | +======= +| `styles={slot: {...}}` (per-slot inline) | yes, all 29 slots | **dropped** | **dropped** | silent — see below | +>>>>>>> origin/main | `class_names={slot: "..."}` | yes, all 29 slots | **dropped** | **dropped** | silent — the SVG writer emits no `class` at all | | `custom_css="..."` | yes (HTML + Chromium capture) | **raises** | **raises** | `_resolve_image_engine`, `export.py:812` | | `xy.legend(style=...)` | yes | 6 keys | 6 keys | merged with the slot and the theme token before the writers see it | From c853f94f239e0cc5f712d533abf0726ec4630996 Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Mon, 27 Jul 2026 12:39:12 -0700 Subject: [PATCH 6/6] Prune pie hatch clipping candidates by bounds --- python/xy/pyplot/_plot_types.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/python/xy/pyplot/_plot_types.py b/python/xy/pyplot/_plot_types.py index c99a80a8..a2b1ef0a 100644 --- a/python/xy/pyplot/_plot_types.py +++ b/python/xy/pyplot/_plot_types.py @@ -629,8 +629,28 @@ def polygon_family(character: str, points: int, radius_factor: float) -> None: y0: list[float] = [] x1: list[float] = [] y1: list[float] = [] + triangle_bounds = [ + ( + float(np.min(triangle[:, 0])), + float(np.max(triangle[:, 0])), + float(np.min(triangle[:, 1])), + float(np.max(triangle[:, 1])), + ) + for triangle in triangles + ] for start, end in candidates: - for triangle in triangles: + segment_xmin, segment_xmax = min(start[0], end[0]), max(start[0], end[0]) + segment_ymin, segment_ymax = min(start[1], end[1]), max(start[1], end[1]) + for triangle, (triangle_xmin, triangle_xmax, triangle_ymin, triangle_ymax) in zip( + triangles, triangle_bounds, strict=True + ): + if ( + segment_xmax < triangle_xmin + or segment_xmin > triangle_xmax + or segment_ymax < triangle_ymin + or segment_ymin > triangle_ymax + ): + continue clipped = _clip_segment_to_triangle(start, end, triangle) if clipped is None: continue