Skip to content

Make FeatureArtist a Collection #2323

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Feb 23, 2024
Merged

Conversation

rcomer
Copy link
Member

@rcomer rcomer commented Feb 6, 2024

Rationale

If we make FeatureArtist inherit from Collection instead of Artist this would

This is currently a pretty rough draft as I wanted to get feedback on the idea before spending time polishing it. Two tests are very broken because they are built around the assumption that we draw via PathCollection instances.

I have eyeballed the output from all the examples, and they look fine.

Implications

@@ -510,7 +510,6 @@ def test_gridliner_remove():
gl.remove()

assert gl not in ax.artists
assert not ax.collections
Copy link
Member Author

Choose a reason for hiding this comment

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

Broke this by making coastlines a collection. However, this line was redundant since the PR that added it also stopped GridLiner adding the LineCollections to the axes.

@greglucas
Copy link
Contributor

It does seem like most of the features are closer to collections, whether it be PolyCollection (LAND), LineCollection (COASTLINES). So this seems reasonable to me.

Are there any issues or benefits with exposing FeatureArtist.set_array() now if it is a collection? We currently have styler which takes in a geometry and styles that geometry. That seems like it could be deprecated and handled by the collection mapping through the geometries with this PR. So, possibly a nice potential of this approach by calling set_array() with values for each of the geometries within the feature similar to what geopandas does: https://geopandas.org/en/stable/docs/user_guide/mapping.html#choropleth-maps

Should the name be updated and called FeatureCollection, and then deprecate FeatureArtist and tell people to use the new names instead?

@rcomer
Copy link
Member Author

rcomer commented Feb 8, 2024

Thanks @greglucas!

I did spot #1789 and wondered if this would help there. I don't really know anything about ScalarMappables though. Does each element in the array correspond to a single path?

I think the point about styler is that when you are using something like Natural Earth the geometries get generated on the fly so the user doesn't necessarily know how many there are or what order they come in. They can also change if you pan or if you zoom with the auto-scaler. Even if you pass explicit geometries, a single geometry might be transformed to multiple geometries if its crs is different from the axes crs. styler gets applied after the transformation.

I also wondered about the name. Having Collection rather than Artist in the name would seem more correct in Matplotlib context. I'm not sure about FeatureCollection though as that seems to imply it handles multiple features (like a LineCollection draws multiple lines). Maybe GeometryCollection, GeomCollection or GeomSet (borrowing from ContourSet)? Within Cartopy having FeatureArtist vs Feature makes it fairly intuitive that the former has something to do with drawing even if you weren't familiar with Matplotlib. So maybe could go a different direction and have something like FeatureDrawer or ...? Hmmm, naming things is hard!

@greglucas
Copy link
Contributor

greglucas commented Feb 9, 2024

I don't really know anything about ScalarMappables though. Does each element in the array correspond to a single path?

Yes, they all get passed to the renderer at once within collections rather than one-by-one as Python objects.
https://github.com/matplotlib/matplotlib/blob/d2cc4d0b0a2e1e9e8a5c1f311f0e10ef8acdb9ef/lib/matplotlib/collections.py#L423-L429

That is a good point about not necessarily knowing the geometries and what is used. Do we actually have all the geometries, it is just not drawing the ones that are out of the view? I'm wondering if we could do some fancy thing with only sending the specific paths we're interested in to the renderer, even if the others still exist. That definitely seems like a lot more work and effort to keep track of all the potential split paths and whats in/out of view!

Naming things is hard :)
I'd say we should avoid Geometry to avoid conflicts with shapely's names: https://shapely.readthedocs.io/en/latest/reference/shapely.GeometryCollection.html I think your point about FeatureArtist being obvious is good, so it is fine to keep the name as it is.

All this to say, this seems like a good idea and enables some other interesting possibilities as follow-ups as well.

@rcomer
Copy link
Member Author

rcomer commented Feb 9, 2024

Hmmm...

import matplotlib.pyplot as plt
import cartopy.crs as ccrs
import shapely.geometry as sgeom

