diff --git a/.github/workflows/testMaps.yml b/.github/workflows/testMaps.yml index 807c4219f..af00f804d 100644 --- a/.github/workflows/testMaps.yml +++ b/.github/workflows/testMaps.yml @@ -20,9 +20,15 @@ jobs: # checkout the repository - uses: actions/checkout@v2 # install miniconda environment - - uses: conda-incubator/setup-miniconda@v2 + - uses: conda-incubator/setup-miniconda@v2.1.1 with: environment-file: tests/test_env.yml + + # use mamba to speed up installation + mamba-version: "*" + channels: conda-forge + channel-priority: true + activate-environment: testMaps show-channel-urls: true diff --git a/.gitignore b/.gitignore index d3f6d2388..aa5f75a0a 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,11 @@ docs/_build docs/debug.log docs/.vscode docs/generated +docs/_tables .spyproject +EOmaps.egg-info +.pytest_cache +logos +tests/Images +tests/.ipynb_checkpoints +docs/_static/example_gifs_very_old diff --git a/README.md b/README.md index 9892b5d09..e705a7505 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ ### A library to create interactive maps of geographical datasets.
lists
, numpy-arrays
, pandas.DataFrames
- 🌲🌳 Checkout the documentation for more details and examples 🌳🌲 -
--- @@ -46,48 +43,26 @@ > There are breaking API changes between `EOmaps v3.x` and `EOmaps v4.0` > To quickly update existing scripts, see: [⚙ Port script from EOmaps v3.x to v4.x](https://eomaps.readthedocs.io/en/latest/FAQ.html#port-script-from-eomaps-v3-x-to-v4-x) -+ + | ++ + | +
+ + | ++ + | +
+ + | ++ + | +
+ + | ++ + | +
- - - - - - -
## 🌳 Basic usage diff --git a/docs/_static/fig1.gif b/docs/_static/fig1.gif index 7502742c4..c5b60d3d8 100644 Binary files a/docs/_static/fig1.gif and b/docs/_static/fig1.gif differ diff --git a/docs/_static/fig1.png b/docs/_static/fig1.png deleted file mode 100644 index 8d8b279ee..000000000 Binary files a/docs/_static/fig1.png and /dev/null differ diff --git a/docs/_static/fig2.gif b/docs/_static/fig2.gif index b9aa530bd..55eb8168a 100644 Binary files a/docs/_static/fig2.gif and b/docs/_static/fig2.gif differ diff --git a/docs/_static/fig2.png b/docs/_static/fig2.png deleted file mode 100644 index 9b60feb3a..000000000 Binary files a/docs/_static/fig2.png and /dev/null differ diff --git a/docs/_static/fig3.png b/docs/_static/fig3.png index 829af94f5..85eb9287a 100644 Binary files a/docs/_static/fig3.png and b/docs/_static/fig3.png differ diff --git a/docs/_static/fig6.gif b/docs/_static/fig6.gif index 7cfafa37f..1ae7197fa 100644 Binary files a/docs/_static/fig6.gif and b/docs/_static/fig6.gif differ diff --git a/docs/_static/inset_maps.png b/docs/_static/inset_maps.png new file mode 100644 index 000000000..b654e6007 Binary files /dev/null and b/docs/_static/inset_maps.png differ diff --git a/docs/api.rst b/docs/api.rst index 253e307d8..8b7f5fb0e 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -24,9 +24,12 @@ Possible ways for specifying the crs for plotting are: -- If you provide an integer, it is identified as an epsg-code (e.g. ``4326``, ``3035``, etc.). +- If you provide an integer, it is identified as an epsg-code (e.g. ``4326``, ``3035``, etc.) + + - 4326 hereby defaults to `PlateCarree` projection + - All other CRS usable for plotting are accessible via ``Maps.CRS``, - e.g.: ``crs=Maps.CRS.Orthographic()`` or ``crs=Maps.CRS.Equi7Grid_projection("EU")``. + e.g.: ``crs=Maps.CRS.Orthographic()``, ``crs=Maps.CRS.GOOGLE_MERCATOR`` or ``crs=Maps.CRS.Equi7Grid_projection("EU")``. (``Maps.CRS`` is just an accessor for ``cartopy.crs``) ▤ Layers @@ -1141,6 +1144,7 @@ To indicate rectangular areas in any given crs, simply use ``m.indicate_extent`` | Before adding a colorbar, you must plot the data using ``m.plot_map()``. | A colorbar with a colored histogram on top can then be added to the map via ``m.add_colorbar``. + .. note:: Colorbars are only visible if the layer at which the data was plotted is visible! diff --git a/eomaps/_cb_container.py b/eomaps/_cb_container.py index dba690877..cc12ddd05 100644 --- a/eomaps/_cb_container.py +++ b/eomaps/_cb_container.py @@ -69,10 +69,11 @@ def _clear_temporary_artists(self): def _sort_cbs(self, cbs): if not cbs: return set() - cbnames = set([i.rsplit("_", 1)[0] for i in cbs]) - + cbnames = set([i.rsplit("__", 1)[0].rsplit("_", 1)[0] for i in cbs]) sortp = self._cb_list + list(set(self._cb_list) ^ cbnames) - return sorted(list(cbs), key=lambda w: sortp.index(w.rsplit("_", 1)[0])) + return sorted( + list(cbs), key=lambda w: sortp.index(w.rsplit("__", 1)[0].rsplit("_", 1)[0]) + ) def __repr__(self): txt = "Attached callbacks:\n " + "\n ".join( diff --git a/eomaps/_containers.py b/eomaps/_containers.py index 3519bd1aa..f6c6cdb5a 100644 --- a/eomaps/_containers.py +++ b/eomaps/_containers.py @@ -138,13 +138,23 @@ def set_colorbar_position(self, pos=None, ratio=None, cb=None): """ if cb is None: - _, _, ax_cb, ax_cb_plot, orientation, _ = self._m._colorbar + ( + _, + _, + ax_cb, + ax_cb_plot, + ax_cb_extend, + extend_frac, + orientation, + _, + ) = self._m._colorbar else: - _, _, ax_cb, ax_cb_plot, orientation, _ = cb + _, _, ax_cb, ax_cb_plot, ax_cb_extend, extend_frac, orientation, _ = cb if orientation == "horizontal": pcb = ax_cb.get_position() pcbp = ax_cb_plot.get_position() + if pos is None: pos = [pcb.x0, pcb.y0, pcb.width, pcb.height + pcbp.height] if ratio is None: @@ -160,6 +170,16 @@ def set_colorbar_position(self, pos=None, ratio=None, cb=None): [pos[0], pos[1] + hcb, pos[2], hp], ) + # adjust colorbar extension arrows + if ax_cb_extend: + frac = ( + ax_cb.bbox.transformed(ax_cb.figure.transFigure.inverted()).width + * extend_frac + ) + ax_cb_extend.set_position( + [pos[0] - frac / 2, pos[1], pos[2] + frac, hcb], + ) + elif orientation == "vertical": pcb = ax_cb.get_position() pcbp = ax_cb_plot.get_position() @@ -177,6 +197,17 @@ def set_colorbar_position(self, pos=None, ratio=None, cb=None): ax_cb_plot.set_position( [pos[0], pos[1], wp, pos[3]], ) + + # adjust colorbar extension arrows + if ax_cb_extend: + frac = ( + ax_cb.bbox.transformed(ax_cb.figure.transFigure.inverted()).height + * extend_frac + ) + ax_cb_extend.set_position( + [pos[0] + wp, pos[1] - frac / 2, wcb, pos[3] + frac], + ) + else: raise TypeError(f"EOmaps: '{orientation}' is not a valid orientation") diff --git a/eomaps/_webmap.py b/eomaps/_webmap.py index ee2fac054..d4a31c343 100644 --- a/eomaps/_webmap.py +++ b/eomaps/_webmap.py @@ -271,7 +271,7 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) pass - def __call__(self, layer=None, **kwargs): + def __call__(self, layer=None, zorder=0, **kwargs): """ Add the WMTS layer to the map @@ -284,6 +284,9 @@ def __call__(self, layer=None, **kwargs): - If None, the layer of the parent object is used. The default is None. + zorder : float + The zorder of the artist (e.g. the stacking level of overlapping artists) + The default is 0 **kwargs : additional kwargs passed to the WebMap service request. (e.g. transparent=True, time='2020-02-05', etc.) @@ -291,6 +294,7 @@ def __call__(self, layer=None, **kwargs): from . import MapsGrid # do this here to avoid circular imports! for m in self._m if isinstance(self._m, MapsGrid) else [self._m]: + self._zorder = zorder self._kwargs = kwargs if layer is None: self._layer = m.layer @@ -311,7 +315,11 @@ def _do_add_layer(self, m, l): print(f"EOmaps: Adding wmts-layer: {self.name}") art = m.figure.ax.add_wmts( - self._wms, self.name, wmts_kwargs=self._kwargs, interpolation="spline36" + self._wms, + self.name, + wmts_kwargs=self._kwargs, + interpolation="spline36", + zorder=self._zorder, ) m.BM.add_bg_artist(art, l) @@ -322,7 +330,7 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) pass - def __call__(self, layer=None, **kwargs): + def __call__(self, layer=None, zorder=0, **kwargs): """ Add the WMS layer to the map @@ -335,6 +343,9 @@ def __call__(self, layer=None, **kwargs): - If None, the layer of the parent object is used. The default is None. + zorder : float + The zorder of the artist (e.g. the stacking level of overlapping artists) + The default is 0 **kwargs : additional kwargs passed to the WebMap service request. (e.g. transparent=True, time='2020-02-05', etc.) @@ -343,6 +354,8 @@ def __call__(self, layer=None, **kwargs): for m in self._m if isinstance(self._m, MapsGrid) else [self._m]: self._kwargs = kwargs + self._zorder = zorder + if layer is None: self._layer = m.layer else: @@ -364,7 +377,11 @@ def _do_add_layer(self, m, l, usem=None): # actually add the layer to the map. print(f"EOmaps: ... adding wms-layer {self.name}") art = m.figure.ax.add_wms( - self._wms, self.name, wms_kwargs=self._kwargs, interpolation="spline36" + self._wms, + self.name, + wms_kwargs=self._kwargs, + interpolation="spline36", + zorder=self._zorder, ) m.BM.add_bg_artist(art, l) @@ -877,6 +894,7 @@ def __call__( transparent=False, alpha=1, interpolation="spline36", + zorder=0, **kwargs, ): """ @@ -904,6 +922,9 @@ def __call__( required (e.g. if you don't use the native projection of the WMS) changing this value will slow down re-projection but it can provide a huge boost in image quality! The default is 750. + zorder : float + The zorder of the artist (e.g. the stacking level of overlapping artists) + The default is 0 **kwargs : Additional kwargs passed to the cartopy-wrapper for matplotlib's `imshow`. @@ -920,7 +941,7 @@ def __call__( if isinstance(self._m, MapsGrid): for m in self._m: self._reinit(m).__call__( - layer, transparent, alpha, interpolation, **kwargs + layer, transparent, alpha, interpolation, zorder, **kwargs ) else: @@ -928,6 +949,7 @@ def __call__( interpolation=interpolation, alpha=alpha, origin="lower" ) self._kwargs.update(kwargs) + self._zorder = zorder if self._layer == "all" or self._m.BM.bg_layer == self._layer: # add the layer immediately if the layer is already active @@ -959,7 +981,9 @@ def _do_add_layer(self, m, l): # (only SlippyImageArtist has been subclassed) self._raster_source.validate_projection(m.ax.projection) - img = SlippyImageArtist_NEW(m.ax, self._raster_source, **self._kwargs) + img = SlippyImageArtist_NEW( + m.ax, self._raster_source, zorder=self._zorder, **self._kwargs + ) with self._m.ax.hold_limits(): m.ax.add_image(img) self._artist = img diff --git a/eomaps/eomaps.py b/eomaps/eomaps.py index 8c7e9a267..518cfa798 100644 --- a/eomaps/eomaps.py +++ b/eomaps/eomaps.py @@ -51,6 +51,10 @@ from matplotlib.colors import LinearSegmentedColormap from matplotlib.gridspec import GridSpec, GridSpecFromSubplotSpec, SubplotSpec +import matplotlib.path as mpath +import matplotlib.patches as mpatches +from matplotlib.collections import PatchCollection + from cartopy import crs as ccrs @@ -1747,15 +1751,20 @@ def _add_colorbar( orientation=cb_orientation, ) # plot the histogram - hist_vals, hist_bins, init_hist = ax_cb_plot.hist( - z_data, - orientation=orientation, - bins=bins if (classified and histbins == "bins") else histbins, - color="k", - align="mid", - range=(vmin, vmax) if (vmin and vmax) else None, - density=density, - ) + # (only if the axis has a finite size) + if ax_cb_plot.bbox.width > 0 and ax_cb_plot.bbox.height > 0: + ax_cb_plot.set_axis_on() + _ = ax_cb_plot.hist( + z_data.clip(vmin, vmax) if (vmin or vmax) else z_data, + orientation=orientation, + bins=bins if (classified and histbins == "bins") else histbins, + color="k", + align="mid", + range=(vmin, vmax) if (vmin and vmax) else None, + density=density, + ) + else: + ax_cb_plot.set_axis_off() # identify position of color-splits in the colorbar if isinstance(n_cmap.cmap, LinearSegmentedColormap): @@ -1885,8 +1894,135 @@ def _add_colorbar( ot.set_horizontalalignment("center") ot.set_position((1, 0)) + # make sure axis limits are correct + if orientation == "vertical": + limsetfunc = ax_cb.set_xlim + else: + limsetfunc = ax_cb.set_ylim + + limsetfunc(vmin, vmax) + return cb + def _add_cb_extend_arrows(self, cb, orientation, extend_frac=0.025, which="auto"): + """ + Add a new axis that holds extension-triangles for the colorbar. + + Parameters + ---------- + cb : matplotilb.colorbar + the colorbar to use + orientation : str + "vertical" or "horizontal" + extend_frac : float, optional + the fraction of the axis to use for the extension-triangles. + (NOTE: triangles will be drawn OUTSIDE the axes!!) + The default is 0.03. + which : str + Indicator which triangles should be drawn + (one of "auto", "upper", "lower" or "both"). + The default is "auto" in which case only relevant arrows are drawn + (e.g. "upper" is only drawn if there are values > vmax). + """ + + if which == "auto": + a = cb.mappable.get_array() + vmin, vmax = cb.mappable.norm.vmin, cb.mappable.norm.vmax + + if (a > vmax).any(): + which = "upper" + if (a < vmin).any(): + if which == "upper": + which = "both" + else: + which = "lower" + + if which == "auto": + # (no extension arrows necessary) + return + + # get the bounding box of the current colorbar axis + bbox = self.figure.ax_cb.bbox.transformed(self.figure.f.transFigure.inverted()) + + if orientation == "horizontal": + if which == "both": + which = "left right" + elif which == "upper": + which = "right" + elif which == "lower": + which = "left" + + frac = extend_frac * (bbox.width) + axcb2 = self.figure.f.add_axes( + (bbox.x0 - frac / 2, bbox.y0, bbox.width + frac, bbox.height), + zorder=-99, + ) + axcb2.get_shared_y_axes().join(axcb2, self.figure.ax_cb) + # set the limits to (0, 1) so we can directly use the fractions + axcb2.set_xlim(0, 1) + + else: + if which == "both": + which = "top bottom" + elif which == "upper": + which = "top" + elif which == "lower": + which = "bottom" + frac = extend_frac * (bbox.height) + axcb2 = self.figure.f.add_axes( + (bbox.x0, bbox.y0 - frac / 2, bbox.width, bbox.height + frac), + zorder=-99, + ) + axcb2.get_shared_x_axes().join(axcb2, self.figure.ax_cb) + # set the limits to (0, 1) so we can directly use the fractions + axcb2.set_ylim(0, 1) + + axcb2.set_axis_off() + + # get the coordinates of the colorbar in units of axcb2 + + bbox_transf = self.figure.ax_cb.bbox.transformed(axcb2.transAxes.inverted()) + + x0, x1, y0, y1 = bbox_transf.x0, bbox_transf.x1, bbox_transf.y0, bbox_transf.y1 + + # make sure the arrow overlapps a little bit with the parent cb-axes + # to avoid gaps between the arrow and the colorbar + ovx, ovy = bbox_transf.width * 0.1, bbox_transf.height * 0.1 + + points = dict( + left=[[0, 0.5], [x0, 1], [x0 + ovx, 1], [x0 + ovx, 0], [x0, 0], [0, 0.5]], + right=[[1, 0.5], [x1, 1], [x1 - ovx, 1], [x1 - ovx, 0], [x1, 0], [1, 0.5]], + top=[[0.5, 1], [0, y1 - ovy], [1, y1 - ovy], [0.5, 1]], + bottom=[[0.5, 0], [0, y0 + ovy], [1, y0 + ovy], [0.5, 0]], + ) + + cmap = self.figure.coll.cmap + colors = dict( + left=cmap.get_under(), + right=cmap.get_over(), + top=cmap.get_over(), + bottom=cmap.get_under(), + ) + + patches = [] + use_colors = [] + for w in which.split(" "): + p = points[w] + Path = mpath.Path + path_data = [ + (Path.MOVETO, p[0]), + *[(Path.LINETO, pi) for pi in p[1:-1]], + (Path.CLOSEPOLY, p[-1]), + ] + codes, verts = zip(*path_data) + path = mpath.Path(verts, codes) + patches.append(mpatches.PathPatch(path)) + use_colors.append(colors[w]) + collection = PatchCollection(patches, fc=use_colors, ec="none", lw=0) + + axcb2.add_collection(collection) + return axcb2 + @property @wraps(NaturalEarth_features) def add_feature(self): @@ -2409,23 +2545,41 @@ def add_annotation( Examples -------- - >>> m.add_annotation(ID=1) - >>> m.add_annotation(xy=(45, 35), xy_crs=4326) + >>> m.add_annotation(ID=1) + >>> m.add_annotation(xy=(45, 35), xy_crs=4326) + + NOTE: You can provide lists to add multiple annotations in one go! + + >>> m.add_annotation(ID=[1, 5, 10, 20]) + >>> m.add_annotation(xy=([23.5, 45.8, 23.7], [5, 6, 7]), xy_crs=4326) + + The text can be customized by providing either a string - NOTE: You can provide lists to add multiple annotations in one go! + >>> m.add_annotation(ID=1, text="some text") - >>> m.add_annotation(ID=[1, 5, 10, 20]) - >>> m.add_annotation(xy=([23.5, 45.8, 23.7], [5, 6, 7]), xy_crs=4326) + or a callable that returns a string with the following signature: - The text can be customized by providing either a string + >>> def addtxt(m, ID, val, pos, ind): + >>> return f"The ID {ID} at position {pos} has a value of {val}" + >>> m.add_annotation(ID=1, text=addtxt) - >>> m.add_annotation(ID=1, text="some text") + **Customizing the appearance** - or a callable that returns a string with the following signature: + For the full set of possibilities, see: + https://matplotlib.org/stable/tutorials/text/annotations.html - >>> def addtxt(m, ID, val, pos, ind): - >>> return f"The ID {ID} at position {pos} has a value of {val}" - >>> m.add_annotation(ID=1, text=addtxt) + >>> m.add_annotation(xy=[7.10, 45.16], xy_crs=4326, + >>> text="blubb", xytext=(30,30), + >>> horizontalalignment="center", verticalalignment="center", + >>> arrowprops=dict(ec="g", + >>> arrowstyle='-[', + >>> connectionstyle="angle", + >>> ), + >>> bbox=dict(boxstyle='circle,pad=0.5', + >>> fc='yellow', + >>> alpha=0.3 + >>> ) + >>> ) """ @@ -2434,7 +2588,7 @@ def add_annotation( mask = np.isin(self._props["ids"], ID) xy = (self._props["xorig"][mask], self._props["yorig"][mask]) val = self._props["z_data"][mask] - ind = self._props["ids"][mask] + ind = np.where(mask)[0] ID = np.atleast_1d(ID) xy_crs = self.data_specs.crs else: @@ -2687,11 +2841,13 @@ def _shade_map( # (e.g. count, std, var ... ) use an automatic "linear" normalization if aggname in ["first", "last", "max", "min", "mean", "mode"]: kwargs.setdefault("norm", self.classify_specs._norm) + kwargs.setdefault("vmin", vmin) + kwargs.setdefault("vmax", vmax) # clip the data to properly account for vmin and vmax # (do this only if we don't intend to use the full dataset!) - if vmin or vmax: - props["z_data"] = props["z_data"].clip(vmin, vmax) + # if vmin or vmax: + # props["z_data"] = props["z_data"].clip(vmin, vmax) else: kwargs.setdefault("norm", "linear") kwargs.setdefault("vmin", vmin) @@ -3080,8 +3236,8 @@ def _plot_map( # clip the data to properly account for vmin and vmax # (do this only if we don't intend to use the full dataset!) - if vmin or vmax: - props["z_data"] = props["z_data"].clip(vmin, vmax) + # if vmin or vmax: + # props["z_data"] = props["z_data"].clip(vmin, vmax) # ---------------------- classify the data cbcmap, norm, bins, classified = self._classify_data( @@ -3224,7 +3380,9 @@ def plot_map( ----------------- vmin, vmax : float, optional Min- and max. values assigned to the colorbar. The default is None. - + zorder : float + The zorder of the artist (e.g. the stacking level of overlapping artists) + The default is 1 kwargs kwargs passed to the initialization of the matpltolib collection (dependent on the plot-shape) [linewidth, edgecolor, facecolor, ...] @@ -3246,6 +3404,10 @@ def plot_map( kwargs["alpha"], ) + # make sure zorder is set to 1 by default + # (by default shading would use 0 while ordinary collections use 1) + kwargs.setdefault("zorder", 1) + if useshape.name.startswith("shade"): self._shade_map( pick_distance=pick_distance, @@ -3389,10 +3551,13 @@ def add_colorbar( bottom=0.1, left=0.1, right=0.05, + histogram_size=9, layer=None, log=False, tick_formatter=None, dynamic_shade_indicator=False, + add_extend_arrows="auto", + extend_frac=0.025, ): """ Add a colorbar to an existing figure. @@ -3403,14 +3568,16 @@ def add_colorbar( By default, the colorbar will only be visible on the layer of the associated Maps-object (you can override this by providing an explicit "layer"-name). - To change the position of the colorbar, use: + To change the position of the colorbar after it has been created, use: - >>> m.add_colorbar() - >>> m.figure.set_colorbar_position(pos=[.1, .05, .8, .2], ratio=10) + >>> cb = m.add_colorbar() + >>> m.figure.set_colorbar_position(pos=[.1, .05, .8, .2], ratio=10, cb=cb) Parameters ---------- gs : float or matpltolib.gridspec.SubplotSpec + The relative size of the colorbar (or an explicit GridSpec definition) + - if float: The fraction of the the parent axes to use for the colorbar. (The colorbar will "steal" some space from the parent axes.) - if SubplotSpec : A SubplotSpec instance that will be used to initialize @@ -3442,6 +3609,16 @@ def add_colorbar( The padding between the colorbar and the parent axes (as fraction of the plot-height (if "horizontal") or plot-width (if "vertical") The default is (0.05, 0.1, 0.1, 0.05) + histogram_size : float + Set the relative size of the histogram compared to the colorbar, e.g.: + + `