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