Skip to content

Added two new coordinate systems: coord_polar and coord_radial. - #1059

Open
iangow wants to merge 116 commits into
has2k1:mainfrom
iangow:coord-polar
Open

Added two new coordinate systems: coord_polar and coord_radial.#1059
iangow wants to merge 116 commits into
has2k1:mainfrom
iangow:coord-polar

Conversation

@iangow

@iangow iangow commented May 4, 2026

Copy link
Copy Markdown
Contributor

This picks up on a thread started with #10. I implemented both the superseded (in ggplot2) coord_polar() and the newer coord_radial(). Almost all arguments of the R equivalents have been implemented (I omitted clip because it is not supported in coord_cartesian() here either).

I created a small gallery of examples here, including examples from the ggplot2 documentation 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 PolarAxes as 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.

@codecov

codecov Bot commented May 5, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.94947% with 60 lines in your changes missing coverage. Please review.
✅ Project coverage is 87.70%. Comparing base (ebf5cb3) to head (4a122da).

Files with missing lines Patch % Lines
plotnine/coords/coord_radial.py 93.11% 8 Missing and 7 partials ⚠️
plotnine/themes/themeable.py 93.67% 8 Missing and 7 partials ⚠️
plotnine/_mpl/_radial_axes.py 91.51% 6 Missing and 8 partials ⚠️
plotnine/_mpl/layout_manager/_plot_side_space.py 86.36% 3 Missing and 3 partials ⚠️
plotnine/_mpl/_radial_axis.py 95.23% 3 Missing and 2 partials ⚠️
plotnine/_mpl/layout_manager/_plot_layout_items.py 89.18% 2 Missing and 2 partials ⚠️
plotnine/_mpl/axes.py 94.11% 0 Missing and 1 partial ⚠️
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@has2k1

has2k1 commented May 5, 2026

Copy link
Copy Markdown
Owner

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 PolarAxes as much as possible to keep the implementation lean. Looking at the code, it seems pretty concise.

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.

@iangow

iangow commented May 5, 2026

Copy link
Copy Markdown
Contributor Author

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 PolarAxes as much as possible to keep the implementation lean. Looking at the code, it seems pretty concise.

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?

@iangow

iangow commented May 5, 2026

Copy link
Copy Markdown
Contributor Author

@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).

@has2k1

has2k1 commented May 5, 2026

Copy link
Copy Markdown
Owner

@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.

@iangow

iangow commented May 5, 2026

Copy link
Copy Markdown
Contributor Author

I realised that one other related thing from ggplot2 that I did not implement is guide_axis_theta() (one thing this does is rotation of axis labels). It seems this wouldn't be too difficult, but I figure this can wait for a review of what is there now.

In the meantime, I may just work on cleaning up the gallery of examples, as these may assist you [@has2k1] in your review.

@has2k1

has2k1 commented May 25, 2026

Copy link
Copy Markdown
Owner

@iangow, please rebase onto main if you can. Or I can take over.

@iangow

iangow commented May 25, 2026

Copy link
Copy Markdown
Contributor Author

@iangow, please rebase onto main if you can. Or I can take over.

@has2k1 Done.

@has2k1 has2k1 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the first pass, this is mainly a refactoring. The goal is to limit the external footprint of custom coord classes outside their modules.

Comment thread plotnine/facets/facet.py
Comment thread plotnine/facets/facet.py Outdated
Comment thread plotnine/ggplot.py Outdated
Comment thread plotnine/facets/facet.py Outdated
Comment thread plotnine/coords/coord.py
@iangow

iangow commented May 28, 2026

Copy link
Copy Markdown
Contributor Author

@has2k1 I took a stab at the requested changes (I made an issue for each of the four changes you requested and you can see the associated commits above). I also implemented the guide_axis_theta() that was missing. I updated the page here to check the changes and to demo the new guide_axis_theta().

@iangow
iangow requested a review from has2k1 June 11, 2026 12:35
@has2k1

has2k1 commented Jun 19, 2026

Copy link
Copy Markdown
Owner

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.

@has2k1

has2k1 commented Jun 23, 2026

Copy link
Copy Markdown
Owner

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.

iangow added 9 commits August 12, 2026 16:46
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π]).
has2k1 added 23 commits August 12, 2026 16:46
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.
has2k1 added 3 commits August 12, 2026 23:27
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.
has2k1 added 2 commits August 13, 2026 00:06
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants