Skip to content

Handling ticks, ticklabels, labels, gridlines and curvilinear boundaries using mpl_toolkits.axisartist? #1773

@blazing216

Description

@blazing216

Description

There seem two existing approaches to implement curvilinear projections using matplotlib, projection and axisartist. The current implementation of cartopy follows the projection approach, which shows a nice consistency with the general matplotlib workflows. However, handling ticks, ticklabels, gridlines with flexibility seem still a challenge. Although gridliner provides increasingly more features, plotting tick markers and using customed curvilinear boundaries are still difficult. The single issue has constantly driven me back to GMT to making the final version of figures for publications.

After waiting for almost two years, I decided to implement the feature myself. The challenge is that using axisartist seems a completely different way from using projection, so I ended up with an alternative GeoAxes class that initialize using the InterProjectionTransform, and floating axes in axisartist. Good thing is, it can handle ticks and everything in a generic way (hopefully). But it lost all the plotting functions in GeoAxes. I just managed to add a function to add Features (e.g. NaturalEarthFeature).

I wonder if there is a way to incorporate it into GeoAxes, or to exist as an alternative in cartopy.

Here is an example.
lambert

from collections import OrderedDict
import warnings
import weakref

import numpy as np
import matplotlib
import matplotlib.pyplot as plt
import matplotlib.artist
import matplotlib.collections
import mpl_toolkits.axisartist.floating_axes as floating_axes
from mpl_toolkits.axisartist.floating_axes import FloatingSubplot

import cartopy.crs as ccrs
import cartopy.mpl.patch as cpatch
from cartopy.mpl.feature_artist import FeatureArtist, _freeze, _GeomKey
from cartopy.mpl.geoaxes import InterProjectionTransform

class AnAlternativeGeoAxes:
    def __init__(self, fig, rect, proj, region):
        """use ax_lonlat for (lon, lat) data
        use ax_proj for (x, y) data"""
        tr = InterProjectionTransform(ccrs.PlateCarree(),
                                             proj)
        grid_helper = floating_axes.GridHelperCurveLinear(
            tr,
            extremes=region,
            grid_locator1=None,
            grid_locator2=None,
            tick_formatter1=None,
            tick_formatter2=None)
        self.ax_proj = myFloatingSubplot(fig, proj, tr, rect, grid_helper=grid_helper)
        fig.add_subplot(self.ax_proj)
        
        self.ax_lonlat = self.ax_proj.get_aux_axes(tr)
        self.ax_lonlat.patch = self.ax_proj.patch  # for aux_ax to have a clip path as in ax
        self.ax_proj.patch.zorder = 0.9  # but this has a side effect that the patch is
        # drawn twice, and possibly over some other
        # artists. So, we decrease the zorder a bit to
        # prevent this.
        self.tr = tr
        self.target_crs = proj
        
    def scatter(self, *args, **kwargs):
        self.ax_lonlat.scatter(*args, **kwargs)
        
    def grid(self, *args, **kwargs):
        self.ax_proj.grid(*args, **kwargs)

    def set_xlabel(self, *args, **kwargs):
        self.ax_proj.set_xlabel(*args, **kwargs)

    def set_ylabel(self, *args, **kwargs):
        self.ax_proj.set_ylabel(*args, **kwargs)

    def set_title(self, *args, **kwargs):
        self.ax_proj.set_title(*args, **kwargs)

    def add_cartopy_feature(self, feature, zorder=1, **kwargs):
        artist = myFeatureArtist(feature, zorder=zorder, **kwargs)
        self.ax_proj.add_artist(artist)

class myFloatingSubplot(FloatingSubplot):
    def __init__(self, fig, proj, tr, *args, **kwargs):
        super().__init__(fig, *args, **kwargs)
        self.projection = proj
        self.tr = tr
    
    # I added 'get_extent' here to be called by myFeatureArtist
    def get_extent(self):
        xlim = self.get_xlim()
        ylim = self.get_ylim()
        extent = (xlim[0], xlim[1], ylim[0], ylim[1])
        return extent
    
class myFeatureArtist(FeatureArtist):
    def __init__(self, feature, **kwargs):
        super().__init__(feature, **kwargs)
    
    def draw(self, renderer, *args, **kwargs):
        """
        Draw the geometries of the feature that intersect with the extent of
        the :class:`cartopy.mpl.GeoAxes` instance to which this
        object has been added.

        """
        if not self.get_visible():
            return

        ax = self.axes
        feature_crs = self._feature.crs

        # Get geometries that we need to draw.
        extent = None
        try:
            extent = ax.get_extent(feature_crs)
        except ValueError:
            warnings.warn('Unable to determine extent. Defaulting to global.')
        except: # added this new get_extent() here
            extent = ax.get_extent()
        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.
        stylised_paths = OrderedDict()
        key = ax.projection
        # key = ccrs.LambertConformal(central_longitude=0)
        for geom in geoms:
            # As Shapely geometries cannot be relied upon to be
            # hashable, we have to use a WeakValueDictionary to manage
            # their weak references. The key can then be a simple,
            # "disposable", hashable geom-key object that just uses the
            # id() of a geometry to determine equality and hash value.
            # The only persistent, strong reference to the geom-key is
            # in the WeakValueDictionary, so when the geometry is
            # garbage collected so is the geom-key.
            # The geom-key is also used to access the WeakKeyDictionary
            # cache of transformed geometries. So when the geom-key is
            # garbage collected so are the transformed geometries.
            geom_key = _GeomKey(geom)
            FeatureArtist._geom_key_to_geometry_cache.setdefault(
                geom_key, geom)
            mapping = FeatureArtist._geom_key_to_path_cache.setdefault(
                geom_key, {})
            geom_paths = mapping.get(key)
            if geom_paths is None:
                if ax.projection != feature_crs:
                    projected_geom = ax.projection.project_geometry(
                        geom, feature_crs)
                else:
                    projected_geom = geom
                geom_paths = cpatch.geos_to_path(projected_geom)
                mapping[key] = geom_paths

            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)
        # transData here is a transform from projected coordinates to 
        # image coordinates (?).
        transform = ax.transData 

        # 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



if __name__ == '__main__':
    import cartopy.feature as cfeature
    land_50m = cfeature.NaturalEarthFeature('physical', 'land', '50m',
                                            edgecolor='k', linewidths=0.5,
                                            facecolor=cfeature.COLORS['land'])
    countries = cfeature.NaturalEarthFeature('cultural', 'admin_0_boundary_lines_land',
        '50m', edgecolor='k', linewidths=0.25, facecolor='none')


    fig = plt.figure(figsize=(8, 4), dpi=150)  
    ax = AnAlternativeGeoAxes(fig, 111, ccrs.LambertConformal(central_longitude=0), region=(-60, 60, 30, 70))

    ax.add_cartopy_feature(land_50m)
    ax.add_cartopy_feature(countries)
    
    ax.set_xlabel('Longitude (\xb0)')
    ax.set_ylabel('Latitude (\xb0)')
    ax.set_title('Lambert Conformal')
    ax.grid()

    plt.savefig('lambert.png', dpi=300)

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions