Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
78 commits
Select commit Hold shift + click to select a range
326b38b
Add Matplotlib log scale compatibility
sselvakumaran Jul 27, 2026
52957ed
Fix pie wedges and annotation connectors
sselvakumaran Jul 27, 2026
24525c1
Support multiline chart chrome layout
sselvakumaran Jul 27, 2026
3ea9d42
Improve Matplotlib tick label fidelity
sselvakumaran Jul 27, 2026
3232f7d
Fix pyplot bar labels and mathtext fallback
sselvakumaran Jul 27, 2026
baf85d0
Fix subplot mosaic geometry
sselvakumaran Jul 27, 2026
f820953
Reconcile multiline tick layout integration
sselvakumaran Jul 27, 2026
55ec967
fix(pyplot): preserve axes-relative table layout
sselvakumaran Jul 27, 2026
bce3da2
Fix multiline top-aligned text boxes
sselvakumaran Jul 27, 2026
1aabfcf
fix(pyplot): resolve quiver in display space
sselvakumaran Jul 27, 2026
dfe560b
fix(pyplot): preserve streamplot trajectories for arrows
sselvakumaran Jul 27, 2026
6f178d3
fix(pyplot): reject incomplete native streamlines
sselvakumaran Jul 27, 2026
b3d3328
test(pyplot): update streamplot width contract
sselvakumaran Jul 27, 2026
d90e187
fix(pyplot): align vector field review contracts
sselvakumaran Jul 27, 2026
72fb45b
Fix PR 340 layout review regressions
sselvakumaran Jul 27, 2026
611bc69
Restore layout and export performance
sselvakumaran Jul 27, 2026
b7a851d
Preserve browser y-title gap at canvas edge
sselvakumaran Jul 27, 2026
4f11c65
Support Axis tick parameters in dollar ticks
sselvakumaran Jul 27, 2026
c53d9e7
Render Matplotlib ticks on requested sides
sselvakumaran Jul 27, 2026
f4d0caa
Fix multiline baseline text box placement
sselvakumaran Jul 27, 2026
ea70e05
Clip axlines to finalized shared domains
sselvakumaran Jul 27, 2026
10cd009
Fix shared Anscombe subplot state
sselvakumaran Jul 27, 2026
31c831d
Bump protocol for mirrored tick sides
sselvakumaran Jul 27, 2026
0a24899
Render tick labels on requested sides
sselvakumaran Jul 27, 2026
f5054b8
Handle tangent and off-view axlines
sselvakumaran Jul 27, 2026
4c95026
Fix default side for extra y ticks
sselvakumaran Jul 27, 2026
5ad02f6
Fix xtick rotation geometry
sselvakumaran Jul 27, 2026
c149d88
Contain terminal x tick labels in static panels
sselvakumaran Jul 27, 2026
3560956
Scale suptitle fonts by output DPI
sselvakumaran Jul 27, 2026
388dbd9
Keep constrained x labels inside static exports
FarhanAliRaza Jul 27, 2026
eec9970
Resolve constrained layout before position reads
sselvakumaran Jul 27, 2026
2fabaed
Honor layout on standalone pyplot figures
sselvakumaran Jul 27, 2026
a12f6fb
Clarify protocol v9 compatibility rationale
sselvakumaran Jul 27, 2026
a1f6871
Support pie hatches and shadows
sselvakumaran Jul 27, 2026
5b1d940
Fix pie shadow regression coverage
sselvakumaran Jul 27, 2026
2a0ff7d
Merge PR 336 log-scale compatibility dependency
sselvakumaran Jul 27, 2026
587cd7b
Format pie compatibility regression tests
sselvakumaran Jul 27, 2026
09241cc
Complete Matplotlib scale gallery compatibility
sselvakumaran Jul 27, 2026
15b4624
Correct scale gallery coordinate assertions
sselvakumaran Jul 27, 2026
b7d347d
Support Matplotlib tick and date gallery APIs
sselvakumaran Jul 27, 2026
96d8087
Update PR 336 scale dependency
sselvakumaran Jul 27, 2026
4b51344
Support Matplotlib figure decorations
sselvakumaran Jul 27, 2026
4571173
Define the Matplotlib text color rcParam
sselvakumaran Jul 27, 2026
eed17e8
Keep compatibility ledgers out of the scale PR
sselvakumaran Jul 27, 2026
f0a889b
Merge current main into scale compatibility
sselvakumaran Jul 27, 2026
bf51d3e
Document the scale protocol revision
sselvakumaran Jul 27, 2026
d54b593
Reject unsupported shared aspect mutation
sselvakumaran Jul 27, 2026
4d1f6cb
Resolve scale and grid review findings
sselvakumaran Jul 27, 2026
da2f5cc
Merge main into pie compatibility work
sselvakumaran Jul 27, 2026
ab68caf
Keep upstream export docs unchanged
sselvakumaran Jul 27, 2026
a665561
Merge remote-tracking branch 'github/main' into agent/quiver-display-…
sselvakumaran Jul 27, 2026
8231b14
Render pyplot errorbar caps in point space
sselvakumaran Jul 27, 2026
0149559
Merge remote-tracking branch 'github/main' into agent/gallery-log-sca…
sselvakumaran Jul 27, 2026
dc35cb5
Address figure layout review feedback
sselvakumaran Jul 27, 2026
78640af
Update tick style expectation for grid defaults
sselvakumaran Jul 27, 2026
1b0958d
Merge PR 336 scale compatibility base
sselvakumaran Jul 27, 2026
23eedf5
Stack pie compatibility work on log-scale compatibility
sselvakumaran Jul 27, 2026
6b0190b
Fix stacked layout compatibility CI
sselvakumaran Jul 27, 2026
be22408
Merge remote-tracking branch 'github/main' into agent/gallery-log-sca…
sselvakumaran Jul 27, 2026
ba7c500
Refresh pie stack on latest gallery compatibility
sselvakumaran Jul 27, 2026
1c0f762
Merge remote-tracking branch 'github/agent/pie-annotation-round2' int…
sselvakumaran Jul 27, 2026
d773d4c
Prune pie hatch clipping candidates by bounds
sselvakumaran Jul 27, 2026
9766fc3
Merge remote-tracking branch 'github/agent/pie-annotation-round2' int…
sselvakumaran Jul 27, 2026
15e4c19
Merge commit 'd054e3eaa60c969d185ec042284df068b5908ac9' into agent/ga…
sselvakumaran Jul 27, 2026
4b097de
Merge commit 'b3710cada9907013db8f0fcda1a62432893f4b47' into agent/ga…
sselvakumaran Jul 27, 2026
a43e66b
Preserve subplot mosaic ownership after stacking
sselvakumaran Jul 27, 2026
bc73d15
Merge latest main into gallery compatibility roll-up
sselvakumaran Jul 27, 2026
b87ab3b
Merge latest main into gallery compatibility roll-up
sselvakumaran Jul 28, 2026
acfe129
Honor authored plot and text styles
sselvakumaran Jul 27, 2026
59f07bb
Preserve cleared errorbar labels
sselvakumaran Jul 27, 2026
6c5e514
Complete axes outer-label and margin semantics
sselvakumaran Jul 27, 2026
6de8b45
Keep tight autoscaling live
sselvakumaran Jul 27, 2026
ba1023b
Center browser Y-axis titles
sselvakumaran Jul 27, 2026
d36bf8d
Reserve rotated Y-axis title gutter
sselvakumaran Jul 27, 2026
93aad1c
Batch Y-axis title attachment layout
sselvakumaran Jul 28, 2026
8bf9e22
Merge Matplotlib compatibility bughunt stack into #340
sselvakumaran Jul 28, 2026
e7f31cd
Summarize Matplotlib compatibility fixes
sselvakumaran Jul 28, 2026
621fe2e
Address consolidated PR review findings
sselvakumaran Jul 28, 2026
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ in the README).
that don't use them are byte-identical.

