Skip to content

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

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

Added two new coordinate systems: coord_polar and coord_radial.#1059
iangow wants to merge 126 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 94.80382% with 49 lines in your changes missing coverage. Please review.
✅ Project coverage is 87.83%. Comparing base (cb1fc22) to head (596006f).

Files with missing lines Patch % Lines
plotnine/_mpl/_radial_axes.py 91.97% 6 Missing and 9 partials ⚠️
plotnine/themes/themeable.py 93.67% 8 Missing and 7 partials ⚠️
plotnine/coords/coord_radial.py 95.77% 5 Missing and 4 partials ⚠️
plotnine/_mpl/layout_manager/_plot_side_space.py 88.63% 3 Missing and 2 partials ⚠️
plotnine/_mpl/_radial_axis.py 96.87% 3 Missing and 1 partial ⚠️
plotnine/_mpl/axes.py 94.11% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1059      +/-   ##
==========================================
+ Coverage   87.41%   87.83%   +0.41%     
==========================================
  Files         211      214       +3     
  Lines       14938    15821     +883     
  Branches     1892     2014     +122     
==========================================
+ Hits        13058    13896     +838     
- Misses       1302     1320      +18     
- Partials      578      605      +27     

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

@has2k1
has2k1 force-pushed the coord-polar branch 2 times, most recently from 470c92d to 69081bd Compare August 12, 2026 20:51
iangow added 7 commits August 14, 2026 21:13
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.
has2k1 added 29 commits August 14, 2026 21:13
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.
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.
Reject `start == end` before computing panel geometry. Direct callers to use `end=None` for a full circle or choose a different end angle.
Transform bounds, endpoints, and intercepts with `x` and `y` so radial reversal and `theta="y"` preserve their dimensions. Let geoms select transformed dimensions through the coordinate-system contract.
Measure the largest visible polar tick label in each dimension and add the larger configured label margin or non-blank tick length. Reserve that band on every panel edge because polar axes follow arcs and spokes rather than Cartesian sides.

This slightly shrinks every polar panel, so each baseline image changes.
A polar panel reserved its largest tick label on all four sides. One long
label therefore cost a third of the panel and bought space on three sides
that had nothing long on them. Each side now reserves the labels that
reach past it, from where a label attaches to the panel, its own size, and
the gap that holds it off its boundary.

How much of that reach falls inside the panel depends on the panel the
layout is still solving for, so it is taken from the previous render. A
first render over-reserves by that difference rather than clipping.

Polar baseline images shift again: the panels are larger.
Move label-bound measurement out of the theta tick class and define the full-circle and outward-direction calculations beside it. This gives polar label placement one coordinate convention without changing theta-label behaviour.
Theta labels were anchored from the nearest bounding-box corner. Between the cardinal directions, this moved the label edge away from its tick and made the gap depend on label width. Centre each label on its tick, then offset the point facing the panel by the themed padding.
Matplotlib's discrete alignments made the gap between an r label and its spoke depend on the spoke angle and label dimensions. Centre each label on its spoke, then offset the edge facing the panel by the themed padding. Apply this placement to full circles and both boundaries of an arc.

Recompute the placement during each draw because it depends on rendered label dimensions. Recompute it before the final redraw so r labels retain the measured position above geoms.
Polar layout reserved each label's full width or height for every panel edge. Long labels therefore shrank the panel along edges they did not extend towards. Calculate clearance from each label's anchor and outward direction, and measure only the label pair assigned to each polar boundary.

For full circles, anchor r-label clearance to the configured label spoke instead of the start of the theta range.
Make the polar axes responsible for identifying full-circle panels, locating r-axis spokes, and returning outward directions. Tick placement and layout now obtain this geometry from the panel instead of duplicating it or depending on Matplotlib's `PolarAxes` type.
The 42 images cover the polar suite: arcs and donuts, reversed theta
and r, secondary theta and radial axes, per-side axis theming, and the
panel spacing that long tick labels and large theta text demand.
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