Circle and CirclePolygon on datetime plots is painful (more generally, uniform patches on non-uniform axes)

This is a restatement of a feature request I posted at github, which was closed with a suggestion that it be discussed here (https://github.com/matplotlib/matplotlib/issues/18267) instead, so that a specific API suggestion can be made.

Summary

Circle, CirclePolygon, regular polygon etc patches have, for example, a radius parameter which may not properly apply to both Axes simultaneously (i.e. which does not have a well defined unit). The frequent example would be Axes on a datetime plot. On such a plot the X axis data may vary by <<1 as the Y axis data may vary by >> 1.

Further, the necessary transforms are hard to come by and not well documented.

I did not feel qualified to suggest specific API improvements to better support putting certain kinds of patches on non-uniform (aspect ratio != 1) Axes, to include:

  1. Allowing parameterization of patches such that the parameter’s transform can be inferred or explicitly specified. E.g., CirclePolygon(..., radius_xdata=None, ...) could specify a radius in x-axis units.
  2. Providing easy access to transforms so that positioning and sizing a CirclePolygon using data units, into Axes space is more easily achieved. E.g., ax.transDataToAxes.transform() (see data_to_axis in below source).
  3. Is the ability to add transforms to each other (per that code) actually documented anywhere? I found it in a stackoverflow answer.

Meanwhile, other patches (such as Rectangle) operate in data-space just fine, creating the impression patches should generally function well in data space.

In the code sample below, only the c1 circle displays properly, and it must be defined in axes space (0…1,0…1), which requires transforming the data coordinate to axes space, which requires knowing about the + operator on transform objects, and inverted() … PITA.

Code example

Includes c2_... and c3_... failed attempts which show what people like me may try. The result, given the datetime X axis and much larger Y range, is a point, a line, or nothing at all, which can be very confusing and a pain to debug.

  # Create new plot
   fig = plt.figure()
   ax = fig.add_subplot(111)

   # Create rectangle x coordinates
   startTime = datetime(year=2020,month=7,day=6,hour=6)
   endTime = startTime + timedelta(seconds = 600)

   # convert to matplotlib date representation
   start = mdates.date2num(startTime)
   end = mdates.date2num(endTime)
   middle = (start+end)/2
   width = end - start

   # assign date locator / formatter to the x-axis to get proper labels
   locator = mdates.AutoDateLocator(minticks=3)
   formatter = mdates.AutoDateFormatter(locator)
   ax.xaxis.set_major_locator(locator)
   ax.xaxis.set_major_formatter(formatter)

   # set the limits
   plt.xlim([start-width, end+width])
   plt.ylim([8000, 10000])

   xlim = ax.get_xlim()
   ylim = ax.get_ylim()

   xspan = xlim[1]-xlim[0]
   yspan = ylim[1]-ylim[0]

   axis_to_data = ax.transAxes + ax.transData.inverted()
   data_to_axis = axis_to_data.inverted()
   trans = data_to_axis.transform

   rect = mpatches.Rectangle((start, 9000), width, 500, color='yellow')

   circle_center = (middle,9250)
   circle_radius_datax = xspan/10
   circle_radius_datay = yspan/10
   circle_radius_ax = trans((xlim[0]+circle_radius_datax,0))[0] # will "by definition" be 0.1 bc datax=xspan/10
   assert round(circle_radius_ax/0.1)==1

   # Plot rectangle
   c1 = mpatches.CirclePolygon(trans(circle_center),radius=circle_radius_ax,resolution=6,color='red',transform=ax.transAxes)
   c2_d = mpatches.CirclePolygon(circle_center,radius=circle_radius_datax,resolution=6,color='green',transform=ax.transData)
   c3_d = mpatches.CirclePolygon(circle_center,radius=circle_radius_datay,resolution=6,color='blue',transform=ax.transData)
   c2_dx = mpatches.CirclePolygon(circle_center,radius=circle_radius_datax,resolution=6,color='green',transform=data_to_axis)
   c3_dx = mpatches.CirclePolygon(circle_center,radius=circle_radius_datay,resolution=6,color='blue',transform=data_to_axis)
   ax.add_patch(rect)
   ax.add_patch(c2_d)
   ax.add_patch(c3_d)
   ax.add_patch(c2_dx)
   ax.add_patch(c3_dx)
   ax.add_patch(c1)

Thanks for moving this here.

What behaviour do you want the “circle” to have? How are you going to define its radius if the “units” of the two axes are different? What you are describing is painful, because its ill-defined. If you just want a circle that has its size defined in physical units (i.e. points on the page), then you can use scatter with the markersize set. There are other ways of getting a circle in physical units, but that is the most straightforward.

If you want the circle in other units, then you will have to explain how that is supposed to work.

Thank you for your time and response. I was not aware of this resource or I’d have started here.

I do understand that the root cause of pain is a fundamental incongruence between the dimensionality of the objects in question. I’m looking at it from a usability standpoint, summarized as:

There are sets of API objects that become difficult to use together under specific configurations (non-unity aspect ratios).

We can ask whether this can be improved. I believe the “root cause” could be a good reason for additional API pain relief such as in # 1-3 above and # 4+ here vs. the status quo.

A non-expert user dealing with datetime plots wonders whether a CirclePolygon might be coded to assume radius is something reasonable like one of the axis dimension units. Trial and error is the only avenue, and the necessary transforms to use the canonical solution of positioning in axes-space is hard to find. Datetime vs numeric Y plotting is an extremely common usecase in many domains, and one of its characteristics is an aspect ratio very far from unity.

Meanwhile, the scatter-plot solves only some of those problems. For example, a user still has to figure out how to get from data space to the documented units of points^2 for marker size parameter s, and has to experiment with the many additional degrees of freedom introduced by the scatter plot’s many parameters and functionalities (how will it affect the rest of the plot, the Axes, etc?)

Somewhere they find out 72 is a magic number, somewhere else they figure out how to extract dpi, somewhere else they figure out how to determine the area used by a set of axes. Specifying a radius in terms of xlim or ylim delta % is simply easier, and correlates well with the visual characteristics of the end result.

As I mentioned in my initial note, one way to resolve the root incongruence is to signal the unit or axes binding in some manner. I am not sure what the canonical way of doing that in matplotlib would be. CirclePolygon could have parameters named radius_xaxis and radius_yaxis, and it could raise an exception or warning if plain radius is used in an unlikely drawing context. In fact, an additional suggestion to # 1-3 above would be:

  1. Implement a warning system so that Artists can emit helpful hints during draw(warnings=True). This could be used when, for example, an artist is sized to invisibility, or to an aspect ratio unlikely to be correct (e.g., a circle that emits a line due to aspect ratio).

As a side note, the mere presence of unitized parameters, or even simply mentioning in the documentation of each “uni-dimensional” patch that unexpected results may occur in datetime and non-unit aspect plots would be a big leg up for users. Thus I could also suggest:

  1. Add this to all relevant patches’ documentation: Note: You probably want to specify transform=ax.transAxes in a datetime and non-unit aspect plots, otherwise the results will not be circular and may be invisible

Thanks for listening.

A few possible resources:

I think its fair to say more documentation would be welcome. I think you will find most of the warnings you would like to emit are quite hard.

  • transforms_tutorial makes no mention of the ability to add transforms, which is a necessary component to building a data_to_axis transform (see code sample above). BTW, I’d be happy to contribute some text or make some proposed additions to that doc if you point the way. Not sure if it’s a git resource I can send a pull request for.
  • ellipse_with_units seems to not apply to the issues I raise, but thank you for the example
  • convert_units has zero documentation and (unless I’m missing something, having looked at the source), applies only to the trivial problem of converting a datetime to a matplotlib float. The whole unit thing seems opaque btw.

Are you of the opinion that none of the suggested API facilities I mentioned are appropriate? Perhaps at a minimum, devs could consider a data_to_axis transform call at the right spot in the heirarchy, documented as being relevant to patches such as Circle and regular polygons and accompanied by an example, linked from Circle & CirclePolygon themselves. That would go a long way.

Again, happy to help/contribute if you point me to the right spot.

I agree that convert has no documentation and that is a big problem. That is what converts units to data floats.

Data floats to axes is indeed just the trans data. + trans axes inverted.

I didn’t address the many API suggestions. I do suspect that most of them are not practical in that the complexity cost would be great. But I’ve not thought deeply about how to implement these things.