Repository: SciTools/cartopy

Issue:
cartopy.mpl.feature_artist.FeatureArtist.draw does not preserve ordering for facecolors, etc
Example code: `ax.add_feature(cfeature.ShapelyFeature(shapefile.geometries(), crs, facecolors=['red','green','blue'])`

Intent: I want to assign a different color to each geometry in shapefile.geometries().  I can loop through shapefile.records().attr[] to create a list of facecolors (red/green/blue in the example above).  

However, the cartopy.mpl.feature_artist.FeatureArtist's draw() routine culls the list of geometries via `geoms = self._feature.intersecting_geometries(extent)`, and then creates a set of N paths for each geometry.

For this to work, draw() will need to select the correct `kwargs['facecolors']` for each geometry, and create `final_kwargs['facecolors'], duplicating that color for each path resulting from the geometry.

These should be handled similarly: edgecolors, linewidths, linestyles and antialiaseds



Discussion:
Thanks @jtbraun. I confess I've wanted this myself, but have always told myself that the extra complexity wasn't worth the extra functionality. As you can see in the example at http://scitools.org.uk/cartopy/docs/latest/examples/hurricane_katrina.html colouring geometries means you (currently) have to go down a slightly different code path than you would with features.

The truth is, even if we close the gap between functionality in the way you describe, there will always be a further case which warrants further functionality. For instance, it may be that I wanted to color geometries based on their area, rather than by specifying specific colors for each geometry.

Would it be helpful if there were an example in the gallery of using the shapereader to colour a country based on some other attribute of the geometry/shape?

I run into this issue while trying to create a `geom_carto` for [plotnine](http://github.com/has2k1/plotnine).

Maybe a solution could start with adding an option for the `Feature.intersecting_geometries` method to
return the indices of the selected geometries.

```python
def intersecting_geometries(self, extent, include_indices=False):
    ...
```

This would be backwards compatible.

Then in `FeatureArtist.draw` the list-like `kwargs` (with the same number of elements as there are geometries) to Matplotlib can be adjusted.
@pelson, is the suggestion in the previous comment sensible? I can work on a PR for it.
@has2k1 - definitely interested in getting your hands on the code!
Is solving this simply a matter of processing callbacks around https://github.com/SciTools/cartopy/blob/master/lib/cartopy/mpl/feature_artist.py#L172?


For example:

```
shapes = shapefile.geometries()

def colorization(shape):
    if shapes.record.name == "USA":
        color = "Red"
    else:
        color = "Blue"
    return color

ax.add_feature(cfeature.ShapelyFeature(shapes, crs),
               facecolors=colorization)
