SciTools / cartopy

Cartopy - a cartographic python library with matplotlib support
https://scitools.org.uk/cartopy/docs/latest
BSD 3-Clause "New" or "Revised" License
1.4k stars 359 forks source link

Zebra style axis for map #1830

Open changliao1025 opened 2 years ago

changliao1025 commented 2 years ago

Problem Zebra style map frame is a nice feature supported in many map applications including ArcGIS, ENVI/IDL, GMT. http://www.idlcoyote.com/idldoc/cg/cgdrawshapes.html https://gmt-tutorials.org/en/making_first_map.html

Proposed solution Add a flag to turn on this feature if plotting a map with a map structure.

Additional context and prior art https://stackoverflow.com/questions/57313303/how-to-plot-zebra-style-axis-in-matplotlib

scottstanie commented 2 years ago

I have the beginning of an answer (which probably is not very general, but might help someone craft a better answer).

I'm plotting alternative black/white lines with black path effects between every tick location. It produces something like this:

image

https://gist.github.com/scottstanie/dff0d597e636440fb60b3c5443f70cae

import cartopy.crs as ccrs
import matplotlib.pyplot as plt

crs = ccrs.PlateCarree()

fig = plt.figure(figsize=(5, 2))
ax = fig.add_subplot(projection=crs)

ax.coastlines()
ax.set_extent((-125, -85, 22, 42))
ax.set_xticks((-120, -110, -100, -90))
ax.set_yticks((25, 30, 35, 40))

add_zebra_frame(ax, crs=crs)

And here's the function itself

import itertools
import matplotlib.patheffects as pe
import numpy as np

def add_zebra_frame(ax, lw=2, crs="pcarree", zorder=None):

    ax.spines["geo"].set_visible(False)
    left, right, bot, top = ax.get_extent()

    # Alternate black and white line segments
    bws = itertools.cycle(["k", "white"])

    xticks = sorted([left, *ax.get_xticks(), right])
    xticks = np.unique(np.array(xticks))
    yticks = sorted([bot, *ax.get_yticks(), top])
    yticks = np.unique(np.array(yticks))
    for ticks, which in zip([xticks, yticks], ["lon", "lat"]):
        for idx, (start, end) in enumerate(zip(ticks, ticks[1:])):
            bw = next(bws)
            if which == "lon":
                xs = [[start, end], [start, end]]
                ys = [[bot, bot], [top, top]]
            else:
                xs = [[left, left], [right, right]]
                ys = [[start, end], [start, end]]

            # For first and lastlines, used the "projecting" effect
            capstyle = "butt" if idx not in (0, len(ticks) - 2) else "projecting"
            for (xx, yy) in zip(xs, ys):
                ax.plot(
                    xx,
                    yy,
                    color=bw,
                    linewidth=lw,
                    clip_on=False,
                    transform=crs,
                    zorder=zorder,
                    solid_capstyle=capstyle,
                    # Add a black border to accentuate white segments
                    path_effects=[
                        pe.Stroke(linewidth=lw + 1, foreground="black"),
                        pe.Normal(),
                    ],
                )
changliao1025 commented 2 years ago

Thanks for this effort! I think this is close enough although I didn't test other SRS. I also believe this approach is similar to what the @idl-coyote uses in the above example.

wohenbushuang commented 2 years ago

My try based on @scottstanie .

The white frame seems not shown on my computer: image

So I update code to deal with the frame linewidth self.spines["geo"].get_linewidth(). Also, I add it as a method of GeoAxes, so I can use it in a simple way ax.zebra_frame(...).

I use the code onto EquidistantConic projection. Unfortunatelly I don't know how to hide[^1] the out of extent part in Cartopy. Some gridline labels are also missing.

[^1]: Update: Maybe https://scitools.org.uk/cartopy/docs/latest/gallery/miscellanea/star_shaped_boundary.html will help to hide the out of extent part.

import itertools
from matplotlib.patheffects import Stroke, Normal
import numpy as np
import cartopy.mpl.geoaxes

