diff --git a/js/src/51_annotations.ts b/js/src/51_annotations.ts
index 664cfde8..1d227029 100644
--- a/js/src/51_annotations.ts
+++ b/js/src/51_annotations.ts
@@ -23,6 +23,7 @@ const XY_ANNOTATION_SHAPE_STYLE_KEYS = new Set([
"curve",
"angle_a",
"angle_b",
+ "elbow",
"gap_start",
"gap_end",
"start_offset",
@@ -106,7 +107,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).
@@ -115,6 +123,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;
@@ -555,11 +564,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";
@@ -634,8 +658,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 38bb5cf1..520d8818 100644
--- a/python/xy/_raster.py
+++ b/python/xy/_raster.py
@@ -30,6 +30,7 @@
_TEXT,
COLORBAR_FONT_SIZE,
DEFAULT_PALETTE,
+ _annotation_connector_unclipped,
_axis_label_geometry,
_axis_scales,
_axis_tick_font_size,
@@ -1333,6 +1334,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))))
@@ -1368,6 +1370,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"])))
@@ -1415,6 +1420,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 c58cf5af..b63de8d1 100644
--- a/python/xy/_svg.py
+++ b/python/xy/_svg.py
@@ -2486,7 +2486,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)
@@ -2690,6 +2690,7 @@ def tick_span(style: dict[str, Any]) -> tuple[float, float, float]:
f'',
*marks,
"",
+ *unclipped_annotation_marks,
baselines,
f'',
*labels,
@@ -2756,6 +2757,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],
@@ -2763,8 +2795,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:
@@ -2800,6 +2833,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"])))
@@ -2811,12 +2847,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''
@@ -2826,12 +2862,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''
)
@@ -2945,7 +2981,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/__init__.py b/python/xy/pyplot/__init__.py
index ff6829f7..cfde9e17 100644
--- a/python/xy/pyplot/__init__.py
+++ b/python/xy/pyplot/__init__.py
@@ -1792,7 +1792,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,
@@ -1812,8 +1812,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 9da2a27b..505d523b 100644
--- a/python/xy/pyplot/_artists.py
+++ b/python/xy/pyplot/_artists.py
@@ -1341,6 +1341,41 @@ def get_linewidths(self) -> np.ndarray:
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,
+ *,
+ 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)
+
@property
def theta1(self) -> float:
"""Starting angle in degrees, matching Matplotlib's public geometry."""
@@ -1482,7 +1517,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", {})
@@ -1510,10 +1545,10 @@ def _legend_item_from_entry(
stroke_width = kw.get("stroke_width")
if stroke_width is not None and np.isscalar(stroke_width):
style["stroke_width"] = float(stroke_width)
- 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/_axes.py b/python/xy/pyplot/_axes.py
index e6cb8e51..525f50e0 100644
--- a/python/xy/pyplot/_axes.py
+++ b/python/xy/pyplot/_axes.py
@@ -3395,6 +3395,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:
@@ -3441,6 +3442,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(
@@ -3484,23 +3486,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 -----------------------------------------------------------
@@ -7745,10 +7753,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()
@@ -7757,7 +7762,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 a139c91e..a2b1ef0a 100644
--- a/python/xy/pyplot/_plot_types.py
+++ b/python/xy/pyplot/_plot_types.py
@@ -416,6 +416,253 @@ 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] = []
+ 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:
+ 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
+ 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)):
@@ -4914,7 +5161,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,
@@ -4934,15 +5181,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:
@@ -4969,10 +5214,88 @@ 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)
- if wedge_style.pop("hatch", None) is not None:
- raise not_implemented("pie(wedgeprops={'hatch': ...})")
+ zorder = float(wedge_style.pop("zorder", 1.0))
+ 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
@@ -4992,39 +5315,135 @@ def pie(
([0.0], np.cumsum(values) / total)
)
mids = (boundaries[:-1] + boundaries[1:]) * 0.5
- wedges: list[Wedge] = []
+ 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)
- face = resolve_color(color_values[index])
+ vertices = (
+ (x0[selected], y0[selected]),
+ (x1[selected], y1[selected]),
+ (x2[selected], y2[selected]),
+ )
+ 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)
+ 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)
+ 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]),
"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)
@@ -5032,7 +5451,61 @@ 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))
+ entry["pie_hatch"] = hatch_values[index]
+ entry["pie_hatch_color"] = resolve_color(hatch_color)
+ wedge_entries.append(entry)
+
+ # Draw clipped hatches and every explicit outline after every fill. A
+ # later neighboring wedge must not overpaint either decoration.
+ wedges: list[Wedge] = []
+ 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(
+ "@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,
+ hatch_entry=hatch_entry,
+ shadow_entries=shadow_entries[index],
+ )
+ )
angle = np.deg2rad(float(startangle))
text_kwargs = _textprops_kwargs(textprops, "pie(textprops=)")
@@ -5074,7 +5547,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/tests/pyplot/test_axes_charts.py b/tests/pyplot/test_axes_charts.py
index f809aeb9..56e5041e 100644
--- a/tests/pyplot/test_axes_charts.py
+++ b/tests/pyplot/test_axes_charts.py
@@ -367,8 +367,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_p3_option_contracts.py b/tests/pyplot/test_p3_option_contracts.py
index c05f6237..48b54512 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
new file mode 100644
index 00000000..e8fe85f9
--- /dev/null
+++ b/tests/pyplot/test_pie_annotation_grouped_repair.py
@@ -0,0 +1,346 @@
+"""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
+from xy.pyplot._colors import resolve_color
+
+
+@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 _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()
+ 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)
+ 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(
+ "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