Added two new coordinate systems: coord_polar and coord_radial. - #1059
Added two new coordinate systems: coord_polar and coord_radial.#1059iangow wants to merge 116 commits into
coord_polar and coord_radial.#1059Conversation
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #1059 +/- ##
==========================================
+ Coverage 87.43% 87.70% +0.26%
==========================================
Files 210 213 +3
Lines 14926 15724 +798
Branches 1892 2001 +109
==========================================
+ Hits 13050 13790 +740
- Misses 1300 1327 +27
- Partials 576 607 +31 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Using AI is fine as is any other tool, but for common development work I would rather leave out the attribution in the commit message. Mainly because we (the humans) are still responsible for reviewing, understanding and maintaining the code. The commit history should reflect intent, decisions and context. We can include tools in the history when their output is deterministic, or when there is purpose in signalling some level of detachment from the result. |
Sure. Would you like me to edit and resubmit? |
|
@has2k1 I added some tests and redid the commit messages to remove attribution to AI. Let me know if you want additional tests (e.g., output image comparisons). |
I will have a better idea when I start reviewing it, hopefully next week. |
|
I realised that one other related thing from In the meantime, I may just work on cleaning up the gallery of examples, as these may assist you [@has2k1] in your review. |
|
@iangow, please rebase onto main if you can. Or I can take over. |
has2k1
left a comment
There was a problem hiding this comment.
For the first pass, this is mainly a refactoring. The goal is to limit the external footprint of custom coord classes outside their modules.
|
On a follow up review, I discovered that while the geoms are drawn properly, fixing the issues with gridlines, ticks and labels some bigger changes. I'm working on that and will finish up this PR. |
|
One of the sticky requirements for radial coordinates is the ability to have theta axis ticks on the outside (along the circumference). Translated to cartesian coordinates, this is the opposite side (e.g. x axis at top of the panel) and PR #1085 makes it possible. Also, #1085 starts of with cherry-picking a refactor request in the earlier review. |
Implements polar coordinates by transforming x/y data to angle/radius at the Cartesian level, so all standard geoms work without modification. Adds a draw() hook to the coord base class for post-layer decorations; coord_polar uses it to draw concentric-circle and radial-spoke grid lines.
Replace the manual Cartesian-projection approach with subplot_kw={"projection": "polar"},
so geom_bar naturally becomes pie/bullseye wedges via munching. transform() now outputs
(theta_rad, r) pairs; draw() configures zero-location, direction, and r limits. Guard
axis_line and axis_text_x theme elements against PolarAxes spine/tick-param differences.
- Override setup_panel_params to fix partial arcs (start/end): set x panel range to [arc_lo, arc_hi] so set_limits_breaks_and_labels does not overwrite set_thetalim with the default (0, 2π) - Add thetalim and rlim parameters for data-space zoom on each axis, matching ggplot2's coord_radial() interface; filter r-axis breaks to within rlim to prevent PolarAxes autoscale expansion - Restore theta axis tick labels on the outer edge for partial-arc plots by converting data-space breaks to radian positions; suppressed for full-circle charts (pac-man, coxcomb) to preserve existing behaviour
… labels Partial-arc plots already show theta tick labels on the outer edge. Full-circle charts (pac-man, coxcomb) suppress them by default. theta_labels=True opts a full-circle plot into the same behaviour, passing scale breaks through to Matplotlib's PolarAxes which places and rotates them outside the circle automatically.
Without padding, theta labels sit right on the outer boundary. 8 points of pad applies whenever theta labels are shown — both for full-circle plots (theta_labels=True) and partial arcs.
Replaces the hard-coded pad=8 with a user-facing theta_label_pad parameter (default 8) so callers can tune the gap between the outer circle spine and theta tick labels without post-processing the figure.
facet.set_limits_breaks_and_labels() called ax.tick_params(axis='x', pad=pad_x) after coord.draw(), silently overwriting any custom theta_label_pad set by coord_radial. Add a post_setup_ax() hook to the coord base class, called by set_limits_breaks_and_labels after the margin pad, so coord_radial can apply theta_label_pad at the correct point in the rendering pipeline.
ax.set_xticks() with negative radian values silently extends xlim below 0, converting a full circle into a partial arc. When start is chosen so that the first few months map to negative radians (e.g. start = -π/2), the theta labels passed to set_xticks triggered this matplotlib behaviour. Normalise all break positions into [0, 2π] for full-circle plots before they are stored in panel_params.x.breaks so that set_xticks never receives a negative value. Partial-arc plots are unaffected (their breaks are always within [arc_lo, arc_hi] which is already in [0, 2π]).
Adds five visual regression tests covering representative partial arcs (half-disc, thin wedge, sliver, quarter, inner-radius half-disc). Title-gap check (half-disc, axis_line_y): panel bottom/left coincide with ink bottom/left (panel 110/50, ink 110/50 px) — title gap fully collapsed; no dropped-title rerouting needed.
A narrow arc combined with inner_radius>0 under-filled its panel. Two matching fixes: - polar_bbox placed the inner ring at inner_radius, but inner_radius is a fraction of the outer radius (0.5 in unit-circle space), so the ring belongs at inner_radius * 0.5. This corrects the aspect that sizes the layout cell. - _TightWedgeBbox measured the wedge with update_from_path, whose Bezier control-point hull reaches back to the centre and over-widens the box for a narrow sector. Use the path's true curve extents instead. With both, narrow donut arcs fill 100% x 100% undistorted, matching the non-donut arcs.
`PolarAxes.draw` recomputes the axes-background `Wedge` (grey fill and the clip path the geoms share) on every draw in axes-fraction space, assuming a square box. In the non-square panel a partial arc now gets, that squashed the background — and the theta tick labels riding on it — into an ellipse mismatched with the data and the spine, even though `transData` itself was correct. Return a `_PanelWedge` from `_gen_axes_patch` that ignores those square-fit geometry setters and instead holds a fixed unit wedge routed through `transWedge + transAxes` — the transform the `polar` spine already uses — so the background traces the same arc as the data. `_reshape_panel_wedge`, run after `super().draw`, restores that geometry each draw. Also simplify `_set_lim_and_transforms` to retype `axesLim` in place rather than rebuilding the transform stack, so the tick labels' cached transform copies do not go stale.
The previous distortion fix reshaped the background wedge *after* `super().draw`, but `PolarAxes.draw` (inside that super call) draws the geoms clipped to the patch in the same call. On a single render the geoms were therefore clipped to the patch's initial square geometry — a half-height ellipse — so most of the plot vanished behind an empty background. A second draw hid it, which is why it was missed. The geoms snapshot the patch's transform *object* when they capture their clip, so it must be set once and never replaced. Fix the `_PanelWedge` transform to `transWedge + transAxes` at construction and block `set_transform` (as the geometry setters already are), so `Axes.clear` cannot reset it to `transAxes`. Reshape the wedge width before `super().draw` rather than after, so the clip is correct on the frame that draws the geoms. Add `test_coord_radial_geoms_clip_to_full_sector`, which asserts the geom collection's clip-path bbox coincides with the data sector — the metric the patch-only test missed.
A partial arc tilted off the axes (e.g. start=-pi/2, end=-pi/3) rendered small and low, with large left/right margins. The layout's protrusion heuristic — meant to reserve room for y tick labels that stick out past a Cartesian axes edge — measured the r-axis labels fanning out along the up-tilted spoke and reserved ~half the figure height above the panel, capping its size and pushing it down. The protrusion concept does not apply to a polar panel: its tick labels are positioned by coord_radial along the arc, inside a panel already sized to the tight sector, and their fixed clearance is already reserved by the polar clearance estimates. Override the four protrusion methods on `PolarPlotLayoutItems` to return zero, alongside the clearance overrides that already handle the "no single side to measure" case. A thin wedge now gets the same panel size as a half-disc of the same aspect, centred in the available space.
A polar panel draws its full theta and r axes on every facet, so their ticks and labels reach into the gullies on all sides and sit at the arc apex inside the panel's top edge. Reserve that space in the panel spacing so decorations no longer cross the gulley, and always shift the strip clear of the apex decorations (a polar panel has no geometric "inside" between the panel and its arc-bound axis, so the shift applies at any strip_placement). Zero the polar tick-mark clearance: polar ticks point inward from the arc and never protrude past the panel edge, so their length only pads the label, which the text clearance already covers. Return the label extent bare from the polar text helpers and add the tick-label margin in each caller, matching the Cartesian contract, so the margin is no longer counted twice in the strip shift.
The wedge boundary spines were partly matplotlib's to decide: `polar` was always on and `inner` followed the geometry, so `axis_line_x` — which replaces the blank `axis_line_x` a theme uses to hide them — drew a line around the donut hole where no theta axis lives. Every spine is now opt-in, visible only through its own axis_line themeable, and the theta leaves gate on an axis being present at that side like the r leaves do.
ggplot2 deprecated coord_radial(direction) in favour of reverse, and plotnine's version overlapped the same ground: the sweep is now always clockwise and reverse alone decides angular order. A mirrored partial arc is still expressible, since the arc always sweeps forward from start -- (start, end, direction=-1) is (-end, -start, reverse="theta"), which the reverse docs now spell out. Nothing depended on a variable sweep once the parameter went, so _mpl_direction goes with it and its two readers name the clockwise convention directly.
It was never a guide: a bare dataclass outside the Registry, with no train/draw and no route into the guides pipeline, since _bind_source matches guides by scale aesthetic and theta is not one. coord_radial read guides.theta directly through cast(Any)/getattr, and six of its seven parameters did nothing. Position guides -- guide_axis and a theta sibling -- will be built comprehensively later, so the placeholder goes now instead of being harmonised twice. Theta labels lose their only rotation control with it, so the rotated-label clearance test goes too. The layout code that test covered stays put: it becomes reachable again when position guides land.
The polar tests reached into spines, tick artists, clip paths and text layout to assert what a plot draws. Baseline images state the same things and survive refactoring of the drawing code, so only the claims an image cannot make are kept: arc-range normalisation, the wedge aspect ratio, label swapping, and the two API contracts. The baseline images themselves follow.
Cover the full circle, half disc, quarter, thin wedge and sliver, an arc whose end wraps past 12 o'clock, a rotated full circle, and three donut shapes including the narrow arc whose inner ring is a fraction of a reduced outer radius.
Cover mapping the radius to x, a discrete theta scale closing the circle, the theta buffer at the ends of a partial arc, and zooming each axis.
Cover running the data the other way around an arc, inverting the radius with and without a donut hole, and both together.
Cover paths and ribbons bending along the arc, a polygon closing across the seam at 12 o'clock, tangential upright text, and the pie and donut idioms built from stacked bars. The pie images record today's behaviour, which leaves a gap at 12 o'clock because the continuous angular scale is expanded. See kata issue 0xfh.
Cover the default theme leaving every polar boundary unlined, axis_line styling the outer circle that panel_border used to hide, the theta line skipping the donut hole, and each radial spoke themed on its own side. A spoke is themeable only where a radial axis sits. A plain partial arc has one, at the start; the end spoke gains one when reversing the angular axis moves it there, or when a secondary axis adds a second.
A plain axis_text, axis_ticks or axis_ticks_minor now reaches a polar panel's angular and radial decorations, tick length applies on both axes, and a blanked tick leaves the label gap alone.
A scale's position moves a polar axis title without moving the axis, and a secondary radial axis renders on the opposite spoke of a partial arc or on the other side of a full circle's single spoke.
Faceted polar panels keep their decorations out of the gulley and below the strips, angular labels sit on one gap regardless of descenders, and a colourbar sits clear of a half-disc panel.
Every image was reviewed against the question of what it claims that its neighbours do not, leaving twenty-nine. Where a group swept a parameter, only the members carrying a named behaviour stayed: two arc shapes rather than five, one donut alongside the narrow arc that regressed, one reverse mode per axis, and one facet layout. Where two claims could share a channel that already tells them apart, they merged into one image: the general themeables now show their reach across labels, marks, grid and a secondary axis together, and both axes zoom on one coordinate system. Some images went because their claim already holds implicitly in the rest of the suite: the default theme leaving every polar boundary unlined, a legend beside a polar panel, and a plain full circle. Expansion kept only the case where it is switched off, the default being visible everywhere. The angular-label descent image went for a different reason. Its two labels sat on opposite sides of the circle, where the bounding boxes face different ways, so the gaps it compared were never comparable. The extension-themeable test in the theme suite asserted a panel colour by reading the axes back. It now compares an image of the red panel, which removes the only pyplot use in that file.
The buffer applies to both axes, holding the data clear of the arc ends as well as the outer radius. The description mentioned only the radius.
Twenty-nine images for coord_radial covering arc and donut shapes, the angular and radial axes, expansion and zoom, the reverse modes, munched geoms, per-side and general theming, secondary radial axes and faceting. One more for the theme suite, where a themeable defined outside plotnine paints the panel red.
A secondary theta axis now reaches the panel with its breaks as angles, trimmed to a partial arc like the primary ones. Without an inner hole there is no rim to draw it on, so it is dropped with a warning.
Draw a theta scale’s secondary ticks and labels along the inner rim, and expose the axis to the inner theta themeables. Continue to warn and omit it when the panel has no hole.
Verify secondary theta axes on partial arcs, with `theta="y"`, and with reversed angular direction. Cover general axis themeables and their inner-theta overrides.
A secondary axis records a polar side as readily as a cartesian one, but flipping is a cartesian operation and only cartesian sides have an opposite. Saying so leaves the type checker with nothing to report.
A polar panel reports its radial axis as sitting on the left of a Cartesian box, so the y-axis tick themeables reached the radial ticks and labels. Every text element carries a visible property, so styling the left of a panel re-showed the labels a polar themeable had just blanked, and blanking axis_text_r or axis_text_r_start had no effect. Cartesian sides now resolve no axis on a polar panel, so only the theta and r themeables reach its decorations.
This picks up on a thread started with #10. I implemented both the superseded (in
ggplot2)coord_polar()and the newercoord_radial(). Almost all arguments of the R equivalents have been implemented (I omittedclipbecause it is not supported incoord_cartesian()here either).I created a small gallery of examples here, including examples from the
ggplot2documentation and some interesting plots that seem to provide some rationale for using these coordinate systems.I made some documentation mirroring the style of other plotnine documentation (and the original from
ggplot2).I got a lot of help from Claude Code (and Codex when I hit limits on Claude) on this, but I was careful to nudge it use Matplotlib's native
PolarAxesas much as possible to keep the implementation lean. Looking at the code, it seems pretty concise.Let me know if there's anything you like me to do to refine this or explain things better.