def zebra_frame(self, lw=2, crs=None, zorder=None):
    # Alternate black and white line segments
    bws = itertools.cycle(["k", "w"])

    self.spines["geo"].set_visible(False)

    left, right, bottom, top = self.get_extent()

    # xticks = sorted([left, *self.get_xticks(), right])
    xticks = sorted([*self.get_xticks()])
    xticks = np.unique(np.array(xticks))
    # yticks = sorted([bottom, *self.get_yticks(), top])
    yticks = sorted([*self.get_yticks()])
    yticks = np.unique(np.array(yticks))

    for ticks, which in zip([xticks, yticks], ["lon", "lat"]):
        for idx, (start, end) in enumerate(zip(ticks, ticks[1:])):
            bw = next(bws)
            if which == "lon":
                xs = [[start, end], [start, end]]
                ys = [[yticks[0], yticks[0]], [yticks[-1], yticks[-1]]]
            else:
                xs = [[xticks[0], xticks[0]], [xticks[-1], xticks[-1]]]
                ys = [[start, end], [start, end]]

            # For first and last lines, used the "projecting" effect
            capstyle = "butt" if idx not in (0, len(ticks) - 2) else "projecting"
            for (xx, yy) in zip(xs, ys):
                self.plot(xx, yy, color=bw, linewidth=max(0, lw - self.spines["geo"].get_linewidth()*2), clip_on=False,
                    transform=crs, zorder=zorder, solid_capstyle=capstyle,
                    # Add a black border to accentuate white segments
                    path_effects=[
                        Stroke(linewidth=lw, foreground="black"),
                        Normal(),
                    ],
                )

setattr(cartopy.mpl.geoaxes.GeoAxes, 'zebra_frame', zebra_frame)
import cartopy.crs as ccrs
import matplotlib.pyplot as plt

crs = ccrs.EquidistantConic(central_longitude=-90)

fig = plt.figure(figsize=(8, 4))
ax = fig.add_subplot(
    1, 1, 1, projection=crs)

ax.coastlines()
ax.set_extent((-125, -60, 0, 30), crs=ccrs.PlateCarree())
ax.set_xticks(np.arange(-120, -60+1, 5))
ax.set_yticks(np.arange(0, 30+1, 5))
ax.set_axis_off()

ax.gridlines(draw_labels=True, dms=True, linestyle='--',
#              #   x_inline=True, y_inline=True,
             xlocs=np.arange(-120, -60+1, 15), ylocs=np.arange(0, 30+1, 15))

ax.zebra_frame(lw=5, crs=ccrs.PlateCarree(), zorder=3)
plt.show()

image

nguyenquangchien commented 4 months ago

Many thanks @wohenbushuang. You may also need to import numpy as np in the second script file.

changliao1025 commented 3 months ago

Based on @wohenbushuang version, I added an option to allow the frame to be plotted using the map extent instead of following the latlon paths:

    import itertools
    from matplotlib.patheffects import Stroke, Normal
    import numpy as np
    import cartopy.mpl.geoaxes

    def zebra_frame(self, lw=3, crs=None, zorder=None, iFlag_outer_frame_in = None):    
        # Alternate black and white line segments
        bws = itertools.cycle(["k", "w"])
        self.spines["geo"].set_visible(False)

        if iFlag_outer_frame_in is not None:
            #get the map spatial reference        
            left, right, bottom, top = self.get_extent()
            crs_map = self.projection
            xticks = np.arange(left, right+(right-left)/9, (right-left)/8)
            yticks = np.arange(bottom, top+(top-bottom)/9, (top-bottom)/8)
            #check spatial reference are the same           
            pass
        else:        
            crs_map =  crs
            xticks = sorted([*self.get_xticks()])
            xticks = np.unique(np.array(xticks))        
            yticks = sorted([*self.get_yticks()])
            yticks = np.unique(np.array(yticks))        

        for ticks, which in zip([xticks, yticks], ["lon", "lat"]):
            for idx, (start, end) in enumerate(zip(ticks, ticks[1:])):
                bw = next(bws)
                if which == "lon":
                    xs = [[start, end], [start, end]]
                    ys = [[yticks[0], yticks[0]], [yticks[-1], yticks[-1]]]
                else:
                    xs = [[xticks[0], xticks[0]], [xticks[-1], xticks[-1]]]
                    ys = [[start, end], [start, end]]

                # For first and last lines, used the "projecting" effect
                capstyle = "butt" if idx not in (0, len(ticks) - 2) else "projecting"
                for (xx, yy) in zip(xs, ys):
                    self.plot(xx, yy, color=bw, linewidth=max(0, lw - self.spines["geo"].get_linewidth()*2), clip_on=False,
                        transform=crs_map, zorder=zorder, solid_capstyle=capstyle,
                        # Add a black border to accentuate white segments
                        path_effects=[
                            Stroke(linewidth=lw, foreground="black"),
                            Normal(),
                        ],
                    )

    setattr(cartopy.mpl.geoaxes.GeoAxes, 'zebra_frame', zebra_frame)

This is the result: 201912

Thank you for all the contributions. @wohenbushuang and @scottstanie

lgolston commented 3 months ago

This looks great! It sounds like (https://stackoverflow.com/questions/57313303/how-to-plot-zebra-style-axis-in-matplotlib) you will make it into a pull request?