```

I'm glossing over the need to include CRS, projected and unprojected geometry, and the record information (if still available at that point...), but in principle I'd rather be handling that than assuming the incoming shapes all need to be in memory at the same time (maybe they already do though...).
I had not considered using callbacks. Although they would provide an improvement over the workaround in the current  [hurricane Katrina example](http://scitools.org.uk/cartopy/docs/latest/examples/hurricane_katrina.html), they would have a few issues.

1. They would not work for my use-case.
2. They create a special case (different from the rest of Matplotlib) for setting parameters used by artists.
3. If we consider that the reason for this issue is that when the geometries are culled it affects the user's expectation of how things should work. Using callbacks would leak the solution into the user API, instead of addressing the expectation.

However, as there are different types downloadable features, with callbacks the user does not have to get the geometries into memory. Even so, I think this would only save the user 2-3 lines of code i.e. creating a list of all the geometries and looping through them to determine any of colours, linewidths, etc. Also, as you would do the calculations for geometries that will be culled anyway, I do not know how big of a deal that would be for most cases.

Resolving patch:
diff --git a/lib/cartopy/examples/hurricane_katrina.py b/lib/cartopy/examples/hurricane_katrina.py
--- a/lib/cartopy/examples/hurricane_katrina.py
+++ b/lib/cartopy/examples/hurricane_katrina.py
@@ -63,19 +63,18 @@ def main():
     # distance)
     track_buffer = track.buffer(2)
 
-    for state in shpreader.Reader(states_shp).geometries():
-        # pick a default color for the land with a black outline,
-        # this will change if the storm intersects with our track
-        facecolor = [0.9375, 0.9375, 0.859375]
-        edgecolor = 'black'
-
-        if state.intersects(track):
+    def colorize_state(geometry):
+        facecolor = (0.9375, 0.9375, 0.859375)
+        if geometry.intersects(track):
             facecolor = 'red'
-        elif state.intersects(track_buffer):
+        elif geometry.intersects(track_buffer):
             facecolor = '#FF7E00'
+        return {'facecolor': facecolor, 'edgecolor': 'black'}
 
-        ax.add_geometries([state], ccrs.PlateCarree(),
-                          facecolor=facecolor, edgecolor=edgecolor)
+    ax.add_geometries(
+        shpreader.Reader(states_shp).geometries(),
+        ccrs.PlateCarree(),
+        styler=colorize_state)
 
     ax.add_geometries([track_buffer], ccrs.PlateCarree(),
                       facecolor='#C8A2C8', alpha=0.5)
diff --git a/lib/cartopy/mpl/feature_artist.py b/lib/cartopy/mpl/feature_artist.py
--- a/lib/cartopy/mpl/feature_artist.py
+++ b/lib/cartopy/mpl/feature_artist.py
@@ -22,9 +22,11 @@
 
 from __future__ import (absolute_import, division, print_function)
 
+from collections import OrderedDict
 import warnings
 import weakref
 
+import numpy as np
 import matplotlib.artist
 import matplotlib.collections
 
@@ -51,6 +53,21 @@ def __hash__(self):
         return hash(self._id)
 
 
+def _freeze(obj):
+    """
+    Recursively freeze the given object so that it might be suitable for
+    use as a hashable.
+
+    """
+    if isinstance(obj, dict):
+        obj = frozenset(((k, _freeze(v)) for k, v in obj.items()))
+    elif isinstance(obj, list):
+        obj = tuple(_freeze(item) for item in obj)
+    elif isinstance(obj, np.ndarray):
+        obj = tuple(obj)
+    return obj
+
+
 class FeatureArtist(matplotlib.artist.Artist):
     """
     A subclass of :class:`~matplotlib.artist.Artist` capable of
@@ -82,6 +99,9 @@ def __init__(self, feature, **kwargs):
         ----------
         feature
             An instance of :class:`cartopy.feature.Feature` to draw.
+        styler
+            A callable that given a gemometry, returns matplotlib styling
+            parameters.
 
         Other Parameters
         ----------------
@@ -94,6 +114,7 @@ def __init__(self, feature, **kwargs):
 
         if kwargs is None:
             kwargs = {}
+        self._styler = kwargs.pop('styler', None)
         self._kwargs = dict(kwargs)
 
         # Set default zorder so that features are drawn before
@@ -136,8 +157,17 @@ def draw(self, renderer, *args, **kwargs):
             warnings.warn('Unable to determine extent. Defaulting to global.')
         geoms = self._feature.intersecting_geometries(extent)
 
+        # Combine all the keyword args in priority order.
+        prepared_kwargs = dict(self._feature.kwargs)
+        prepared_kwargs.update(self._kwargs)
+        prepared_kwargs.update(kwargs)
+
+        # Freeze the kwargs so that we can use them as a dict key. We will
+        # need to unfreeze this with dict(frozen) before passing to mpl.
+        prepared_kwargs = _freeze(prepared_kwargs)
+
         # Project (if necessary) and convert geometries to matplotlib paths.
-        paths = []
+        stylised_paths = OrderedDict()
         key = ax.projection
         for geom in geoms:
             # As Shapely geometries cannot be relied upon to be
@@ -165,17 +195,31 @@ def draw(self, renderer, *args, **kwargs):
                     projected_geom = geom
                 geom_paths = cpatch.geos_to_path(projected_geom)
                 mapping[key] = geom_paths
-            paths.extend(geom_paths)
 
-        # Build path collection and draw it.
+            if not self._styler:
+                style = prepared_kwargs
+            else:
+                # Unfreeze, then add the computed style, and then re-freeze.
+                style = dict(prepared_kwargs)
+                style.update(self._styler(geom))
+                style = _freeze(style)
+
+            stylised_paths.setdefault(style, []).extend(geom_paths)
+
         transform = ax.projection._as_mpl_transform(ax)
-        # Combine all the keyword args in priority order
-        final_kwargs = dict(self._feature.kwargs)
-        final_kwargs.update(self._kwargs)
-        final_kwargs.update(kwargs)
-        c = matplotlib.collections.PathCollection(paths,
-                                                  transform=transform,
-                                                  **final_kwargs)
-        c.set_clip_path(ax.patch)
-        c.set_figure(ax.figure)
-        return c.draw(renderer)
+
+        # Draw one PathCollection per style. We could instead pass an array
+        # of style items through to a single PathCollection, but that
+        # complexity does not yet justify the effort.
+        for style, paths in stylised_paths.items():
+            # Build path collection and draw it.
+            c = matplotlib.collections.PathCollection(
+                    paths,
+                    transform=transform,
+                    **dict(style))
+            c.set_clip_path(ax.patch)
+            c.set_figure(ax.figure)
+            c.draw(renderer)
+
+        # n.b. matplotlib.collection.Collection.draw returns None
+        return None
diff --git a/lib/cartopy/mpl/geoaxes.py b/lib/cartopy/mpl/geoaxes.py
--- a/lib/cartopy/mpl/geoaxes.py
+++ b/lib/cartopy/mpl/geoaxes.py
@@ -116,6 +116,17 @@ def __repr__(self):
                                                self.source_projection,
                                                self.target_projection))
 
+    def __eq__(self, other):
+        if not isinstance(other, self.__class__):
+            result = NotImplemented
+        else:
+            result = (self.source_projection == other.source_projection and
+                      self.target_projection == other.target_projection)
+        return result
+
+    def __ne__(self, other):
+        return not self == other
+
     def transform_non_affine(self, xy):
         """
         Transform from source to target coordinates.
@@ -562,6 +573,8 @@ def add_geometries(self, geoms, crs, **kwargs):
             A collection of shapely geometries.
         crs
             The cartopy CRS in which the provided geometries are defined.
+        styler
+            A callable that returns matplotlib patch styling given a geometry.
 
         Returns
         -------
@@ -576,8 +589,9 @@ def add_geometries(self, geoms, crs, **kwargs):
 
 
         """
+        styler = kwargs.pop('styler', None)
         feature = cartopy.feature.ShapelyFeature(geoms, crs, **kwargs)
-        return self.add_feature(feature)
+        return self.add_feature(feature, styler=styler)
 
     def get_extent(self, crs=None):
         """


Test patch:
diff --git a/lib/cartopy/tests/mpl/test_axes.py b/lib/cartopy/tests/mpl/test_axes.py
--- a/lib/cartopy/tests/mpl/test_axes.py
+++ b/lib/cartopy/tests/mpl/test_axes.py
@@ -1,4 +1,4 @@
-# (C) British Crown Copyright 2011 - 2017, Met Office
+# (C) British Crown Copyright 2011 - 2018, Met Office
 #
 # This file is part of cartopy.
 #
@@ -20,11 +20,15 @@
 from matplotlib.testing.decorators import cleanup
 import matplotlib.path as mpath
 import matplotlib.pyplot as plt
+try:
+    from unittest import mock
+except ImportError:
+    import mock
 import numpy as np
 import pytest
 
 import cartopy.crs as ccrs
-from cartopy.mpl.geoaxes import InterProjectionTransform
+from cartopy.mpl.geoaxes import InterProjectionTransform, GeoAxes
 from cartopy.tests.mpl.test_caching import CallCounter
 
 
@@ -81,6 +85,51 @@ def test_transform_PlateCarree_shortcut():
         assert counter.count == 2
 
 
+class Test_InterProjectionTransform():
+    def pc_2_pc(self):
+        return InterProjectionTransform(
+            ccrs.PlateCarree(), ccrs.PlateCarree())
+
+    def pc_2_rob(self):
+        return InterProjectionTransform(ccrs.PlateCarree(), ccrs.Robinson())
+
+    def rob_2_rob_shifted(self):
+        return InterProjectionTransform(
+            ccrs.Robinson(), ccrs.Robinson(central_longitude=0))
+
+    def test_eq(self):
+        assert self.pc_2_pc() == self.pc_2_pc()
+        assert self.pc_2_rob() == self.pc_2_rob()
+        assert self.rob_2_rob_shifted() == self.rob_2_rob_shifted()
+
+        assert not self.pc_2_rob() == self.rob_2_rob_shifted()
+        assert not self.pc_2_pc() == 'not a transform obj'
+
+    def test_ne(self):
+        assert not self.pc_2_pc() != self.pc_2_pc()
+        print(self.pc_2_pc() != self.pc_2_rob())
+        assert self.pc_2_pc() != self.pc_2_rob()
+
+
+class Test_Axes_add_geometries():
+    def teardown_method(self):
+        plt.close()
+
+    @mock.patch('cartopy.mpl.geoaxes.GeoAxes.add_feature')
+    @mock.patch('cartopy.feature.ShapelyFeature')
+    def test_styler_kwarg(self, ShapelyFeature, add_feature_method):
+        ax = GeoAxes(plt.figure(), [0, 0, 1, 1],
+                     map_projection=ccrs.Robinson())
+        ax.add_geometries(mock.sentinel.geometries, mock.sentinel.crs,
+                          styler=mock.sentinel.styler, wibble='wobble')
+
+        ShapelyFeature.assert_called_once_with(
+            mock.sentinel.geometries, mock.sentinel.crs, wibble='wobble')
+
+        add_feature_method.assert_called_once_with(
+            ShapelyFeature(), styler=mock.sentinel.styler)
+
+
 @cleanup
 def test_geoaxes_subplot():
     ax = plt.subplot(1, 1, 1, projection=ccrs.PlateCarree())
diff --git a/lib/cartopy/tests/mpl/test_feature_artist.py b/lib/cartopy/tests/mpl/test_feature_artist.py
new file mode 100644
--- /dev/null
+++ b/lib/cartopy/tests/mpl/test_feature_artist.py
@@ -0,0 +1,136 @@
+# (C) British Crown Copyright 2018, Met Office
+#
+# This file is part of cartopy.
+#
+# cartopy is free software: you can redistribute it and/or modify it under
+# the terms of the GNU Lesser General Public License as published by the
+# Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# cartopy is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with cartopy.  If not, see <https://www.gnu.org/licenses/>.
+
+from __future__ import (absolute_import, division, print_function)
+
+import numpy as np
+import pytest
+import shapely.geometry as sgeom
+from matplotlib.transforms import IdentityTransform
+try:
+    from unittest import mock
+except ImportError:
+    import mock
+
+import cartopy.crs as ccrs
+import cartopy.mpl.geoaxes as geoaxes
+from cartopy.feature import ShapelyFeature
+from cartopy.mpl.feature_artist import FeatureArtist, _freeze, _GeomKey
+
+
+@pytest.mark.parametrize("source, expected", [
+    [{1: 0}, frozenset({(1, 0)})],
+    [[1, 2], (1, 2)],
+    [[1, {}], (1, frozenset())],
+    [[1, {'a': [1, 2, 3]}], (1, frozenset([('a', (1, 2, 3))]))],
+    [{'edgecolor': 'face', 'zorder': -1,
+      'facecolor': np.array([0.9375, 0.9375, 0.859375])},
+     frozenset([('edgecolor', 'face'), ('zorder', -1),
+                ('facecolor', (0.9375, 0.9375, 0.859375))])],
+])
+def test_freeze(source, expected):
+    assert _freeze(source) == expected
+
+
+@pytest.fixture
+def feature():
+    unit_circle = sgeom.Point(0, 0).buffer(0.5)
+    unit_square = unit_circle.envelope
+    geoms = [unit_circle, unit_square]
+    feature = ShapelyFeature(geoms, ccrs.PlateCarree())
+    return feature
+
+
+def mocked_axes(extent, projection=ccrs.PlateCarree()):
+    return mock.MagicMock(
+        get_extent=mock.Mock(return_value=extent),
+        projection=projection,
+        spec=geoaxes.GeoAxes,
+        transData=IdentityTransform(),
+        patch=mock.sentinel.patch,
+        figure=mock.sentinel.figure)
+
+
+def style_from_call(call):
+    args, kwargs = call
+    # Drop the transform keyword.
+    kwargs.pop('transform')
+    return kwargs
+
+
+def cached_paths(geom, target_projection):
+    # Use the cache in FeatureArtist to get back the projected path
+    # for the given geometry.
+    geom_cache = FeatureArtist._geom_key_to_path_cache.get(_GeomKey(geom), {})
+    return geom_cache.get(target_projection, None)
+
+
+@mock.patch('matplotlib.collections.PathCollection')
+def test_feature_artist_draw(path_collection_cls, feature):
+    geoms = list(feature.geometries())
+
+    fa = FeatureArtist(feature, facecolor='red')
+    prj_crs = ccrs.Robinson()
+    fa.axes = mocked_axes(extent=[-10, 10, -10, 10], projection=prj_crs)
+    fa.draw(mock.sentinel.renderer)
+
+    transform = prj_crs._as_mpl_transform(fa.axes)
+    expected_paths = (cached_paths(geoms[0], prj_crs) +
+                      cached_paths(geoms[1], prj_crs), )
+    expected_style = {'facecolor': 'red'}
+
+    args, kwargs = path_collection_cls.call_args_list[0]
+    assert transform == kwargs.pop('transform', None)
+    assert kwargs == expected_style
+    assert args == expected_paths
+
+    path_collection_cls().set_clip_path.assert_called_once_with(fa.axes.patch)
+    path_collection_cls().set_figure.assert_called_once_with(fa.axes.figure)
+    path_collection_cls().draw(mock.sentinel.renderer)
+
+
+@mock.patch('matplotlib.collections.PathCollection')
+def test_feature_artist_draw_styler(path_collection_cls, feature):
+    geoms = list(feature.geometries())
+    style1 = {'facecolor': 'blue', 'edgecolor': 'white'}
+    style2 = {'color': 'black', 'linewidth': 1}
+
+    def styler(geom):
+        if geom == geoms[0]:
+            return style1
+        else:
+            return style2
+
+    fa = FeatureArtist(feature, styler=styler, linewidth=2)
+    extent = [-10, 10, -10, 10]
+    prj_crs = ccrs.Robinson()
+    fa.axes = mocked_axes(extent, projection=prj_crs)
+    fa.draw(mock.sentinel.renderer)
+
+    transform = prj_crs._as_mpl_transform(fa.axes)
+
+    calls = [{'paths': (cached_paths(geoms[0], prj_crs), ),
+              'style': dict(linewidth=2, **style1)},
+             {'paths': (cached_paths(geoms[1], prj_crs), ),
+              'style': style2}]
+
+    assert path_collection_cls.call_count == 2
+    for expected_call, (actual_args, actual_kwargs) in \
+            zip(calls, path_collection_cls.call_args_list):
+        assert expected_call['paths'] == actual_args
+        assert transform == actual_kwargs.pop('transform')
+        assert expected_call['style'] == actual_kwargs