square1 = sgeom.Polygon([(-10, -10), (10, -10), (10, 10), (-10, 10), (-10, 10) ])
square2 = sgeom.Polygon([(30, 0), (50, 0), (50, 20), (30, 20), (30, 0)])

ax = plt.figure().add_subplot(111, projection=ccrs.PlateCarree())
ax.coastlines()

ax.add_geometries([square1, square2], crs=ccrs.PlateCarree(),
                  facecolors=['r', 'b'])

plt.show()

image

If we pan/zoom so that the red square is out of the picture, the blue square turns red!

Also,

ax = plt.figure().add_subplot(111, projection=ccrs.PlateCarree(central_longitude=180))
ax.coastlines()

ax.add_geometries([square1, square2], crs=ccrs.PlateCarree(),
                  facecolors=['r', 'b'])

image

This is also true with v0.22, so I haven't actually broken anything here.

Edit: #2325 addresses the second of these.

@rcomer
Copy link
Member Author

rcomer commented Feb 9, 2024

Do we actually have all the geometries, it is just not drawing the ones that are out of the view?

Currently draw only accesses the geometries that intersect with the map

geoms = self._feature.intersecting_geometries(extent)

If you call add_geometries then you are creating a ShapelyFeature instance which is distinct from Natural Earth, etc. So it might be reasonable to get hold of all the geometries specifically for the ShapelyFeature but leave as is for the rest?

@greglucas
Copy link
Contributor

If you call add_geometries then you are creating a ShapelyFeature instance which is distinct from Natural Earth, etc. So it might be reasonable to get hold of all the geometries specifically for the ShapelyFeature but leave as is for the rest?

I think this might be similar to what we are doing with the GeoQuadMesh, where a user has all the data and would call set_array() on all of their geometries, but then we dispatch out to whatever quads are specifically shown. So this draw method would just grab the facecolors and other attributes of the elements that it intends to draw.

@rcomer rcomer changed the title POC: make FeatureArtist a Collection Make FeatureArtist a Collection Feb 17, 2024
@rcomer
Copy link
Member Author

rcomer commented Feb 17, 2024

I think I am pretty happy with this one now. I should squash the commits before merge but the history may be useful for review.

In particular, I found some weird behaviour when I made the change within draw at 5124784: four of the five image tests failed, as the blue square shifted position slightly. Only the test that uses styler was unaffected, but that one was still drawing a single path at a time. I made the styler test consistent with the others by adding an extra geometry so the styler test is now drawing two paths at once. I have no idea why drawing a single path vs multiple paths would make this difference and I'm also not sure whether the difference is big enough to matter - the RMS difference was ~3.7.

The doc failure appears to be because of WMTS shenanigans.

I think this now also closes #1789.

@rcomer rcomer linked an issue Feb 17, 2024 that may be closed by this pull request
Copy link
Member Author

@rcomer rcomer left a comment

Choose a reason for hiding this comment

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

I decided to draw one path per geometry for the ShapelyFeature case rather than trying to line up the various style parameters (which may or may not be lists) with the geometries that get drawn. Collection has some logic for cycling the properties if there are fewer than the number of paths and also some special handling if you set different numbers of linestyles and linewidths. I think it would be a headache to try to account for all of that!

@@ -189,8 +219,7 @@ def draw(self, renderer, *args, **kwargs):
projected_geom = geom

geom_paths = cpatch.geos_to_path(projected_geom)
if not geom_paths:
continue
Copy link
Member Author

@rcomer rcomer Feb 17, 2024

Choose a reason for hiding this comment

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

If we call make_compound_path with zero paths then it returns an empty path. Keeping hold of this helps us preserve one-path-per-geometry.

# `array` or a list of facecolors then we should keep the colours
# consistent after pan/zoom. Do this by creating a Path for every
# geometry regardless of whether they are currently in view.
geoms = self._feature.geometries()
Copy link
Member Author

@rcomer rcomer Feb 17, 2024

Choose a reason for hiding this comment

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

I thought about inserting empty paths for geometries that are not in view, which should be more efficient than transforming all of them. However I'm not sure that the extra code complexity is justified: since the mapping from geometry to path is cached, it will only make a difference at the first draw. Also if someone is passing a specific list of geometries then I would think they usually want to draw most/all of them at least once.

Copy link
Contributor

Choose a reason for hiding this comment

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

Do you think this will this cause any confusion with the difference between classes of whether geometries is all of the geometries versus only some of them? I'm not sure and as you say it might be a lot more code complexity to justify something that isn't needed.

Copy link
Member Author

Choose a reason for hiding this comment

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

It's a definite possibility that that would cause confusion! But I suspect that the vast majority of users using Natural Earth or the other downloads would only pass one style for all geometries so will never notice the difference.

@rcomer rcomer marked this pull request as ready for review February 17, 2024 14:24
Copy link
Contributor

@greglucas greglucas left a comment

Choose a reason for hiding this comment

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

🎉 I like this update and think it brings a lot of nice capabilities for color-mapping geometries!

Comment on lines +129 to +130
self.set(**feature.kwargs)
self.set(**self._kwargs)
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not sure if set() emits any callbacks, but we might want to merge the dictionaries before setting to avoid setting properties twice. Could even bring in a zorder dictionary first too {"zorder": 1.5} | ...

Suggested change
self.set(**feature.kwargs)
self.set(**self._kwargs)
self.set(**(feature.kwargs | self._kwargs))

Copy link
Member Author

@rcomer rcomer Feb 17, 2024

Choose a reason for hiding this comment

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

My thinking for calling twice rather than merging the dictionaries is that this way we don't have to sort out the aliases ourselves. So if one has "facecolor" and the other has "fc", then the second will win. Before this PR we use style.merge for that. So we could go back to that, though I think we should add in the plurals (facecolors, etc) which the comment at the top of the style module says are deliberately omitted but does not state why.


self._feature = feature

def set_facecolor(self, c):
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not entirely clear why there is a deviation here from Matplotlib with adding "never" other than that previously the facecolor and edgecolor weren't available for a user to set so they had to go through color directly.

Now that we have inherited set_facecolor() I'm wondering if we should deprecate the "never" option? Users can now explicitly update the color they want: facecolor -> only face, edgecolor -> only edge, color -> both. Maybe I'm missing something else though and this would be really annoying to get rid of?

Copy link
Member Author

Choose a reason for hiding this comment

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

"never" is used by BORDERS and COASTLINES so my suspicion is it was done to make things more intuitive for the basic user. If I don't know anything about how artists work I would be surprised that setting color on a coastline also colours the land in.

I think you could previously pass edgecolor and facecolor because ultimately they were just passed in to PathCollection.

Copy link
Member Author

@rcomer rcomer Feb 23, 2024

Choose a reason for hiding this comment

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

Another solution to that might be to have a flag on the feature that says "these are edges" and override set_color to only set the edgecolor in that case. This would make us more consistent with LineCollection which will still allow you to explicitly set facecolor if you really want. This might also work as a solution for #1782 - though there we probably need to account for some geometries being edges and some not.

Copy link
Contributor

Choose a reason for hiding this comment

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

That does seem like a nice way to go. But, I think what you have here is good and consistent with what we had before, so lets not stall this.

Copy link
Member Author

Choose a reason for hiding this comment

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

Yeah, I was thinking more for a follow-up PR as I'm sure there are pitfalls I haven't thought of!

Thanks for the merge!

# `array` or a list of facecolors then we should keep the colours
# consistent after pan/zoom. Do this by creating a Path for every
# geometry regardless of whether they are currently in view.
geoms = self._feature.geometries()
Copy link
Contributor

Choose a reason for hiding this comment

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

Do you think this will this cause any confusion with the difference between classes of whether geometries is all of the geometries versus only some of them? I'm not sure and as you say it might be a lot more code complexity to justify something that isn't needed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Make FeatureArtist a ScalarMappable Objects created with 'add_geometries' are missing the 'color', 'edgecolor', ... properties
2 participants