Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 30 additions & 6 deletions js/src/51_annotations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const XY_ANNOTATION_SHAPE_STYLE_KEYS = new Set([
"curve",
"angle_a",
"angle_b",
"elbow",
"gap_start",
"gap_end",
"start_offset",
Expand Down Expand Up @@ -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).
Expand All @@ -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;
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -634,8 +658,8 @@ Object.assign(ChartView.prototype, {
ann
);
}
ctx.restore();
}
ctx.restore();
},

_drawAnnotationLabels(updateLabels) {
Expand Down
14 changes: 12 additions & 2 deletions python/xy/_arrowgeom.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]]:
Expand All @@ -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
Expand Down
7 changes: 7 additions & 0 deletions python/xy/_raster.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
_TEXT,
COLORBAR_FONT_SIZE,
DEFAULT_PALETTE,
_annotation_connector_unclipped,
_axis_label_geometry,
_axis_scales,
_axis_tick_font_size,
Expand Down Expand Up @@ -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))))
Expand Down Expand Up @@ -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"])))
Expand Down Expand Up @@ -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
Expand Down
50 changes: 43 additions & 7 deletions python/xy/_svg.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -2690,6 +2690,7 @@ def tick_span(style: dict[str, Any]) -> tuple[float, float, float]:
f'<g clip-path="url(#{clip_id})">',
*marks,
"</g>",
*unclipped_annotation_marks,
baselines,
f'<g fill="{escape(default_text)}">',
*labels,
Expand Down Expand Up @@ -2756,15 +2757,47 @@ 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],
sy: Callable[[float], float],
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:
Expand Down Expand Up @@ -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"])))
Expand All @@ -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'<polygon points="{taper}" fill="{color}" fill-opacity="{_num(opacity)}"/>'
)
else:
shaft = " ".join(f"{_num(px)},{_num(py)}" for px, py in shapes["shaft"])
marks.append(
connector_marks.append(
f'<polyline points="{shaft}" fill="none" '
f'stroke="{color}" stroke-width="{stroke_width}" '
f'stroke-opacity="{_num(opacity)}"{_dash_attr(style)}/>'
Expand All @@ -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'<polygon points="{points}" fill="{color}" '
f'fill-opacity="{_num(opacity)}"/>'
)
else:
marks.append(
connector_marks.append(
f'<polyline points="{points}" fill="none" stroke="{color}" '
f'stroke-width="{stroke_width}" stroke-opacity="{_num(opacity)}"/>'
)
Expand Down Expand Up @@ -2945,7 +2981,7 @@ def _annotation_svg(
+ (f'fill-opacity="{_num(text_opacity)}" ' if text_opacity < 1 else "")
+ f'fill="{label_color}">{tspans}</text>'
)
return marks, labels
return marks, unclipped_marks, labels


def _svg_font_attrs(style: dict[str, Any]) -> str:
Expand Down
7 changes: 4 additions & 3 deletions python/xy/pyplot/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
41 changes: 38 additions & 3 deletions python/xy/pyplot/_artists.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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", {})
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading