Repository: SciTools/cartopy

Issue:
Support matplotlib hexbin()
Current cartopy does not seem to provide `hexbin` support.  I assume this isn't a simple implementation like how `geoaxes.scatter` works or else it would have been done by now?  I will take a swing at this and see how "bad" things look on the various projections!


Discussion:
xref #770
The simple implementation did not work :(  I wonder if it is related to the troubles I am having getting `PatchCollections` to work, so will keep after it!
Here's an initial implementation for `mpl/geoaxis.py` for your feedback
```python
    def hexbin(self, x, y, *args, **kwargs):
        """
        Reprojects data, if necessary, and calls
        :func:`~matplotlib.pyplot.hexbin'.

        Extra kwargs:

            transform - a :class:`~cartopy.crs.Projection`.

        """
        # exclude Geodetic as a vaild source CS
        if (isinstance(kwargs.get('transform', None),
                       InterProjectionTransform) and
                kwargs['transform'].source_projection.is_geodetic()):
            raise ValueError('Cartopy cannot currently do spherical '
                             'hexbin. The source CRS cannot be a '
                             'geodetic, consider using the cyllindrical form '
                             '(PlateCarree or RotatedPole).')

        t = kwargs.get('transform', None)
        if t is not None and t != self.projection:
            pairs = self.projection.transform_points(t,
                                                     np.asarray(x),
                                                     np.asarray(y))
            x = pairs[:, 0]
            y = pairs[:, 1]
            # remove prior to sending to matplotlib.hexbin
            kwargs.pop('transform')

        result = matplotlib.axes.Axes.hexbin(self, x, y, *args, **kwargs)
        self.autoscale_view()
        return result
```

As I think about this, the concept of doing hexbin in a different crs than the axes is likely not what the user wants (or even what I want :)).  Instead, the user expects the provided point data to be reprojected first, if necessary, and then `hexbin` done in the crs of the axes?

The above is basically a no-op when either:
 - no `transform` is provided 
 - `transform` equals the axes projection

I am only a week into cartopy and likely need to send more time understanding how `pcolormesh` handles this situation.  Sorry to be making noise otherwise!
I would definitely use this feature if it existed.
That seems like a fair implementation to me.
I would still love to see this feature implemented. Are there any plans by the Cartopy devs to try to turn @akrherz's prototype into a supported feature? Thanks to all the volunteers who maintain this great library.
That looks reasonable to me as well. @akrherz, do you want to open up a PR with your proposed change?

Resolving patch:
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
@@ -1537,6 +1537,33 @@ def scatter(self, *args, **kwargs):
         self.autoscale_view()
         return result
 
+    @_add_transform
+    def hexbin(self, x, y, *args, **kwargs):
+        """
+        Add the "transform" keyword to :func:`~matplotlib.pyplot.hexbin'.
+
+        The points are first transformed into the projection of the axes and
+        then the hexbin algorithm is computed using the data in the axes
+        projection.
+
+        Other Parameters
+        ----------------
+        transform
+            A :class:`~cartopy.crs.Projection`.
+        """
+        t = kwargs.pop('transform')
+        pairs = self.projection.transform_points(
+            t,
+            np.asarray(x),
+            np.asarray(y),
+        )
+        x = pairs[:, 0]
+        y = pairs[:, 1]
+
+        result = matplotlib.axes.Axes.hexbin(self, x, y, *args, **kwargs)
+        self.autoscale_view()
+        return result
+
     @_add_transform
     def pcolormesh(self, *args, **kwargs):
         """


Test patch:
diff --git a/lib/cartopy/tests/mpl/baseline_images/mpl/test_mpl_integration/global_hexbin_wrap.png b/lib/cartopy/tests/mpl/baseline_images/mpl/test_mpl_integration/global_hexbin_wrap.png
new file mode 100644
Binary files /dev/null and b/lib/cartopy/tests/mpl/baseline_images/mpl/test_mpl_integration/global_hexbin_wrap.png differ
diff --git a/lib/cartopy/tests/mpl/test_mpl_integration.py b/lib/cartopy/tests/mpl/test_mpl_integration.py
--- a/lib/cartopy/tests/mpl/test_mpl_integration.py
+++ b/lib/cartopy/tests/mpl/test_mpl_integration.py
@@ -129,6 +129,42 @@ def test_global_scatter_wrap_no_transform():
     plt.scatter(x, y, c=data)
 
 
+@pytest.mark.natural_earth
+@ImageTesting(['global_hexbin_wrap'],
+              tolerance=27.7 if MPL_VERSION < '2.1.0' else 0.5)
+def test_global_hexbin_wrap():
+    ax = plt.axes(projection=ccrs.PlateCarree())
+    ax.coastlines(zorder=2)
+    x, y = np.meshgrid(np.arange(-179, 181), np.arange(-90, 91))
+    data = np.sin(np.sqrt(x**2 + y**2))
+    plt.hexbin(
+        x.flatten(),
+        y.flatten(),
+        C=data.flatten(),
+        gridsize=20,
+        zorder=1,
+    )
+
+
+@pytest.mark.natural_earth
+@ImageTesting(['global_hexbin_wrap'],
+              tolerance=27.7 if MPL_VERSION < '2.1.0' else 0.5)
+def test_global_hexbin_wrap_transform():
+    ax = plt.axes(projection=ccrs.PlateCarree())
+    ax.coastlines(zorder=2)
+    x, y = np.meshgrid(np.arange(0, 360), np.arange(-90, 91))
+    # wrap values so to match x values from test_global_hexbin_wrap
+    x_wrap = np.where(x >= 180, x-360, x)
+    data = np.sin(np.sqrt(x_wrap**2 + y**2))
+    plt.hexbin(
+        x.flatten(),
+        y.flatten(),
+        C=data.flatten(),
+        gridsize=20,
+        zorder=1,
+    )
+
+
 @ImageTesting(['global_map'],
               tolerance=1.93 if MPL_VERSION < '2.1.0' else 0.55)
 def test_global_map():