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 126 commits into
coord_polar and coord_radial.#1059Conversation
Codecov Report❌ Patch coverage is 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. 🚀 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. |
470c92d to
69081bd
Compare
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.
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.
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.