### Fixed
- Improved Matplotlib compatibility for pie and vector-field plots, multiline
layout, authored styles, axes helpers/autoscaling, and browser Y-axis titles.
- A mark-level `animation=xy.animation(...)` no longer resets the chart-level
policy fields it does not mention. It was a complete spec spread over the
chart's, so `xy.animation(duration=90)` on a mark silently reset `match`,
Expand Down
5 changes: 4 additions & 1 deletion benchmarks/test_codspeed_pyplot.py
Original file line number Diff line number Diff line change
Expand Up @@ -360,7 +360,10 @@ def test_build_styled_panel_pyplot(benchmark, panel_data):
"""
x, actual, target, sample = panel_data
payload_bytes = benchmark(_pyplot_styled_panel_payload, x, actual, target, sample)
assert payload_bytes == _raw_styled_panel_payload(x, actual, target, sample)
raw_payload_bytes = _raw_styled_panel_payload(x, actual, target, sample)
# Pyplot preserves Matplotlib's axes-title y/pad geometry as two float32
# values. The raw title= API intentionally uses its fixed legacy fallback.
assert payload_bytes == raw_payload_bytes + 2 * np.dtype(np.float32).itemsize


# -- export pair ----------------------------------------------------------------
Expand Down
10 changes: 7 additions & 3 deletions js/src/00_header.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,13 @@
// v8: legend/colorbar geometry, extra colormap names, and match-fill strokes
// add wire values an older v7 client would accept but silently misrender.
// v9: explicit minor axis ticks/styles, log nonpositive behavior,
// scalar-normalization scale, colorbar padding/explicit-axes placement, and
// contour-line overlays. A v8 client silently misrenders these values.
export const PROTOCOL = 9;
// `axis.tick_sides`/`axis.tick_label_sides`, scalar-normalization scale,
// colorbar padding/explicit-axes placement, and contour-line overlays add wire
// values a cached v8 client would accept but silently misrender.
// v10: `title_options` carries independent left/center/right title artists,
// including axes-fraction y and pixel padding. A v9 client would silently omit
// non-center slots and their placement, so v10 rejects it before rendering.
export const PROTOCOL = 10;

// HTTP binary frame v1 (spec/design/wire-protocol.md §7; Python side in
// python/xy/_framing.py). The chart spec's PROTOCOL
Expand Down
605 changes: 471 additions & 134 deletions js/src/50_chartview.ts

Large diffs are not rendered by default.

61 changes: 50 additions & 11 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,20 +564,41 @@ 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();
}
Comment thread
sselvakumaran marked this conversation as resolved.
const style = ann && typeof ann.style === "object" ? ann.style : {};
if (ann.kind === "band") {
const vertical = ann.axis === "x";
const a = vertical ? this._dataPxX(Number(ann.start)) : this._dataPxY(Number(ann.start));
const b = vertical ? this._dataPxX(Number(ann.end)) : this._dataPxY(Number(ann.end));
if (!Number.isFinite(a) || !Number.isFinite(b)) continue;
if (!Number.isFinite(a) || !Number.isFinite(b)) {
ctx.restore();
continue;
}
const lo = Math.max(vertical ? p.x : p.y, Math.min(a, b));
const hi = Math.min(vertical ? p.x + p.w : p.y + p.h, Math.max(a, b));
if (hi <= lo) continue;
if (hi <= lo) {
ctx.restore();
continue;
}
ctx.save();
ctx.globalAlpha = this._styleNumber(style, "opacity", 0.14);
ctx.fillStyle = this._annotationPaint(style, [0.39, 0.45, 0.55, 1]);
Expand All @@ -581,9 +611,18 @@ Object.assign(ChartView.prototype, {
} else if (ann.kind === "rule") {
const vertical = ann.axis === "x";
const pos = vertical ? this._dataPxX(Number(ann.value)) : this._dataPxY(Number(ann.value));
if (!Number.isFinite(pos)) continue;
if (vertical && (pos < p.x - 1 || pos > p.x + p.w + 1)) continue;
if (!vertical && (pos < p.y - 1 || pos > p.y + p.h + 1)) continue;
if (!Number.isFinite(pos)) {
ctx.restore();
continue;
}
if (vertical && (pos < p.x - 1 || pos > p.x + p.w + 1)) {
ctx.restore();
continue;
}
if (!vertical && (pos < p.y - 1 || pos > p.y + p.h + 1)) {
ctx.restore();
continue;
}
const crisp = Math.round(pos) + 0.5;
ctx.save();
ctx.globalAlpha = this._styleNumber(style, "opacity", 1);
Expand Down Expand Up @@ -634,8 +673,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
36 changes: 35 additions & 1 deletion python/xy/_figure.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

import math
import warnings
from collections.abc import Mapping
from collections.abc import Mapping, Sequence
from os import PathLike
from typing import Any, Optional, TypeAlias, Union

Expand Down Expand Up @@ -112,6 +112,10 @@ def __init__(
# padding + hidden axes gives an edge-to-edge sparkline for dashboards.
self.padding = self._padding(padding, "padding")
self.title = self._optional_text(title, "title")
# Optional renderer-owned title slots. Declarative charts keep using
# ``title``; the pyplot shim fills this with Matplotlib's independent
# left/center/right title artists.
self.title_options: list[dict[str, Any]] = []
self.x_label = self._optional_text(x_label, "x_label")
self.y_label = self._optional_text(y_label, "y_label")
self.axis_options: dict[str, dict[str, Any]] = {
Expand Down Expand Up @@ -254,6 +258,8 @@ def set_axis(
tick_label_anchor: Optional[str] = None,
tick_label_min_gap: Optional[float] = None,
side: Optional[str] = None,
tick_sides: Optional[Any] = None,
tick_label_sides: Optional[Any] = None,
style: Optional[dict[str, Any]] = None,
minor_style: Optional[dict[str, Any]] = None,
nonpositive: Optional[str] = None,
Expand Down Expand Up @@ -287,6 +293,28 @@ def set_axis(
raise ValueError("x axis side must be 'top' or 'bottom'")
elif axis_dim == "y" and side not in {"left", "right"}:
raise ValueError("y axis side must be 'left' or 'right'")
if tick_sides is not None:
if isinstance(tick_sides, (str, bytes)) or not isinstance(tick_sides, Sequence):
raise ValueError(f"{axis_id} axis tick_sides must be a sequence")
allowed_tick_sides = ("bottom", "top") if axis_dim == "x" else ("left", "right")
tick_sides = list(tick_sides)
if any(value not in allowed_tick_sides for value in tick_sides):
raise ValueError(
f"{axis_id} axis tick_sides must contain only {list(allowed_tick_sides)}"
)
tick_sides = [value for value in allowed_tick_sides if value in tick_sides]
if tick_label_sides is not None:
if isinstance(tick_label_sides, (str, bytes)) or not isinstance(
tick_label_sides, Sequence
):
raise ValueError(f"{axis_id} axis tick_label_sides must be a sequence")
allowed_label_sides = ("bottom", "top") if axis_dim == "x" else ("left", "right")
tick_label_sides = list(tick_label_sides)
if any(value not in allowed_label_sides for value in tick_label_sides):
raise ValueError(
f"{axis_id} axis tick_label_sides must contain only {list(allowed_label_sides)}"
)
tick_label_sides = [value for value in allowed_label_sides if value in tick_label_sides]
values = (
None
if tick_values is None
Expand Down Expand Up @@ -338,6 +366,8 @@ def set_axis(
if tick_label_min_gap is None
else self._nonnegative_scalar(tick_label_min_gap, f"{axis_id} axis tick_label_min_gap"),
"side": side,
"tick_sides": tick_sides,
"tick_label_sides": tick_label_sides,
"style": styles.compile_axis_style(style, f"{axis_id} axis style"),
"minor_style": styles.compile_axis_style(minor_style, f"{axis_id} minor axis style"),
"nonpositive": nonpositive,
Expand Down Expand Up @@ -1273,6 +1303,10 @@ def _axis_spec(self, axis_id: str, range_: tuple[float, float]) -> dict[str, Any
"range": list(range_),
"side": opts.get("side", "bottom" if axis == "x" else "left"),
}
if opts.get("tick_sides") is not None:
spec["tick_sides"] = list(opts["tick_sides"])
if opts.get("tick_label_sides") is not None:
spec["tick_label_sides"] = list(opts["tick_label_sides"])
if label_position is not None:
spec["label_position"] = label_position
if label_offset is not None:
Expand Down
13 changes: 13 additions & 0 deletions python/xy/_payload.py
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,19 @@ def axis_range(axis_id: str) -> tuple[float, float]:
"ranges": {axis_id: list(axis["range"]) for axis_id, axis in axis_specs.items()}
},
}
if self.title_options:
spec["title_options"] = [
{
**{key: value for key, value in entry.items() if key not in {"y", "pad"}},
"geometry": pw.ship_scalar(
np.asarray(
(entry.get("y", 1.0), entry.get("pad", 8.0)),
dtype=np.float64,
)
),
}
for entry in self.title_options
]
if self.palette is not None:
# Chart-level categorical cycle (`xy.theme(palette=...)`). Every
# trace already bakes its own color and every categorical channel
Expand Down
Loading
Loading