diff --git a/.github/workflows/testMaps.yml b/.github/workflows/testMaps.yml index 19ac018f9..1f1803cae 100755 --- a/.github/workflows/testMaps.yml +++ b/.github/workflows/testMaps.yml @@ -13,7 +13,7 @@ jobs: # set operating systems to test os: [ubuntu-latest] # set python versions to test - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] name: test_Maps ${{ matrix.os }} ${{ matrix.python-version }} steps: @@ -34,7 +34,7 @@ jobs: shell: bash -l {0} run: | pip install -e .[test] - python -m pytest -v --cov=eomaps --cov-report=xml + python -m pytest -v --cov=eomaps --cov-report=xml -n auto - name: Upload Image Comparison Artefacts if: ${{ failure() }} uses: actions/upload-artifact@v4 diff --git a/docs/source/_static/example_images/example_agg_filters.png b/docs/source/_static/example_images/example_agg_filters.png new file mode 100644 index 000000000..5b2063ab9 Binary files /dev/null and b/docs/source/_static/example_images/example_agg_filters.png differ diff --git a/docs/source/api/eomaps.eomaps.Maps.rst b/docs/source/api/eomaps.eomaps.Maps.rst index fa7a63d98..9346fd587 100755 --- a/docs/source/api/eomaps.eomaps.Maps.rst +++ b/docs/source/api/eomaps.eomaps.Maps.rst @@ -21,6 +21,8 @@ Properties Maps.f Maps.ax + Maps.l + Maps.ll Maps.layer Maps.crs_plot @@ -29,9 +31,7 @@ Properties :template: obj_with_attributes_no_toc.rst :nosignatures: - Maps.data Maps.data_specs - Maps.classify_specs Maps.colorbar @@ -144,7 +144,6 @@ Data visualization Maps.set_data Maps.set_shape Maps.set_classify - Maps.set_classify_specs .. autosummary:: :toctree: ../generated @@ -229,7 +228,6 @@ Miscellaneous :nosignatures: Maps.config - Maps.BM .. autosummary:: :toctree: ../generated diff --git a/docs/source/conf.py b/docs/source/conf.py index 48878b3b8..a02468605 100755 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -98,10 +98,7 @@ def setup(app): Maps.cb.move.attach.__name__ = "attach" Maps.cb.move.get.__name__ = "get" - Maps.BM.__name__ = "BM" - Maps.data_specs.__name__ = "data_specs" - Maps.classify_specs.__name__ = "classify_specs" # -- Project information diff --git a/docs/source/gen_autodoc_file.py b/docs/source/gen_autodoc_file.py index 3d5e79f20..4a9646c96 100755 --- a/docs/source/gen_autodoc_file.py +++ b/docs/source/gen_autodoc_file.py @@ -4,14 +4,6 @@ from eomaps import Maps, widgets -# TODO there must be a better way than this... -# BM needs to be a property otherwise there are problems with jupyter notebooks -# In order to make BM still accessible to sphinx, override it prior to generating -# the autodoc-files -from eomaps._blit_manager import BlitManager - -Maps.BM = BlitManager - def get_autosummary( currentmodule="eomaps.eomaps", @@ -75,9 +67,7 @@ def make_feature_toctree_file(): "read_file", "util", "add_wms", - "BM", "data_specs", - "classify_specs", ): members.extend(get_members(Maps, key, False)) for key in ("add_feature", "cb"): diff --git a/docs/source/user_guide/how_to_use/api_basics.rst b/docs/source/user_guide/how_to_use/api_basics.rst index f1db8d546..ec9904904 100755 --- a/docs/source/user_guide/how_to_use/api_basics.rst +++ b/docs/source/user_guide/how_to_use/api_basics.rst @@ -175,12 +175,12 @@ You can create as many layers as you need! The following image explains how it w If you use methods that are **NOT provided by EOmaps**, the corresponding artists will always appear on the ``"base"`` layer by default! (e.g. ``cartopy`` or ``matplotlib`` methods accessible via ``m.ax.`` or ``m.f.`` like ``m.ax.plot(...)``) - In most cases this behavior is sufficient... for more complicated use-cases, artists must be explicitly added to the **Blit Manager** (``m.BM``) so that ``EOmaps`` can handle drawing accordingly. + In most cases this behavior is sufficient... for more complicated use-cases, artists must be explicitly added to the ``Maps`` object so that ``EOmaps`` can handle drawing accordingly. To put the artists on dedicated layers, use one of the the following options: - - For artists that are dynamically updated on each event, use ``m.BM.add_artist(artist, layer=...)`` - - For "background" artists that only require updates on pan/zoom/resize, use ``m.BM.add_bg_artist(artist, layer=...)`` + - For artists that are dynamically updated on each event, use ``m.add_artist(artist)`` + - For "background" artists that only require updates on pan/zoom/resize, use ``m.add_bg_artist(artist)`` .. code-block:: python @@ -195,9 +195,9 @@ You can create as many layers as you need! The following image explains how it w (l1, ) = m.ax.plot([0, 1], [0, 1], lw=5, c="r", transform=m.ax.transAxes) (l2, ) = m.ax.plot([0, 1], [1, 0], lw=5, c="r", transform=m.ax.transAxes) - m.BM.add_bg_artist(l1, layer="mylayer") - m.BM.add_bg_artist(l2, layer="mylayer") - m.show_layer("mylayer") + m.l.mylayer.add_bg_artist(l1) + m.l.mylayer.add_bg_artist(l2) + m.l.mylayer.show() .. _combine_layers: @@ -642,15 +642,15 @@ Dynamic updates of figures ************************** As soon as a :py:class:`Maps`-object is attached to a figure, EOmaps will handle re-drawing of the figure! - Therefore **dynamically updated** artists must be added to the "blit-manager" (``m.BM``) to ensure + Therefore **dynamically updated** artists must be added to the ``Maps``-object to ensure that they are correctly updated. - - use ``m.BM.add_artist(artist, layer=...)`` if the artist should be re-drawn on **any event** in the figure - - use ``m.BM.add_bg_artist(artist, layer=...)`` if the artist should **only** be re-drawn if the extent of the map changes + - use ``m.add_artist(artist, layer=...)`` if the artist should be re-drawn on **any event** in the figure + - use ``m.add_bg_artist(artist, layer=...)`` if the artist should **only** be re-drawn if the extent of the map changes .. note:: - In most cases it is sufficient to simply add the whole axes-object as artist via ``m.BM.add_artist(...)``. + In most cases it is sufficient to simply add the whole axes-object as artist via ``m.add_artist(...)``. This ensures that all artists of the axes are updated as well! @@ -658,7 +658,6 @@ Dynamic updates of figures Here's an example to show how it works: - .. grid:: 1 1 1 2 .. grid-item:: @@ -685,7 +684,7 @@ Here's an example to show how it works: # Since we want to dynamically update the data on the axis, it must be # added to the BlitManager to ensure that the artists are properly updated. # (EOmaps handles interactive re-drawing of the figure) - m.BM.add_artist(ax, layer=m.layer) + m.add_artist(ax, layer=m.layer) # plot some static data on the axis ax.plot([10, 20, 30, 40, 50], [10, 20, 30, 40, 50]) @@ -710,9 +709,9 @@ MapsGrid objects .. note:: - While :py:class:`MapsGrid` objects provide some convenience, starting with EOmaps v6.x, - the preferred way of combining multiple maps and/or matplotlib axes in a figure - is by using one of the options presented in the previous sections! + Starting with EOmaps v9.0 MapsGrid objects support the full range of functionalities + offered by single Maps objects. + A :py:class:`MapsGrid` creates a grid of :py:class:`Maps` objects (and/or ordinary ``matplotlib`` axes), and provides convenience-functions to perform actions on all maps of the figure. @@ -722,22 +721,21 @@ and provides convenience-functions to perform actions on all maps of the figure. from eomaps import MapsGrid mg = MapsGrid(r=2, c=2, crs=4326) - # you can then access the individual Maps-objects via: + # you can then access the individual Maps-objects via the ``m__`` properties + # (useful for auto-completion) mg.m_0_0.add_feature.preset.ocean() - mg.m_0_1.add_feature.preset.land() - mg.m_1_0.add_feature.preset.urban_areas() - mg.m_1_1.add_feature.preset.rivers_lake_centerlines() - m_0_0_ocean = mg.m_0_0.new_layer("ocean") - m_0_0_ocean.add_feature.preset.ocean() + # or via 1d or 2d indexing + mg[0, 1].add_feature.preset.land() + mg[1, 0].add_feature.preset.urban_areas() + mg[3].add_feature.preset.rivers_lake_centerlines() # functions executed on MapsGrid objects will be executed on all Maps-objects: mg.add_feature.preset.coastline() mg.add_compass() + mg.add_gridlines(10, c="lightblue") - # to perform more complex actions on all Maps-objects, simply loop over the MapsGrid object - for m in mg: - m.add_gridlines(10, c="lightblue") + mg.l.ocean.add_feature.preset.ocean() # set the margins of the plot-grid mg.subplots_adjust(left=0.1, right=0.9, bottom=0.05, top=0.95, hspace=0.1, wspace=0.05) @@ -745,80 +743,6 @@ and provides convenience-functions to perform actions on all maps of the figure. Make sure to checkout the :ref:`layout_editor` which greatly simplifies the arrangement of multiple axes within a figure! -Custom grids and mixed axes -+++++++++++++++++++++++++++ - -Fully customized grid-definitions can be specified by providing ``m_inits`` and/or ``ax_inits`` dictionaries -of the following structure: - -- The keys of the dictionary are used to identify the objects -- The values of the dictionary are used to identify the position of the associated axes -- The position can be either an integer ``N``, a tuple of integers or slices ``(row, col)`` -- Axes that span over multiple rows or columns, can be specified via ``slice(start, stop)`` - -.. code-block:: python - - dict( - name1 = N # position the axis at the Nth grid cell (counting first) - name2 = (row, col), # position the axis at the (row, col) grid-cell - name3 = (row, slice(col_start, col_end)) # span the axis over multiple columns - name4 = (slice(row_start, row_end), col) # span the axis over multiple rows - ) - -- ``m_inits`` is used to initialize :py:class:`Maps` objects -- ``ax_inits`` is used to initialize ordinary ``matplotlib`` axes - -The individual :py:class:`Maps` objects and ``matplotlib-Axes`` are then accessible via: - -.. code-block:: python - :name: test_mapsgrid_custom - - from eomaps import MapsGrid - mg = MapsGrid(2, 3, - m_inits=dict(ocean=(0, 0), land=(0, 2)), - ax_inits=dict(someplot=(1, slice(0, 3))) - ) - # Maps object with the name "left" - mg.m_ocean.add_feature.preset.ocean() - # the Maps object with the name "right" - mg.m_land.add_feature.preset.land() - - # the ordinary matplotlib-axis with the name "someplot" - mg.ax_someplot.plot([1,2,3], marker="o") - mg.subplots_adjust(left=0.1, right=0.9, bottom=0.2, top=0.9) - -❗ NOTE: if ``m_inits`` and/or ``ax_inits`` are provided, ONLY the explicitly defined objects are initialized! - - -- The initialization of the axes is based on matplotlib's `GridSpec `_ functionality. - All additional keyword-arguments (``width_ratios, height_ratios, etc.``) are passed to the initialization of the ``GridSpec`` object. - -- To specify unique ``crs`` for each :py:class:`Maps` object, provide a dictionary of ``crs`` specifications. - -.. code-block:: python - :name: test_mapsgrid_custom_02 - - from eomaps import MapsGrid - # initialize a grid with 2 Maps objects and 1 ordinary matplotlib axes - mg = MapsGrid(2, 2, - m_inits=dict(top_row=(0, slice(0, 2)), - bottom_left=(1, 0)), - crs=dict(top_row=4326, - bottom_left=3857), - ax_inits=dict(bottom_right=(1, 1)), - width_ratios=(1, 2), - height_ratios=(2, 1)) - - # a map extending over the entire top-row of the grid (in epsg=4326) - mg.m_top_row.add_feature.preset.coastline() - - # a map in the bottom left corner of the grid (in epsg=3857) - mg.m_bottom_left.add_feature.preset.ocean() - - # an ordinary matplotlib axes in the bottom right corner of the grid - mg.ax_bottom_right.plot([1, 2, 3], marker="o") - mg.subplots_adjust(left=0.1, right=0.9, bottom=0.1, top=0.9) - .. currentmodule:: eomaps.mapsgrid @@ -826,16 +750,6 @@ The individual :py:class:`Maps` objects and ``matplotlib-Axes`` are then accessi :nosignatures: MapsGrid - MapsGrid.join_limits - MapsGrid.share_click_events - MapsGrid.share_pick_events - MapsGrid.set_data - MapsGrid.set_classify_specs - MapsGrid.add_wms - MapsGrid.add_feature - MapsGrid.add_annotation - MapsGrid.add_marker - MapsGrid.add_gdf Syntax and Autocompletion diff --git a/docs/source/user_guide/miscellaneous/api_misc.rst b/docs/source/user_guide/miscellaneous/api_misc.rst index cf62237d3..66c78538d 100755 --- a/docs/source/user_guide/miscellaneous/api_misc.rst +++ b/docs/source/user_guide/miscellaneous/api_misc.rst @@ -12,7 +12,6 @@ Some additional functions and properties that might come in handy: Maps.on_layer_activation Maps.set_extent_to_location Maps.get_crs - Maps.BM Maps.join_limits Maps.snapshot Maps.refetch_wms_on_size_change diff --git a/eomaps/_blit_manager.py b/eomaps/_blit_manager.py index ebe67fba3..216cdc2e5 100755 --- a/eomaps/_blit_manager.py +++ b/eomaps/_blit_manager.py @@ -7,15 +7,17 @@ import logging from contextlib import ExitStack, contextmanager -from functools import lru_cache +from functools import lru_cache, wraps from itertools import chain -from weakref import WeakSet +import weakref import matplotlib.pyplot as plt import numpy as np from matplotlib.spines import Spine from matplotlib.transforms import Bbox +from .helpers import _proxy + _log = logging.getLogger(__name__) @@ -98,33 +100,38 @@ def _get_combined_layer_name(*args): """ try: combnames = [] - for i in args: - if isinstance(i, str): - combnames.append(i) - elif isinstance(i, (list, tuple)): + for arg in args: + if isinstance(arg, str): + layer = arg.split("__", 1)[0] + combnames.append(layer) + elif isinstance(arg, (list, tuple)): assert ( - len(i) == 2 - and isinstance(i[0], str) - and i[1] >= 0 - and i[1] <= 1 + len(arg) == 2 + and isinstance(arg[0], str) + and arg[1] >= 0 + and arg[1] <= 1 ), ( - f"EOmaps: unable to identify the layer-assignment: {i} .\n" + f"EOmaps: unable to identify the layer-assignment: {arg} .\n" "You can provide either a single layer-name as string, a list " "of layer-names or a list of tuples of the form: " "(< layer-name (str) >, < layer-transparency [0-1] > )" ) - if i[1] < 1: - combnames.append(i[0] + "{" + str(i[1]) + "}") + layer, alpha = arg + layer = layer.split("__", 1)[0] + + if alpha < 1: + combnames.append(layer + "{" + str(alpha) + "}") else: - combnames.append(i[0]) + combnames.append(layer) else: raise TypeError( - f"EOmaps: unable to identify the layer-assignment: {i} .\n" + f"EOmaps: unable to identify the layer-assignment: {layer} .\n" "You can provide either a single layer-name as string, a list " "of layer-names or a list of tuples of the form: " "(< layer-name (str) >, < layer-transparency [0-1] > )" ) + return "|".join(combnames) except Exception: raise TypeError(f"EOmaps: Unable to combine the layer-names {args}") @@ -135,7 +142,7 @@ def _check_layer_name(layer): _log.info("EOmaps: All layer-names are converted to strings!") layer = str(layer) - if layer.startswith("__") and not layer.startswith("__inset_"): + if layer.startswith("__") and not layer.startswith("**inset_"): raise TypeError( "EOmaps: Layer-names starting with '__' are reserved " "for internal use and cannot be used as Maps-layer-names!" @@ -166,13 +173,224 @@ def _check_layer_name(layer): return layer +class ArtistAccessor: + def __init__(self, ca, name="artists"): + self._ca = ca + self._name = name + + # used for private artists not obtained from Maps objects + # (e.g. Spines, background patches etc.) + # NOTE: sub-layer syntax is not supported for free artists + # (e.g. keys should be layer-names not "__") + self._free_artists = {} + + def add(self, layer, *artists): + "Add a 'free' artist to the blit-manager not connected to a Maps-object" + self._free_artists.setdefault(layer, weakref.WeakSet()).update(artists) + + def __getitem__(self, key): + return [ + *getattr(self._ca, f"_get_{self._name}")(key), + *self._free_artists.get(key, {}), + ] + + def __setitem__(self, layer, artist): + return getattr(self._ca.get_maps(layer), f"_{self._name}").add(artist) + + def __iter__(self): + return chain( + getattr(self._ca, f"_get_{self._name}")(), *self._free_artists.values() + ) + + +class ChildAccessor: + def __init__(self): + self._children = {} + + self.artists = ArtistAccessor(self, "artists") + self.bg_artists = ArtistAccessor(self, "bg_artists") + + def __getitem__(self, key): + return self._children[key] + + def __setitem__(self, key, value): + self._children[key] = value + + def __iter__(self): + return iter(chain(*self._children.values())) + + def add(self, m): + self._children.setdefault(m.layer, weakref.WeakSet()).add(m) + + def remove(self, m): + self._children[m.layer].remove(m) + + def _get_artists(self, layer=None): + if layer is None: + return chain(*(m._artists for m in self)) + + return chain(*(m._artists for m in self.get_maps(layer))) + + def _get_bg_artists(self, layer=None): + if layer is None: + return chain(*(m._bg_artists for m in self)) + + return chain(*(m._bg_artists for m in self.get_maps(layer))) + + def _get_maps(self, layer): + return chain( + *( + ms + for key, ms in self._children.items() + if key.split("__", 1)[0] == layer + ) + ) + + def get_artists(self, layer=None): + return list(self._get_artists(layer)) + + def get_bg_artists(self, layer=None): + return list(self._get_bg_artists(layer)) + + def get_maps(self, layer): + return list(self._get_maps(layer)) + + +class Hooks: + def __init__(self, *args, **kwargs): + self.__hooks = dict() + + super().__init__(*args, **kwargs) + + def add_hook(self, hook, method, permanent=True, layer="all", unique=True): + self.__add( + hook=hook, method=method, permanent=permanent, layer=layer, unique=unique + ) + + def remove_hook(self, hook, method=None, permanent=None, layer=None, silent=True): + self.__remove( + hook=hook, method=method, permanent=permanent, layer=layer, silent=silent + ) + + def run_hook(self, name, layer="all", **kwargs): + # always run callbacks assigned to the "all" layer... + if layer != "all": + self.__run(name, layer="all", **kwargs) + + self.__run(name, layer=layer, **kwargs) + + if name == "layer_activation": + self.figure._EOmaps_parent._emit_signal("lazyLayerActivated") + + def _get_hooks(self, name, layer="all", permanent=False): + if (hook := self.__hooks.get(name, None)) is None: + return [] + + return hook.get(permanent, {}).get(layer, []) + + def __run(self, name, layer="all", **kwargs): + if (hook := self.__hooks.get(name, None)) is None: + return + + single_shot_cb = hook.get(False, {}).get(layer, []) + permanent_cb = hook.get(True, {}).get(layer, []) + + # run single-shot actions + while len(single_shot_cb) > 0: + try: + action = single_shot_cb.pop(0) + action(layer=layer, **kwargs) + except Exception as ex: + _log.error( + f"EOmaps: Issue during single-shot hook '{hook}': {ex}", + exc_info=_log.getEffectiveLevel() <= logging.DEBUG, + ) + + # run permanent actions + for action in permanent_cb: + try: + action(layer=layer, **kwargs) + except Exception as ex: + _log.error( + f"EOmaps: Issue during permanent hook '{hook}': {ex}", + exc_info=_log.getEffectiveLevel() <= logging.DEBUG, + ) + + def __add(self, hook, method, permanent=False, layer="all", unique=True): + cb = ( + self.__hooks.setdefault(hook, {}) + .setdefault(permanent, {}) + .setdefault(layer, []) + ) + + if not unique or method not in cb: + cb.append(method) + + def __remove(self, hook, method=None, permanent=None, layer=None, silent=True): + # if permanent is None, try to remove method as either temporary or + # permanent callback + if permanent is None: + # try to remove method from permanent hook + q = self.__remove( + hook=hook, method=method, permanent=True, layer=layer, silent=True + ) + # if no method is specified, also remove all temporary hooks of layer! + if q is False or method is None: + # try to remove method from temporary hook if not found in permanent + q = self.__remove( + hook=hook, method=method, permanent=False, layer=layer, silent=True + ) + if q is False and not silent: + _log.warning(f"EOmaps: method {method} not found in hook '{hook}'") + + return q + + found = False + if (hook := self.__hooks.get(hook, None)) is not None: + if method is None: + if layer is None: + # if method is None, and layer is None, remove ALL callbacks of hook + q = hook.pop(permanent, None) is not None + else: + # remove ALL callbacks assigned to the specified layer + if (hook_callbacks := hook.get(permanent, None)) is not None: + q = hook_callbacks.pop(layer, None) is not None + else: + q = False + return q + + # search for method + if (hook_callbacks := hook.get(permanent, None)) is not None: + # if None is passed as layer, traverse all layer assignments + for l in (layer,) if layer else hook_callbacks.keys(): + cb = hook_callbacks.get(l, []) + if method in cb: + found = True + break + + if not found: + if not silent: + _log.warning(f"EOmaps: method {method} not found in hook '{hook}'") + return False + + try: + cb.remove(method) + return True + except Exception as ex: + _log.debug( + f"EOmaps: unable to remove method {method} from '{hook}' hooks: {ex}", + exc_info=_log.getEffectiveLevel() <= logging.DEBUG, + ) + return False + + # taken from https://matplotlib.org/stable/tutorials/advanced/blitting.html#class-based-example -class BlitManager(LayerParser): +class BlitManager(LayerParser, Hooks): """Manager used to schedule draw events, cache backgrounds, etc.""" _snapshot_on_update = False - def __init__(self, m): + def __init__(self, f, bg_layer="base"): """ Manager used to schedule draw events, cache backgrounds, etc. @@ -190,15 +408,13 @@ def __init__(self, m): self._disable_draw = False self._disable_update = False - self._m = m - self._bg_layer = self._m.layer + self._f = _proxy(f) + self._children = ChildAccessor() - self._artists = dict() + self._bg_layer = bg_layer + self._bg_layers = {} - self._bg_artists = dict() - self._bg_layers = dict() - - self._pending_webmaps = dict() + self._managed_axes = weakref.WeakSet() # the name of the layer at which all "unmanaged" artists are drawn self._unmanaged_artists_layer = "base" @@ -206,9 +422,6 @@ def __init__(self, m): # grab the background on every draw self._cid_draw = self.canvas.mpl_connect("draw_event", self._on_draw_cb) - self._after_update_actions = [] - self._after_restore_actions = [] - self._artists_to_clear = dict() self._hidden_artists = set() @@ -229,180 +442,74 @@ def __init__(self, m): self._mpl_backend_force_full = False self._mpl_backend_blit_fix = False - # True = persistent, False = execute only once - self._on_layer_change = {True: list(), False: list()} - self._on_layer_activation = {True: dict(), False: dict()} - - self._on_add_bg_artist = list() - self._on_remove_bg_artist = list() - - self._before_fetch_bg_actions = list() - self._before_update_actions = list() - self._refetch_blank = True self._blank_bg = None - self._managed_axes = set() - self._clear_on_layer_change = False self._on_layer_change_running = False # a weak set containing artists that should NOT be identified as # unmanaged artists - self._ignored_unmanaged_artists = WeakSet() + self._ignored_unmanaged_artists = weakref.WeakSet() - def _get_renderer(self): - # don't return the renderer if the figure is saved. - # in this case the normal draw-routines are used (see m.savefig) so there is - # no need to trigger updates (also `canvas.get_renderer` is undefined for - # pdf/svg exports since those canvas do not expose the renderer) - # ... this is required to support vector format outputs! - if self.canvas.is_saving(): - return None + super().__init__() - try: - return self.canvas.get_renderer() - except Exception: - return None + @property + def _artists(self): + return self._children.artists - def _get_all_map_axes(self): - maxes = { - m.ax - for m in (self._m.parent, *self._m.parent._children) - if getattr(m, "_new_axis_map", False) - } - return maxes + artists = {} + for m in self._children: + artists.setdefault(m.layer, list()).extend(m._artists) + return artists - def _get_managed_axes(self): - return (*self._get_all_map_axes(), *self._managed_axes) + @property + def _bg_artists(self): + return self._children.bg_artists - def _get_unmanaged_axes(self): - # return a list of all axes that are not managed by the blit-manager - # (to ensure that "unmanaged" axes are drawn as well) + artists = {} + for m in self._children: + artists.setdefault(m.layer, list()).extend(m._bg_artists) - # EOmaps axes - managed_axes = self._get_managed_axes() - allaxes = set(self._m.f.axes) + return artists - unmanaged_axes = allaxes.difference(managed_axes) - return unmanaged_axes + def _remove_artist(self, artist, layer=None): + for m in self._children: + if artist in m._artists: + m._remove_artist(artist) + break + + def _remove_bg_artist(self, artist, layer=None): + for m in self._children: + if artist in m._artists: + m._remove_bg_artist(artist) + break + + # TODO layer is currently ignored! + def remove_artist(self, artist, layer=None): + for m in self._children: + if artist in m._artists: + m.remove_artist(artist) + break + + # TODO layer is currently ignored! + def remove_bg_artist(self, artist, layer=None, draw=False): + for m in self._children: + if artist in m._bg_artists: + m.remove_bg_artist(artist, draw=draw) + break @property def figure(self): """The matplotlib figure instance.""" - return self._m.f + return self._f @property def canvas(self): """The figure canvas instance.""" return self.figure.canvas - @contextmanager - def _cx_on_layer_change_running(self): - # a context-manager to avoid recursive on_layer_change calls - try: - self._on_layer_change_running = True - yield - finally: - self._on_layer_change_running = False - - def _do_on_layer_change(self, layer, new=False): - # avoid recursive calls to "_do_on_layer_change" - # This is required in case the executed functions trigger actions that would - # trigger "_do_on_layer_change" again which can result in a mixed-up order of - # the scheduled functions. - if self._on_layer_change_running is True: - return - - # do not execute layer-change callbacks on private layer activation! - if layer.startswith("__"): - return - - with self._cx_on_layer_change_running(): - # only execute persistent layer-change callbacks if the layer changed! - if new: - # general callbacks executed on any layer change - # persistent callbacks - for f in reversed(self._on_layer_change[True]): - f(layer=layer) - - # single-shot callbacks - # (execute also if the layer is already active) - while len(self._on_layer_change[False]) > 0: - try: - f = self._on_layer_change[False].pop(0) - f(layer=layer) - except Exception as ex: - _log.error( - f"EOmaps: Issue during layer-change action: {ex}", - exc_info=_log.getEffectiveLevel() <= logging.DEBUG, - ) - - sublayers, _ = self._parse_multi_layer_str(layer) - if new: - for l in sublayers: - # individual callables executed if a specific layer is activated - # persistent callbacks - for f in reversed(self._on_layer_activation[True].get(layer, [])): - f(layer=l) - - for l in sublayers: - # single-shot callbacks - single_shot_funcs = self._on_layer_activation[False].get(l, []) - while len(single_shot_funcs) > 0: - try: - f = single_shot_funcs.pop(0) - f(layer=l) - except Exception as ex: - _log.error( - f"EOmaps: Issue during layer-change action: {ex}", - exc_info=_log.getEffectiveLevel() <= logging.DEBUG, - ) - - # clear the list of pending webmaps once the layer has been activated - if layer in self._pending_webmaps: - self._pending_webmaps.pop(layer) - - @contextmanager - def _without_artists(self, artists=None, layer=None): - try: - removed_artists = {layer: set(), "all": set()} - if artists is None: - yield - else: - for a in artists: - if a in self._artists.get(layer, []): - self.remove_artist(a, layer=layer) - removed_artists[layer].add(a) - elif a in self._artists.get("all", []): - self.remove_artist(a, layer="all") - removed_artists["all"].add(a) - - yield - finally: - for layer, artists in removed_artists.items(): - for a in artists: - self.add_artist(a, layer=layer) - - def _get_active_bg(self, exclude_artists=None): - with self._without_artists(artists=exclude_artists, layer=self.bg_layer): - # fetch the current background (incl. dynamic artists) - self.update() - - with ExitStack() as stack: - # get rid of the figure background patch - # (done by putting the patch on the __BG__ layer!) - - # get rid of the axes background patch - for ax_i in self._get_all_map_axes(): - stack.enter_context( - ax_i.patch._cm_set(facecolor="none", edgecolor="none") - ) - bg = self.canvas.copy_from_bbox(self.figure.bbox) - - return bg - @property def bg_layer(self): """The currently visible layer-name.""" @@ -429,7 +536,7 @@ def bg_layer(self, val): self._do_on_layer_change(layer=val, new=new) # hide all colorbars that are not on the visible layer - for m in [self._m.parent, *self._m.parent._children]: + for m in self._children: layer_visible = self._layer_is_subset(val, m.layer) for cb in getattr(m, "_colorbars", []): @@ -443,33 +550,96 @@ def bg_layer(self, val): self._hidden_artists.add(cb) # hide all wms_legends that are not on the visible layer - if hasattr(self._m.parent, "_wms_legend"): - for layer, legends in self._m.parent._wms_legend.items(): - layer_visible = self._layer_is_subset(val, layer) - - if layer_visible: - for i in legends: - i.set_visible(True) - else: - for i in legends: - i.set_visible(False) + # TODO fix this! + # if hasattr(self._m.parent, "_wms_legend"): + # for layer, legends in self._m.parent._wms_legend.items(): + # layer_visible = self._layer_is_subset(val, layer) + + # if layer_visible: + # for i in legends: + # i.set_visible(True) + # else: + # for i in legends: + # i.set_visible(False) if self._clear_on_layer_change: self._clear_temp_artists("on_layer_change") - @contextmanager - def _cx_dont_clear_on_layer_change(self): - # a context-manager to avoid clearing artists on layer-changes - # (used in savefig to avoid clearing artists when re-fetching - # layers with backgrounds) - init_val = self._clear_on_layer_change - try: - self._clear_on_layer_change = False - yield - finally: - self._clear_on_layer_change = init_val + def get_artists(self, layer): + """ + Get all (sorted) dynamically updated artists assigned to a given layer-name. + + Parameters + ---------- + layer : str + The layer name for which artists should be fetched. + + Returns + ------- + artists : list + A list of artists on the specified layer, sorted with respect to the + vertical stacking (layer-order / zorder). + + """ + + artists = list() + for l in np.atleast_1d(layer): + # get all relevant artists for combined background layers + l = str(l) # w make sure we convert non-string layer names to string! + + # get artists defined on the layer itself + # Note: it's possible to create explicit multi-layers and attach + # artists that are only visible if both layers are visible! (e.g. "l1|l2") + artists.extend(self._artists[l]) + + # make the list unique but maintain order (dicts keep order for python>3.7) + artists = dict.fromkeys(artists) + # sort artists by zorder (respecting inset-map priority) + artists = sorted(artists, key=self._bg_artists_sort) + + return artists + + def get_bg_artists(self, layer): + """ + Get all (sorted) background artists assigned to a given layer-name. + + Parameters + ---------- + layer : str + The layer name for which artists should be fetched. + + Returns + ------- + artists : list + A list of artists on the specified layer, sorted with respect to the + vertical stacking (layer-order / zorder). + + """ + artists = list() + for l in np.atleast_1d(layer): + # get all relevant artists for combined background layers + l = str(l) # w make sure we convert non-string layer names to string! + + # get artists defined on the layer itself + # Note: it's possible to create explicit multi-layers and attach + # artists that are only visible if both layers are visible! (e.g. "l1|l2") + artists.extend(self._bg_artists[l]) + + # make sure to also trigger drawing unmanaged artists on inset-maps! + if l in ( + self._unmanaged_artists_layer, + f"**inset_{self._unmanaged_artists_layer}", + ): + artists.extend(self._get_unmanaged_artists()) + + # make the list unique but maintain order (dicts keep order for python>3.7) + artists = dict.fromkeys(artists) + # sort artists by zorder (respecting inset-map priority) + artists = sorted(artists, key=self._bg_artists_sort) + + return artists - def on_layer(self, func, layer=None, persistent=False, m=None): + def on_layer(self, func, layer=None, persistent=False, **kwargs): """ Add callables that are executed whenever the visible layer changes. @@ -496,151 +666,262 @@ def on_layer(self, func, layer=None, persistent=False, m=None): Indicator if the function should be called only once (False) or if it should be called whenever a layer is activated. The default is False. - m : eomaps.Maps - The Maps-object to pass as argument to the function execution. - If None, the parent Maps-object is used. - """ - if m is None: - m = self._m + # in case the layer is currently visible, directly execute the callback + if layer in self._get_active_layers_alphas[0]: + func(layer, **kwargs) + if persistent is False: + return - def cb(*args, **kwargs): - func(m=m, *args, **kwargs) + @wraps(func) + def layer_callback(layer): + func(layer, **kwargs) if _log.getEffectiveLevel() <= 10: logmsg = ( f"Adding {'persistent' if persistent else 'single-shot'} " - f"layer change action for: '{layer if layer else 'all layers'}': {getattr(func, '__qualname__', func)}" + f"layer change action for: '{layer if layer else 'all layers'}': " + f"{getattr(layer_callback, '__qualname__', layer_callback)}" ) _log.debug(logmsg) if layer is None: - self._on_layer_change[persistent].append(cb) + self.add_hook("layer_change", layer_callback, persistent) else: # treat inset-map layers like normal layers - if layer.startswith("__inset_"): + if layer.startswith("**inset_"): layer = layer[8:] - self._on_layer_activation[persistent].setdefault(layer, list()).append(cb) - def _refetch_layer(self, layer): - if layer == "all": - # if the all layer changed, all backgrounds need a refetch - self._refetch_bg = True - else: - # set any background that contains the layer for refetch - self._layers_to_refetch.add(layer) + self.add_hook("layer_activation", layer_callback, persistent, layer=layer) - for l in self._bg_layers: - sublayers, _ = self._parse_multi_layer_str(l) - if layer in sublayers: - self._layers_to_refetch.add(l) + self.run_hook("on_layer_callback_added") - def _bg_artists_sort(self, art): - sortp = [] + # clear cached backgrounds to enforce a re-draw of the target-layer + for l in list(self._bg_layers): + if layer in l.split("|"): + self._bg_layers.pop(l) - # ensure that inset-map artists are always drawn after all other artists - if art.axes is not None: - if art.axes.get_label() == "inset_map": - sortp.append(1) - else: - sortp.append(0) + def fetch_bg(self, layer=None, bbox=None): + """ + Trigger fetching (and caching) the background for a given layer-name. - sortp.append(getattr(art, "zorder", -1)) - return sortp + Parameters + ---------- + layer : str, optional + The layer for which the background should be fetched. + If None, the currently visible layer is fetched. + The default is None. + bbox : bbox, optional + The region-boundaries (in figure coordinates) for which the background + should be fetched (x0, y0, w, h). If None, the whole figure is fetched. + The default is None. - def get_bg_artists(self, layer): """ - Get all (sorted) background artists assigned to a given layer-name. + if layer is None: + layer = self.bg_layer + + if layer in self._bg_layers: + # don't re-fetch existing layers + # (layers get cleared automatically if re-draw is necessary) + return + + with self._disconnect_draw(): + self._do_fetch_bg(layer, bbox) + + def update( + self, + layers=None, + bbox_bounds=None, + bg_layer=None, + artists=None, + clear=False, + blit=True, + clear_snapshot=True, + ): + """ + Update the screen with animated artists. Parameters ---------- - layer : str - The layer name for which artists should be fetched. - - Returns - ------- - artists : list - A list of artists on the specified layer, sorted with respect to the - vertical stacking (layer-order / zorder). + layers : list, optional + The layers to redraw (if None and artists is None, all layers will be redrawn). + The default is None. + bbox_bounds : tuple, optional + the blit-region bounds to update. The default is None. + bg_layer : int, optional + the background-layer name to restore. The default is None. + artists : list, optional + A list of artists to update. + If provided NO layer will be automatically updated! + The default is None. + clear : bool, optional + If True, all temporary artists tagged for removal will be cleared. + The default is False. + blit : bool, optional + If True, figure.cavas.blit() will be called to update the figure. + If False, changes will only be visible on the next blit-event! + The default is True. + clear_snapshot : bool, optional + Only relevant if the `inline` backend is used in a jupyter-notebook + or an Ipython console. + If True, clear the active cell before plotting a snapshot of the figure. + The default is True. """ - artists = list() - for l in np.atleast_1d(layer): - # get all relevant artists for combined background layers - l = str(l) # w make sure we convert non-string layer names to string! + if self._disable_update: + # don't update during layout-editing + return + cv = self.canvas - # get artists defined on the layer itself - # Note: it's possible to create explicit multi-layers and attach - # artists that are only visible if both layers are visible! (e.g. "l1|l2") - artists.extend(self._bg_artists.get(l, [])) + if bg_layer is None: + bg_layer = self.bg_layer - # make sure to also trigger drawing unmanaged artists on inset-maps! - if l in ( - self._unmanaged_artists_layer, - f"__inset_{self._unmanaged_artists_layer}", - ): - artists.extend(self._get_unmanaged_artists()) + self.run_hook("before_update") - # make the list unique but maintain order (dicts keep order for python>3.7) - artists = dict.fromkeys(artists) - # sort artists by zorder (respecting inset-map priority) - artists = sorted(artists, key=self._bg_artists_sort) + if clear: + self._clear_temp_artists(clear) - return artists + # restore the background + # add additional layers (background, spines etc.) + show_layer = self._get_showlayer_name() - def get_artists(self, layer): + if show_layer not in self._bg_layers: + # make sure the background is properly fetched + self.fetch_bg(show_layer) + + cv.restore_region(self._get_background(show_layer)) + + self.run_hook("after_restore") + + # draw all of the animated artists + self._draw_animated(layers=layers, artists=artists) + if blit: + # workaround for nbagg backend to avoid glitches + # it's slow but at least it works... + # check progress of the following issues + # https://github.com/matplotlib/matplotlib/issues/19116 + if self._mpl_backend_force_full: + cv._force_full = True + + if bbox_bounds is not None: + + class bbox: + bounds = bbox_bounds + + cv.blit(bbox) + else: + # update the GUI state + cv.blit(self.figure.bbox) + + self.run_hook("after_update") + + # let the GUI event loop process anything it has to do + # don't do this! it is causing infinite loops + # cv.flush_events() + + # TODO do we need this? + # if blit and BlitManager._snapshot_on_update is True: + # self._m.snapshot(clear=clear_snapshot) + + def blit_artists(self, artists, bg="active", blit=True): """ - Get all (sorted) dynamically updated artists assigned to a given layer-name. + Blit artists (optionally on top of a given background) Parameters ---------- - layer : str - The layer name for which artists should be fetched. + artists : iterable + the artists to draw + bg : matplotlib.BufferRegion, None or "active", optional + A fetched background that is restored before drawing the artists. + The default is "active". + blit : bool + Indicator if canvas.blit() should be called or not. + The default is True + """ + cv = self.canvas + renderer = self._get_renderer() + if renderer is None: + _log.error("EOmaps: encountered a problem while trying to blit artists...") + return - Returns - ------- - artists : list - A list of artists on the specified layer, sorted with respect to the - vertical stacking (layer-order / zorder). + # restore the background + if bg is not None: + if bg == "active": + bg = self._get_active_bg() + cv.restore_region(bg) - """ + for a in artists: + try: + self.figure.draw_artist(a) + except np.linalg.LinAlgError: + # Explicitly catch numpy LinAlgErrors resulting from singular matrices + # that can occur when colorbar histogram sizes are dynamically updated + if _log.getEffectiveLevel() <= logging.DEBUG: + _log.debug(f"problem drawing artist {a}", exc_info=True) - artists = list() - for l in np.atleast_1d(layer): - # get all relevant artists for combined background layers - l = str(l) # w make sure we convert non-string layer names to string! + if blit: + cv.blit() - # get artists defined on the layer itself - # Note: it's possible to create explicit multi-layers and attach - # artists that are only visible if both layers are visible! (e.g. "l1|l2") - artists.extend(self._artists.get(l, [])) + def _get_renderer(self): + # don't return the renderer if the figure is saved. + # in this case the normal draw-routines are used (see m.savefig) so there is + # no need to trigger updates (also `canvas.get_renderer` is undefined for + # pdf/svg exports since those canvas do not expose the renderer) + # ... this is required to support vector format outputs! + if self.canvas.is_saving(): + return None - # make the list unique but maintain order (dicts keep order for python>3.7) - artists = dict.fromkeys(artists) - # sort artists by zorder (respecting inset-map priority) - artists = sorted(artists, key=self._bg_artists_sort) + try: + return self.canvas.get_renderer() + except Exception: + return None - return artists + def _get_all_map_axes(self): + maxes = {m.ax for m in (self._children) if getattr(m, "_new_axis_map", False)} + return maxes - def _layer_visible(self, layer): - """ - Return True if the layer is currently visible. + def _get_managed_axes(self): + return (*self._get_all_map_axes(), *self._managed_axes) - - layer is considered visible if all sub-layers of a combined layer are visible - - transparency assignments do not alter the layer visibility + def _get_unmanaged_axes(self): + # return a list of all axes that are not managed by the blit-manager + # (to ensure that "unmanaged" axes are drawn as well) - Parameters - ---------- - layer : str - The combined layer-name to check. (e.g. 'A|B{.4}|C{.3}') + # EOmaps axes + managed_axes = self._get_managed_axes() + allaxes = set(self.figure.axes) - Returns - ------- - visible: bool - True if the layer is currently visible, False otherwise + unmanaged_axes = allaxes.difference(managed_axes) + return unmanaged_axes - """ - return layer == "all" or self._layer_is_subset(layer, self.bg_layer) + def _get_artist_zorder(self, a): + try: + return a.get_zorder() + except Exception: + _log.error(f"EOmaps: unalble to identify zorder of {a}... using 99") + return 99 + + def _get_active_bg(self, exclude_artists=None): + with self._without_artists(artists=exclude_artists, layer=self.bg_layer): + # fetch the current background (incl. dynamic artists) + self.update() + + with ExitStack() as stack: + # get rid of the figure background patch + # (done by putting the patch on the **BG** layer!) + + # get rid of the axes background patch + for ax_i in self._get_all_map_axes(): + stack.enter_context( + ax_i.patch._cm_set(facecolor="none", edgecolor="none") + ) + stack.enter_context( + self.figure.patch._cm_set(facecolor="none", edgecolor="none") + ) + + bg = self.canvas.copy_from_bbox(self.figure.bbox) + + return bg @property def _get_active_layers_alphas(self): @@ -655,69 +936,188 @@ def _get_active_layers_alphas(self): """ return self._parse_multi_layer_str(self.bg_layer) - # cache the last 10 combined backgrounds to avoid re-combining backgrounds - # on updates of interactive artists - # cache is automatically cleared on draw if any layer is tagged for re-fetch! - @lru_cache(10) - def _combine_bgs(self, layer): - layers, alphas = self._parse_multi_layer_str(layer) + def _get_array(self, l, a=1): + if l not in self._bg_layers: + return None + rgba = np.array(self._bg_layers[l])[::-1, :, :] + if a != 1: + rgba = rgba.copy() + rgba[..., -1] = (rgba[..., -1] * a).astype(rgba.dtype) + return rgba - # make sure all layers are already fetched - for l in layers: - if l not in self._bg_layers: - # execute actions on layer-changes - # (to make sure all lazy WMS services are properly added) - self._do_on_layer_change(layer=l, new=False) - self.fetch_bg(l) + def _get_background(self, layer, bbox=None, cache=False): + if layer not in self._bg_layers: + if "|" in layer: + bg = self._combine_bgs(layer) + else: + self.fetch_bg(layer, bbox=bbox) + bg = self._bg_layers[layer] + else: + bg = self._bg_layers[layer] + + if cache is True: + # explicitly cache the layer + # (for peek-layer callbacks to avoid re-fetching the layers all the time) + self._bg_layers[layer] = bg + + return bg + + def _get_restore_bg_action( + self, + layer, + bbox_bounds=None, + alpha=1, + clip_path=None, + set_clip_path=False, + ): + """ + Update a part of the screen with a different background + (intended as after-restore action) + + bbox_bounds = (x, y, width, height) + """ + if bbox_bounds is None: + bbox = self.figure.bbox + else: + bbox = Bbox.from_bounds(*bbox_bounds) + + def action(*args, **kwargs): + renderer = self._get_renderer() + if renderer is None: + return + + if self.bg_layer == layer: + return + + x0, y0, w, h = bbox.bounds + + # make sure to restore the initial background + init_bg = renderer.copy_from_bbox(self.figure.bbox) + # convert the buffer to rgba so that we can add transparency + buffer = self._get_background(layer, cache=True) + self.canvas.restore_region(init_bg) + + x = buffer.get_extents() + ncols, nrows = x[2] - x[0], x[3] - x[1] + + argb = ( + np.frombuffer(buffer, dtype=np.uint8).reshape((nrows, ncols, 4)).copy() + ) + argb = argb[::-1, :, :] + + argb[:, :, -1] = (argb[:, :, -1] * alpha).astype(np.int8) - renderer = self._get_renderer() - # clear the renderer to avoid drawing on existing backgrounds - renderer.clear() - if renderer: gc = renderer.new_gc() - gc.set_clip_rectangle(self.canvas.figure.bbox) - x0, y0, w, h = self.figure.bbox.bounds - for l, a in zip(layers, alphas): - rgba = self._get_array(l, a=a) - if rgba is None: - # to handle completely empty layers - continue - renderer.draw_image( - gc, - int(x0), - int(y0), - rgba[int(y0) : int(y0 + h), int(x0) : int(x0 + w), :], - ) - bg = renderer.copy_from_bbox(self._m.f.bbox) + gc.set_clip_rectangle(bbox) + if set_clip_path is True: + gc.set_clip_path(clip_path) + + renderer.draw_image( + gc, + int(x0), + int(y0), + argb[int(y0) : int(y0 + h), int(x0) : int(x0 + w), :], + ) gc.restore() - return bg - def _get_array(self, l, a=1): - if l not in self._bg_layers: - return None - rgba = np.array(self._bg_layers[l])[::-1, :, :] - if a != 1: - rgba = rgba.copy() - rgba[..., -1] = (rgba[..., -1] * a).astype(rgba.dtype) - return rgba + return action + + def _get_showlayer_name(self, layer=None, transparent=False): + # combine all layers that should be shown + # (e.g. to add spines, backgrounds and inset-maps) + + if layer is None: + layer = self.bg_layer + + # pass private layers through + if layer.startswith("__"): + return layer + + if transparent is True: + show_layers = [layer, "**SPINES**"] + else: + show_layers = ["**BG**", layer, "**SPINES**"] + + # show inset map layers and spines only if they contain at least 1 artist + inset_Q = False + for l in self._parse_multi_layer_str(layer)[0]: + narts = len(self._bg_artists["**inset_" + l]) + + if narts > 0: + show_layers.append(f"**inset_{l}") + inset_Q = True + + if inset_Q: + show_layers.append("**inset_**SPINES**") + + return self._get_combined_layer_name(*show_layers) + + def _get_unmanaged_artists(self): + # return all artists not explicitly managed by the blit-manager + # (e.g. any artist added via cartopy or matplotlib functions) + managed_artists = set( + chain( + self._bg_artists, + self._artists, + self._ignored_unmanaged_artists, + ) + ) + + axes = {m.ax for m in self._children if m.ax is not None} + + allartists = set() + for ax in axes: + # only include axes titles if they are actually set + # (otherwise empty artists appear in the widget) + titles = [ + i + for i in (ax.title, ax._left_title, ax._right_title) + if len(i.get_text()) > 0 + ] + + axartists = { + *ax._children, + *titles, + *([ax.legend_] if ax.legend_ is not None else []), + } + + allartists.update(axartists) - def _get_background(self, layer, bbox=None, cache=False): - if layer not in self._bg_layers: - if "|" in layer: - bg = self._combine_bgs(layer) - else: - self.fetch_bg(layer, bbox=bbox) - bg = self._bg_layers[layer] - else: - bg = self._bg_layers[layer] + return allartists.difference(managed_artists) - if cache is True: - # explicitly cache the layer - # (for peek-layer callbacks to avoid re-fetching the layers all the time) - self._bg_layers[layer] = bg + @contextmanager + def _cx_on_layer_change_running(self): + # a context-manager to avoid recursive on_layer_change calls + try: + self._on_layer_change_running = True + yield + finally: + self._on_layer_change_running = False - return bg + def _do_on_layer_change(self, layer, new=False): + # avoid recursive calls to "_do_on_layer_change" + # This is required in case the executed functions trigger actions that would + # trigger "_do_on_layer_change" again which can result in a mixed-up order of + # the scheduled functions. + if self._on_layer_change_running is True: + return + + # do not execute layer-change callbacks on private layer activation! + if layer.startswith("**"): + return + + with self._cx_on_layer_change_running(): + # only execute persistent layer-change callbacks if the layer changed! + if new: + # TODO check how to handle "layer change" actions + self.run_hook("layer_change", layer=layer) + + sublayers, _ = self._parse_multi_layer_str(layer) + for l in sublayers: + # individual callables executed if a specific layer is activated + # persistent callbacks + self.run_hook("layer_activation", layer=l) def _do_fetch_bg(self, layer, bbox=None): renderer = self._get_renderer() @@ -740,10 +1140,10 @@ def _do_fetch_bg(self, layer, bbox=None): # use contextmanagers to make sure the background patches are not stored # in the buffer regions! with ExitStack() as stack: - if layer not in ["__BG__"]: + if layer not in ["**BG**"]: # get rid of the axes background patches for all layers except - # the __BG__ layer - # (the figure background patch is on the "__BG__" layer) + # the **BG** layer + # (the figure background patch is on the "**BG**" layer) for ax_i in self._get_all_map_axes(): stack.enter_context( ax_i.patch._cm_set(facecolor="none", edgecolor="none") @@ -751,17 +1151,16 @@ def _do_fetch_bg(self, layer, bbox=None): # execute actions before fetching new artists # (e.g. update data based on extent etc.) - for action in self._before_fetch_bg_actions: - action(layer=layer, bbox=bbox) + self.run_hook("before_fetch_bg", layer=layer, bbox=bbox) # get all relevant artists to plot and remember zorders # self.get_bg_artists() already returns artists sorted by zorder! - if layer in ["__SPINES__", "__BG__", "__inset___SPINES__"]: + if layer in ["**SPINES**", "**BG**", "**inset_**SPINES**"]: # avoid fetching artists from the "all" layer for private layers allartists = self.get_bg_artists(layer) else: - if layer.startswith("__inset"): - allartists = self.get_bg_artists(["__inset_all", layer]) + if layer.startswith("**inset"): + allartists = self.get_bg_artists(["**inset_all", layer]) else: allartists = self.get_bg_artists(["all", layer]) @@ -789,47 +1188,6 @@ def _do_fetch_bg(self, layer, bbox=None): self._bg_layers[layer] = renderer.copy_from_bbox(bbox) - def fetch_bg(self, layer=None, bbox=None): - """ - Trigger fetching (and caching) the background for a given layer-name. - - Parameters - ---------- - layer : str, optional - The layer for which the background should be fetched. - If None, the currently visible layer is fetched. - The default is None. - bbox : bbox, optional - The region-boundaries (in figure coordinates) for which the background - should be fetched (x0, y0, w, h). If None, the whole figure is fetched. - The default is None. - - """ - - if layer is None: - layer = self.bg_layer - - if layer in self._bg_layers: - # don't re-fetch existing layers - # (layers get cleared automatically if re-draw is necessary) - return - - with self._disconnect_draw(): - self._do_fetch_bg(layer, bbox) - - @contextmanager - def _disconnect_draw(self): - try: - # temporarily disconnect draw-event callback to avoid recursion - if self._cid_draw is not None: - self.canvas.mpl_disconnect(self._cid_draw) - self._cid_draw = None - yield - finally: - # reconnect draw event - if self._cid_draw is None: - self._cid_draw = self.canvas.mpl_connect("draw_event", self._on_draw_cb) - def _on_draw_cb(self, event): """Callback to register with 'draw_event'.""" @@ -892,234 +1250,141 @@ def _on_draw_cb(self, event): else: self.update(blit=False) - # re-draw indicator-shapes of active drawer - # (to show indicators during zoom-events) - active_drawer = getattr(self._m.parent, "_active_drawer", None) - if active_drawer is not None: - active_drawer.redraw(blit=False) - except Exception: # we need to catch exceptions since QT does not like them... if loglevel <= 5: _log.log(5, "There was an error during draw!", exc_info=True) - def add_artist(self, *artists, layer=None): - """ - Add a dynamic-artist to be managed. - (Dynamic artists are re-drawn on every update!) - - Parameters - ---------- - artists : Artist - - The artist to be added. Will be set to 'animated' (just - to be safe). *art* must be in the figure associated with - the canvas this class is managing. - layer : str or None, optional - The layer name at which the artist should be drawn. + @contextmanager + def _without_artists(self, artists=None, layer=None): + try: + removed_artists = {layer: set(), "all": set()} + if artists is None: + yield + else: + for a in artists: + if a in self._artists[layer]: + self._remove_artist(a, layer=layer) + removed_artists[layer].add(a) + elif a in self._artists["all"]: + self._remove_artist(a, layer="all") + removed_artists["all"].add(a) - - If "all": the corresponding feature will be added to ALL layers + yield + finally: + for layer, artists in removed_artists.items(): + for a in artists: + self.add_artist(a, layer=layer) - The default is None in which case the layer of the base-Maps object is used. - """ - if layer is None: - layer = self._m.layer + @contextmanager + def _cx_dont_clear_on_layer_change(self): + # a context-manager to avoid clearing artists on layer-changes + # (used in savefig to avoid clearing artists when re-fetching + # layers with backgrounds) + init_val = self._clear_on_layer_change + try: + self._clear_on_layer_change = False + yield + finally: + self._clear_on_layer_change = init_val - # make sure all layers are converted to string - layer = str(layer) + def _refetch_layer(self, layer): + if layer == "all": + # if the all layer changed, all backgrounds need a refetch + self._refetch_bg = True + else: + # set any background that contains the layer for refetch + self._layers_to_refetch.add(layer) - for art in artists: - if art.figure != self.figure: - raise RuntimeError( - "EOmaps: The artist does not belong to the figure" - "of this Maps-object!" - ) + for l in self._bg_layers: + sublayers, _ = self._parse_multi_layer_str(l) + if layer in sublayers: + self._layers_to_refetch.add(l) - self._artists.setdefault(layer, list()) + def _bg_artists_sort(self, art): + sortp = [] - if art in self._artists[layer]: - continue + # ensure that inset-map artists are always drawn after all other artists + if art.axes is not None: + if art.axes.get_label() == "inset_map": + sortp.append(1) else: - art.set_animated(True) - self._artists[layer].append(art) - - if isinstance(art, plt.Axes): - self._managed_axes.add(art) - - def add_bg_artist(self, *artists, layer=None, draw=True): - """ - Add a background-artist to be managed. - (Background artists are only updated on zoom-events... they are NOT animated!) - - Parameters - ---------- - artists : Artist - The artist to be added. Will be set to 'animated' (just - to be safe). *art* must be in the figure associated with - the canvas this class is managing. - layer : str or None, optional - The layer name at which the artist should be drawn. + sortp.append(0) - - If "all": the corresponding feature will be added to ALL layers + sortp.append(getattr(art, "zorder", -1)) + return sortp - The default is None in which case the layer of the base-Maps object is used. - draw : bool, optional - If True, `figure.draw_idle()` is called after adding the artist. - The default is True. + def _layer_visible(self, layer): """ + Return True if the layer is currently visible. - if layer is None: - layer = self._m.layer - - # make sure all layer names are converted to string - layer = str(layer) - - for art in artists: - if art.figure != self.figure: - raise RuntimeError - - # put all artist of inset-maps on dedicated layers - if ( - getattr(art, "axes", None) is not None - and art.axes.get_label() == "inset_map" - and not layer.startswith("__inset_") - ): - layer = "__inset_" + str(layer) - - if layer in self._bg_artists and art in self._bg_artists[layer]: - _log.info( - f"EOmaps: Background-artist '{art}' already added on layer '{layer}'" - ) - continue - - art.set_animated(True) - self._bg_artists.setdefault(layer, []).append(art) - - if isinstance(art, plt.Axes): - self._managed_axes.add(art) - - # tag all relevant layers for refetch - self._refetch_layer(layer) - - for f in self._on_add_bg_artist: - f() - - if draw: - self.canvas.draw_idle() - - def remove_bg_artist(self, art, layer=None, draw=True): - """ - Remove a (background) artist from the map. + - layer is considered visible if all sub-layers of a combined layer are visible + - transparency assignments do not alter the layer visibility Parameters ---------- - art : Artist - The artist that should be removed. - layer : str or None, optional - If provided, the artist is only searched on the provided layer, otherwise - all map layers are searched. The default is None. - draw : bool, optional - If True, `figure.draw_idle()` is called after removing the artist. - The default is True. - - Note - ---- - This only removes the artist from the blit-manager and does not call its - remove method! - - """ - # handle the "__inset_" prefix of inset-map artists - if ( - layer is not None - and getattr(art, "axes", None) is not None - and art.axes.get_label() == "inset_map" - and not layer.startswith("__inset_") - ): - layer = "__inset_" + str(layer) - - removed = False - if layer is None: - layers = [] - for key, val in self._bg_artists.items(): - if art in val: - art.set_animated(False) - val.remove(art) - - # remove axes from the managed_axes set as well! - if art in self._managed_axes: - self._managed_axes.remove(art) - - removed = True - layers.append(key) - layer = self._get_combined_layer_name(*layers) - else: - if layer not in self._bg_artists: - return - if art in self._bg_artists[layer]: - art.set_animated(False) - self._bg_artists[layer].remove(art) - - # remove axes from the managed_axes set as well! - if art in self._managed_axes: - self._managed_axes.remove(art) - - removed = True - - if removed: - for f in self._on_remove_bg_artist: - f() - - # tag all relevant layers for refetch - self._refetch_layer(layer) + layer : str + The combined layer-name to check. (e.g. 'A|B{.4}|C{.3}') - if draw: - self.canvas.draw_idle() + Returns + ------- + visible: bool + True if the layer is currently visible, False otherwise - def remove_artist(self, art, layer=None): """ - Remove a (dynamically updated) artist from the blit-manager. - - Parameters - ---------- - art : matplotlib.Artist - The artist to remove. - layer : str, optional - The layer to search for the artist. If None, all layers are searched. - The default is None. - - Note - ---- - This only removes the artist from the blit-manager and does not call its - remove method! + layer = layer.split("__", 1)[0] + return layer == "all" or self._layer_is_subset(layer, self.bg_layer) - """ - if layer is None: - for key, layerartists in self._artists.items(): - if art in layerartists: - art.set_animated(False) - layerartists.remove(art) + # cache the last 10 combined backgrounds to avoid re-combining backgrounds + # on updates of interactive artists + # cache is automatically cleared on draw if any layer is tagged for re-fetch! + @lru_cache(10) + def _combine_bgs(self, layer): + layers, alphas = self._parse_multi_layer_str(layer) - # remove axes from the managed_axes set as well! - if art in self._managed_axes: - self._managed_axes.remove(art) + # make sure all layers are already fetched + for l in layers: + if l not in self._bg_layers: + # execute actions on layer-changes + # (to make sure all lazy WMS services are properly added) + self._do_on_layer_change(layer=l, new=False) + self.fetch_bg(l) - else: - if art in self._artists.get(layer, []): - art.set_animated(False) - self._artists[layer].remove(art) + renderer = self._get_renderer() + # clear the renderer to avoid drawing on existing backgrounds + renderer.clear() + if renderer: + gc = renderer.new_gc() + gc.set_clip_rectangle(self.canvas.figure.bbox) - # remove axes from the managed_axes set as well! - if art in self._managed_axes: - self._managed_axes.remove(art) - else: - _log.debug(f"The artist {art} is not on the layer '{layer}'") + x0, y0, w, h = self.figure.bbox.bounds + for l, a in zip(layers, alphas): + rgba = self._get_array(l, a=a) + if rgba is None: + # to handle completely empty layers + continue + renderer.draw_image( + gc, + int(x0), + int(y0), + rgba[int(y0) : int(y0 + h), int(x0) : int(x0 + w), :], + ) + bg = renderer.copy_from_bbox(self.figure.bbox) + gc.restore() + return bg - def _get_artist_zorder(self, a): + @contextmanager + def _disconnect_draw(self): try: - return a.get_zorder() - except Exception: - _log.error(f"EOmaps: unalble to identify zorder of {a}... using 99") - return 99 + # temporarily disconnect draw-event callback to avoid recursion + if self._cid_draw is not None: + self.canvas.mpl_disconnect(self._cid_draw) + self._cid_draw = None + yield + finally: + # reconnect draw event + if self._cid_draw is None: + self._cid_draw = self.canvas.mpl_connect("draw_event", self._on_draw_cb) def _draw_animated(self, layers=None, artists=None): """ @@ -1138,11 +1403,8 @@ def _draw_animated(self, layers=None, artists=None): if layers is None: active_layers, _ = self._get_active_layers_alphas layers = [self.bg_layer, *active_layers] - else: - (layers,) = list( - chain(*(self._parse_multi_layer_str(l)[0] for l in layers)) - ) - + else: + layers = list(chain(*(self._parse_multi_layer_str(l)[0] for l in layers))) if artists is None: artists = [] @@ -1163,7 +1425,7 @@ def _draw_animated(self, layers=None, artists=None): # redraw artists from the selected layers and explicitly provided artists # (sorted by zorder for each layer) layer_artists = list( - sorted(self._artists.get(layer, []), key=self._get_artist_zorder) + sorted(self._artists[layer], key=self._get_artist_zorder) for layer in layers ) @@ -1178,45 +1440,14 @@ def _draw_animated(self, layers=None, artists=None): for a in chain(*layer_artists, artists): fig.draw_artist(a) - def _get_unmanaged_artists(self): - # return all artists not explicitly managed by the blit-manager - # (e.g. any artist added via cartopy or matplotlib functions) - managed_artists = set( - chain( - *self._bg_artists.values(), - *self._artists.values(), - self._ignored_unmanaged_artists, - ) - ) - - axes = {m.ax for m in (self._m, *self._m._children) if m.ax is not None} - - allartists = set() - for ax in axes: - # only include axes titles if they are actually set - # (otherwise empty artists appear in the widget) - titles = [ - i - for i in (ax.title, ax._left_title, ax._right_title) - if len(i.get_text()) > 0 - ] - - axartists = { - *ax._children, - *titles, - *([ax.legend_] if ax.legend_ is not None else []), - } - - allartists.update(axartists) - - return allartists.difference(managed_artists) - + # TODO fix this for EOmaps v9.0! def _clear_all_temp_artists(self): - for method in self._m.cb._methods: - container = getattr(self._m.cb, method, None) - if container: - container._clear_temporary_artists() - self._clear_temp_artists(method) + _log.warning("clear_all_temp_artists NotImplemented for EOmaps v9.0") + # for method in self._m.cb._methods: + # container = getattr(self._m.cb, method, None) + # if container: + # container._clear_temporary_artists() + # self._clear_temp_artists(method) def _clear_temp_artists(self, method, forward=True): # clear artists from connected methods @@ -1239,11 +1470,6 @@ def _clear_temp_artists(self, method, forward=True): if art in met_artists: art.set_visible(False) self.remove_artist(art) - try: - art.remove() - except ValueError: - # ignore errors if the artist no longer exists - pass met_artists.remove(art) else: artists = self._artists_to_clear.pop(method, []) @@ -1251,11 +1477,6 @@ def _clear_temp_artists(self, method, forward=True): art = artists.pop(-1) art.set_visible(False) self.remove_artist(art) - try: - art.remove() - except ValueError: - # ignore errors if the artist no longer exists - pass try: self._artists_to_clear.get("on_layer_change", []).remove(art) @@ -1263,238 +1484,6 @@ def _clear_temp_artists(self, method, forward=True): # ignore errors if the artist is not present in the list pass - def _get_showlayer_name(self, layer=None, transparent=False): - # combine all layers that should be shown - # (e.g. to add spines, backgrounds and inset-maps) - - if layer is None: - layer = self.bg_layer - - # pass private layers through - if layer.startswith("__"): - return layer - - if transparent is True: - show_layers = [layer, "__SPINES__"] - else: - show_layers = ["__BG__", layer, "__SPINES__"] - - # show inset map layers and spines only if they contain at least 1 artist - inset_Q = False - for l in self._parse_multi_layer_str(layer)[0]: - narts = len(self._bg_artists.get("__inset_" + l, [])) - - if narts > 0: - show_layers.append(f"__inset_{l}") - inset_Q = True - - if inset_Q: - show_layers.append("__inset___SPINES__") - - return self._get_combined_layer_name(*show_layers) - - def update( - self, - layers=None, - bbox_bounds=None, - bg_layer=None, - artists=None, - clear=False, - blit=True, - clear_snapshot=True, - ): - """ - Update the screen with animated artists. - - Parameters - ---------- - layers : list, optional - The layers to redraw (if None and artists is None, all layers will be redrawn). - The default is None. - bbox_bounds : tuple, optional - the blit-region bounds to update. The default is None. - bg_layer : int, optional - the background-layer name to restore. The default is None. - artists : list, optional - A list of artists to update. - If provided NO layer will be automatically updated! - The default is None. - clear : bool, optional - If True, all temporary artists tagged for removal will be cleared. - The default is False. - blit : bool, optional - If True, figure.cavas.blit() will be called to update the figure. - If False, changes will only be visible on the next blit-event! - The default is True. - clear_snapshot : bool, optional - Only relevant if the `inline` backend is used in a jupyter-notebook - or an Ipython console. - - If True, clear the active cell before plotting a snapshot of the figure. - The default is True. - """ - if self._disable_update: - # don't update during layout-editing - return - - cv = self.canvas - - if bg_layer is None: - bg_layer = self.bg_layer - - for action in self._before_update_actions: - action() - - if clear: - self._clear_temp_artists(clear) - - # restore the background - # add additional layers (background, spines etc.) - show_layer = self._get_showlayer_name() - - if show_layer not in self._bg_layers: - # make sure the background is properly fetched - self.fetch_bg(show_layer) - - cv.restore_region(self._get_background(show_layer)) - - # execute after restore actions (e.g. peek layer callbacks) - while len(self._after_restore_actions) > 0: - action = self._after_restore_actions.pop(0) - action() - - # draw all of the animated artists - self._draw_animated(layers=layers, artists=artists) - if blit: - # workaround for nbagg backend to avoid glitches - # it's slow but at least it works... - # check progress of the following issues - # https://github.com/matplotlib/matplotlib/issues/19116 - if self._mpl_backend_force_full: - cv._force_full = True - - if bbox_bounds is not None: - - class bbox: - bounds = bbox_bounds - - cv.blit(bbox) - else: - # update the GUI state - cv.blit(self.figure.bbox) - - # execute all actions registered to be called after blitting - while len(self._after_update_actions) > 0: - action = self._after_update_actions.pop(0) - action() - - # let the GUI event loop process anything it has to do - # don't do this! it is causing infinite loops - # cv.flush_events() - - if blit and BlitManager._snapshot_on_update is True: - self._m.snapshot(clear=clear_snapshot) - - def blit_artists(self, artists, bg="active", blit=True): - """ - Blit artists (optionally on top of a given background) - - Parameters - ---------- - artists : iterable - the artists to draw - bg : matplotlib.BufferRegion, None or "active", optional - A fetched background that is restored before drawing the artists. - The default is "active". - blit : bool - Indicator if canvas.blit() should be called or not. - The default is True - """ - cv = self.canvas - renderer = self._get_renderer() - if renderer is None: - _log.error("EOmaps: encountered a problem while trying to blit artists...") - return - - # restore the background - if bg is not None: - if bg == "active": - bg = self._get_active_bg() - cv.restore_region(bg) - - for a in artists: - try: - self.figure.draw_artist(a) - except np.linalg.LinAlgError: - # Explicitly catch numpy LinAlgErrors resulting from singular matrices - # that can occur when colorbar histogram sizes are dynamically updated - if _log.getEffectiveLevel() <= logging.DEBUG: - _log.debug(f"problem drawing artist {a}", exc_info=True) - - if blit: - cv.blit() - - def _get_restore_bg_action( - self, - layer, - bbox_bounds=None, - alpha=1, - clip_path=None, - set_clip_path=False, - ): - """ - Update a part of the screen with a different background - (intended as after-restore action) - - bbox_bounds = (x, y, width, height) - """ - if bbox_bounds is None: - bbox = self.figure.bbox - else: - bbox = Bbox.from_bounds(*bbox_bounds) - - def action(): - renderer = self._get_renderer() - if renderer is None: - return - - if self.bg_layer == layer: - return - - x0, y0, w, h = bbox.bounds - - # make sure to restore the initial background - init_bg = renderer.copy_from_bbox(self._m.f.bbox) - # convert the buffer to rgba so that we can add transparency - buffer = self._get_background(layer, cache=True) - self.canvas.restore_region(init_bg) - - x = buffer.get_extents() - ncols, nrows = x[2] - x[0], x[3] - x[1] - - argb = ( - np.frombuffer(buffer, dtype=np.uint8).reshape((nrows, ncols, 4)).copy() - ) - argb = argb[::-1, :, :] - - argb[:, :, -1] = (argb[:, :, -1] * alpha).astype(np.int8) - - gc = renderer.new_gc() - - gc.set_clip_rectangle(bbox) - if set_clip_path is True: - gc.set_clip_path(clip_path) - - renderer.draw_image( - gc, - int(x0), - int(y0), - argb[int(y0) : int(y0 + h), int(x0) : int(x0 + w), :], - ) - gc.restore() - - return action - def _cleanup_layer(self, layer): """Trigger cleanup methods for a given layer.""" self._cleanup_bg_artists(layer) @@ -1528,16 +1517,11 @@ def _cleanup_artists(self, layer): a = artists.pop() try: self.remove_artist(a) - # no need to remove spines (to avoid NotImplementedErrors)! - if not isinstance(a, Spine): - a.remove() except Exception: _log.debug( f"EOmaps-cleanup: Problem while clearing dynamic artist:\n {a}" ) - del self._artists[layer] - def _cleanup_bg_layers(self, layer): try: # remove cached background-layers @@ -1549,12 +1533,4 @@ def _cleanup_bg_layers(self, layer): ) def _cleanup_on_layer_activation(self, layer): - try: - # remove not yet executed lazy-activation methods - # (e.g. not yet fetched WMS services) - if layer in self._on_layer_activation: - del self._on_layer_activation[layer] - except Exception: - _log.debug( - "EOmaps-cleanup: Problem while clearing layer activation methods" - ) + self.remove_hook("layer_activation", method=None, permanent=None, layer=layer) diff --git a/eomaps/_data_manager.py b/eomaps/_data_manager.py index 6cac8c514..d98df725d 100755 --- a/eomaps/_data_manager.py +++ b/eomaps/_data_manager.py @@ -29,6 +29,8 @@ def __init__(self, m): self._extent_margin_factor = 0.1 + self._callbacks_attached = False + def set_margin_factors(self, radius_margin_factor, extent_margin_factor): """ Set the margin factors that are applied to the plot extent @@ -105,6 +107,9 @@ def set_props( dynamic=False, only_pick=False, ): + + self._dynamic = dynamic + # cleanup existing callbacks before attaching new ones self.cleanup_callbacks() @@ -149,21 +154,22 @@ def set_props( # attach a hook that updates the collection whenever a new # background is fetched # ("shade" shapes take care about updating the data themselves!) - self.attach_callbacks(dynamic=dynamic) + self.attach_callbacks() - def attach_callbacks(self, dynamic): - if dynamic is True: - if self.on_fetch_bg not in self.m.BM._before_update_actions: - self.m.BM._before_update_actions.append(self.on_fetch_bg) + def attach_callbacks(self): + self._callbacks_attached = True + if self._dynamic is True: + self.m._bm.add_hook("before_update", self.on_fetch_bg, True) else: - if self.on_fetch_bg not in self.m.BM._before_fetch_bg_actions: - self.m.BM._before_fetch_bg_actions.append(self.on_fetch_bg) + self.m._bm.add_hook("before_fetch_bg", self.on_fetch_bg, True) def cleanup_callbacks(self): - if self.on_fetch_bg in self.m.BM._before_fetch_bg_actions: - self.m.BM._before_fetch_bg_actions.remove(self.on_fetch_bg) - if self.on_fetch_bg in self.m.BM._before_update_actions: - self.m.BM._before_update_actions.remove(self.on_fetch_bg) + if not self._callbacks_attached: + return + if self._dynamic is True: + self.m._bm.remove_hook("before_update", self.on_fetch_bg, True) + else: + self.m._bm.remove_hook("before_fetch_bg", self.on_fetch_bg, True) def _identify_pandas(self, data=None, x=None, y=None, parameter=None): (pd,) = register_modules("pandas", raise_exception=False) @@ -626,7 +632,7 @@ def indicate_masked_points(self, **kwargs): # remove previous mask artist if self._masked_points_artist is not None: try: - self.m.BM.remove_bg_artist(self._masked_points_artist) + self.m.l[self.layer].remove_bg_artist(self._masked_points_artist) self._masked_points_artist.remove() self._masked_points_artist = None except Exception: @@ -658,7 +664,7 @@ def indicate_masked_points(self, **kwargs): **kwargs, ) - self.m.BM.add_bg_artist(self._masked_points_artist, layer=self.layer) + self.m.l[self.layer].add_bg_artist(self._masked_points_artist) def redraw_required(self, layer): """ @@ -669,6 +675,7 @@ def redraw_required(self, layer): layer : str The layer for which the background is fetched. """ + if not self.m._data_plotted: return @@ -681,11 +688,11 @@ def redraw_required(self, layer): # don't re-draw if the layer of the dataset is not requested # (note multi-layers trigger re-draws of individual layers as well) - if not self.m.BM._layer_is_subset(layer, self.layer): + if not self.m._bm._layer_is_subset(layer, self.layer): return False # don't re-draw if the collection has been hidden in the companion-widget - if self.m.coll in self.m.BM._hidden_artists: + if self.m.coll in self.m._bm._hidden_artists: return False # re-draw if the data has never been plotted @@ -707,13 +714,10 @@ def _remove_existing_coll(self): if self.m.coll is not None: try: if getattr(self.m, "_coll_dynamic", False): - self.m.BM.remove_artist(self.m._coll) + self.m.l[self.layer].remove_artist(self.m._coll) else: - self.m.BM.remove_bg_artist(self.m._coll) + self.m.l[self.layer].remove_bg_artist(self.m._coll) - # if the collection is still attached to the axes, remove it - if self.m.coll.axes is not None: - self.m.coll.remove() self.m._coll = None except Exception: _log.exception("EOmaps: Error while trying to remove collection.") @@ -906,7 +910,11 @@ def on_fetch_bg(self, layer=None, bbox=None, check_redraw=True): coll = self._get_coll(props, **self.m._coll_kwargs) coll.set_clim(self.m._vmin, self.m._vmax) - coll.set_label("Dataset " f"({self.m.shape.name} | {self.z_data.shape})") + coll.set_label( + "Dataset " + f"({self.m.shape.name} | {self.z_data.shape})" + f" on layer {self.layer}" + ) if self.m.shape.name not in ["scatter_points", "contour", "hexbin"]: # avoid use "autolim=True" since it can cause problems in @@ -916,9 +924,9 @@ def on_fetch_bg(self, layer=None, bbox=None, check_redraw=True): self.m.ax.add_collection(coll, autolim=False) if self.m._coll_dynamic: - self.m.BM.add_artist(coll, layer=self.layer) + self.m.l[self.layer].add_artist(coll) else: - self.m.BM.add_bg_artist(coll, layer=self.layer) + self.m.l[self.layer].add_bg_artist(coll) self.m._coll = coll diff --git a/eomaps/_maps_base.py b/eomaps/_maps_base.py index ce2ae78fa..2dd3ce357 100644 --- a/eomaps/_maps_base.py +++ b/eomaps/_maps_base.py @@ -5,24 +5,26 @@ """Base class for Maps objects.""" -import gc import logging -from contextlib import ExitStack -from pyproj import CRS, Transformer + +_log = logging.getLogger(__name__) + +from contextlib import contextmanager, ExitStack from functools import lru_cache, wraps from itertools import chain +from textwrap import fill +import importlib.metadata import weakref - -import numpy as np - -from cartopy import crs as ccrs +import gc import matplotlib.pyplot as plt from matplotlib.gridspec import GridSpec, SubplotSpec +from cartopy import crs as ccrs -_log = logging.getLogger(__name__) +from pyproj import CRS, Transformer +import numpy as np -from .helpers import _parse_log_level +from .helpers import _parse_log_level, _proxy from .layout_editor import LayoutEditor from ._blit_manager import BlitManager from .projections import Equi7Grid_projection # import also supercharges cartopy.ccrs @@ -130,7 +132,7 @@ def config( from . import set_loglevel if companion_widget_key is not None: - cls._companion_widget_key = companion_widget_key + cls._CompanionMixin__companion_widget_key = companion_widget_key if always_on_top is not None: cls._always_on_top = always_on_top @@ -233,22 +235,298 @@ def handle_event(self, event): FigureManagerWebAgg.refresh_all = refresh_all +class MultiCaller: + """ + A class to distribute attribute-access and method calls across + multiple objects. + """ + + def __init__(self, elements): + self._elements = elements + + def __call__(self, *args, **kwargs): + ret = [obj.__call__(*args, **kwargs) for obj in self] + if ret.count(None) != len(self): + return ret + + def __dir__(self): + # to support autocompletion, return public attributes of elements + return [i for i in dir(self._elements[0]) if not i.startswith("_")] + + @property + def __doc__(self): + return self._elements[0].__doc__ + + def __getattr__(self, name): + return MultiCaller([getattr(i, name) for i in self]) + + def __getattribute__(self, name): + if name.startswith("_"): + return object.__getattribute__(self, name) + + return MultiCaller( + [ + object.__getattribute__(i, name) + for i in object.__getattribute__(self, "_elements") + ] + ) + + def __getitem__(self, name): + return MultiCaller([i[name] for i in self]) + + def __iter__(self): + return (i for i in self._elements) + + def __len__(self): + return len(self._elements) + + def __add__(self, value): + return MultiCaller([*self._elements, value]) + + +class LazyCaller: + def __init__(self, m, attr, name="Maps"): + self._m = m + self._attr = attr + self._name = name + + def __dir__(self): + # in case attributes ready for lazy-evaluation are explicitly defined, + # return them, else return all public attributes + return getattr( + self._attr, + "_lazy_attrs", + [i for i in dir(self._attr) if not i.startswith("_")], + ) + + @property + def __doc__(self): + return f"LazyCaller object for {self._m}" + + def __getattr__(self, name): + attr = object.__getattribute__(self, "_attr") + get_attr = object.__getattribute__(attr, name) + + if name.startswith("_") or isinstance( + get_attr, (list, set, tuple, dict, int, float, np.number, np.ndarray) + ): + return get_attr + + return LazyCaller( + self._m, object.__getattribute__(attr, name), f"{self._name}.{name}" + ) + + def __call__(self, *args, persistent=False, **kwargs): + if self._m is self._attr: + lazy_method = args[0] + + @wraps(lazy_method) + def _lazy_method(layer): + lazy_method(self._m, *args[1:], **kwargs) + + if _log.getEffectiveLevel() <= logging.DEBUG: + _log.debug( + f"lazy method submitted for activation of '{self._m.layer}' layer: {lazy_method.__name__}" + ) + + else: + + @wraps(self._attr.__call__) + def _lazy_method(layer): + self._attr.__call__(*args, **kwargs) + + _lazy_method.__qualname__ = f"{self._name}(...)" + + if _log.getEffectiveLevel() <= logging.DEBUG: + _log.debug( + f"lazy method submitted for activation of '{self._m.layer}' layer: {self._name}(...)" + ) + + self._m._bm.on_layer( + func=_lazy_method, + layer=self._m.layer, + persistent=persistent, + ) + + +class LayerNamespace: + """ + Accessor to create, access and populate layers on the map. + + `m.l.my_layer` will return a :py:class:`Maps` object on the layer + named`"my_layer"`. + + - If no :py:class:`Maps` object exists in the LayerNamespace, it will be created. + - Otherwise, the existing :py:class:`Maps` object is returned + - To create additional :py:class`Maps` objects on the same layer, + you can use double-underscores in the name, e.g. "my_layer__a" + + Examples + -------- + + Create a :py:class:`Maps` object on the `"overlay"` layer and populate + the layer with the "ocean" and "land" features. + + >>> m = Maps() + >>> m.l.overlay.add_feature.preset.ocean() + >>> m.l.overlay.add_feature.preset.land() + + """ + + def __init__(self, m): + self._m = m + self._layers = {} + + # self._ingest_layer(self._m) + + def _ingest_layer(self, m, name=None): + # don't include the all-layer + # (it's special and only accessible via m.all) + if name == "all": + return + + if name is None: + name = m.layer + + self._layers[name] = m + super().__setattr__(name, m) + + def _remove_layer(self, layer): + self._layers.pop(layer) + delattr(self, layer) + + def _get_layer_names(self): + return (i.split("__", 1)[0] for i in self._layers) + + def __dir__(self): + return [l for l in self._layers if not l.startswith("**")] + + def __iter__(self): + return iter(self._layers.values()) + + def __len__(self): + return len(self._layers) + + def __getitem__(self, name): + if isinstance(name, str): + return getattr(self, name) + else: + return MultiCaller([getattr(self, n) for n in name]) + + def __repr__(self): + return fill( + 'LayerNamespace("' + + '", "'.join(i for i in sorted(self._layers)[:5]) + + '"' + + (" ..." if len(self._layers) > 5 else "") + + ")" + ) + + def __setattr__(self, name, value): + if not name.startswith("_"): + raise TypeError("LayerNamespace does not allow attribute assignment.") + + super().__setattr__(name, value) + + def __getattr__(self, name): + # private attributes are handled in ordinary manner. + # only public attribute names will trigger layer-creation! + if name.startswith("_"): + return super().__getattribute__(name) + + # Note: new_layer calls "LayerNamespace._ingest_layer" to ingest + # the new layer into the namespace! + return self._layers.get(name, self._m.new_layer(name)) + + +class LazyLayerNamespace(LayerNamespace): + """ + Accessor to create, access and **lazily** populate layers on the map. + + Any action run on the LazyLayerNamespace will only become effective + if the associated layer becomes visible! + + `m.ll.my_layer` will return a LazyCaller instance for the :py:class:`Maps` + object on the layer named`"my_layer"`. + + - If no :py:class:`Maps` object exists in the LayerNamespace, it will be created. + - Otherwise, the existing :py:class:`Maps` object is used + - To create additional :py:class`Maps` objects on the same layer, + you can use double-underscores in the name, e.g. "my_layer__a" + + Examples + -------- + + Create a :py:class:`Maps` object on the `"overlay"` layer and lazily + populate the layer with the "ocean" and "land" features. + + >>> m = Maps() + >>> m.ll.overlay.add_feature.preset.ocean() + >>> m.ll.overlay.add_feature.preset.land() + + """ + + def __init__(self, parent_namespace): + self._parent_namespace = parent_namespace + self._m = self._parent_namespace._m + + @property + def _layers(self): + return self._parent_namespace._layers + + def __dir__(self): + return dir(self._parent_namespace) + + def __getattr__(self, name): + m = getattr(self._parent_namespace, name) + return LazyCaller(m, m, "Maps") + + +class MapsLayerBase: + def __init__(self, layer=None, parent=None, *args, **kwargs): + if parent is None: + self._parent = self + else: + self._parent = _proxy(parent) + + # make sure the used layer-name is valid + if layer is None: + layer = "base" + + layer = BlitManager._check_layer_name(layer) + self._layer = layer + + super().__init__(*args, **kwargs) + + @property + def layer(self): + """The layer-name associated with this Maps-object.""" + return self._layer + + @property + def parent(self): + """ + The parent-object to which this Maps-object is connected to. + """ + return self._parent + + class MapsBase(metaclass=_MapsMeta): def __init__( self, crs=None, - layer="base", f=None, ax=None, **kwargs, ): - self._BM = None + self._artists = weakref.WeakSet() + self._bg_artists = weakref.WeakSet() + self._layout_editor = None - # make sure the used layer-name is valid - layer = BlitManager._check_layer_name(layer) - self._layer = layer + self._log_on_event_messages = dict() + self._log_on_event_cids = dict() if isinstance(ax, plt.Axes) and hasattr(ax, "figure"): if isinstance(ax.figure, plt.Figure): @@ -262,18 +540,9 @@ def __init__( self._f = f self._ax = None - self._parent = None self._children = set() # weakref.WeakSet() self._after_add_child = list() - # check if the self represents a new-layer or an object on an existing layer - if any( - i.layer == layer for i in (self.parent, *self.parent._children) if i != self - ): - self._is_sublayer = True - else: - self._is_sublayer = False - if isinstance(ax, plt.Axes): # set the plot_crs only if no explicit axes is provided if crs is not None: @@ -292,19 +561,35 @@ def __init__( self._crs_plot = crs self._init_figure(**kwargs) + self._init_axes(ax=ax, plot_crs=crs, **kwargs) - # Make sure the figure-background patch is on an explicit layer - # This is used to avoid having the background patch on each fetched - # background while maintaining the capability of restoring it - if self.f.patch not in self.BM._bg_artists.get("__BG__", []): - self.f.patch.set_zorder(-2) - self.BM.add_bg_artist(self.f.patch, layer="__BG__") + if self.layer in self._l._layers: + name = self.layer.split("__", 1)[0] + i = 0 + while name in self._l._layers: + name = f"{self.layer}__{i}" + i += 1 - self._init_axes(ax=ax, plot_crs=crs, **kwargs) + print( + f"The layer name '{self.layer}' already exists!\n" + f"It has been re-named to {name} in the LayerNamespace!" + ) + self._layer = name + + self._l._ingest_layer(self) + self._add_child(self) + + if self.parent == self: + # Make sure the figure-background patch is on an explicit layer + # This is used to avoid having the background patch on each fetched + # background while maintaining the capability of restoring it + if self.f.patch not in self._bm._bg_artists["**BG**"]: + self.f.patch.set_zorder(-2) + self._bm._bg_artists.add("**BG**", self.f.patch) - if self.ax.patch not in self.BM._bg_artists.get("__BG__", []): - self.ax.patch.set_zorder(-1) - self.BM.add_bg_artist(self.ax.patch, layer="__BG__") + if self.ax.patch not in self._bm._bg_artists["**BG**"]: + self.ax.patch.set_zorder(-1) + self._bm._bg_artists.add("**BG**", self.ax.patch) # Treat cartopy geo-spines separately in the blit-manager # to avoid issues with overlapping spines that are drawn on each layer @@ -316,6 +601,67 @@ def __init__( self._crs_plot_cartopy = self._get_cartopy_crs(self._crs_plot) + if self.parent == self and self.__class__._always_on_top: + self._set_always_on_top(True) + + super().__init__() + + def add_artist(self, artist): + artist.set_animated(True) + + # TODO is there a better way to handle axes? + # NOTE: this is required to avoid consecutive re-draws of axes-artists + # such as backgrounds, spines etc. during fast executed callbacks (e.g. move)! + if isinstance(artist, plt.Axes): + self._bm._managed_axes.add(artist) + + self._artists.add(artist) + self._bm.run_hook("add_artist") + + def add_bg_artist(self, artist, draw=True): + artist.set_animated(True) + + # TODO is there a better way to handle axes? + # NOTE: this is required to avoid consecutive re-draws of axes-artists + # such as backgrounds, spines etc. during fast executed callbacks (e.g. move)! + if isinstance(artist, plt.Axes): + self._bm._managed_axes.add(artist) + + self._bg_artists.add(artist) + self._bm.run_hook("add_bg_artist") + + if draw: + self.redraw(self.layer) + + def _remove_artist(self, artist): + self._artists.remove(artist) + self._bm.run_hook("remove_artist") + + def remove_artist(self, artist): + self._remove_artist(artist) + artist.remove() + + def _remove_bg_artist(self, artist): + self._bg_artists.remove(artist) + self._bm.run_hook("remove_bg_artist") + + def remove_bg_artist(self, artist, draw=True): + self._remove_bg_artist(artist) + artist.remove() + + if draw: + self.redraw(self.layer) + + def __add__(self, value): + return MultiCaller([self, value]) + + # to add support for sum() + def __radd__(self, value): + if value == 0: + return MultiCaller([self]) + else: + return self.__add__(value) + def __repr__(self): try: return f"" @@ -332,7 +678,7 @@ def __getattribute__(self, key): return object.__getattribute__(self, key) def __enter__(self): - assert not self._is_sublayer, ( + assert isinstance(self, MapsBase), ( "EOmaps: using a Maps-object as a context-manager is only possible " "if you create a NEW layer (not a Maps-object on an existing layer)!" ) @@ -345,71 +691,28 @@ def __exit__(self, type, value, traceback): plt.close(self.f) gc.collect() - def _emit_signal(self, *args, **kwargs): - # TODO - pass - - def _handle_spines(self): - # put cartopy spines on a separate layer - for spine in self.ax.spines.values(): - if spine and spine not in self.BM._bg_artists.get("__SPINES__", []): - self.BM.add_bg_artist(spine, layer="__SPINES__") - - def _on_resize(self, event): - # make sure the background is re-fetched if the canvas has been resized - # (required for peeking layers after the canvas has been resized - # and for webagg and nbagg backends to correctly re-draw the layer) - - self.BM._refetch_bg = True - self.BM._refetch_blank = True - - # update the figure dimensions in case shading is used. - # Avoid flushing events during resize - # TODO - if hasattr(self, "_update_shade_axis_size"): - self._update_shade_axis_size(flush=False) - - def _on_close(self, event): - # reset attributes that might use up a lot of memory when the figure is closed - for m in [self.parent, *self.parent._children]: - if hasattr(m.f, "_EOmaps_parent"): - m.f._EOmaps_parent = None - - m.cleanup() - - # run garbage-collection to immediately free memory - gc.collect - - def _on_xlims_change(self, *args, **kwargs): - self.BM._refetch_bg = True - - def _on_ylims_change(self, *args, **kwargs): - self.BM._refetch_bg = True - @property - def BM(self): - """The Blit-Manager used to dynamically update the plots.""" - m = weakref.proxy(self) - if self.parent._BM is None: - self.parent._BM = BlitManager(m) - self.parent._BM._bg_layer = m.parent.layer - return self.parent._BM + def f(self): + """Matplotlib Figure associated with this Maps-object.""" + # always return the figure of the parent object + return self._f @property def ax(self): - """The matplotlib (cartopy) GeoAxes associated with this Maps-object.""" + """Cartopy GeoAxes associated with this Maps-object.""" return self._ax @property - def f(self): - """The matplotlib Figure associated with this Maps-object.""" - # always return the figure of the parent object - return self._f + @wraps(LayerNamespace) + def l(self): + """LayerNamespace accessor to create/access layers on the map.""" + return self._l @property - def layer(self): - """The layer-name associated with this Maps-object.""" - return self._layer + @wraps(LazyLayerNamespace) + def ll(self): + """LazyLayerNamespace accessor to lazily create/access layers on the map.""" + return self._ll @property def all(self): @@ -421,237 +724,62 @@ def all(self): >>> m.all.cb.click.attach.annotate() """ - if not hasattr(self, "_all"): - self._all = self.new_layer("all") - return self._all - - @property - def parent(self): - """ - The parent-object to which this Maps-object is connected to. + return self.l["all"] - If None, `self` is returned! + def redraw(self, *args, force_data_redraw=False): """ - if self._parent is None: - self._set_parent() + Force a re-draw of cached background layers. - return self._parent + - Use this at the very end of your code to trigger a final re-draw + to make sure artists not managed by EOmaps are properly drawn! - def _init_figure(self, **kwargs): - if self.parent.f is None: - # do this on any new figure since "%matplotlib inline" tries to re-activate - # interactive mode all the time! - _handle_backends() + Parameters + ---------- + forece_data_redraw : bool + Force a re-draw of already plotted datasets. + The default is False. - self._f = plt.figure(**kwargs) - # to hide canvas header in jupyter notebooks (default figure label) - self._f.canvas.header_visible = False + Note + ---- + Don't use this to interactively update artists on a map! + since it will trigger a re-draw background-layers! - _log.debug("EOmaps: New figure created") + To dynamically re-draw an artist whenever you interact with the map, use: - # make sure we keep a "real" reference otherwise overwriting the - # variable of the parent Maps-object while keeping the figure open - # causes all weakrefs to be garbage-collected! - self.parent.f._EOmaps_parent = self.parent._real_self - else: - if not hasattr(self.parent.f, "_EOmaps_parent"): - self.parent.f._EOmaps_parent = self.parent._real_self - self.parent._add_child(self) + >>> m.add_artist(artist) - if self.parent == self: # use == instead of "is" since the parent is a proxy! + To make an artist temporary (e.g. remove it on the next event), use + one of : - # override Figure.savefig with Maps.savefig but keep original - # method accessible via Figure._mpl_orig_savefig - # (this ensures that using the save-buttons in the gui or pressing - # control+s will redirect the save process to the eomaps routine) - self._f._mpl_orig_savefig = self._f.savefig - self._f.savefig = self.savefig + >>> m.cb.click.add_temporary_artist(artist) + >>> m.cb.pick.add_temporary_artist(artist) + >>> m.cb.keypress.add_temporary_artist(artist) + >>> m.cb.move.add_temporary_artist(artist) - # only attach resize- and close-callbacks if we initialize a parent - # Maps-object - # attach a callback that is executed when the figure is closed - self._cid_onclose = self.f.canvas.mpl_connect("close_event", self._on_close) - # attach a callback that is executed if the figure canvas is resized - self._cid_resize = self.f.canvas.mpl_connect( - "resize_event", self._on_resize - ) + Parameters + ---------- + *args : str + Positional arguments provided to redraw are identified as layer-names + that should be re-drawn. If no arguments are provided, all layers + are re-drawn! - # if we haven't attached an axpicker so far, do it! - if self.parent._layout_editor is None: - self.parent._layout_editor = LayoutEditor(self.parent, modifier="alt+l") + """ + if len(args) == 0: + # in case no argument is provided, force a complete re-draw of + # all layers (and datasets) of the map + self._bm._refetch_bg = True + if force_data_redraw and getattr(self, "_data_manager", None) is not None: + self._data_manager.last_extent = None - active_backend = plt.get_backend() - - if active_backend == "module://matplotlib_inline.backend_inline": - # close the figure to avoid duplicated (empty) plots created - # by the inline-backend manager in jupyter notebooks - plt.close(self.f) - - def _init_axes(self, ax, plot_crs, **kwargs): - if isinstance(ax, plt.Axes): - # check if the axis is already used by another maps-object - if ax not in (i.ax for i in (self.parent, *self.parent._children)): - newax = True - ax.set_animated(True) - # make sure axes are drawn once to properly set transforms etc. - # (otherwise pan/zoom, ax.contains_point etc. will not work) - ax.draw(self.f.canvas.get_renderer()) - - else: - newax = False - else: - newax = True - # create a new axis - if ax is None: - gs = GridSpec( - nrows=1, ncols=1, left=0.01, right=0.99, bottom=0.05, top=0.95 - ) - gsspec = [gs[:]] - elif isinstance(ax, SubplotSpec): - gsspec = [ax] - elif isinstance(ax, (list, tuple)) and len(ax) == 4: - # absolute position - l, b, w, h = ax - - gs = GridSpec( - nrows=1, ncols=1, left=l, bottom=b, right=l + w, top=b + h - ) - gsspec = [gs[:]] - elif isinstance(ax, int) and len(str(ax)) == 3: - gsspec = [ax] - elif isinstance(ax, tuple) and len(ax) == 3: - gsspec = ax - else: - raise TypeError("EOmaps: The provided value for 'ax' is invalid.") - - projection = self._get_cartopy_crs(plot_crs) - - ax = self.f.add_subplot( - *gsspec, - projection=projection, - aspect="equal", - adjustable="box", - label=self._get_ax_label(), - animated=True, - ) - # make sure axes are drawn once to properly set transforms etc. - # (otherwise pan/zoom, ax.contains_point etc. will not work) - ax.draw(self.f.canvas.get_renderer()) - - self._ax = ax - self._gridspec = ax.get_gridspec() - - # add support for "frameon" kwarg - if kwargs.get("frameon", True) is False: - self.ax.spines["geo"].set_edgecolor("none") - - if newax: # only if a new axis has been created - self._new_axis_map = True - - # explicitly set initial limits to global to avoid issues if NE-features - # are added (and clipped) before actual limits are set - # TODO - if hasattr(self.ax, "set_global"): - self.ax.set_global() - - self._cid_xlim = self.ax.callbacks.connect( - "xlim_changed", self._on_xlims_change - ) - self._cid_xlim = self.ax.callbacks.connect( - "ylim_changed", self._on_ylims_change - ) - else: - self._new_axis_map = False - - def _get_ax_label(self): - return "map" - - def _set_parent(self): - """Identify the parent object.""" - assert self._parent is None, "EOmaps: There is already a parent Maps object!" - # check if the figure to which the Maps-object is added already has a parent - parent = None - if getattr(self._f, "_EOmaps_parent", False): - parent = self._proxy(self._f._EOmaps_parent) - - if parent is None: - parent = self - - self._parent = self._proxy(parent) - - if parent not in [self, None]: - # add the child to the topmost parent-object - self.parent._add_child(self) - - @staticmethod - def _proxy(obj): - # None cannot be weak-referenced! - if obj is None: - return None - - # create a proxy if the object is not yet a proxy - if type(obj) is not weakref.ProxyType: - return weakref.proxy(obj) - else: - return obj - - @property - def _real_self(self): - # workaround to obtain a non-weak reference for the parent - # (e.g. self.parent._real_self is a non-weak ref to parent) - # see https://stackoverflow.com/a/49319989/9703451 - return self - - def _add_child(self, m): - self.parent._children.add(m) - - # execute hooks to notify the gui that a new child was added - for action in self._after_add_child: - try: - action() - except Exception: - _log.exception("EOmaps: Problem executing 'on_add_child' action:") - - def redraw(self, *args): - """ - Force a re-draw of cached background layers. - - - Use this at the very end of your code to trigger a final re-draw - to make sure artists not managed by EOmaps are properly drawn! - - Note - ---- - Don't use this to interactively update artists on a map! - since it will trigger a re-draw background-layers! - - To dynamically re-draw an artist whenever you interact with the map, use: - - >>> m.BM.add_artist(artist) - - To make an artist temporary (e.g. remove it on the next event), use - one of : - - >>> m.cb.click.add_temporary_artist(artist) - >>> m.cb.pick.add_temporary_artist(artist) - >>> m.cb.keypress.add_temporary_artist(artist) - >>> m.cb.move.add_temporary_artist(artist) - - Parameters - ---------- - *args : str - Positional arguments provided to redraw are identified as layer-names - that should be re-drawn. If no arguments are provided, all layers - are re-drawn! - - """ - if len(args) == 0: - # in case no argument is provided, force a complete re-draw of - # all layers (and datasets) of the map - self.BM._refetch_bg = True else: # only re-fetch the required layers - for l in args: - self.BM._refetch_layer(l) + for layer in args: + self._bm._refetch_layer(layer) + if ( + force_data_redraw + and getattr(self.l[layer], "_data_manager", None) is not None + ): + self.l[layer]._data_manager.last_extent = None self.f.canvas.draw_idle() @@ -699,33 +827,36 @@ def show_layer(self, *args, clear=True): Maps.util.layer_slider : Add a slider to switch layers to the map. """ - name = self.BM._get_combined_layer_name(*args) + name = self._bm._get_combined_layer_name(*args) if not isinstance(name, str): _log.info("EOmaps: All layer-names are converted to strings!") name = str(name) # check if all layers exist - existing_layers = self._get_layers() - layers_to_show, _ = self.BM._parse_multi_layer_str(name) + existing_layers = self._get_layers(exclude_private=False) + layers_to_show, _ = self._bm._parse_multi_layer_str(name) # don't check private layer-names layers_to_show = [i for i in layers_to_show if not i.startswith("_")] missing_layers = set(layers_to_show).difference(set(existing_layers)) if len(missing_layers) > 0: - lstr = " - " + "\n - ".join(map(str, existing_layers)) + public_layers = self._get_layers(exclude_private=True) - _log.error( - f"EOmaps: The layers {missing_layers} do not exist...\n" - + f"Use one of: \n{lstr}" + lstr = " - " + "\n - ".join(map(str, public_layers)) + + _log.warning( + 'EOmaps: The layers: "' + + '","'.join(sorted(missing_layers)) + + '" do not (yet?) exist!\n' + + f"Currently available layers are: \n{lstr}" ) - return # invoke the bg_layer setter of the blit-manager - self.BM.bg_layer = name - self.BM.update() + self._bm.bg_layer = name + self._bm.update() # plot a snapshot to jupyter notebook cell if inline backend is used - if not self.BM._snapshot_on_update and plt.get_backend() in [ + if not self._bm._snapshot_on_update and plt.get_backend() in [ "module://matplotlib_inline.backend_inline" ]: self.snapshot(clear=clear) @@ -751,6 +882,8 @@ def show(self, clear=True): show_layer : Set the currently visible layer. """ + self.show_layer(self.layer) + try: __IPYTHON__ except NameError: @@ -764,6 +897,77 @@ def show(self, clear=True): else: plt.show() + def set_extent(self, extents, crs=None): + """ + Set the extent (x0, x1, y0, y1) of the map in the given coordinate system. + + Parameters + ---------- + extents : array-like + The extent in the given crs (x0, x1, y0, y1). + crs : a crs identifier, optional + The coordinate-system in which the extent is evaluated. + + - if None, epsg=4326 (e.g. lon/lat projection) is used + + The default is None. + + """ + # just a wrapper to make sure that previously set extents are not + # reset when plotting data! + + # ( e.g. once .set_extent is called .plot_map does NOT set the extent!) + if crs is not None: + crs = self._get_cartopy_crs(crs) + else: + crs = ccrs.PlateCarree() + + self.ax.set_extent(extents, crs=crs) + self._set_extent_on_plot = False + + def get_extent(self, crs=None): + """ + Get the extent (x0, x1, y0, y1) of the map in the given coordinate system. + + Parameters + ---------- + crs : a crs identifier, optional + The coordinate-system in which the extent is evaluated. + + - if None, the extent is provided in epsg=4326 (e.g. lon/lat projection) + + The default is None. + + Returns + ------- + extent : The extent in the given crs (x0, x1, y0, y1). + + """ + + # fast track if plot-crs is requested + if crs == self.crs_plot: + x0, x1, y0, y1 = (*self.ax.get_xlim(), *self.ax.get_ylim()) + + bnds = self._crs_boundary_bounds + # clip the map-extent with respect to the boundary bounds + # (to avoid returning values outside the crs bounds) + try: + x0, x1 = np.clip([x0, x1], bnds[0], bnds[2]) + y0, y1 = np.clip([y0, y1], bnds[1], bnds[3]) + except Exception: + _log.debug( + "EOmaps: Error while trying to clip map extent", exc_info=True + ) + else: + if crs is not None: + crs = self._get_cartopy_crs(crs) + else: + crs = self._get_cartopy_crs(4326) + + x0, x1, y0, y1 = self.ax.get_extent(crs=crs) + + return x0, x1, y0, y1 + def fetch_layers(self, layers=None): """ Fetch (and cache) the layers of a map. @@ -787,7 +991,7 @@ def fetch_layers(self, layers=None): Maps.cb.keypress.attach.fetch_layers : use a keypress callback to fetch layers """ - active_layer = self.BM._bg_layer + active_layer = self._bm._bg_layer all_layers = self._get_layers() if layers is None: @@ -809,26 +1013,12 @@ def fetch_layers(self, layers=None): self.show_layer(l) self.show_layer(active_layer) - self.BM.update() + self._bm.update() def _get_layers(self, exclude=None, exclude_private=True): # return a list of all (empty and non-empty) layer-names - layers = set((m.layer for m in (self.parent, *self.parent._children))) - # add layers that are not yet activated (but have an activation - # method defined...) - layers = layers.union(set(self.BM._on_layer_activation[True])) - layers = layers.union(set(self.BM._on_layer_activation[False])) - - # add all (possibly still invisible) layers with artists defined - # (ONLY do this for unique layers... skip multi-layers ) - layers = layers.union( - chain( - *( - self.BM._parse_multi_layer_str(i)[0] - for i in (*self.BM._bg_artists, *self.BM._artists) - ) - ) - ) + layers = set(self.l._get_layer_names()) + layers = set(chain(*(m.l._get_layer_names() for m in self._bm._children))) # exclude private layers if exclude_private: @@ -838,8 +1028,11 @@ def remove_prefix(text, prefix): return text[len(prefix) :] return text - layers = {remove_prefix(i, "__inset_") for i in layers} - layers = {i for i in layers if not i.startswith("__")} + layers = {remove_prefix(i, "**inset_") for i in layers} + layers = {i for i in layers if not i.startswith("**")} + else: + layers.add("**BG**") + layers.add("**SPINES**") if exclude: for i in exclude: @@ -898,25 +1091,25 @@ def snapshot(self, *layer, transparent=False, clear=False): with ExitStack() as stack: # don't clear on layer-changes - stack.enter_context(self.BM._cx_dont_clear_on_layer_change()) + stack.enter_context(self._bm._cx_dont_clear_on_layer_change()) if len(layer) == 0: - layer = None + layer = [self.layer] if layer is not None: - layer = self.BM._get_combined_layer_name(*layer) + layer = self._bm._get_combined_layer_name(*layer) # add the figure background patch as the bottom layer - initial_layer = self.BM.bg_layer + initial_layer = self._bm.bg_layer if transparent is False: - showlayer_name = self.BM._get_showlayer_name( + showlayer_name = self._bm._get_showlayer_name( layer=layer, transparent=transparent ) self.show_layer(showlayer_name) sn = self._get_snapshot() # restore the previous layer - self.BM._refetch_layer(showlayer_name) + self._bm._refetch_layer(showlayer_name) self.show_layer(initial_layer) else: if layer is not None: @@ -947,14 +1140,6 @@ def snapshot(self, *layer, transparent=False, clear=False): finally: self._snapshotting = False - def _get_snapshot(self, layer=None): - if layer is None: - buf = self.f.canvas.print_to_buffer() - x = np.frombuffer(buf[0], dtype=np.uint8).reshape(buf[1][1], buf[1][0], 4) - else: - x = self.BM._get_array(layer)[::-1, ...] - return x - @wraps(LayoutEditor.get_layout) def get_layout(self, *args, **kwargs): """Get the current layout.""" @@ -991,7 +1176,7 @@ def edit_layout(self, filepath=None): def subplots_adjust(self, **kwargs): """Adjust the margins of subplots.""" with self.delay_draw(): - for m in (self.parent, *self.parent._children): + for m in self._bm._children: try: m.ax.get_gridspec().update(**kwargs) except AttributeError: @@ -1011,7 +1196,7 @@ def savefig(self, *args, refetch_wms=False, rasterize_data=True, **kwargs): dpi = kwargs.get("dpi", None) # get the currently visible layer (to restore it after saving is done) - initial_layer = self.BM.bg_layer + initial_layer = self._bm.bg_layer if plt.get_backend() == "agg": # make sure that a draw-event was triggered when using the agg backend @@ -1022,11 +1207,11 @@ def savefig(self, *args, refetch_wms=False, rasterize_data=True, **kwargs): with ExitStack() as stack: # don't clear on layer-changes - stack.enter_context(self.BM._cx_dont_clear_on_layer_change()) + stack.enter_context(self._bm._cx_dont_clear_on_layer_change()) # add the figure background patch as the bottom layer if transparent=False transparent = kwargs.get("transparent", False) - showlayer_name = self.BM._get_showlayer_name(initial_layer, transparent) + showlayer_name = self._bm._get_showlayer_name(initial_layer, transparent) self.show_layer(showlayer_name) redraw = False @@ -1035,24 +1220,24 @@ def savefig(self, *args, refetch_wms=False, rasterize_data=True, **kwargs): # clear all cached background layers before saving to make sure they # are re-drawn with the correct dpi-settings - self.BM._refetch_bg = True + self._bm._refetch_bg = True # get all layer names that should be drawn - savelayers, alphas = self.BM._parse_multi_layer_str(showlayer_name) + savelayers, alphas = self._bm._parse_multi_layer_str(showlayer_name) # make sure inset-maps are drawn on top of normal maps - savelayers.sort(key=lambda x: x.startswith("__inset_")) + savelayers.sort(key=lambda x: x.startswith("**inset_")) zorder = 0 for layer, alpha in zip(savelayers, alphas): # get all (sorted) artists of a layer - if layer.startswith("__inset"): - artists = self.BM.get_bg_artists(["__inset_all", layer]) + if layer.startswith("**inset"): + artists = self._bm.get_bg_artists(["**inset_all", layer]) else: - if layer.startswith("__"): - artists = self.BM.get_bg_artists([layer]) + if layer.startswith("**"): + artists = self._bm.get_bg_artists([layer]) else: - artists = self.BM.get_bg_artists(["all", layer]) + artists = self._bm.get_bg_artists(["all", layer]) for a in artists: if isinstance(a, plt.Axes): @@ -1068,9 +1253,9 @@ def savefig(self, *args, refetch_wms=False, rasterize_data=True, **kwargs): stack.enter_context(a._cm_set(alpha=current_alpha)) - if any(l.startswith("__inset") for l in savelayers): - if "__inset_all" not in savelayers: - savelayers.append("__inset_all") + if any(l.startswith("**inset") for l in savelayers): + if "**inset_all" not in savelayers: + savelayers.append("**inset_all") alphas.append(1) if "all" not in savelayers: savelayers.append("all") @@ -1079,21 +1264,28 @@ def savefig(self, *args, refetch_wms=False, rasterize_data=True, **kwargs): # always draw dynamic artists on top of background artists for layer, alpha in zip(savelayers, alphas): # get all (sorted) artists of a layer - artists = self.BM.get_artists([layer]) + artists = self._bm.get_artists([layer]) for a in artists: zorder += 1 stack.enter_context(a._cm_set(zorder=zorder, animated=False)) # hide all artists on non-visible layers - for key, val in chain( - self.BM._bg_artists.items(), self.BM._artists.items() - ): - if key not in savelayers: - for a in val: + # for key, val in chain( + # self._bm._bg_artists.items(), self._bm._artists.items() + # ): + # if key not in savelayers: + # for a in val: + # stack.enter_context(a._cm_set(visible=False, animated=True)) + + for m in self._bm._children: + # hide all artists on non-visible layers + + # TODO use proper layer parsing not hard-coding! + if m.layer.split("__", 1)[0] not in savelayers: + for a in (*m._artists, *m._bg_artists): stack.enter_context(a._cm_set(visible=False, animated=True)) - for m in (self.parent, *self.parent._children): # re-enable normal axis draw cycle by making axes non-animated. # This is needed for backward-compatibility, since saving a figure # ignores the animated attribute for axis-children but not for the axis @@ -1102,7 +1294,7 @@ def savefig(self, *args, refetch_wms=False, rasterize_data=True, **kwargs): stack.enter_context(m.ax._cm_set(animated=False)) # explicitly set axes to non-animated to re-enable draw cycle - for a in m.BM._managed_axes: + for a in m._bm._managed_axes: stack.enter_context(a._cm_set(animated=False)) # trigger a redraw of all savelayers to make sure unmanaged artists @@ -1137,7 +1329,7 @@ def cleanup(self): try: # disconnect callback on xlim-change (only relevant for parent) - if not self._is_sublayer: + if not isinstance(self, MapsBase): try: if hasattr(self, "_cid_xlim"): self.ax.callbacks.disconnect(self._cid_xlim) @@ -1148,13 +1340,28 @@ def cleanup(self): exc_info=_log.getEffectiveLevel() <= logging.DEBUG, ) - # cleanup all artists and cached background-layers from the blit-manager - if not self._is_sublayer: - self.BM._cleanup_layer(self.layer) + # cleanup all artists + for a in (*self._artists, *self._bg_artists): + try: + a.remove() + except Exception: + _log.error( + f"EOmaps-cleanup: Problem while trying to remove artist: {a}", + exc_info=_log.getEffectiveLevel() <= logging.DEBUG, + ) + self._artists.clear() + self._bg_artists.clear() + + # remove the child from the LayerNamespace + self.l._remove_layer(self.layer) + + self._bm.remove_hook( + "layer_activation", method=None, permanent=None, layer=self.layer + ) # remove the child from the parent Maps object - if self in self.parent._children: - self.parent._children.remove(self) + if self in self._bm._children: + self._bm._children.remove(self) except Exception: _log.error( "EOmaps: Cleanup problem!", @@ -1166,17 +1373,231 @@ def crs_plot(self): """The crs used for plotting.""" return self._crs_plot_cartopy - @staticmethod @lru_cache() - def _get_cartopy_crs(crs): + def get_crs(self, crs="plot"): + """ + Get the pyproj CRS instance of a given crs specification. + + Parameters + ---------- + crs : "in", "out" or a crs definition + the crs to return + + - if "in" : the crs defined in m.data_specs.crs + - if "out" or "plot" : the crs used for plotting + + Returns + ------- + crs : pyproj.CRS + the pyproj CRS instance + + """ + # check for strings first to avoid expensive equality checking for CRS objects! if isinstance(crs, str): - try: - # TODO use crs=int(crs.upper().removeprefix("EPSG:")) when python>=3.9 - # is required - crs = crs.upper() - if crs.startswith("EPSG:"): - crs = crs[5:] - crs = int(crs) + if crs == "in": + crs = self.data_specs.crs + elif crs == "out" or crs == "plot": + if self.crs_plot == ccrs.PlateCarree(): + crs = 4326 + else: + crs = self.crs_plot + + crs = CRS.from_user_input(crs) + return crs + + def transform_plot_to_lonlat(self, x, y): + """ + Transform plot-coordinates to longitude and latitude values. + + Parameters + ---------- + x, y : float or array-like + The coordinates values in the coordinate-system of the plot. + + Returns + ------- + lon, lat : The coordinates transformed to longitude and latitude values. + + """ + return self._transf_plot_to_lonlat.transform(x, y) + + def transform_lonlat_to_plot(self, lon, lat): + """ + Transform longitude and latitude values to plot coordinates. + + Parameters + ---------- + lon, lat : float or array-like + The longitude and latitude values to transform. + + Returns + ------- + x, y : The coordinates transformed to the plot-coordinate system. + + """ + return self._transf_lonlat_to_plot.transform(lon, lat) + + def _init_figure(self, **kwargs): + if self.parent.f is None: + # do this on any new figure since "%matplotlib inline" tries to re-activate + # interactive mode all the time! + _handle_backends() + + self._f = plt.figure(**kwargs) + # to hide canvas header in jupyter notebooks (default figure label) + self._f.canvas.header_visible = False + + _log.debug("EOmaps: New figure created") + + # make sure we keep a "real" reference otherwise overwriting the + # variable of the parent Maps-object while keeping the figure open + # causes all weakrefs to be garbage-collected! + self._f._EOmaps_parent = self + else: + if not hasattr(self.parent.f, "_EOmaps_parent"): + # e.g. in case a explicit figure is provided + self.parent.f._EOmaps_parent = self.parent._real_self + + if getattr(self.parent, "_bm", None) is not None: + self._bm = self.parent._bm + else: + _log.debug("New BlitManager initialized") + self._bm = BlitManager(self.f) + self._bm._bg_layer = self.layer + + if self.parent == self: # use == instead of "is" since the parent is a proxy! + # override Figure.savefig with Maps.savefig but keep original + # method accessible via Figure._mpl_orig_savefig + # (this ensures that using the save-buttons in the gui or pressing + # control+s will redirect the save process to the eomaps routine) + self._f._mpl_orig_savefig = self._f.savefig + self._f.savefig = self.savefig + + # only attach resize- and close-callbacks if we initialize a parent + # Maps-object + # attach a callback that is executed when the figure is closed + self._cid_onclose = self.f.canvas.mpl_connect("close_event", self._on_close) + # attach a callback that is executed if the figure canvas is resized + self._cid_resize = self.f.canvas.mpl_connect( + "resize_event", self._on_resize + ) + + # if we haven't attached an axpicker so far, do it! + if self.parent._layout_editor is None: + self.parent._layout_editor = LayoutEditor(self.parent, modifier="alt+l") + + active_backend = plt.get_backend() + + if active_backend == "module://matplotlib_inline.backend_inline": + # close the figure to avoid duplicated (empty) plots created + # by the inline-backend manager in jupyter notebooks + plt.close(self.f) + + def _init_axes(self, ax, plot_crs, **kwargs): + if isinstance(ax, plt.Axes): + # check if the axis is already used by another maps-object + if ax not in (i.ax for i in self._bm._children): + newax = True + ax.set_animated(True) + # make sure axes are drawn once to properly set transforms etc. + # (otherwise pan/zoom, ax.contains_point etc. will not work) + ax.draw(self.f.canvas.get_renderer()) + else: + newax = False + else: + newax = True + # create a new axis + if ax is None: + gs = GridSpec( + nrows=1, ncols=1, left=0.01, right=0.99, bottom=0.05, top=0.95 + ) + gsspec = [gs[:]] + elif isinstance(ax, SubplotSpec): + gsspec = [ax] + elif isinstance(ax, (list, tuple)) and len(ax) == 4: + # absolute position + l, b, w, h = ax + + gs = GridSpec( + nrows=1, ncols=1, left=l, bottom=b, right=l + w, top=b + h + ) + gsspec = [gs[:]] + elif isinstance(ax, int) and len(str(ax)) == 3: + gsspec = [ax] + elif isinstance(ax, tuple) and len(ax) == 3: + gsspec = ax + else: + raise TypeError("EOmaps: The provided value for 'ax' is invalid.") + + projection = self._get_cartopy_crs(plot_crs) + + ax = self.f.add_subplot( + *gsspec, + projection=projection, + aspect="equal", + adjustable="box", + label=self._get_ax_label(), + animated=True, + ) + # make sure axes are drawn once to properly set transforms etc. + # (otherwise pan/zoom, ax.contains_point etc. will not work) + ax.draw(self.f.canvas.get_renderer()) + + self._ax = ax + self._gridspec = ax.get_gridspec() + + # add support for "frameon" kwarg + if kwargs.get("frameon", True) is False: + self.ax.spines["geo"].set_edgecolor("none") + + if newax: # only if a new axis has been created + self._new_axis_map = True + + self._l = LayerNamespace(self) + self._ll = LazyLayerNamespace(self._l) + + # explicitly set initial limits to global to avoid issues if NE-features + # are added (and clipped) before actual limits are set + # TODO + if hasattr(self.ax, "set_global"): + self.ax.set_global() + + self._cid_xlim = self.ax.callbacks.connect( + "xlim_changed", self._on_xlims_change + ) + self._cid_xlim = self.ax.callbacks.connect( + "ylim_changed", self._on_ylims_change + ) + else: + self._new_axis_map = False + + # use the namespace from the parent map + self._l, self._ll = next( + ((m._l, m._ll) for m in self._bm._children if m.ax is ax) + ) + + def _get_snapshot(self, layer=None): + if layer is None: + buf = self.f.canvas.print_to_buffer() + x = np.frombuffer(buf[0], dtype=np.uint8).reshape(buf[1][1], buf[1][0], 4) + else: + x = self._bm._get_array(layer)[::-1, ...] + return x + + def _get_ax_label(self): + return "map" + + @staticmethod + @lru_cache() + def _get_cartopy_crs(crs): + if isinstance(crs, str): + try: + # TODO use crs=int(crs.upper().removeprefix("EPSG:")) when python>=3.9 + # is required + crs = crs.upper() + if crs.startswith("EPSG:"): + crs = crs[5:] + crs = int(crs) except ValueError: raise ValueError( f"The provided crs '{crs}' cannot be identified. " @@ -1214,6 +1635,23 @@ def _get_transformer(crs_from, crs_to): # create a pyproj Transformer object and cache it for later use return Transformer.from_crs(crs_from, crs_to, always_xy=True) + @property + def _real_self(self): + # workaround to obtain a non-weak reference for the parent + # (e.g. self.parent._real_self is a non-weak ref to parent) + # see https://stackoverflow.com/a/49319989/9703451 + return self + + def _add_child(self, m): + self._bm._children.add(m) + + # execute hooks to notify the gui that a new child was added + for action in self._after_add_child: + try: + action() + except Exception: + _log.exception("EOmaps: Problem executing 'on_add_child' action:") + @property def _transf_plot_to_lonlat(self): return self._get_transformer( @@ -1228,37 +1666,42 @@ def _transf_lonlat_to_plot(self): self.crs_plot, ) - def transform_plot_to_lonlat(self, x, y): - """ - Transform plot-coordinates to longitude and latitude values. + def _handle_spines(self): + # put cartopy spines on a separate layer + for spine in self.ax.spines.values(): + if spine and spine not in self._bm._bg_artists["**SPINES**"]: + self._bm._bg_artists.add("**SPINES**", spine) - Parameters - ---------- - x, y : float or array-like - The coordinates values in the coordinate-system of the plot. + def _on_resize(self, event): + # make sure the background is re-fetched if the canvas has been resized + # (required for peeking layers after the canvas has been resized + # and for webagg and nbagg backends to correctly re-draw the layer) - Returns - ------- - lon, lat : The coordinates transformed to longitude and latitude values. + self._bm._refetch_bg = True + self._bm._refetch_blank = True - """ - return self._transf_plot_to_lonlat.transform(x, y) + # update the figure dimensions in case shading is used. + # Avoid flushing events during resize + # TODO + if hasattr(self, "_update_shade_axis_size"): + self._update_shade_axis_size(flush=False) - def transform_lonlat_to_plot(self, lon, lat): - """ - Transform longitude and latitude values to plot coordinates. + def _on_close(self, event): + # reset attributes that might use up a lot of memory when the figure is closed + for m in list(self._bm._children): + if hasattr(m.f, "_EOmaps_parent"): + m.f._EOmaps_parent = None - Parameters - ---------- - lon, lat : float or array-like - The longitude and latitude values to transform. + m.cleanup() - Returns - ------- - x, y : The coordinates transformed to the plot-coordinate system. + # run garbage-collection to immediately free memory + gc.collect - """ - return self._transf_lonlat_to_plot.transform(lon, lat) + def _on_xlims_change(self, *args, **kwargs): + self._bm._refetch_bg = True + + def _on_ylims_change(self, *args, **kwargs): + self._bm._refetch_bg = True def on_layer_activation(self, func, layer=None, persistent=False, **kwargs): """ @@ -1327,81 +1770,124 @@ def on_layer_activation(self, func, layer=None, persistent=False, **kwargs): layer = str(layer) m = self.new_layer(layer) - def cb(m, layer): + @wraps(func) + def cb(layer): func(m=m, **kwargs) - self.BM.on_layer(func=cb, layer=layer, persistent=persistent, m=m) + self._bm.on_layer(func=cb, layer=layer, persistent=persistent) - def set_extent(self, extents, crs=None): + @property + def on_all_layers(self): """ - Set the extent (x0, x1, y0, y1) of the map in the given coordinate system. + Return a MultiCaller that executes action on all layers defined + on the map at the moment of execution. - Parameters - ---------- - extents : array-like - The extent in the given crs (x0, x1, y0, y1). - crs : a crs identifier, optional - The coordinate-system in which the extent is evaluated. - - if None, epsg=4326 (e.g. lon/lat projection) is used - - The default is None. + >>> from eomaps import Maps + >>> m = Maps() + >>> m.l.second.add_title("a second layer") + >>> m.all_layers.add_feature.preset.coastline() """ - # just a wrapper to make sure that previously set extents are not - # reset when plotting data! + return sum([*self.l]) - # ( e.g. once .set_extent is called .plot_map does NOT set the extent!) - if crs is not None: - crs = self._get_cartopy_crs(crs) - else: - crs = ccrs.PlateCarree() + @lru_cache() + def _get_nominatim_response(self, q, user_agent=None): + import requests - self.ax.set_extent(extents, crs=crs) - self._set_extent_on_plot = False + _log.info(f"Querying {q}") + if user_agent is None: + version = importlib.metadata.version("eomaps") + user_agent = f"EOMaps v{version}" - def get_extent(self, crs=None): + headers = { + "User-Agent": user_agent, + } + + resp = requests.get( + rf"https://nominatim.openstreetmap.org/search?q={q}&format=json&addressdetails=1&limit=1", + headers=headers, + ).json() + + if len(resp) == 0: + raise TypeError(f"Unable to resolve the location: {q}") + + return resp[0] + + def set_extent_to_location( + self, location, buffer=0, annotate=False, user_agent=None + ): """ - Get the extent (x0, x1, y0, y1) of the map in the given coordinate system. + Set the map-extent based on a given location query. + + The bounding-box is hereby resolved via the OpenStreetMap Nominatim service. + + Note + ---- + The OSM Nominatim service has a strict usage policy that explicitly + disallows "heavy usage" (e.g.: an absolute maximum of 1 request per second). + + EOMaps caches requests so using a location multiple times in the same + session does not cause multiple requests! + + For more details, see: + https://operations.osmfoundation.org/policies/nominatim/ + https://openstreetmap.org/copyright Parameters ---------- - crs : a crs identifier, optional - The coordinate-system in which the extent is evaluated. + location : str + An arbitrary string used to identify the region of interest. + (e.g. a country, district, address etc.) + + For example: + "Austria", "Vienna" + buffer : float + Fraction of the found extent added as a buffer. + The default is 0. + annotate : bool, optional + Indicator if an annotation should be added to the center of the identified + location or not. The default is False. + user_agent: str, optional + The user-agent used for the Nominatim request - - if None, the extent is provided in epsg=4326 (e.g. lon/lat projection) + Examples + -------- + >>> m = Maps() + >>> m.set_extent_to_location("Austria") + >>> m.add_feature.preset.countries() - The default is None. - - Returns - ------- - extent : The extent in the given crs (x0, x1, y0, y1). + >>> m = Maps(Maps.CRS.GOOGLE_MERCATOR) + >>> m.set_extent_to_location("Vienna") + >>> m.add_wms.OpenStreetMap.add_layer.default() """ + r = self._get_nominatim_response(location) - # fast track if plot-crs is requested - if crs == self.crs_plot: - x0, x1, y0, y1 = (*self.ax.get_xlim(), *self.ax.get_ylim()) + # get bbox of found location + lon0, lon1, lat0, lat1 = map(float, r["boundingbox"]) - bnds = self._crs_boundary_bounds - # clip the map-extent with respect to the boundary bounds - # (to avoid returning values outside the crs bounds) - try: - x0, x1 = np.clip([x0, x1], bnds[0], bnds[2]) - y0, y1 = np.clip([y0, y1], bnds[1], bnds[3]) - except Exception: - _log.debug( - "EOmaps: Error while trying to clip map extent", exc_info=True - ) - else: - if crs is not None: - crs = self._get_cartopy_crs(crs) - else: - crs = self._get_cartopy_crs(4326) + dlon, dlat = lon1 - lon0, lat1 - lat0 + lon0 -= dlon * buffer + lon1 += dlon * buffer + lat0 -= dlat * buffer + lat1 += dlat * buffer - x0, x1, y0, y1 = self.ax.get_extent(crs=crs) + # set extent to found bbox + self.set_extent((lat0, lat1, lon0, lon1), crs=ccrs.PlateCarree()) - return x0, x1, y0, y1 + # add annotation + if annotate is not False: + if isinstance(annotate, str): + text = annotate + else: + text = fill(r["display_name"], 20) + + self.add_annotation( + xy=(r["lon"], r["lat"]), xy_crs=4326, text=text, fontsize=8 + ) + else: + _log.info(f"Centering Map to:\n {r['display_name']}") def join_limits(self, *args): """ @@ -1413,10 +1899,18 @@ def join_limits(self, *args): the axes to join. """ for m in args: - if m is not self: - self._join_axis_limits(weakref.proxy(m)) + if m._real_self is not self: + self._join_axis_limits(m) + + # a WeakSet holding weak-references to maps that share axes limits + # (used to make sure limits are only shared once between Maps) + __joined_limits = weakref.WeakSet() def _join_axis_limits(self, m): + if (m._real_self in self.__joined_limits) or (self in m.__joined_limits): + # make sure limits are only joined once between maps + return + if self.ax.projection != m.ax.projection: _log.warning( "EOmaps: joining axis-limits is only possible for " @@ -1459,6 +1953,8 @@ def parent_ylims_change(event_ax): m.ax.callbacks.connect("xlim_changed", parent_xlims_change) m.ax.callbacks.connect("ylim_changed", parent_ylims_change) + self.__joined_limits.add(m) + def _log_on_event(self, level, msg, event): """ Schedule a log message that will be shown on the next matplotlib event. @@ -1505,3 +2001,84 @@ def log_message(*args, **kwargs): self._log_on_event_cids[event] = self.f.canvas.mpl_connect( event, log_message ) + + def _get_always_on_top(self): + try: + if "qt" in plt.get_backend().lower(): + from qtpy import QtCore + + w = self.f.canvas.window() + return bool(w.windowFlags() & QtCore.Qt.WindowStaysOnTopHint) + except Exception: + _log.debug("Error while trying to get 'always_on_top' flag") + return False + return False + + def _set_always_on_top(self, q): + # keep pyqt window on top + try: + from qtpy import QtCore + + if q: + # only do this if necessary to avoid flickering + # see https://stackoverflow.com/a/40007740/9703451 + if not self._get_always_on_top(): + # in case pyqt is used as backend, also keep the figure on top + if "qt" in plt.get_backend().lower(): + w = self.f.canvas.window() + ws = w.size() + w.setWindowFlags( + w.windowFlags() | QtCore.Qt.WindowStaysOnTopHint + ) + w.resize(ws) + w.show() + + # handle companion-widget (in case it has been activated) + self._CompanionMixin__set_always_on_top(q) + + else: + if self._get_always_on_top(): + if "qt" in plt.get_backend().lower(): + w = self.f.canvas.window() + ws = w.size() + w.setWindowFlags( + w.windowFlags() & ~QtCore.Qt.WindowStaysOnTopHint + ) + w.resize(ws) + w.show() + + # handle companion-widget (in case it has been activated) + self._CompanionMixin__set_always_on_top(q) + + except Exception: + pass + + @contextmanager + def delay_draw(self, redraw=True): + """ + A contextmanager to delay drawing until the context exits. + + This is particularly useful to avoid intermediate draw-events when plotting + a lot of features or datasets on the currently visible layer. + + + Examples + -------- + + >>> m = Maps() + >>> with m.delay_draw(): + >>> m.add_feature.preset.coastline() + >>> m.add_feature.preset.ocean() + >>> m.add_feature.preset.land() + + """ + try: + self._bm._disable_draw = True + self._bm._disable_update = True + + yield + finally: + self._bm._disable_draw = False + self._bm._disable_update = False + if redraw: + self.redraw() diff --git a/eomaps/_webmap.py b/eomaps/_webmap.py index fce409f75..cd7e6158f 100755 --- a/eomaps/_webmap.py +++ b/eomaps/_webmap.py @@ -7,7 +7,7 @@ import requests from functools import lru_cache, partial -from warnings import warn, filterwarnings, catch_warnings +from warnings import filterwarnings, catch_warnings from types import SimpleNamespace from contextlib import contextmanager from urllib3.exceptions import InsecureRequestWarning @@ -31,11 +31,6 @@ _log = logging.getLogger(__name__) -def _add_pending_webmap(m, layer, name): - # indicate that there is a pending webmap in the companion-widget editor - m.BM._pending_webmaps.setdefault(layer, []).append(name) - - class _WebMapLayer: # base class for adding methods to the _WMSLayer- and _WMTSLayer objects def __init__(self, m, wms, name): @@ -155,7 +150,7 @@ def add_legend(self, style=None, img=None): _log.warning( "EOmaps: The WebMap for the legend is not yet added to the map!" ) - self._layer = self._m.BM._bg_layer + self._layer = self._m._bm._bg_layer axpos = self._m.ax.get_position() legax = self._m.f.add_axes((axpos.x0, axpos.y0, 0.25, 0.5)) @@ -169,10 +164,10 @@ def add_legend(self, style=None, img=None): legax.imshow(legend) # hide the legend if the corresponding layer is not active at the moment - if not self._m.BM._layer_visible(self._layer): + if not self._m._bm._layer_visible(self._layer): legax.set_visible(False) - self._m.BM.add_artist(legax, layer=self._layer) + self._m.l[self._layer].add_artist(legax) def cb_move(event): if not self._legend_picked: @@ -200,7 +195,7 @@ def cb_move(event): bbox = bbox.transformed(self._m.f.transFigure.inverted()) legax.set_position(bbox) - self._m.BM.blit_artists([legax]) + self._m._bm.blit_artists([legax]) def cb_release(event): self._legend_picked = False @@ -219,10 +214,9 @@ def cb_keypress(event): return if event.key in ["delete", "backspace"]: - self._m.BM.remove_artist(legax, self._layer) - legax.remove() + self._m._bm.remove_artist(legax, self._layer) - self._m.BM.update() + self._m._bm.update() def cb_scroll(event): if not self._legend_picked: @@ -240,7 +234,7 @@ def cb_scroll(event): ) ) - self._m.BM.blit_artists([legax]) + self._m._bm.blit_artists([legax]) self._m.f.canvas.mpl_connect("scroll_event", cb_scroll) self._m.f.canvas.mpl_connect("button_press_event", cb_pick) @@ -250,7 +244,7 @@ def cb_scroll(event): self._m.parent._wms_legend.setdefault(self._layer, list()).append(legax) - self._m.BM.update() + self._m._bm.update() return legax @@ -397,10 +391,10 @@ def __call__(self, layer=None, zorder=0, alpha=1, **kwargs): else: self._layer = layer - if self._layer == "all" or m.BM._layer_visible(self._layer): + if self._layer == "all" or m._bm._layer_visible(self._layer): # add the layer immediately if the layer is already active self._do_add_layer( - self._m, + m=self._m, layer=self._layer, wms_kwargs=kwargs, zorder=zorder, @@ -408,14 +402,17 @@ def __call__(self, layer=None, zorder=0, alpha=1, **kwargs): ) else: # delay adding the layer until it is effectively activated - _add_pending_webmap(self._m, self._layer, self.name) - self._m.BM.on_layer( - func=partial( - self._do_add_layer, - wms_kwargs=kwargs, - zorder=zorder, - alpha=alpha, - ), + func = partial( + self._do_add_layer, + wms_kwargs=kwargs, + zorder=zorder, + alpha=alpha, + ) + # used to display pending method in widget + func.__qualname__ = f"Add WebMap layer: {self.name}" + + self._m._bm.on_layer( + func=func, layer=self._layer, persistent=False, m=m, @@ -439,7 +436,7 @@ def _add_wmts(ax, wms, layers, wms_kwargs=None, **kwargs): ax.add_image(img) return img - def _do_add_layer(self, m, layer, **kwargs): + def _do_add_layer(self, layer, m, **kwargs): # actually add the layer to the map. _log.info(f"EOmaps: Adding wmts-layer: {self.name}") @@ -456,7 +453,7 @@ def _do_add_layer(self, m, layer, **kwargs): if hasattr(self, "_EOmaps_source_code"): art._EOmaps_source_code = self._EOmaps_source_code - m.BM.add_bg_artist(art, layer=layer) + m.l[layer].add_bg_artist(art) class _WMSLayer(_WebMapLayer): @@ -508,7 +505,7 @@ def __call__(self, layer=None, zorder=0, alpha=1, **kwargs): else: self._layer = layer - if m.BM._layer_visible(self._layer): + if m._bm._layer_visible(self._layer): # add the layer immediately if the layer is already active self._do_add_layer( m=m, @@ -519,14 +516,17 @@ def __call__(self, layer=None, zorder=0, alpha=1, **kwargs): ) else: # delay adding the layer until it is effectively activated - _add_pending_webmap(self._m, self._layer, self.name) - m.BM.on_layer( - func=partial( - self._do_add_layer, - wms_kwargs=kwargs, - zorder=zorder, - alpha=alpha, - ), + func = partial( + self._do_add_layer, + wms_kwargs=kwargs, + zorder=zorder, + alpha=alpha, + ) + # used to display pending method in widget + func.__qualname__ = f"Add WebMap layer: {self.name}" + + m._bm.on_layer( + func=func, layer=self._layer, persistent=False, m=m, @@ -585,7 +585,7 @@ def _native_srs(self, *args, **kwargs): return img - def _do_add_layer(self, m, layer, **kwargs): + def _do_add_layer(self, layer, m, **kwargs): # actually add the layer to the map. _log.info(f"EOmaps: ... adding wms-layer {self.name}") @@ -600,7 +600,7 @@ def _do_add_layer(self, m, layer, **kwargs): if hasattr(self, "_EOmaps_source_code"): art._EOmaps_source_code = self._EOmaps_source_code - m.BM.add_bg_artist(art, layer=layer) + m.l[layer].add_bg_artist(art) class _WebServiceCollection: @@ -1227,20 +1227,23 @@ def __call__( kwargs.setdefault("alpha", alpha) kwargs.setdefault("origin", "lower") - if self._layer in ["all", self._m.BM.bg_layer]: + if self._layer in ["all", self._m._bm.bg_layer]: # add the layer immediately if the layer is already active - self._do_add_layer(self._m, layer=self._layer, **kwargs) + self._do_add_layer(layer=self._layer, m=self._m, **kwargs) else: # delay adding the layer until it is effectively activated - _add_pending_webmap(self._m, self._layer, self.name) - self._m.BM.on_layer( - func=partial(self._do_add_layer, **kwargs), + func = partial(self._do_add_layer, **kwargs) + # used to display pending method in widget + func.__qualname__ = f"Add WebMap layer: {self.name}" + + self._m._bm.on_layer( + func=func, layer=self._layer, persistent=False, m=self._m, ) - def _do_add_layer(self, m, layer, **kwargs): + def _do_add_layer(self, layer, m, **kwargs): # actually add the layer to the map. _log.info(f"EOmaps: ... adding wms-layer {self.name}") @@ -1271,7 +1274,7 @@ def _do_add_layer(self, m, layer, **kwargs): if hasattr(self, "_EOmaps_source_code"): self._artist._EOmaps_source_code = self._EOmaps_source_code - m.BM.add_bg_artist(self._artist, layer=layer) + m.l[layer].add_bg_artist(self._artist) class _XyzTileServiceNonEarth(_XyzTileService): diff --git a/eomaps/annotation_editor.py b/eomaps/annotation_editor.py index 665b6b7b2..f9878ebb8 100755 --- a/eomaps/annotation_editor.py +++ b/eomaps/annotation_editor.py @@ -241,7 +241,7 @@ def on_release(self, event): self._select_signal() if self.annotation.figure is not None: - self.annotation.figure._EOmaps_parent.BM.update() + self.annotation.figure._EOmaps_parent._bm.update() def on_motion(self, evt): # check if a keypress event triggered a change of the interaction @@ -257,7 +257,7 @@ def on_motion(self, evt): super().on_motion(evt) if self.annotation.figure is not None: - self.annotation.figure._EOmaps_parent.BM.update(artists=[self.annotation]) + self.annotation.figure._EOmaps_parent._bm.update(artists=[self.annotation]) # emit signal if provided if self._edit_signal is not None: self._edit_signal() @@ -315,40 +315,29 @@ def show_info_text(self): fontfamily="monospace", ) - self.m.BM.add_artist(self._info_artist, layer="all") + self.m.all.add_artist(self._info_artist) self._info_cids.add( self.m.f.canvas.mpl_connect("button_press_event", self._on_press) ) - self.m.BM._before_fetch_bg_actions.append(self._update_info_fontsize) - self.m.BM.update() + self.m._bm.add_hook("before_fetch_bg", self._update_info_fontsize, True) + self.m._bm.update() def toggle_info_text(self): if getattr(self, "_info_artist", None) is not None: self._info_artist.set_visible(not self._info_artist.get_visible()) - self.m.BM.update() + self.m._bm.update() def remove_info_text(self): while len(self._info_cids) > 0: self.m.f.canvas.mpl_disconnect(self._info_cids.pop()) - try: - self.m.BM._before_fetch_bg_actions.remove(self._update_info_fontsize) - except ValueError: - pass + self.m._bm.remove_hook("before_fetch_bg", self._update_info_fontsize, True) if getattr(self, "_info_artist", None) is not None: - self.m.BM.remove_artist(self._info_artist, "all") - try: - self._info_artist.remove() - except Exception: - _log.error( - "There was a problem while trying to remove the " - "Editor info text artist." - ) - + self.m._bm.remove_artist(self._info_artist, "all") self._info_artist = None - self.m.BM.update() + self.m._bm.update() def _update_info_fontsize(self, *args, **kwargs): if getattr(self, "_info_artist", None) is not None: @@ -451,7 +440,7 @@ def __call__(self, q=True): ) self.m._emit_signal("annotationEditorActivated") - self.m.BM._clear_all_temp_artists() + self.m._bm._clear_all_temp_artists() self.show_info_text() self.m.cb.execute_callbacks(False) @@ -472,7 +461,7 @@ def __call__(self, q=True): self.remove_info_text() self.m._emit_signal("annotationEditorDeactivated") - self.m.BM.update() + self.m._bm.update() self.m.cb.execute_callbacks(True) def _make_ann_editable(self, ann, drag_coords=True): @@ -561,7 +550,7 @@ def update_text(self, text, what="all"): else: ann.a.set_text(str(text)) - self.m.BM.update() + self.m._bm.update() def print_code( self, @@ -721,13 +710,12 @@ def update_selected_text(self, text=None): if text is not None: _eomaps_picked_ann.set_text(text) - self.m.BM.update() + self.m._bm.update() def remove_selected_annotation(self, event): if event is None or event.key == "delete": global _eomaps_picked_ann if _eomaps_picked_ann: - self.m.BM.remove_artist(_eomaps_picked_ann) - _eomaps_picked_ann.remove() + self.m._bm.remove_artist(_eomaps_picked_ann) _eomaps_picked_ann = None - self.m.BM.update() + self.m._bm.update() diff --git a/eomaps/callbacks.py b/eomaps/callbacks.py index 11ed0a8b9..77e654719 100755 --- a/eomaps/callbacks.py +++ b/eomaps/callbacks.py @@ -100,7 +100,7 @@ def _get_annotation_text( if text is None: # use "ind is not None" to distinguish between click and pick # TODO implement better distinction between click and pick! - if self.m.data is not None and ind is not None: + if self.m.data_specs.data is not None and ind is not None: if not multipick: x, y = [ np.format_float_positional(i, trim="-", precision=pos_precision) @@ -428,13 +428,13 @@ def annotate( if permanent is False: # make the annotation temporary self._temporary_artists.append(annotation) - self.m.BM.add_artist(annotation, layer=layer) + self.m.l[layer].add_artist(annotation) else: if isinstance(permanent, str) and permanent == "fixed": - self.m.BM.add_bg_artist(annotation, layer=layer) + self.m.l[layer].add_bg_artist(annotation) else: - self.m.BM.add_artist(annotation, layer=layer) + self.m.l[layer].add_artist(annotation) if not hasattr(self, "permanent_annotations"): self.permanent_annotations = [] @@ -506,8 +506,14 @@ def mark( The default is None which defaults to the used shape for plotting if possible and else "ellipses". - buffer : float, optional - A factor to scale the size of the shape. The default is 1. + buffer : float or array of float, optional + A factor to scale the size of the shape. + + If a list of buffer values is provided, style-arguments like + linewidth, facecolor etc. can also be lists to style each buffer + shape individually. + + The default is 1. permanent : bool or None Indicator if the markers should be temporary (False) or permanent (True). @@ -595,12 +601,10 @@ def mark( pixelQ = False # get manually specified radius (e.g. if radius != "estimate") - if isinstance(radius, list): - radius = [i * buffer for i in radius] + if isinstance(radius, (list, int, float)): + radius = np.multiply(radius, buffer) elif isinstance(radius, tuple): - radius = tuple([i * buffer for i in radius]) - elif isinstance(radius, (int, float)): - radius = radius * buffer + radius = tuple([np.multiply(i, buffer) for i in radius]) if self.m.shape and self.m.shape.name == "geod_circles": if shape != "geod_circles" and pixelQ: @@ -636,8 +640,13 @@ def mark( else: raise TypeError(f"EOmaps: '{shape}' is not a valid marker-shape") + n_buffer = len(np.atleast_1d(buffer)) + coll = shp.get_coll( - np.atleast_1d(pos[0]), np.atleast_1d(pos[1]), pos_crs, **kwargs + np.tile(np.atleast_1d(pos[0]), n_buffer), + np.tile(np.atleast_1d(pos[1]), n_buffer), + pos_crs, + **kwargs, ) marker = self.m.ax.add_collection(coll, autolim=False) @@ -654,11 +663,11 @@ def mark( if permanent is False: # make the annotation temporary self._temporary_artists.append(marker) - self.m.BM.add_artist(marker, layer=layer) + self.m.l[layer].add_artist(marker) elif permanent is None: - self.m.BM.add_bg_artist(marker, layer=layer) + self.m.l[layer].add_bg_artist(marker) elif permanent is True: - self.m.BM.add_artist(marker, layer=layer) + self.m.l[layer].add_artist(marker) if not hasattr(self, "permanent_markers"): self.permanent_markers = [marker] @@ -748,10 +757,10 @@ def peek_layer( shape = "ellipses" if shape == "round" else "rectangles" if not isinstance(layer, str): - layer = self.m.BM._get_combined_layer_name(*layer) + layer = self.m._bm._get_combined_layer_name(*layer) # add spines and relevant inset-map layers to the specified peek-layer - layer = self.m.BM._get_showlayer_name(layer, transparent=True) + layer = self.m._bm._get_showlayer_name(layer, transparent=True) ID, pos, val, ind, picker_name, val_color = self._popargs(kwargs) @@ -870,26 +879,25 @@ def peek_layer( self.m.cb.click.add_temporary_artist(marker) # make sure to clear the marker at the next update to avoid savefig issues - def doit(): - self.m.BM._artists_to_clear.setdefault("peek", []).append(marker) - self.m.BM._clear_temp_artists("peek") + def doit(*args, **kwargs): + self.m._bm._artists_to_clear.setdefault("peek", []).append(marker) + self.m._bm._clear_temp_artists("peek") - self.m.BM._after_update_actions.append(doit) + self.m._bm.add_hook("after_update", doit, False) # create a TransformedPath as needed for clipping clip_path = TransformedPath( clip_path, self.m.ax.projection._as_mpl_transform(self.m.ax) ) - self.m.BM._after_restore_actions.append( - self.m.BM._get_restore_bg_action( - self.m.BM._get_combined_layer_name(self.m.BM.bg_layer, layer), - (x0, y0, blitw, blith), - alpha=alpha, - clip_path=clip_path, - set_clip_path=False if shape == "rectangles" else True, - ) + action = self.m._bm._get_restore_bg_action( + self.m._bm._get_combined_layer_name(self.m._bm.bg_layer, layer), + (x0, y0, blitw, blith), + alpha=alpha, + clip_path=clip_path, + set_clip_path=False if shape == "rectangles" else True, ) + self.m._bm.add_hook("after_restore", action, False) class _ClickCallbacks(_CallbacksBase): @@ -949,16 +957,14 @@ def clear_annotations(self, **kwargs): if hasattr(self, "permanent_annotations"): while len(self.permanent_annotations) > 0: ann = self.permanent_annotations.pop(0) - self.m.BM.remove_artist(ann) - ann.remove() + self.m._bm.remove_artist(ann) def clear_markers(self, **kwargs): """Remove all temporary and permanent annotations from the plot.""" if hasattr(self, "permanent_markers"): while len(self.permanent_markers) > 0: marker = self.permanent_markers.pop(0) - self.m.BM.remove_artist(marker) - marker.remove() + self.m._bm.remove_artist(marker) del self.permanent_markers def get_values(self, **kwargs): @@ -1315,19 +1321,19 @@ def overlay_layer(self, layer, key="x"): """ if isinstance(layer, list): - layer = self._m.BM._get_combined_layer_name(*layer) + layer = self._m._bm._get_combined_layer_name(*layer) elif isinstance(layer, tuple): # e.g. (layer-name, layer-transparency) - layer = self._m.BM._get_combined_layer_name(layer) + layer = self._m._bm._get_combined_layer_name(layer) # in case the layer is currently on top, remove it - if not self._m.BM.bg_layer.endswith(f"|{layer}"): - self._m.show_layer(self._m.BM.bg_layer, layer) + if not self._m._bm.bg_layer.endswith(f"|{layer}"): + self._m.show_layer(self._m._bm.bg_layer, layer) else: if sys.version_info >= (3, 9): - newlayer = self._m.BM.bg_layer.removesuffix(f"|{layer}") + newlayer = self._m._bm.bg_layer.removesuffix(f"|{layer}") else: - newlayer = _removesuffix(self._m.BM.bg_layer, f"|{layer}") + newlayer = _removesuffix(self._m._bm.bg_layer, f"|{layer}") if len(newlayer) > 0: self._m.show_layer(newlayer) diff --git a/eomaps/cb_container.py b/eomaps/cb_container.py index 8bfcf469c..701c0fa65 100755 --- a/eomaps/cb_container.py +++ b/eomaps/cb_container.py @@ -9,7 +9,7 @@ from types import SimpleNamespace from functools import partial, wraps from contextlib import contextmanager -from itertools import chain +from itertools import chain, permutations from weakref import proxy from .callbacks import ( @@ -18,7 +18,7 @@ KeypressCallbacks, MoveCallbacks, ) -from .helpers import register_modules +from .helpers import register_modules, _proxy import matplotlib.pyplot as plt from pyproj import Transformer @@ -152,7 +152,7 @@ def _objs(self): # make sure that "all" layer callbacks are executed before other callbacks ms, malls = [], [] - for m in reversed((*self._m.parent._children, self._m.parent)): + for m in self._m._bm._children: if m.layer == "all": malls.append(m) else: @@ -186,12 +186,12 @@ def _objs(self): and not obj._check_toolbar_mode() ): objs.append(obj) - return objs + return set(objs) def _clear_temporary_artists(self): while len(self._temporary_artists) > 0: art = self._temporary_artists.pop(-1) - self._m.BM._artists_to_clear.setdefault(self._method, []).append(art) + self._m._bm._artists_to_clear.setdefault(self._method, []).append(art) def _sort_cbs(self, cbs): _cb_list = self._attach._available_callbacks() @@ -221,7 +221,7 @@ def forward_events(self, *args): The Maps-objects that should execute the callback. """ for m in args: - self._fwd_cbs[id(m)] = m + self._fwd_cbs[id(m._real_self)] = m def share_events(self, *args): """ @@ -234,10 +234,16 @@ def share_events(self, *args): args : eomaps.Maps The Maps-objects that should execute the callback. """ - for m1 in (self._m, *args): - for m2 in (self._m, *args): - if m1 is not m2: - self._getobj(m1)._fwd_cbs[id(m2)] = m2 + + ms = [] + for i in (self._m, *args): + if i not in ms: + ms.append(i) + + for m1, m2 in permutations(ms, 2): + obj = self._getobj(m1) + if id(m2._real_self) not in obj._fwd_cbs: + obj._fwd_cbs[id(m2._real_self)] = m2 if self._method == "click": self._m.cb._click_move.share_events(*args) @@ -317,13 +323,12 @@ def add_temporary_artist(self, *artists, layer=None): for artist in artists: # in case the artist has already been added as normal or background # artist, remove it first! - if artist in chain(*self._m.BM._bg_artists.values()): - self._m.BM.remove_bg_artist(artist) + if artist in self._m.l[layer]._bg_artists: + # use private method since we only want to switch from + # being a bg-artist to being a dynamic artist + self._m.l[layer]._remove_bg_artist(artist) - if artist in chain(*self._m.BM._artists.values()): - self._m.BM.remove_artist(artist) - - self._m.BM.add_artist(artist, layer=layer) + self._m.l[layer].add_artist(artist) self._temporary_artists.append(artist) def _execute_cb(self, layer): @@ -349,7 +354,7 @@ def _execute_cb(self, layer): if self.execute_on_all_layers or layer == "all": return True - return self._m.BM._layer_visible(layer) + return self._m._bm._layer_visible(layer) @property def execute_on_all_layers(self): @@ -792,7 +797,7 @@ def _init_picker(self): if getattr(self._m, "tree", None) is None: from .helpers import SearchTree - self._m.tree = SearchTree(m=self._m._proxy(self._m)) + self._m.tree = SearchTree(m=_proxy(self._m)) self._m.cb.pick._set_artist(self._m.coll) self._m.cb.pick._init_cbs() self._m.cb._methods.add("pick") @@ -1034,7 +1039,7 @@ class _get(_ClickContainer._get): get = _get def _init_cbs(self): - if self._m.parent is self._m: + if self._m.parent == self._m: self._add_click_callback() def _get_clickdict(self, event): @@ -1126,7 +1131,7 @@ def _onrelease(self, event): def _reset_cids(self): # clear all temporary artists self._clear_temporary_artists() - self._m.BM._clear_temp_artists(self._method) + self._m._bm._clear_temp_artists(self._method) if self._cid_button_press_event: self._m.f.canvas.mpl_disconnect(self._cid_button_press_event) @@ -1162,9 +1167,9 @@ def clickcb(event): # forward callbacks to the connected maps-objects obj._fwd_cb(event) - self._m.BM._clear_temp_artists(self._method) + self._m._bm._clear_temp_artists(self._method) - self._m.parent.BM.update(clear=self._method) + self._m.parent._bm.update(clear=self._method) except ReferenceError: pass @@ -1292,13 +1297,13 @@ class _get(_ClickContainer._get): get = _get def _init_cbs(self): - if self._m.parent is self._m: + if self._m.parent == self._m: self._add_move_callback() def _reset_cids(self): # clear all temporary artists self._clear_temporary_artists() - self._m.BM._clear_temp_artists(self._method) + self._m._bm._clear_temp_artists(self._method) if self._cid_motion_event: self._m.f.canvas.mpl_disconnect(self._cid_motion_event) @@ -1319,7 +1324,7 @@ def movecb(event): if self._method == "move": for obj in self._objs: obj._clear_temporary_artists() - self._m.BM._clear_temp_artists(self._method) + self._m._bm._clear_temp_artists(self._method) return else: if event.button: # or (event.inaxes != self._m.ax): @@ -1327,7 +1332,7 @@ def movecb(event): if self._method == "move": for obj in self._objs: obj._clear_temporary_artists() - self._m.BM._clear_temp_artists(self._method) + self._m._bm._clear_temp_artists(self._method) return # execute onclick on the maps object that belongs to the clicked axis @@ -1342,7 +1347,7 @@ def movecb(event): # clear temporary artists before executing new callbacks to avoid # having old artists around when callbacks are triggered again obj._clear_temporary_artists() - self._m.BM._clear_temp_artists(self._method) + self._m._bm._clear_temp_artists(self._method) obj._onclick(event) # forward callbacks to the connected maps-objects @@ -1353,9 +1358,9 @@ def movecb(event): if call_update: if self._button_down: if event.button: - self._m.parent.BM.update(clear=self._method) + self._m.parent._bm.update(clear=self._method) else: - self._m.parent.BM.update(clear=self._method) + self._m.parent._bm.update(clear=self._method) except ReferenceError: pass @@ -1548,7 +1553,8 @@ def _set_artist(self, artist): self._artist.set_picker(self._picker) def _init_cbs(self): - # if self._m.parent is self._m: + # Pick callbacks must be added to each map individually (not just the + # parent) so they can pick the right dataset! self._add_pick_callback() def _default_picker(self, artist, event): @@ -1682,7 +1688,7 @@ def _onpick(self, event): # make sure temporary artists are cleared before executing new callbacks # to avoid having old artists around when callbacks are triggered again self._clear_temporary_artists() - self._m.BM._clear_temp_artists(self._method) + self._m._bm._clear_temp_artists(self._method) clickdict = self._get_pickdict(event) @@ -1727,7 +1733,7 @@ def _onpick(self, event): def _reset_cids(self): # clear all temporary artists self._clear_temporary_artists() - self._m.BM._clear_temp_artists(self._method) + self._m._bm._clear_temp_artists(self._method) for method, cid in self._cid_pick_event.items(): self._m.f.canvas.mpl_disconnect(cid) @@ -1898,13 +1904,13 @@ def __init__(self, m, cb_cls=None, method="keypress"): self.get = self._get(self) def _init_cbs(self): - if self._m.parent is self._m: + if self._m.parent == self._m: self._initialize_callbacks() def _reset_cids(self): # clear all temporary artists self._clear_temporary_artists() - self._m.BM._clear_temp_artists(self._method) + self._m._bm._clear_temp_artists(self._method) if self._cid_keypress_event: self._m.f.canvas.mpl_disconnect(self._cid_keypress_event) @@ -1965,11 +1971,11 @@ def _onpress(event): # DO NOT UPDATE in here! # otherwise keypress modifiers for peek-layer callbacks will # have glitches! - # self._m.parent.BM.update(clear=self._method) + # self._m.parent._bm.update(clear=self._method) except ReferenceError: pass - if self._m is self._m.parent: + if self._m.parent == self._m: self._cid_keypress_event = self._m.f.canvas.mpl_connect( "key_press_event", _onpress ) @@ -2188,7 +2194,7 @@ class CallbackContainer: keypress = KeypressContainer def __init__(self, m): - self._m = m + self._m = proxy(m) self._methods = { "click", diff --git a/eomaps/colorbar.py b/eomaps/colorbar.py index 6a1bcc47e..237c8d956 100755 --- a/eomaps/colorbar.py +++ b/eomaps/colorbar.py @@ -559,7 +559,7 @@ def _style_hist_ticks(self): def _redraw(self, *args, **kwargs): # only re-draw if the corresponding layer is visible - if not self._m.BM._layer_visible(self.layer): + if not self._m._bm._layer_visible(self.layer): return self.ax_cb.clear() @@ -677,7 +677,7 @@ def _hide_singular_axes(self): # colorbars that are not on the visible layer super()._hide_singular_axes() - if not self._m.BM._layer_visible(self.layer): + if not self._m._bm._layer_visible(self.layer): self.ax_cb.set_visible(False) self.ax_cb_plot.set_visible(False) @@ -705,7 +705,7 @@ def _identify_parent_cb(self): else: # check if self is actually just another layer of an existing Maps object # that already has a colorbar assigned - for m in [self._m.parent, *self._m.parent._children]: + for m in self._m._bm._children: if m is not self._m and m.ax is self._m.ax: if m.colorbar is not None: if m.colorbar._parent_cb is None: @@ -721,24 +721,24 @@ def remove(self): """Remove the colorbar from the map.""" if self._dynamic_shade_indicator: try: - self._m.BM._before_fetch_bg_actions.remove(self._check_data_updated) + self._m._bm.remove_hook( + "before_fetch_bg", self._check_data_updated, True + ) except Exception: _log.debug("Problem while removing dynamic-colorbar callback") - self._m.BM.remove_artist(self.ax_cb, self.layer) - self._m.BM.remove_artist(self.ax_cb_plot, self.layer) + self._m.l[self.layer].remove_artist(self.ax_cb) + self._m.l[self.layer].remove_artist(self.ax_cb_plot) else: - self._m.BM.remove_bg_artist(self.ax_cb, self.layer, draw=False) - self._m.BM.remove_bg_artist(self.ax_cb_plot, self.layer, draw=False) + self._m.l[self.layer].remove_bg_artist(self.ax_cb, draw=False) + self._m.l[self.layer].remove_bg_artist(self.ax_cb_plot, draw=False) if self.ax_cb in self._ax._eomaps_cb_axes: self._ax._eomaps_cb_axes.remove(self.ax_cb) if self.ax_cb_plot in self._ax._eomaps_cb_axes: self._ax._eomaps_cb_axes.remove(self.ax_cb_plot) - self.ax_cb.remove() - self.ax_cb_plot.remove() self._ax.remove() def _set_map(self, m): @@ -755,24 +755,22 @@ def _set_map(self, m): self._cmap = self._m.coll.cmap def _add_axes_to_layer(self, dynamic): - BM = self._m.BM - # add all axes as artists self.ax_cb.set_navigate(False) for a in (self.ax_cb, self.ax_cb_plot): if a is not None: if dynamic is True: - BM.add_artist(a, layer=self._layer) + self._m.l[self._layer].add_artist(a) else: - BM.add_bg_artist(a, layer=self._layer) + self._m.l[self._layer].add_bg_artist(a) # we need to re-draw all layers since the axis size has changed! self._m.redraw() def _set_hist_size(self, *args, **kwargs): super()._set_hist_size(*args, **kwargs) - self._m.BM._refetch_layer(self.layer) + self._m._bm._refetch_layer(self.layer) def set_hist_size(self, size=None): """ @@ -790,7 +788,7 @@ def set_hist_size(self, size=None): The default is None. """ self._set_hist_size(size, update_all=True) - self._m.BM.update() + self._m._bm.update() def _check_data_updated(self, *args, **kwargs): # make sure the artist is updated before checking for new data @@ -824,9 +822,9 @@ def _make_dynamic(self): self._cid_redraw = False if self._cid_redraw is False: - self._m.BM._before_fetch_bg_actions.append(self._check_data_updated) + self._m._bm.add_hook("before_fetch_bg", self._check_data_updated, True) - self._m.BM.on_layer( + self._m._bm.on_layer( lambda *args, **kwargs: self._redraw, layer=self.layer, persistent=True, @@ -1207,7 +1205,7 @@ def set_bin_labels(self, bins, names, tick_lines="center", show_values=False): left=False, top=False, labelleft=False, labeltop=False, which="both" ) - self._m.BM._refetch_layer(self.layer) + self._m._bm._refetch_layer(self.layer) def _set_tick_formatter(self): if "format" in self._cb_kwargs: @@ -1216,7 +1214,7 @@ def _set_tick_formatter(self): if self._m._classified: unique_bins = np.unique( - np.clip(self._m.classify_specs._bins, self._vmin, self._vmax) + np.clip(self._m._classify_specs._bins, self._vmin, self._vmax) ) if len(unique_bins) <= self.max_n_classify_bins_to_label: self.cb.set_ticks(unique_bins) @@ -1312,7 +1310,7 @@ def set_labels(self, cb_label=None, hist_label=None, **kwargs): # no need to redraw the background for dynamically updated artists self._m.redraw(self.layer) else: - self._m.BM.update() + self._m._bm.update() def tick_params(self, what="colorbar", **kwargs): """Set the appearance of the colorbar (or histogram) ticks.""" @@ -1598,7 +1596,7 @@ def _new_colorbar( cb._plot_colorbar(extend=extend, **kwargs) bins = ( - m.classify_specs._bins + m._classify_specs._bins if (m._classified and hist_bins == "bins") else hist_bins ) diff --git a/eomaps/compass.py b/eomaps/compass.py index 9632bf33d..aa96e725c 100644 --- a/eomaps/compass.py +++ b/eomaps/compass.py @@ -125,7 +125,7 @@ def __call__( self.layer = layer self._ignore_invalid_angles = ignore_invalid_angles - # self._m.BM.update() + # self._m._bm.update() ax2data = self._m.ax.transAxes + self._m.ax.transData.inverted() @@ -157,7 +157,7 @@ def __call__( self._artist = self._get_artist(pos) self._m.ax.add_artist(self._artist) - self._m.BM.add_artist(self._artist, layer=self.layer) + self._m.l[self.layer].add_artist(self._artist) self._set_position(pos) @@ -173,10 +173,9 @@ def __call__( self._canvas.mpl_connect("scroll_event", self._on_scroll), ] - if self._update_offset not in self._m.BM._before_fetch_bg_actions: - self._m.BM._before_fetch_bg_actions.append(self._update_offset) + self._m._bm.add_hook("before_fetch_bg", self._update_offset, True) - self._m.BM.update() + self._m._bm.update() def _get_artist(self, pos): if self._style == "north arrow": @@ -342,7 +341,7 @@ def _on_motion(self, evt): x, y = self._m.ax.transData.inverted().transform((evt.x, evt.y)) self._update_offset(x, y) - self._m.BM.update(artists=[self._artist]) + self._m._bm.update(artists=[self._artist]) def _on_scroll(self, event): if not self._layer_visible: @@ -351,7 +350,7 @@ def _on_scroll(self, event): if self._check_still_parented() and self._got_artist: self.set_scale(max(1, self._scale + event.step)) - self._m.BM.update(artists=[self._artist]) + self._m._bm.update(artists=[self._artist]) def _on_pick(self, evt): if not self._layer_visible: @@ -407,7 +406,7 @@ def _on_release(self, event): linewidth=self._last_patch_lw, ) - self._m.BM.update() + self._m._bm.update() def _check_still_parented(self): if self._artist.figure is None: @@ -418,15 +417,14 @@ def _check_still_parented(self): @property def _layer_visible(self): - return self._m.BM._layer_visible(self.layer) + return self._m._bm._layer_visible(self.layer) def _disconnect(self): """Disconnect the callbacks.""" for cid in self._cids: self._canvas.mpl_disconnect(cid) - if self._update_offset in self._m.BM._before_fetch_bg_actions: - self._m.BM._before_fetch_bg_actions.append(self._update_offset) + self._m._bm.remove_hook("before_fetch_bg", self._update_offset, True) try: c1 = self._c1 @@ -456,9 +454,8 @@ def remove(self): """ self._disconnect() - self._m.BM.remove_artist(self._artist) - self._artist.remove() - self._m.BM.update() + self._m._bm.remove_artist(self._artist) + self._m._bm.update() def set_patch(self, facecolor=None, edgecolor=None, linewidth=None): """ @@ -516,7 +513,7 @@ def set_pickable(self, b): self._artist.set_picker(b) def _set_position(self, pos, coords="data"): - # Avoid calling BM.update() in here! It results in infinite + # Avoid calling m._bm.update() in here! It results in infinite # recursions on zoom events because the position of the scalebar is # dynamically updated on each re-fetch of the background! @@ -547,7 +544,7 @@ def set_position(self, pos, coords="data"): The default is "data". """ self._set_position(pos, coords="data") - self._m.BM.update(artists=[self._artist]) + self._m._bm.update(artists=[self._artist]) def get_position(self, coords="data"): """ diff --git a/eomaps/draw.py b/eomaps/draw.py index 6465b92f7..4c2078ddd 100644 --- a/eomaps/draw.py +++ b/eomaps/draw.py @@ -116,11 +116,15 @@ def _active_drawer(self): else: return d + @_active_drawer.setter + def _active_drawer(self, val): + self._m.parent._active_drawer = val + @property def layer(self): # always draw on the active layer if no explicit layer is specified if self._layer is None: - return self._m.BM._bg_layer + return self._m._bm._bg_layer else: return self._layer @@ -128,12 +132,8 @@ def layer(self): def _background(self): # always use the currently active background as draw-background # (and make sure to cache it) - layer = self._m.BM._get_showlayer_name(self._m.BM._bg_layer) - return self._m.BM._get_background(layer, cache=True) - - @_active_drawer.setter - def _active_drawer(self, val): - self._m.parent._active_drawer = val + layer = self._m._bm._get_showlayer_name(self._m._bm._bg_layer) + return self._m._bm._get_background(layer, cache=True) def new_drawer(self, layer=None, dynamic=True): """ @@ -192,6 +192,8 @@ def _finish_drawing(self, cb=None): while len(active_drawer._cids) > 0: active_drawer._m.f.canvas.mpl_disconnect(active_drawer._cids.pop()) + self._m._bm.remove_hook("after_restore", self.redraw, True) + # Cleanup. if plt.fignum_exists(active_drawer._m.f.number): self._line = None @@ -209,7 +211,7 @@ def _finish_drawing(self, cb=None): active_drawer._clicks.clear() - self._m.BM.update() + self._m._bm.update() self._active_drawer = None self._m._emit_signal("drawFinished") @@ -241,11 +243,16 @@ def remove_last_shape(self): ID = list(self._artists)[-1] a = self._artists.pop(ID) - if self._dynamic: - self._m.BM.remove_artist(a) + + remove_method = "remove_artist" if self._dynamic else "remove_bg_artist" + + if self._layer: + # remove the artist from the known layer + getattr(self._m.l[self._layer], remove_method)(a) else: - self._m.BM.remove_bg_artist(a) - a.remove() + # search for the artist on all layers and remove it + # (required for drawers that draw on the "active" layer) + getattr(self._m._bm, remove_method, a) if self._can_save: self.gdf = self.gdf.drop(ID) @@ -253,12 +260,15 @@ def remove_last_shape(self): for cb in self._on_poly_remove: cb() - self._m.BM._on_draw_cb(None) + self._m._bm._on_draw_cb(None) def _init_draw_line(self): if self._line is None: props = dict( - transform=self._m.ax.transData, clip_box=self._m.ax.bbox, clip_on=True + transform=self._m.ax.transData, + clip_box=self._m.ax.bbox, + clip_on=True, + animated=True, ) # the line to use for indicating polygon-shape during draw @@ -287,19 +297,9 @@ def _indicator_artists(self): if i is not None ) - def redraw(self, blit=True, *args): + def redraw(self, *args, blit=False, **kwargs): """Trigger re-drawing shapes.""" - # NOTE: If a drawer is active, this function is also called on any ordinary - # draw-event (e.g. zoom/pan/resize) to keep the indicators visible. - # see "m.BM._on_draw_cb()" - - artists = self._indicator_artists - - if self._dynamic: - # draw all previously drawn shapes as well - artists = (*artists, *self._artists.values()) - - self._m.BM.blit_artists(artists, bg=self._background, blit=blit) + self._m._bm.blit_artists(self._indicator_artists, bg=None, blit=blit) # This is basically a copy of matplotlib's ginput function adapted for EOmaps # matplotlib's original ginput function is here: @@ -360,7 +360,7 @@ def _ginput( manager) selects a point. """ - canvas = self._m.BM.canvas + canvas = self._m._bm.canvas # self.fetch_bg() self._m.cb.execute_callbacks(False) @@ -451,7 +451,7 @@ def handler(event): if len(self._clicks) == n and n > 0: self._finish_drawing(cb=cb) - self.redraw() + self._m._bm.update() eventnames = [ "button_press_event", @@ -464,6 +464,8 @@ def handler(event): for event in eventnames: self._cids.append(canvas.mpl_connect(event, handler)) + self._m._bm.add_hook("after_restore", self.redraw, True) + # draw only a single point and draw a second point on escape # This is basically a copy of matplotlib's ginput function adapted for EOmaps # matplotlib's original ginput function is here: @@ -527,7 +529,7 @@ def _ginput2( manager) selects a point. """ - canvas = self._m.BM.canvas + canvas = self._m._bm.canvas # self.fetch_bg() self._m.cb.execute_callbacks(False) @@ -600,7 +602,7 @@ def handler(event): if show_clicks: self._line.set_data(*zip(*self._clicks)) - self.redraw() + self._m._bm.update() eventnames = [ "button_press_event", @@ -613,6 +615,8 @@ def handler(event): for event in eventnames: self._cids.append(canvas.mpl_connect(event, handler)) + self._m._bm.add_hook("after_restore", self.redraw, True) + def polygon(self, smooth=False, draw_on_drag=True, **kwargs): """ Draw arbitrary polygons @@ -653,10 +657,10 @@ def _polygon(self, **kwargs): (ph,) = self._m.ax.fill(pts[:, 0], pts[:, 1], **kwargs) if self._dynamic: - self._m.BM.add_artist(ph, layer=self.layer) + self._m.l[self.layer].add_artist(ph) else: - self._m.BM.add_bg_artist(ph, layer=self.layer) - self._m.BM._on_draw_cb(None) + self._m.l[self.layer].add_bg_artist(ph) + self._m._bm._on_draw_cb(None) ID = max(self._artists) + 1 if self._artists else 0 self._artists[ID] = ph @@ -704,7 +708,7 @@ def movecb(event, pts): np.array([pts[0][0]]), np.array([pts[0][1]]), "out", - [r, r], + r, "out", 100, ) @@ -716,10 +720,10 @@ def movecb(event, pts): # draw all previously drawn shapes as well artists = (*artists, *self._artists.values()) - self._m.BM.blit_artists(artists, bg=self._background) + self._m._bm.blit_artists(artists, bg=self._background) else: if self._pointer is not None: - self._m.BM.blit_artists( + self._m._bm.blit_artists( (*self._artists.values(), self._pointer), bg=self._background ) @@ -738,17 +742,17 @@ def _circle(self, **kwargs): r = np.sqrt(sum((pts[1] - pts[0]) ** 2)) pts = Shapes._Ellipses(self._m)._get_points( - np.array([pts[0][0]]), np.array([pts[0][1]]), "out", [r, r], "out", 100 + np.array([pts[0][0]]), np.array([pts[0][1]]), "out", r, "out", 100 ) with autoscale_turned_off(self._m.ax): (ph,) = self._m.ax.fill(pts[0][0], pts[1][0], **kwargs) if self._dynamic: - self._m.BM.add_artist(ph, layer=self.layer) + self._m.l[self.layer].add_artist(ph) else: - self._m.BM.add_bg_artist(ph, layer=self.layer) - self._m.BM._on_draw_cb(None) + self._m.l[self.layer].add_bg_artist(ph) + self._m._bm._on_draw_cb(None) ID = max(self._artists) + 1 if self._artists else 0 self._artists[ID] = ph @@ -802,10 +806,10 @@ def movecb(event, pts): # draw all previously drawn shapes as well artists = (*artists, *self._artists.values()) - self._m.BM.blit_artists(artists, bg=self._background) + self._m._bm.blit_artists(artists, bg=self._background) else: if self._pointer is not None: - self._m.BM.blit_artists( + self._m._bm.blit_artists( (*self._artists.values(), self._pointer), bg=self._background ) @@ -830,10 +834,10 @@ def _rectangle(self, **kwargs): (ph,) = self._m.ax.fill(pts[:, 0], pts[:, 1], **kwargs) if self._dynamic: - self._m.BM.add_artist(ph, layer=self.layer) + self._m.l[self.layer].add_artist(ph) else: - self._m.BM.add_bg_artist(ph, layer=self.layer) - self._m.BM._on_draw_cb(None) + self._m.l[self.layer].add_bg_artist(ph) + self._m._bm._on_draw_cb(None) ID = max(self._artists) + 1 if self._artists else 0 self._artists[ID] = ph diff --git a/eomaps/eomaps.py b/eomaps/eomaps.py index 01512fe6e..ff48d3b99 100755 --- a/eomaps/eomaps.py +++ b/eomaps/eomaps.py @@ -9,87 +9,42 @@ _log = logging.getLogger(__name__) -from contextlib import ExitStack, contextmanager -from functools import lru_cache, wraps -from itertools import repeat, chain -from pathlib import Path -from types import SimpleNamespace -from textwrap import fill -from difflib import get_close_matches - -import copy import importlib.metadata -import weakref - -import numpy as np -from pyproj import CRS +from functools import wraps +from contextlib import ExitStack +import copy -import matplotlib as mpl import matplotlib.pyplot as plt -import matplotlib.patches as mpatches import matplotlib.path as mpath - from cartopy import crs as ccrs +import numpy as np + +from .helpers import _add_to_docstring + +from ._maps_base import MapsBase, MapsLayerBase +from .mixins.add_mixin import AddMixin +from .mixins.gpd_mixin import GeopandasMixin +from .mixins.clipboard_mixin import ClipboardMixin +from .mixins.companion_mixin import CompanionMixin +from .mixins.tools_mixin import ToolsMixin +from .mixins.data_mixin import DataMixin +from .mixins.callback_mixin import CallbackMixin -from ._maps_base import MapsBase -from .helpers import ( - pairwise, - cmap_alpha, - progressbar, - SearchTree, - _TransformedBoundsLocator, - register_modules, - _key_release_event, - _add_to_docstring, -) - -from .shapes import Shapes -from .colorbar import ColorBar -from ._containers import DataSpecs, ClassifySpecs -from .ne_features import NaturalEarthFeatures -from .cb_container import CallbackContainer, GeoDataFramePicker -from .scalebar import ScaleBar -from .compass import Compass -from .reader import read_file, from_file, new_layer_from_file -from .grid import GridFactory -from .utilities import Utilities -from .draw import ShapeDrawer -from .annotation_editor import AnnotationEditor -from ._data_manager import DataManager - -try: - from ._webmap import refetch_wms_on_size_change, _cx_refetch_wms_on_size_change - from .webmap_containers import WebMapContainer -except ImportError as ex: - _log.error(f"EOmaps: Unable to import dependencies required for WebMaps: {ex}") - refetch_wms_on_size_change = None - _cx_refetch_wms_on_size_change = None - WebMapContainer = None __version__ = importlib.metadata.version("eomaps") -# hardcoded list of available mapclassify-classifiers -# (to avoid importing it on startup) -_CLASSIFIERS = ( - "BoxPlot", - "EqualInterval", - "FisherJenks", - "FisherJenksSampled", - "HeadTailBreaks", - "JenksCaspall", - "JenksCaspallForced", - "JenksCaspallSampled", - "MaxP", - "MaximumBreaks", - "NaturalBreaks", - "Quantiles", - "Percentiles", - "StdMean", - "UserDefined", -) - - -class Maps(MapsBase): + +class Maps( + MapsLayerBase, + MapsBase, + AddMixin, + GeopandasMixin, + ClipboardMixin, + CallbackMixin, + ToolsMixin, + DataMixin, + CompanionMixin, +): """ The base-class for generating plots with EOmaps. @@ -102,6 +57,10 @@ class Maps(MapsBase): See Also -------- + Maps.l : :py:class:`~eomaps._maps_base.LayerNamespace` to create/access layers on the map + + Maps.ll : :py:class:`~eomaps._maps_base.LazyLayerNamespace` to lazily populate layers on the map + Maps.new_layer : Create a new layer for the map. Maps.new_map : Add a new map to the figure. @@ -178,11 +137,10 @@ class Maps(MapsBase): >>> # add basic background features to the map >>> m.add_feature.preset("coastline", "ocean", "land") >>> # create a new layer and add more features - >>> m1 = m.new_layer("layer 1") - >>> m1.add_feature.physical.coastline(fc="none", ec="b", lw=2, scale=50) - >>> m1.add_feature.cultural.admin_0_countries(fc=(.2,.1,.4,.2), ec="b", lw=1, scale=50) + >>> m.l.my_layer.add_feature.physical.coastline(fc="none", ec="b", lw=2, scale=50) + >>> m.l.my_layer.add_feature.cultural.admin_0_countries(fc=(.2,.1,.4,.2), ec="b", lw=1, scale=50) >>> # overlay a part of the new layer in a circle if you click on the map - >>> m.cb.click.attach.peek_layer(m1.layer, how=0.4, shape="round") + >>> m.cb.click.attach.peek_layer("my_layer", how=0.4, shape="round") Use Maps-objects as context-manager to close the map and free memory once the map is exported. @@ -202,291 +160,47 @@ class Maps(MapsBase): """ __version__ = __version__ - - from_file = from_file - new_layer_from_file = new_layer_from_file - read_file = read_file - CRS = ccrs - # the keyboard shortcut to activate the companion-widget - _companion_widget_key = "w" - # max. number of layers to show all layers as tabs in the widget - # (otherwise only recently active layers are shown as tabs) - _companion_widget_n_layer_tabs = 50 - - CLASSIFIERS = SimpleNamespace(**dict(zip(_CLASSIFIERS, _CLASSIFIERS))) - "Accessor for available classification schemes." - - # arguments passed to m.savefig when using "ctrl+c" to export figure to clipboard - _clipboard_kwargs = dict() - - # to make namespace accessible for sphinx - set_shape = Shapes - draw = ShapeDrawer - add_feature = NaturalEarthFeatures - util = Utilities - cb = CallbackContainer - - classify_specs = ClassifySpecs - data_specs = DataSpecs - - if WebMapContainer is not None: - add_wms = WebMapContainer - def __init__( self, crs=None, - layer="base", + layer=None, f=None, ax=None, - preferred_wms_service="wms", + *args, **kwargs, ): + super().__init__( crs=crs, layer=layer, f=f, ax=ax, + *args, **kwargs, ) - self._log_on_event_messages = dict() - self._log_on_event_cids = dict() - - try: - from .qtcompanion.signal_container import _SignalContainer - - # initialize the signal container (MUST be done before init of the widget!) - self._signal_container = _SignalContainer() - except Exception: - _log.debug("SignalContainer could not be initialized", exc_info=True) - self._signal_container = None - - self._inherit_classification = None - - self._util = None - - self._colorbars = [] - self._coll = None # slot for the collection created by m.plot_map() - - self._companion_widget = None # slot for the pyqt widget - - self._cid_keypress = None # callback id for PyQt5 keypress callbacks - # attach a callback to show/hide the companion-widget with the "w" key - if self.parent._cid_keypress is None: - # NOTE the companion-widget is ONLY attached to the parent map - # since it will identify the clicked map automatically! The - # widget will only be initialized on Maps-objects that create - # NEW axes. This is required to make sure that any additional - # Maps-object on the same axes will then always use the - # same widget. (otherwise each layer would get its own widget) - - self.parent._cid_keypress = self.f.canvas.mpl_connect( - "key_press_event", self.parent._on_keypress - ) - - # a list to remember newly registered colormaps - self._registered_cmaps = [] - - # a list of actions that are executed whenever the widget is shown - self._on_show_companion_widget = [] - - # preferred way of accessing WMS services (used in the WMS container) - assert preferred_wms_service in [ - "wms", - "wmts", - ], "preferred_wms_service must be either 'wms' or 'wmts' !" - self._preferred_wms_service = preferred_wms_service - - # default classify specs - self.classify_specs = ClassifySpecs(weakref.proxy(self)) - - self.data_specs = DataSpecs( - weakref.proxy(self), - x=None, - y=None, - crs=4326, - ) - - # initialize the data-manager - self._data_manager = DataManager(self._proxy(self)) - self._data_plotted = False - self._set_extent_on_plot = True - - self.cb = self.cb(weakref.proxy(self)) # accessor for the callbacks - - # initialize the callbacks - self.cb._init_cbs() - - if WebMapContainer is not None: - self.add_wms = self.add_wms(weakref.proxy(self)) - - self.new_layer_from_file = new_layer_from_file(weakref.proxy(self)) - - self.set_shape = self.set_shape(weakref.proxy(self)) - self._shape = None - # the dpi used for shade shapes - self._shade_dpi = None - - # the radius is estimated when plot_map is called - self._estimated_radius = None - - # a set to hold references to the compass objects - self._compass = set() - - if not hasattr(self.parent, "_wms_legend"): - self.parent._wms_legend = dict() - - if not hasattr(self.parent, "_execute_callbacks"): - self.parent._execute_callbacks = True - - # evaluate and cache crs boundary bounds (for extent clipping) - self._crs_boundary_bounds = self.crs_plot.boundary.bounds - - # a factory to create gridlines - if self.parent == self: - self._grid = GridFactory(self.parent) - - if Maps._always_on_top: - self._set_always_on_top(True) - - self.add_feature = self.add_feature(weakref.proxy(self)) - self.draw = self.draw(weakref.proxy(self)) - if self.parent == self: - self.util = Utilities(self) - else: - self.util = self.parent.util - - @contextmanager - def delay_draw(self): - """ - A contextmanager to delay drawing until the context exits. - - This is particularly useful to avoid intermediate draw-events when plotting - a lot of features or datasets on the currently visible layer. - - - Examples - -------- - - >>> m = Maps() - >>> with m.delay_draw(): - >>> m.add_feature.preset.coastline() - >>> m.add_feature.preset.ocean() - >>> m.add_feature.preset.land() - - """ - try: - self.BM._disable_draw = True - self.BM._disable_update = True - - yield - finally: - self.BM._disable_draw = False - self.BM._disable_update = False - self.redraw() - - @property - def coll(self): - """The collection representing the dataset plotted by m.plot_map().""" - return self._coll + self._cid_keypress = self.f.canvas.mpl_connect( + "key_press_event", self._on_keypress + ) @property - def _shape_assigned(self): - """Return True if the shape is explicitly assigned and False otherwise""" - # the shape is considered assigned if an explicit shape is set - # or if the data has been plotted with the default shape - - q = self._shape is None or ( - getattr(self._shape, "_is_default", False) and not self._data_plotted + def _lazy_attrs(self): + from itertools import chain + + return sorted( + set( + chain( + *[ + getattr(self, f"_{i.__name__}__lazy_attrs", []) + for i in self.__class__.mro() + ] + ) + ) ) - return not q - - @property - def shape(self): - """ - The shape that is used to represent the dataset if `m.plot_map()` is called. - - By default "ellipses" is used for datasets < 500k datapoints and for plots - where no explicit data is assigned, and otherwise "shade_raster" is used - for 2D datasets and "shade_points" is used for unstructured datasets. - - """ - - if not self._shape_assigned: - self._set_default_shape() - self._shape._is_default = True - - return self._shape - - @property - def colorbar(self): - """ - Get the **most recently added** colorbar of this Maps-object. - - Returns - ------- - ColorBar - EOmaps colorbar object. - """ - if len(self._colorbars) > 0: - return self._colorbars[-1] - - @property - def data(self): - """The data assigned to this Maps-object.""" - return self.data_specs.data - - @data.setter - def data(self, val): - # for downward-compatibility - self.data_specs.data = val - - @lru_cache() - def get_crs(self, crs="plot"): - """ - Get the pyproj CRS instance of a given crs specification. - - Parameters - ---------- - crs : "in", "out" or a crs definition - the crs to return - - - if "in" : the crs defined in m.data_specs.crs - - if "out" or "plot" : the crs used for plotting - - Returns - ------- - crs : pyproj.CRS - the pyproj CRS instance - - """ - # check for strings first to avoid expensive equality checking for CRS objects! - if isinstance(crs, str): - if crs == "in": - crs = self.data_specs.crs - elif crs == "out" or crs == "plot": - if self.crs_plot == self.CRS.PlateCarree(): - crs = 4326 - else: - crs = self.crs_plot - - crs = CRS.from_user_input(crs) - return crs - - @property - def _edit_annotations(self): - if getattr(self.parent, "_edit_annotations_parent", None) is None: - self.parent._edit_annotations_parent = AnnotationEditor(self.parent) - return self.parent._edit_annotations_parent - - @wraps(AnnotationEditor.__call__) - def edit_annotations(self, b=True, **kwargs): - self._edit_annotations(b, **kwargs) - def new_map( self, ax=None, @@ -567,7 +281,7 @@ def new_map( The Maps object representing the new map. """ - m2 = Maps(f=self.f, ax=ax, **kwargs) + m2 = Maps(f=self.f, ax=ax, parent=self.parent, **kwargs) if inherit_data: m2.inherit_data(self) @@ -586,10 +300,10 @@ def new_map( m2.ax.set_label("inset_map") spine = m2.ax.spines["geo"] - if spine in self.BM._bg_artists.get("___SPINES__", []): - self.BM.remove_bg_artist(spine, layer="___SPINES__") - if spine not in self.BM._bg_artists.get("__inset___SPINES__", []): - self.BM.add_bg_artist(spine, layer="__inset___SPINES__") + if spine in self._bm._bg_artists["**SPINES**"]: + self._bm._bg_artists._free_artists["**SPINES**"].remove(spine) + if spine not in self._bm._bg_artists["**inset_**SPINES**"]: + self._bm._bg_artists.add("**inset_**SPINES**", spine) return m2 @@ -598,7 +312,7 @@ def new_layer( layer=None, inherit_data=False, inherit_classification=False, - inherit_shape=True, + inherit_shape=False, **kwargs, ): """ @@ -606,7 +320,7 @@ def new_layer( Parameters ---------- - layer : int, str or None + layer : str or None The name of the layer at which map-features are plotted. - If "all": the corresponding feature will be added to ALL layers @@ -659,23 +373,6 @@ def new_layer( Maps.copy : general way for copying Maps objects """ - depreciated_names = [ - ("copy_data_specs", "inherit_data"), - ("copy_classify_specs", "inherit_classification"), - ("copy_shape", "inherit_shape"), - ] - - for old, new in depreciated_names: - if old in kwargs: - from warnings import warn - - warn( - f"EOmaps: Using '{old}' is depreciated! Use '{new}' instead! " - "NOTE: Datasets are now inherited (e.g. shared) and not copied. " - "To explicitly copy attributes, see m.copy(...)!", - category=FutureWarning, - stacklevel=2, - ) inherit_data = kwargs.get("copy_data_specs", inherit_data) inherit_classification = kwargs.get( @@ -684,7 +381,7 @@ def new_layer( inherit_shape = kwargs.get("copy_shape", inherit_shape) if layer is None: - layer = copy.deepcopy(self.layer) + layer = self.layer else: layer = str(layer) if len(layer) == 0: @@ -692,12 +389,15 @@ def new_layer( "EOmaps: Unable to create a layer with an empty layer-name!" ) + _log.debug(f"EOmaps: New layer '{layer}' created.") + m = self.copy( data_specs=False, classify_specs=False, shape=False, ax=self.ax, layer=layer, + parent=self.parent, ) if inherit_data: @@ -712,7 +412,7 @@ def new_layer( m._set_extent_on_plot = self._set_extent_on_plot # re-initialize all sliders and buttons to include the new layer - self.util._reinit_widgets() + self.parent.util._reinit_widgets() # share the companion-widget with the parent m._companion_widget = self._companion_widget @@ -898,7 +598,8 @@ def new_inset_map( from .inset_maps import InsetMaps m2 = InsetMaps( - parent=self, + parent_m=self, + parent=self.parent, crs=inset_crs, layer=layer, xy=xy, @@ -936,450 +637,9 @@ def new_inset_map( ) def new_subplot(self, *args, layer=None, **kwargs): ax = self.f.add_subplot(*args, **kwargs) - self.BM.add_artist(ax, layer=layer) + self.l[layer].add_artist(ax) return ax - def set_data( - self, - data=None, - x=None, - y=None, - crs=None, - encoding=None, - cpos="c", - cpos_radius=None, - parameter=None, - ): - """ - Set the properties of the dataset you want to plot. - - Use this function to update multiple data-specs in one go - Alternatively you can set the data-specifications via - - >>> m.data_specs.< property > = ...` - - Parameters - ---------- - data : array-like - The data of the Maps-object. - Accepted inputs are: - - - a pandas.DataFrame with the coordinates and the data-values - - a pandas.Series with only the data-values - - a 1D or 2D numpy-array with the data-values - - a 1D list of data values - - x, y : array-like or str, optional - Specify the coordinates associated with the provided data. - Accepted inputs are: - - - a string (corresponding to the column-names of the `pandas.DataFrame`) - - - ONLY if "data" is provided as a pandas.DataFrame! - - - a pandas.Series - - a 1D or 2D numpy-array - - a 1D list - - The default is "lon" and "lat". - crs : int, dict or str - The coordinate-system of the provided coordinates. - Can be one of: - - - PROJ string - - Dictionary of PROJ parameters - - PROJ keyword arguments for parameters - - JSON string with PROJ parameters - - CRS WKT string - - An authority string [i.e. 'epsg:4326'] - - An EPSG integer code [i.e. 4326] - - A tuple of ("auth_name": "auth_code") [i.e ('epsg', '4326')] - - An object with a `to_wkt` method. - - A :class:`pyproj.crs.CRS` class - - (see `pyproj.CRS.from_user_input` for more details) - - The default is 4326 (e.g. geographic lon/lat crs) - parameter : str, optional - MANDATORY IF a pandas.DataFrame that specifies both the coordinates - and the data-values is provided as `data`! - - The name of the column that should be used as parameter. - - If None, the first column (despite of the columns assigned as "x" and "y") - will be used. The default is None. - encoding : dict or False, optional - A dict containing the encoding information in case the data is provided as - encoded values (useful to avoid decoding large integer-encoded datasets). - - If provided, the data will be decoded "on-demand" with respect to the - provided "scale_factor" and "add_offset" according to the formula: - - >>> actual_value = encoding["add_offset"] + encoding["scale_factor"] * value - - Note: Colorbars and pick-callbakcs will use the encoding-information to - display the actual data-values! - - If False, no value-transformation is performed. - The default is False - cpos : str, optional - Indicator if the provided x-y coordinates correspond to the center ("c"), - upper-left corner ("ul"), lower-left corner ("ll") etc. of the pixel. - If any value other than "c" is provided, a "cpos_radius" must be set! - The default is "c". - cpos_radius : int or tuple, optional - The pixel-radius (in the input-crs) that will be used to set the - center-position of the provided data. - If a number is provided, the pixels are treated as squares. - If a tuple (rx, ry) is provided, the pixels are treated as rectangles. - The default is None. - - Examples - -------- - - using a single `pandas.DataFrame` - - >>> data = pd.DataFrame(dict(lon=[...], lat=[...], a=[...], b=[...])) - >>> m.set_data(data, x="lon", y="lat", parameter="a", crs=4326) - - - using individual `pandas.Series` - - >>> lon, lat, vals = pd.Series([...]), pd.Series([...]), pd.Series([...]) - >>> m.set_data(vals, x=lon, y=lat, crs=4326) - - - using 1D lists - - >>> lon, lat, vals = [...], [...], [...] - >>> m.set_data(vals, x=lon, y=lat, crs=4326) - - - using 1D or 2D numpy.arrays - - >>> lon, lat, vals = np.array([[...]]), np.array([[...]]), np.array([[...]]) - >>> m.set_data(vals, x=lon, y=lat, crs=4326) - - - integer-encoded datasets - - >>> lon, lat, vals = [...], [...], [1, 2, 3, ...] - >>> encoding = dict(scale_factor=0.01, add_offset=1) - >>> # colorbars and pick-callbacks will now show values as (1 + 0.01 * value) - >>> # e.g. the "actual" data values are [0.01, 0.02, 0.03, ...] - >>> m.set_data(vals, x=lon, y=lat, crs=4326, encoding=encoding) - - """ - if data is not None: - self.data_specs.data = data - - if x is not None: - self.data_specs.x = x - - if y is not None: - self.data_specs.y = y - - if crs is not None: - self.data_specs.crs = crs - - if encoding is not None: - self.data_specs.encoding = encoding - - if cpos is not None: - self.data_specs.cpos = cpos - - if cpos_radius is not None: - self.data_specs.cpos_radius = cpos_radius - - if parameter is not None: - self.data_specs.parameter = parameter - - @property - def set_classify(self): - """ - Interface to the classifiers provided by the 'mapclassify' module. - - To set a classification scheme for a given Maps-object, simply use: - - >>> m.set_classify.< SCHEME >(...) - - Where `< SCHEME >` is the name of the desired classification and additional - parameters are passed in the call. (check docstrings for more info!) - - A list of available classification-schemes is accessible via - `m.classify_specs.SCHEMES` - - - BoxPlot (hinge) - - EqualInterval (k) - - FisherJenks (k) - - FisherJenksSampled (k, pct, truncate) - - HeadTailBreaks () - - JenksCaspall (k) - - JenksCaspallForced (k) - - JenksCaspallSampled (k, pct) - - MaxP (k, initial) - - MaximumBreaks (k, mindiff) - - NaturalBreaks (k, initial) - - Quantiles (k) - - Percentiles (pct) - - StdMean (multiples) - - UserDefined (bins) - - Examples - -------- - >>> m.set_classify.Quantiles(k=5) - - >>> m.set_classify.EqualInterval(k=5) - - >>> m.set_classify.UserDefined(bins=[5, 10, 25, 50]) - - """ - (mapclassify,) = register_modules("mapclassify") - - s = SimpleNamespace( - **{ - i: self._get_mcl_subclass(getattr(mapclassify, i)) - for i in mapclassify.CLASSIFIERS - } - ) - - s.__doc__ = Maps.set_classify.__doc__ - - return s - - def set_classify_specs(self, scheme=None, **kwargs): - """ - Set classification specifications for the data. - - The classification is ultimately performed by the `mapclassify` module! - - Note - ---- - The following calls have the same effect: - - >>> m.set_classify.Quantiles(k=5) - >>> m.set_classify_specs(scheme="Quantiles", k=5) - - Using `m.set_classify()` is the same as using `m.set_classify_specs()`! - However, `m.set_classify()` will provide autocompletion and proper - docstrings once the Maps-object is initialized which greatly enhances - the usability. - - Parameters - ---------- - scheme : str - The classification scheme to use. - (the list is accessible via `m.classify_specs.SCHEMES`) - - E.g. one of (possible kwargs in brackets): - - - BoxPlot (hinge) - - EqualInterval (k) - - FisherJenks (k) - - FisherJenksSampled (k, pct, truncate) - - HeadTailBreaks () - - JenksCaspall (k) - - JenksCaspallForced (k) - - JenksCaspallSampled (k, pct) - - MaxP (k, initial) - - MaximumBreaks (k, mindiff) - - NaturalBreaks (k, initial) - - Quantiles (k) - - Percentiles (pct) - - StdMean (multiples) - - UserDefined (bins) - - kwargs : - kwargs passed to the call to the respective mapclassify classifier - (dependent on the selected scheme... see above) - - """ - register_modules("mapclassify") - self.classify_specs._set_scheme_and_args(scheme, **kwargs) - - def inherit_data(self, m): - """ - Use the data of another Maps-object (without copying). - - NOTE - ---- - If the data is inherited, any change in the data of the parent - Maps-object will be reflected in this Maps-object as well! - - Parameters - ---------- - m : eomaps.Maps or None - The Maps-object that provides the data. - """ - if m is not None: - self.data_specs = m.data_specs - - def set_data(*args, **kwargs): - raise AssertionError( - "EOmaps: You cannot set data for a Maps object that " - "inherits data!" - ) - - self.set_data = set_data - - def inherit_classification(self, m): - """ - Use the classification of another Maps-object when plotting the data. - - NOTE - ---- - If the classification is inherited, the following arguments - for `m.plot_map()` will have NO effect (they are inherited): - - - "cmap" - - "vmin" - - "vmax" - - Parameters - ---------- - m : eomaps.Maps or None - The Maps-object that provides the classification specs. - """ - if m is not None: - self._inherit_classification = self._proxy(m) - else: - self._inherit_classification = None - - def set_extent_to_location( - self, location, buffer=0, annotate=False, user_agent=None - ): - """ - Set the map-extent based on a given location query. - - The bounding-box is hereby resolved via the OpenStreetMap Nominatim service. - - Note - ---- - The OSM Nominatim service has a strict usage policy that explicitly - disallows "heavy usage" (e.g.: an absolute maximum of 1 request per second). - - EOMaps caches requests so using a location multiple times in the same - session does not cause multiple requests! - - For more details, see: - https://operations.osmfoundation.org/policies/nominatim/ - https://openstreetmap.org/copyright - - Parameters - ---------- - location : str - An arbitrary string used to identify the region of interest. - (e.g. a country, district, address etc.) - - For example: - "Austria", "Vienna" - buffer : float - Fraction of the found extent added as a buffer. - The default is 0. - annotate : bool, optional - Indicator if an annotation should be added to the center of the identified - location or not. The default is False. - user_agent: str, optional - The user-agent used for the Nominatim request - - Examples - -------- - >>> m = Maps() - >>> m.set_extent_to_location("Austria") - >>> m.add_feature.preset.countries() - - >>> m = Maps(Maps.CRS.GOOGLE_MERCATOR) - >>> m.set_extent_to_location("Vienna") - >>> m.add_wms.OpenStreetMap.add_layer.default() - - """ - r = self._get_nominatim_response(location) - - # get bbox of found location - lon0, lon1, lat0, lat1 = map(float, r["boundingbox"]) - - dlon, dlat = lon1 - lon0, lat1 - lat0 - lon0 -= dlon * buffer - lon1 += dlon * buffer - lat0 -= dlat * buffer - lat1 += dlat * buffer - - # set extent to found bbox - self.set_extent((lat0, lat1, lon0, lon1), crs=Maps.CRS.PlateCarree()) - - # add annotation - if annotate is not False: - if isinstance(annotate, str): - text = annotate - else: - text = fill(r["display_name"], 20) - - self.add_annotation( - xy=(r["lon"], r["lat"]), xy_crs=4326, text=text, fontsize=8 - ) - else: - _log.info(f"Centering Map to:\n {r['display_name']}") - - def _set_gdf_path_boundary(self, gdf, set_extent=True): - geom = gdf.to_crs(self.crs_plot).union_all() - if "Polygon" in geom.geom_type: - geom = geom.boundary - - if geom.geom_type == "MultiLineString": - boundary_linestrings = geom.geoms - elif geom.geom_type == "LineString": - boundary_linestrings = [geom] - else: - raise TypeError( - f"Geometries of type {geom.type} cannot be used as map-boundary." - ) - - vertices, codes = [], [] - for g in boundary_linestrings: - x, y = g.xy - codes.extend( - [mpath.Path.MOVETO, *[mpath.Path.LINETO] * len(x), mpath.Path.CLOSEPOLY] - ) - vertices.extend([(x[0], y[0]), *zip(x, y), (x[-1], y[-1])]) - - path = mpath.Path(vertices, codes) - - self.ax.set_boundary(path, self.ax.transData) - if set_extent: - x0, y0 = np.min(vertices, axis=0) - x1, y1 = np.max(vertices, axis=0) - - self.set_extent([x0, x1, y0, y1], gdf.crs) - - def _get_country_frame(self, countries, scale=50): - """ - Get the map-frame to one (or more) country boarders defined by - the NaturalEarth admin_0_countries dataset. - - For more details, see: - - https://www.naturalearthdata.com/downloads/10m-cultural-vectors/10m-admin-0-countries/ - - Parameters - ---------- - countries : str or list of str - The countries who should be included in the map-frame. - scale : int, optional - The scale factor of the used NaturalEarth dataset. - One of 10, 50, 110. - The default is 50. - """ - countries = [i.lower() for i in np.atleast_1d(countries)] - gdf = self.add_feature.cultural.admin_0_countries.get_gdf(scale=scale) - names = gdf.NAME.str.lower().values - - q = np.isin(names, countries) - - if np.count_nonzero(q) == len(countries): - return gdf[q] - else: - for c in countries: - if c not in names: - print( - f"Unable to identify the country '{c}'. " - f"Fid you mean {get_close_matches(c, gdf.NAME)}" - ) - def set_frame(self, rounded=0, gdf=None, countries=None, **kwargs): """ Set the properties of the map boundary and the background patch. @@ -1496,7 +756,7 @@ def set_frame(self, rounded=0, gdf=None, countries=None, **kwargs): # properties are fetched from the axes! if not getattr(self.ax, "_EOmaps_rounded_spine_attached", False): - def cb(*args, **kwargs): + def update_round_map_frame_corners(*args, **kwargs): if self.ax._EOmaps_rounded_spine_frac == 0: return @@ -1536,1936 +796,27 @@ def cb(*args, **kwargs): path = mpath.Path(np.column_stack((xs, ys))) self.ax.set_boundary(path, transform=self.crs_plot) - self.BM._before_fetch_bg_actions.append(cb) + self._bm.add_hook( + "before_fetch_bg", update_round_map_frame_corners, True + ) + self.ax._EOmaps_rounded_spine_attached = True self.ax.spines["geo"].update(kwargs) self.redraw() - @staticmethod - def set_clipboard_kwargs(**kwargs): + def copy( + self, + data_specs=False, + classify_specs=True, + shape=True, + **kwargs, + ): """ - Set GLOBAL savefig parameters for all Maps objects on export to the clipboard. + Create a (deep)copy of the Maps object that shares selected specifications. - - press "control + c" to export the figure to the clipboard - - All arguments are passed to :meth:`Maps.savefig` - - Useful options are - - - dpi : the dots-per-inch of the figure - - refetch_wms: re-fetch webmaps with respect to the export-`dpi` - - bbox_inches: use "tight" to export figure with a tight boundary - - pad_inches: the size of the boundary if `bbox_inches="tight"` - - transparent: if `True`, export with a transparent background - - facecolor: the background color - - - Parameters - ---------- - kwargs : - Keyword-arguments passed to :meth:`Maps.savefig`. - - Note - ---- - This function sets the clipboard kwargs for all Maps-objects! - - Exporting to the clipboard only works if `PyQt5` is used as matplotlib backend! - (the default if `PyQt` is installed) - - See Also - -------- - Maps.savefig : Save the figure as jpeg, png, etc. - - """ - # use Maps to make sure InsetMaps do the same thing! - Maps._set_clipboard_kwargs(**kwargs) - # trigger companion-widget setter for all open figures that contain maps - for i in plt.get_fignums(): - try: - m = getattr(plt.figure(i), "_EOmaps_parent", None) - if m is not None: - if m._companion_widget is not None: - m._emit_signal("clipboardKwargsChanged") - except Exception: - _log.exception("UPS") - - @staticmethod - def _set_clipboard_kwargs(**kwargs): - # use Maps to make sure InsetMaps do the same thing! - Maps._clipboard_kwargs = kwargs - - def add_title(self, title, x=0.5, y=1.01, **kwargs): - """ - Convenience function to add a title to the map. - - (The title will be visible at the assigned layer.) - - Parameters - ---------- - title : str - The title. - x, y : float, optional - The position of the text in axis-coordinates (0-1). - The default is 0.5, 1.01. - kwargs : - Additional kwargs are passed to `m.text()` - The defaults are: - - - `"fontsize": "large"` - - `horizontalalignment="center"` - - `verticalalignment="bottom"` - - See Also - -------- - - :py:meth:`Maps.text` : General function to add text to the figure. - - """ - kwargs.setdefault("fontsize", "large") - kwargs.setdefault("horizontalalignment", "center") - kwargs.setdefault("verticalalignment", "bottom") - kwargs.setdefault("transform", self.ax.transAxes) - - self.text(x, y, title, layer=self.layer, **kwargs) - - def add_gdf( - self, - gdf, - picker_name=None, - pick_method="contains", - val_key=None, - layer=None, - temporary_picker=None, - clip=False, - reproject="gpd", - verbose=False, - only_valid=False, - set_extent=False, - permanent=True, - **kwargs, - ): - """ - Plot a `geopandas.GeoDataFrame` on the map. - - Parameters - ---------- - gdf : geopandas.GeoDataFrame, str or pathlib.Path - A GeoDataFrame that should be added to the plot. - - If a string (or pathlib.Path) is provided, it is identified as the path to - a file that should be read with `geopandas.read_file(gdf)`. - - picker_name : str or None - A unique name that is used to identify the pick-method. - - If a `picker_name` is provided, a new pick-container will be - created that can be used to pick geometries of the GeoDataFrame. - - The container can then be accessed via: - >>> m.cb.pick__ - or - >>> m.cb.pick[picker_name] - and it can be used in the same way as `m.cb.pick...` - - pick_method : str or callable - if str : - The operation that is executed on the GeoDataFrame to identify - the picked geometry. - Possible values are: - - - "contains": - pick a geometry only if it contains the clicked point - (only works with polygons! (not with lines and points)) - - "centroids": - pick the closest geometry with respect to the centroids - (should work with any geometry whose centroid is defined) - - The default is "centroids" - - if callable : - A callable that is used to identify the picked geometry. - The call-signature is: - - >>> def picker(artist, mouseevent): - >>> # if the pick is NOT successful: - >>> return False, dict() - >>> ... - >>> # if the pick is successful: - >>> return True, dict(ID, pos, val, ind) - - The default is "contains" - - val_key : str - The dataframe-column used to identify values for pick-callbacks. - The default is the value provided via `column=...` or None. - layer : int, str or None - The name of the layer at which the dataset will be plotted. - - - If "all": the corresponding feature will be added to ALL layers - - If None, the layer assigned to the Maps-object is used (e.g. `m.layer`) - - The default is None. - temporary_picker : str, optional - The name of the picker that should be used to make the geometry - temporary (e.g. remove it after each pick-event) - clip : str or False - This feature can help with re-projection issues for non-global crs. - (see example below) - - Indicator if geometries should be clipped prior to plotting or not. - - - if "crs": clip with respect to the boundary-shape of the crs - - if "crs_bounds" : clip with respect to a rectangular crs boundary - - if "extent": clip with respect to the current extent of the plot-axis. - - if the 'gdal' python-bindings are installed, you can use gdal to clip - the shapes with respect to the crs-boundary. (slower but more robust) - The following logical operations are supported: - - - "gdal_SymDifference" : symmetric difference - - "gdal_Intersection" : intersection - - "gdal_Difference" : difference - - "gdal_Union" : union - - If a suffix "_invert" is added to the clip-string (e.g. "crs_invert" - or "gdal_Intersection_invert") the obtained (clipped) polygons will be - inverted. - - - >>> mg = MapsGrid(2, 3, crs=3035) - >>> mg.m_0_0.add_feature.preset.ocean(use_gpd=True) - >>> mg.m_0_1.add_feature.preset.ocean(use_gpd=True, clip="crs") - >>> mg.m_0_2.add_feature.preset.ocean(use_gpd=True, clip="extent") - >>> mg.m_1_0.add_feature.preset.ocean(use_gpd=False) - >>> mg.m_1_1.add_feature.preset.ocean(use_gpd=False, clip="crs") - >>> mg.m_1_2.add_feature.preset.ocean(use_gpd=False, clip="extent") - - reproject : str, optional - Similar to "clip" this feature mainly addresses issues in the way how - re-projected geometries are displayed in certain coordinate-systems. - (see example below) - - - if "gpd": re-project geometries geopandas - - if "cartopy": re-project geometries with cartopy (slower but more robust) - - The default is "gpd". - - >>> mg = MapsGrid(2, 1, crs=Maps.CRS.Stereographic()) - >>> mg.m_0_0.add_feature.preset.ocean(reproject="gpd") - >>> mg.m_1_0.add_feature.preset.ocean(reproject="cartopy") - - verbose : bool, optional - Indicator if a progressbar should be printed when re-projecting - geometries with "use_gpd=False". The default is False. - only_valid : bool, optional - - If True, only valid geometries (e.g. `gdf.is_valid`) are plotted. - - If False, all geometries are attempted to be plotted - (this might result in errors for infinite geometries etc.) - - The default is True - set_extent: bool, optional - - if True, set map extent to the extent of the geometries with +-5% margin. - - if float, use the value as margin (0-1). - - The default is True. - permanent : bool, optional - If True, all created artists are added as "permanent" background - artists. If False, artists are added as dynamic artists. - The default is True. - kwargs : - all remaining kwargs are passed to `geopandas.GeoDataFrame.plot(**kwargs)` - - Returns - ------- - new_artists : matplotlib.Artist - The matplotlib-artists added to the plot - - """ - (gpd,) = register_modules("geopandas") - - if val_key is None: - val_key = kwargs.get("column", None) - - gdf = self._handle_gdf( - gdf, - val_key=val_key, - only_valid=only_valid, - clip=clip, - reproject=reproject, - verbose=verbose, - ) - - # plot gdf and identify newly added collections - # (geopandas always uses collections) - colls = [id(i) for i in self.ax.collections] - artists, prefixes = [], [] - - # drop all invalid geometries - if only_valid: - valid = gdf.is_valid - n_invald = np.count_nonzero(~valid) - gdf = gdf[valid] - if len(gdf) == 0: - _log.error("EOmaps: GeoDataFrame contains only invalid geometries!") - return - elif n_invald > 0: - _log.warning( - "EOmaps: {n_invald} invalid GeoDataFrame geometries are ignored!" - ) - - if set_extent: - extent = np.array( - [ - gdf.bounds["minx"].min(), - gdf.bounds["maxx"].max(), - gdf.bounds["miny"].min(), - gdf.bounds["maxy"].max(), - ] - ) - - if isinstance(set_extent, (int, float, np.number)): - margin = set_extent - else: - margin = 0.05 - - dx = extent[1] - extent[0] - dy = extent[3] - extent[2] - - d = max(dx, dy) * margin - extent[[0, 2]] -= d - extent[[1, 3]] += d - - self.set_extent(extent, crs=gdf.crs) - - for geomtype, geoms in gdf.groupby(gdf.geom_type): - gdf.plot(ax=self.ax, aspect=self.ax.get_aspect(), **kwargs) - artists = [i for i in self.ax.collections if id(i) not in colls] - for i in artists: - prefixes.append(f"_{i.__class__.__name__.replace('Collection', '')}") - - if picker_name is not None: - if isinstance(pick_method, str): - picker_cls = GeoDataFramePicker( - gdf=gdf, pick_method=pick_method, val_key=val_key - ) - picker = picker_cls.get_picker() - elif callable(pick_method): - picker = pick_method - picker_cls = None - else: - _log.error( - "EOmaps: The provided pick_method is invalid." - "Please provide either a string or a function." - ) - return - - if len(artists) > 1: - log_names = [picker_name + prefix for prefix in np.unique(prefixes)] - _log.warning( - "EOmaps: Multiple geometry types encountered in `m.add_gdf`. " - + "The pick containers are re-named to" - + f"{log_names}" - ) - else: - prefixes = [""] - - for artist, prefix in zip(artists, prefixes): - # make the newly added collection pickable - self.cb.add_picker(picker_name + prefix, artist, picker=picker) - # attach the re-projected GeoDataFrame to the pick-container - self.cb.pick[picker_name + prefix].data = gdf - self.cb.pick[picker_name + prefix].val_key = val_key - self.cb.pick[picker_name + prefix]._picker_cls = picker_cls - - if layer is None: - layer = self.layer - - if temporary_picker is not None: - if temporary_picker == "default": - for art, prefix in zip(artists, prefixes): - self.cb.pick.add_temporary_artist(art) - else: - for art, prefix in zip(artists, prefixes): - self.cb.pick[temporary_picker].add_temporary_artist(art) - else: - for art, prefix in zip(artists, prefixes): - art.set_label(f"EOmaps GeoDataframe ({prefix.lstrip('_')}, {len(gdf)})") - if permanent is True: - self.BM.add_bg_artist(art, layer=layer) - else: - self.BM.add_artist(art, layer=layer) - return artists - - def _handle_gdf( - self, - gdf, - val_key=None, - only_valid=True, - clip=False, - reproject="gpd", - verbose=False, - ): - (gpd,) = register_modules("geopandas") - - if isinstance(gdf, (str, Path)): - gdf = gpd.read_file(gdf) - - if only_valid: - gdf = gdf[gdf.is_valid] - - try: - # explode the GeoDataFrame to avoid picking multi-part geometries - gdf = gdf.explode(index_parts=False) - except Exception: - # geopandas sometimes has problems exploding geometries... - # if it does not work, just continue with the Multi-geometries! - _log.error("EOmaps: Exploding geometries did not work!") - pass - - if clip: - gdf = self._clip_gdf(gdf, clip) - if reproject == "gpd": - gdf = gdf.to_crs(self.crs_plot) - elif reproject == "cartopy": - # optionally use cartopy's re-projection routines to re-project - # geometries - - cartopy_crs = self._get_cartopy_crs(gdf.crs) - if self.ax.projection != cartopy_crs: - geoms = gdf.geometry - if len(geoms) > 0: - proj_geoms = [] - - if verbose: - for g in progressbar(geoms, "EOmaps: re-projecting... ", 20): - proj_geoms.append( - self.ax.projection.project_geometry(g, cartopy_crs) - ) - else: - for g in geoms: - proj_geoms.append( - self.ax.projection.project_geometry(g, cartopy_crs) - ) - gdf = gdf.set_geometry(proj_geoms) - gdf = gdf.set_crs(self.ax.projection, allow_override=True) - gdf = gdf[~gdf.is_empty] - else: - raise AssertionError( - f"EOmaps: '{reproject}' is not a valid reproject-argument." - ) - - return gdf - - def _clip_gdf(self, gdf, how="crs"): - """ - Clip the shapes of a GeoDataFrame with respect to the given boundaries. - - Parameters - ---------- - gdf : geopandas.GeoDataFrame - The GeoDataFrame containing the geometries. - how : str, optional - Identifier how the clipping should be performed. - - If a suffix "_invert" is added to the string, the polygon will be - inverted (via a symmetric-difference to the clip-shape) - - - clipping with geopandas: - - "crs" : use the actual crs boundary polygon - - "crs_bounds" : use the boundary-envelope of the crs - - "extent" : use the current plot-extent - - - clipping with gdal (always uses the crs domain as clip-shape): - - "gdal_Intersection" - - "gdal_SymDifference" - - "gdal_Difference" - - "gdal_Union" - - The default is "crs". - - Returns - ------- - gdf - A GeoDataFrame with the clipped geometries - """ - (gpd,) = register_modules("geopandas") - - if how.startswith("gdal"): - methods = ["SymDifference", "Intersection", "Difference", "Union"] - # "SymDifference", "Intersection", "Difference" - method = how.split("_")[1] - assert method in methods, "EOmaps: '{how}' is not a valid clip-method" - try: - from osgeo import gdal - from shapely import wkt - except ImportError: - raise ImportError( - "EOmaps: Missing dependency: 'osgeo'\n" - + "...clipping with gdal requires 'osgeo.gdal'" - ) - - e = self.ax.projection.domain - e2 = gdal.ogr.CreateGeometryFromWkt(e.wkt) - if not e2.IsValid(): - e2 = e2.MakeValid() - - # only reproject geometries if crs cannot be identified - # as the initially provided (or cartopy converted) crs - if gdf.crs != self.crs_plot and gdf.crs != self._crs_plot: - gdf = gdf.to_crs(self.crs_plot) - - clipgeoms = [] - for g in gdf.geometry: - g2 = gdal.ogr.CreateGeometryFromWkt(g.wkt) - - if g2 is None: - continue - - if not g2.IsValid(): - g2 = g2.MakeValid() - - i = getattr(g2, method)(e2) - - if how.endswith("_invert"): - i = i.SymDifference(e2) - - gclip = wkt.loads(i.ExportToWkt()) - clipgeoms.append(gclip) - - gdf = gpd.GeoDataFrame(geometry=clipgeoms, crs=self.crs_plot) - - return gdf - - if how == "crs" or how == "crs_invert": - clip_shp = gpd.GeoDataFrame( - geometry=[self.ax.projection.domain], crs=self.crs_plot - ).to_crs(gdf.crs) - elif how == "extent" or how == "extent_invert": - self.BM.update() - x0, x1, y0, y1 = self.get_extent() - clip_shp = self._make_rect_poly(x0, y0, x1, y1, self.crs_plot).to_crs( - gdf.crs - ) - elif how == "crs_bounds" or how == "crs_bounds_invert": - x0, x1, y0, y1 = self.get_extent() - clip_shp = self._make_rect_poly( - *self.crs_plot.boundary.bounds, self.crs_plot - ).to_crs(gdf.crs) - else: - raise TypeError(f"EOmaps: '{how}' is not a valid clipping method") - - clip_shp = clip_shp.buffer(0) # use this to make sure the geometry is valid - - # add 1% of the extent-diameter as buffer - bnd = clip_shp.boundary.bounds - d = np.sqrt((bnd.maxx - bnd.minx) ** 2 + (bnd.maxy - bnd.miny) ** 2) - clip_shp = clip_shp.buffer(d / 100) - - # clip the geo-dataframe with the buffered clipping shape - clipgdf = gdf.clip(clip_shp) - - if how.endswith("_invert"): - clipgdf = clipgdf.symmetric_difference(clip_shp) - - return clipgdf - - def add_marker( - self, - ID=None, - xy=None, - xy_crs=None, - radius=None, - radius_crs=None, - shape="ellipses", - buffer=1, - n=100, - layer=None, - update=True, - **kwargs, - ): - """ - Add a marker to the plot. - - Parameters - ---------- - ID : any - The index-value of the pixel in m.data. - xy : tuple - A tuple of the position of the pixel provided in "xy_crs". - If "xy_crs" is None, xy must be provided in the plot-crs! - The default is None - xy_crs : any - the identifier of the coordinate-system for the xy-coordinates - radius : float or "pixel", optional - - If float: The radius of the marker. - - If "pixel": It will represent the dimensions of the selected pixel. - (check the `buffer` kwarg!) - - The default is None in which case "pixel" is used if a dataset is - present and otherwise a shape with 1/10 of the axis-size is plotted - radius_crs : str or a crs-specification - The crs specification in which the radius is provided. - Either "in", "out", or a crs specification (e.g. an epsg-code, - a PROJ or wkt string ...) - The default is "in" (e.g. the crs specified via `m.data_specs.crs`). - (only relevant if radius is NOT specified as "pixel") - shape : str, optional - Indicator which shape to draw. Currently supported shapes are: - - geod_circles - - ellipses - - rectangles - - The default is "circle". - buffer : float, optional - A factor to scale the size of the shape. The default is 1. - n : int - The number of points to calculate for the shape. - The default is 100. - layer : str, int or None - The name of the layer at which the marker should be drawn. - If None, the layer associated with the used Maps-object (e.g. m.layer) - is used. The default is None. - kwargs : - kwargs passed to the matplotlib patch. - (e.g. `zorder`, `facecolor`, `edgecolor`, `linewidth`, `alpha` etc.) - update : bool, optional - If True, call m.BM.update() to immediately show dynamic annotations - If False, dynamic annotations will only be shown at the next update - - Examples - -------- - >>> m.add_marker(ID=1, buffer=5) - >>> m.add_marker(ID=1, radius=2, radius_crs=4326, shape="rectangles") - >>> m.add_marker(xy=(4, 3), xy_crs=4326, radius=20000, shape="geod_circles") - """ - if ID is not None: - assert xy is None, "You can only provide 'ID' or 'pos' not both!" - else: - if isinstance(radius, str) and radius != "pixel": - raise TypeError(f"I don't know what to do with radius='{radius}'") - - if xy is not None: - ID = None - if xy_crs is not None: - # get coordinate transformation - transformer = self._get_transformer( - self.get_crs(xy_crs), - self.crs_plot, - ) - # transform coordinates - xy = transformer.transform(*xy) - - if layer is None: - layer = self.layer - - # using permanent=None results in permanent makers that are NOT - # added to the "m.cb.click.get.permanent_markers" list that is - # used to manage callback-markers - - permanent = kwargs.pop("permanent", None) - - # call the "mark" callback function to add the marker - marker = self.cb.click._attach.mark( - self.cb.click.attach, - ID=ID, - pos=xy, - radius=radius, - radius_crs=radius_crs, - ind=None, - shape=shape, - buffer=buffer, - n=n, - layer=layer, - permanent=permanent, - **kwargs, - ) - - if permanent is False and update: - self.BM.update() - - return marker - - def add_annotation( - self, - ID=None, - xy=None, - xy_crs=None, - text=None, - update=True, - **kwargs, - ): - """ - Add an annotation to the plot. - - Parameters - ---------- - ID : str, int, float or array-like - The index-value of the pixel in m.data. - xy : tuple of float or array-like - A tuple of the position of the pixel provided in "xy_crs". - If None, xy must be provided in the coordinate-system of the plot! - The default is None. - xy_crs : any - the identifier of the coordinate-system for the xy-coordinates - text : callable or str, optional - if str: the string to print - if callable: A function that returns the string that should be - printed in the annotation with the following call-signature: - - >>> def text(m, ID, val, pos, ind): - >>> # m ... the Maps object - >>> # ID ... the ID - >>> # pos ... the position - >>> # val ... the value - >>> # ind ... the index of the clicked pixel - >>> - >>> return "the string to print" - - The default is None. - update : bool, optional - If True, call m.BM.update() to immediately show dynamic annotations - If False, dynamic annotations will only be shown at the next update - **kwargs - kwargs passed to m.cb.annotate - - Examples - -------- - >>> 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 - - >>> m.add_annotation(ID=1, text="some text") - - or a callable that returns a string with the following signature: - - >>> 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) - - **Customizing the appearance** - - For the full set of possibilities, see: - https://matplotlib.org/stable/tutorials/text/annotations.html - - >>> 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 - >>> ) - >>> ) - - """ - inp_ID = ID - - if xy is None and ID is None: - x = self.ax.bbox.x0 + self.ax.bbox.width / 2 - y = self.ax.bbox.y0 + self.ax.bbox.height / 2 - xy = self.ax.transData.inverted().transform((x, y)) - - if ID is not None: - assert xy is None, "You can only provide 'ID' or 'pos' not both!" - # avoid using np.isin directly since it needs a lot of ram - # for very large datasets! - mask, ind = self._find_ID(ID) - - xy = ( - self._data_manager.xorig.ravel()[mask], - self._data_manager.yorig.ravel()[mask], - ) - val = self._data_manager.z_data.ravel()[mask] - ID = np.atleast_1d(ID) - xy_crs = self.data_specs.crs - - is_ID_annotation = False - else: - val = repeat(None) - ind = repeat(None) - ID = repeat(None) - - is_ID_annotation = True - - assert ( - xy is not None - ), "EOmaps: you must provide either ID or xy to position the annotation!" - - xy = (np.atleast_1d(xy[0]), np.atleast_1d(xy[1])) - - if xy_crs is not None: - # get coordinate transformation - transformer = self._get_transformer( - CRS.from_user_input(xy_crs), - self.crs_plot, - ) - # transform coordinates - xy = transformer.transform(*xy) - else: - transformer = None - - kwargs.setdefault("permanent", None) - - if isinstance(text, str) or callable(text): - usetext = repeat(text) - else: - try: - usetext = iter(text) - except TypeError: - usetext = repeat(text) - - for x, y, texti, vali, indi, IDi in zip(xy[0], xy[1], usetext, val, ind, ID): - ann = self.cb.click._attach.annotate( - self.cb.click.attach, - ID=IDi, - pos=(x, y), - val=vali, - ind=indi, - text=texti, - **kwargs, - ) - - if kwargs.get("permanent", False) is not False: - self._edit_annotations._add( - a=ann, - kwargs={ - "ID": inp_ID, - "xy": (x, y), - "xy_crs": xy_crs, - "text": text, - **kwargs, - }, - transf=transformer, - drag_coords=is_ID_annotation, - ) - - if update: - self.BM.update(clear=False) - return ann - - @wraps(Compass.__call__) - def add_compass(self, *args, **kwargs): - """Add a compass (or north-arrow) to the map.""" - c = Compass(weakref.proxy(self)) - c(*args, **kwargs) - # store a reference to the object (required for callbacks)! - self._compass.add(c) - return c - - @wraps(ScaleBar.__init__) - def add_scalebar( - self, - pos=None, - rotation=0, - scale=None, - n=10, - preset=None, - autoscale_fraction=0.25, - auto_position=(0.8, 0.25), - scale_props=None, - patch_props=None, - label_props=None, - line_props=None, - layer=None, - size_factor=1, - pickable=True, - ): - """Add a scalebar to the map.""" - s = ScaleBar( - m=self, - preset=preset, - scale=scale, - n=n, - autoscale_fraction=autoscale_fraction, - auto_position=auto_position, - scale_props=scale_props, - patch_props=patch_props, - label_props=label_props, - line_props=line_props, - layer=layer, - size_factor=size_factor, - ) - - # add the scalebar to the map at the desired position - s._add_scalebar(pos=pos, azim=rotation, pickable=pickable) - self.BM.update() - return s - - def add_line( - self, - xy, - xy_crs=4326, - connect="geod", - n=None, - del_s=None, - mark_points=None, - layer=None, - **kwargs, - ): - """ - Draw a line by connecting a set of anchor-points. - - The points can be connected with either "geodesic-lines", "straight lines" or - "projected straight lines with respect to a given crs" (see `connect` kwarg). - - Parameters - ---------- - xy : list, set or numpy.ndarray - The coordinates of the anchor-points that define the line. - Expected shape: [(x0, y0), (x1, y1), ...] - xy_crs : any, optional - The crs of the anchor-point coordinates. - (can be any crs definition supported by PyProj) - The default is 4326 (e.g. lon/lat). - connect : str, optional - The connection-method used to draw the segments between the anchor-points. - - - "geod": Connect the anchor-points with geodesic lines - - "straight": Connect the anchor-points with straight lines - - "straight_crs": Connect the anchor-points with straight lines in the - `xy_crs` projection and reproject those lines to the plot-crs. - - The default is "geod". - n : int, list or None optional - The number of intermediate points to use for each line-segment. - - - If an integer is provided, each segment is equally divided into n parts. - - If a list is provided, it is used to specify "n" for each line-segment - individually. - - (NOTE: The number of segments is 1 less than the number of anchor-points!) - - If both n and del_s is None, n=100 is used by default! - - The default is None. - del_s : int, float or None, optional - Only relevant if `connect="geod"`! - - The target-distance in meters between the subdivisions of the line-segments. - - - If a number is provided, each segment is equally divided. - - If a list is provided, it is used to specify "del_s" for each line-segment - individually. - - (NOTE: The number of segments is 1 less than the number of anchor-points!) - - The default is None. - mark_points : str, dict or None, optional - Set the marker-style for the anchor-points. - - - If a string is provided, it is identified as a matplotlib "format-string", - e.g. "r." for red dots, "gx" for green x markers etc. - - if a dict is provided, it will be used to set the style of the markers - e.g.: dict(marker="o", facecolor="orange", edgecolor="g") - - See https://matplotlib.org/stable/gallery/lines_bars_and_markers/marker_reference.html - for more details - - The default is "o" - - layer : str, int or None - The name of the layer at which the line should be drawn. - If None, the layer associated with the used Maps-object (e.g. m.layer) - is used. Use "all" to add the line to all layers! - The default is None. - kwargs : - additional keyword-arguments passed to plt.plot(), e.g. - "c" (or "color"), "lw" (or "linewidth"), "ls" (or "linestyle"), - "markevery", etc. - - See https://matplotlib.org/stable/api/_as_gen/matplotlib.axes.Axes.plot.html - for more details. - - Returns - ------- - out_d_int : list - Only relevant for `connect="geod"`! (An empty list is returned otherwise.) - A list of the subdivision distances of the line-segments (in meters). - out_d_tot : list - Only relevant for `connect="geod"` (An empty list is returned otherwise.) - A list of total distances of the line-segments (in meters). - - """ - if layer is None: - layer = self.layer - - # intermediate and total distances - out_d_int, out_d_tot = [], [] - - if len(xy) <= 1: - _log.error("you must provide at least 2 points") - - if n is not None: - assert del_s is None, "EOmaps: Provide either `del_s` or `n`, not both!" - del_s = 0 # pyproj's geod uses 0 as identifier! - - if not isinstance(n, int): - assert len(n) == len(xy) - 1, ( - "EOmaps: The number of subdivisions per line segment (n) must be" - + " 1 less than the number of points!" - ) - - elif del_s is not None: - assert n is None, "EOmaps: Provide either `del_s` or `n`, not both!" - n = 0 # pyproj's geod uses 0 as identifier! - - assert connect in ["geod"], ( - "EOmaps: Setting a fixed subdivision-distance (e.g. `del_s`) is only " - + "possible for `geod` lines! Use `n` instead!" - ) - - if not isinstance(del_s, (int, float, np.number)): - assert len(del_s) == len(xy) - 1, ( - "EOmaps: The number of subdivision-distances per line segment " - + "(`del_s`) must be 1 less than the number of points!" - ) - else: - # use 100 subdivisions by default - n = 100 - del_s = 0 - - t_xy_plot = self._get_transformer( - self.get_crs(xy_crs), - self.crs_plot, - ) - xplot, yplot = t_xy_plot.transform(*zip(*xy)) - - if connect == "geod": - # connect points via geodesic lines - if xy_crs != 4326: - t = self._get_transformer( - self.get_crs(xy_crs), - self.get_crs(4326), - ) - x, y = t.transform(*zip(*xy)) - else: - x, y = zip(*xy) - - geod = self.crs_plot.get_geod() - - if n is None or isinstance(n, int): - n = repeat(n) - - if del_s is None or isinstance(del_s, (int, float, np.number)): - del_s = repeat(del_s) - - xs, ys = [], [] - for (x0, x1), (y0, y1), ni, di in zip(pairwise(x), pairwise(y), n, del_s): - npts, d_int, d_tot, lon, lat, _ = geod.inv_intermediate( - lon1=x0, - lat1=y0, - lon2=x1, - lat2=y1, - del_s=di, - npts=ni, - initial_idx=0, - terminus_idx=0, - ) - - out_d_int.append(d_int) - out_d_tot.append(d_tot) - - lon, lat = lon.tolist(), lat.tolist() - xi, yi = self._transf_lonlat_to_plot.transform(lon, lat) - xs += xi - ys += yi - (art,) = self.ax.plot(xs, ys, **kwargs) - - elif connect == "straight": - (art,) = self.ax.plot(xplot, yplot, **kwargs) - - elif connect == "straight_crs": - # draw a straight line that is defined in a given crs - - x, y = zip(*xy) - if isinstance(n, int): - # use same number of points for all segments - xs = np.linspace(x[:-1], x[1:], n).T.ravel() - ys = np.linspace(y[:-1], y[1:], n).T.ravel() - else: - # use different number of points for individual segments - - xs = list( - chain( - *(np.linspace(a, b, ni) for (a, b), ni in zip(pairwise(x), n)) - ) - ) - ys = list( - chain( - *(np.linspace(a, b, ni) for (a, b), ni in zip(pairwise(y), n)) - ) - ) - - x, y = t_xy_plot.transform(xs, ys) - - (art,) = self.ax.plot(x, y, **kwargs) - else: - raise TypeError(f"EOmaps: '{connect}' is not a valid connection-method!") - - art.set_label(f"Line ({connect})") - self.BM.add_bg_artist(art, layer=layer) - - if mark_points: - zorder = kwargs.get("zorder", 10) - - if isinstance(mark_points, dict): - # only use zorder of the line if no explicit zorder is provided - mark_points["zorder"] = mark_points.get("zorder", zorder) - - art2 = self.ax.scatter(xplot, yplot, **mark_points) - - elif isinstance(mark_points, str): - # use matplotlib's single-string style identifiers, - # (e.g. "r.", "go", "C0x" etc.) - (art2,) = self.ax.plot(xplot, yplot, mark_points, zorder=zorder, lw=0) - - art2.set_label(f"Line Marker ({connect})") - self.BM.add_bg_artist(art2, layer=layer) - - return out_d_int, out_d_tot - - def add_logo( - self, - filepath=None, - position="lr", - size=0.12, - pad=0.1, - layer=None, - fix_position=False, - **kwargs, - ): - """ - Add a small image (png, jpeg etc.) to the map. - - The position of the image is dynamically updated if the plot is resized or - zoomed. - - Parameters - ---------- - filepath : str, optional - if str: The path to the image-file. - The default is None in which case an EOmaps logo is added to the map. - position : str, optional - The position of the logo. - - "ul", "ur" : upper left, upper right - - "ll", "lr" : lower left, lower right - The default is "lr". - size : float, optional - The size of the logo as a fraction of the axis-width. - The default is 0.15. - pad : float, tuple optional - Padding between the axis-edge and the logo as a fraction of the logo-width. - If a tuple is passed, (x-pad, y-pad) - The default is 0.1. - layer : str or None, optional - The layer at which the logo should be visible. - If None, the logo will be added to all layers and will be drawn on - top of all background artists. The default is None. - fix_position : bool, optional - If True, the relative position of the logo (with respect to the map-axis) - is fixed (and dynamically updated on zoom / resize events) - - NOTE: If True, the logo can NOT be moved with the layout_editor! - The default is False. - kwargs : - Additional kwargs are passed to plt.imshow - """ - if layer is None: - layer = "__SPINES__" - - if filepath is None: - filepath = Path(__file__).parent / "logo.png" - - im = mpl.image.imread(filepath) - - def getpos(pos): - s = size - if isinstance(pad, tuple): - pwx, pwy = (s * pad[0], s * pad[1]) - else: - pwx, pwy = (s * pad, s * pad) - - if position == "lr": - p = dict(rect=[pos.x1 - s - pwx, pos.y0 + pwy, s, s], anchor="SE") - elif position == "ll": - p = dict(rect=[pos.x0 + pwx, pos.y0 + pwy, s, s], anchor="SW") - elif position == "ur": - p = dict(rect=[pos.x1 - s - pwx, pos.y1 - s - pwy, s, s], anchor="NE") - elif position == "ul": - p = dict(rect=[pos.x0 + pwx, pos.y1 - s - pwy, s, s], anchor="NW") - return p - - figax = self.f.add_axes( - **getpos(self.ax.get_position()), label="logo", zorder=999, animated=True - ) - - figax.set_navigate(False) - figax.set_axis_off() - - kwargs.setdefault("aspect", "equal") - kwargs.setdefault("zorder", 999) - kwargs.setdefault("interpolation_stage", "rgba") - - _ = figax.imshow(im, **kwargs) - - self.BM.add_bg_artist(figax, layer=layer) - - if fix_position: - fixed_pos = ( - figax.get_position() - .transformed(self.f.transFigure) - .transformed(self.ax.transAxes.inverted()) - ) - - figax.set_axes_locator( - _TransformedBoundsLocator(fixed_pos.bounds, self.ax.transAxes) - ) - - @wraps(ColorBar._new_colorbar) - def add_colorbar(self, *args, **kwargs): - """Add a colorbar to the map.""" - if self.coll is None: - raise AttributeError( - "EOmaps: You must plot a dataset before " "adding a colorbar!" - ) - - colorbar = ColorBar._new_colorbar(self, *args, **kwargs) - - self._colorbars.append(colorbar) - self.BM._refetch_layer(self.layer) - self.BM._refetch_layer("__SPINES__") - - return colorbar - - @wraps(GridFactory.add_grid) - def add_gridlines(self, *args, **kwargs): - """Add gridlines to the Map.""" - return self.parent._grid.add_grid(m=self, *args, **kwargs) - - def add_background_patch(self, color, layer=None, **kwargs): - """ - Add a background-patch for the map. - - Useful for overlapping axes if you don't want to "see-through" - the top map. - - Parameters - ---------- - color : str, rgba tuple - The color of the patch. - layer : str, optional - The layer to use. - If None, the layer assigned to the Maps-object is used. - The default is None. - kwargs : - All additional kwargs are passed to the created Patch. - (e.g. alpha, hatch, ...) - - Returns - ------- - art : TYPE - DESCRIPTION. - - """ - if layer is None: - layer = self.layer - - (art,) = self.ax.fill( - [0, 0, 1, 1], - [0, 1, 1, 0], - fc=color, - ec="none", - zorder=-9999, - transform=self.ax.transAxes, - **kwargs, - ) - - art.set_label("Background patch") - - self.BM.add_bg_artist(art, layer=layer) - return art - - def indicate_extent(self, x0, y0, x1, y1, crs=4326, npts=100, **kwargs): - """ - Indicate a rectangular extent in a given crs on the map. - - Parameters - ---------- - x0, y0, y1, y1 : float - the boundaries of the shape - npts : int, optional - The number of points used to draw the polygon-lines. - (e.g. to correctly display the distortion of the extent-rectangle when - it is re-projected to another coordinate-system) - The default is 100. - crs : any, optional - A coordinate-system identifier. - The default is 4326 (e.g. lon/lat). - kwargs : - Additional keyword-arguments passed to `m.add_gdf()`. - """ - register_modules("geopandas") - - gdf = self._make_rect_poly(x0, y0, x1, y1, self.get_crs(crs), npts) - self.add_gdf(gdf, **kwargs) - - @wraps(plt.Figure.text) - def text(self, *args, layer=None, **kwargs): - """Add text to the map.""" - kwargs.setdefault("animated", True) - kwargs.setdefault("horizontalalignment", "center") - kwargs.setdefault("verticalalignment", "center") - - a = self.f.text(*args, **kwargs) - - if layer is None: - layer = self.layer - self.BM.add_artist(a, layer=layer) - self.BM.update() - - return a - - def plot_map( - self, - layer=None, - dynamic=False, - set_extent=True, - assume_sorted=True, - indicate_masked_points=False, - **kwargs, - ): - """ - Plot the dataset assigned to this Maps-object. - - - To set the data, see `m.set_data()` - - To change the "shape" that is used to represent the datapoints, see - `m.set_shape`. - - To classify the data, see `m.set_classify` or `m.set_classify_specs()` - - NOTE - ---- - Each call to `plot_map(...)` will override the previously plotted dataset! - - If you want to plot multiple datasets, use a new layer for each dataset! - (e.g. via `m2 = m.new_layer()`) - - Parameters - ---------- - layer : str or None - The layer at which the dataset will be plotted. - ONLY relevant if `dynamic = False`! - - - If "all": the corresponding feature will be added to ALL layers - - If None, the layer assigned to the Maps object is used (e.g. `m.layer`) - - The default is None. - dynamic : bool - If True, the collection will be dynamically updated. - set_extent : bool - Set the plot-extent to the data-extent. - - - if True: The plot-extent will be set to the extent of the data-coordinates - - if False: The plot-extent is kept as-is - - The default is True - assume_sorted : bool, optional - ONLY relevant for the shapes "raster" and "shade_raster" - (and only if coordinates are provided as 1D arrays and data is a 2D array) - - Sort values with respect to the coordinates prior to plotting - (required for QuadMesh if unsorted coordinates are provided) - - The default is True. - indicate_masked_points : bool or dict - If False, masked points are not indicated. - - If True, any datapoints that could not be properly plotted - with the currently assigned shape are indicated with a - circle with a red boundary. - - If a dict is provided, it can be used to update the appearance of the - masked points (arguments are passed to matplotlibs `plt.scatter()`) - ('s': markersize, 'marker': the shape of the marker, ...) - - The default is False - - Other Parameters - ---------------- - 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 matplotlib collection - (dependent on the plot-shape) [linewidth, edgecolor, facecolor, ...] - - For "shade_points" or "shade_raster" shapes, kwargs are passed to - `datashader.mpl_ext.dsshow` - - """ - verbose = kwargs.pop("verbose", None) - if verbose is not None: - _log.error("EOmaps: The parameter verbose is ignored.") - - # make sure zorder is set to 1 by default - # (by default shading would use 0 while ordinary collections use 1) - if self.shape.name != "contour": - kwargs.setdefault("zorder", 1) - else: - # put contour lines by default at level 10 - if self.shape._filled: - kwargs.setdefault("zorder", 1) - else: - kwargs.setdefault("zorder", 10) - - if getattr(self, "coll", None) is not None and len(self.cb.pick.get.cbs) > 0: - _log.info( - "EOmaps: Calling `m.plot_map()` or " - "`m.make_dataset_pickable()` more than once on the " - "same Maps-object overrides the assigned PICK-dataset!" - ) - - if layer is None: - layer = self.layer - else: - if not isinstance(layer, str): - _log.info("EOmaps: The layer-name has been converted to a string!") - layer = str(layer) - - useshape = self.shape # invoke the setter to set the default shape - shade_q = useshape.name.startswith("shade_") # indicator if shading is used - - # make sure the colormap is properly set and transparencies are assigned - cmap = kwargs.pop("cmap", "viridis") - - if "alpha" in kwargs and kwargs["alpha"] < 1: - # get a unique name for the colormap - cmapname = self._get_alpha_cmap_name(kwargs["alpha"]) - - cmap = cmap_alpha( - cmap=cmap, - alpha=kwargs["alpha"], - name=cmapname, - ) - - plt.colormaps.register(name=cmapname, cmap=cmap) - self._emit_signal("cmapsChanged") - # remember registered colormaps (to de-register on close) - self._registered_cmaps.append(cmapname) - - # ---------------------- prepare the data - - _log.debug("EOmaps: Preparing dataset") - - # ---------------------- assign the data to the data_manager - - # shade shapes use datashader to update the data of the collections! - update_coll_on_fetch = False if shade_q else True - - self._data_manager.set_props( - layer=layer, - assume_sorted=assume_sorted, - update_coll_on_fetch=update_coll_on_fetch, - indicate_masked_points=indicate_masked_points, - dynamic=dynamic, - ) - - # ---------------------- classify the data - self._set_vmin_vmax( - vmin=kwargs.pop("vmin", None), vmax=kwargs.pop("vmax", None) - ) - - if not self._inherit_classification: - if self.classify_specs.scheme is not None: - _log.debug("EOmaps: Classifying...") - elif self.shape.name == "contour" and kwargs.get("levels", None) is None: - # TODO use custom contour-levels as UserDefined classification? - self.set_classify.EqualInterval(k=5) - - cbcmap, norm, bins, classified = self._classify_data( - vmin=self._vmin, - vmax=self._vmax, - cmap=cmap, - classify_specs=self.classify_specs, - ) - - if norm is not None: - if "norm" in kwargs: - raise TypeError( - "EOmaps: You cannot provide an explicit norm for the dataset if a " - "classification scheme is used!" - ) - else: - if "norm" in kwargs: - norm = kwargs.pop("norm") - if not isinstance(norm, str): # to allow datashader "eq_hist" norm - norm.vmin = self._vmin - norm.vmax = self._vmax - else: - norm = plt.Normalize(vmin=self._vmin, vmax=self._vmax) - - # todo remove duplicate attributes - self.classify_specs._cbcmap = cbcmap - self.classify_specs._norm = norm - self.classify_specs._bins = bins - self.classify_specs._classified = classified - - self._cbcmap = cbcmap - self._norm = norm - self._bins = bins - self._classified = classified - - # ---------------------- plot the data - - if shade_q: - self._shade_map( - layer=layer, - dynamic=dynamic, - set_extent=set_extent, - assume_sorted=assume_sorted, - **kwargs, - ) - self.f.canvas.draw_idle() - else: - # dont set extent if "m.set_extent" was called explicitly - if set_extent and self._set_extent_on_plot: - # note bg-layers are automatically triggered for re-draw - # if the extent changes! - self._data_manager._set_lims() - - self._plot_map( - layer=layer, - dynamic=dynamic, - set_extent=set_extent, - assume_sorted=assume_sorted, - **kwargs, - ) - - self.BM._refetch_layer(layer) - - if getattr(self, "_data_mask", None) is not None and not np.all( - self._data_mask - ): - _log.info("EOmaps: Some datapoints could not be drawn!") - - self._data_plotted = True - - self._emit_signal("dataPlotted") - - self.BM.update() - - def _plot_map( - self, - layer=None, - dynamic=False, - set_extent=True, - assume_sorted=True, - **kwargs, - ): - _log.info( - "EOmaps: Plotting " - f"{self._data_manager.z_data.size} datapoints ({self.shape.name})" - ) - - for key in ("array",): - assert ( - key not in kwargs - ), f"The key '{key}' is assigned internally by EOmaps!" - - try: - self._set_extent = set_extent - - # ------------- plot the data - self._coll_kwargs = kwargs - self._coll_dynamic = dynamic - - # NOTE: the actual plot is performed by the data-manager - # at the next call to m.BM.fetch_bg() for the corresponding layer - # this is called to make sure m.coll is properly set - self._data_manager.on_fetch_bg(check_redraw=False) - - except Exception as ex: - raise ex - - def _shade_map( - self, - layer=None, - dynamic=False, - set_extent=True, - assume_sorted=True, - **kwargs, - ): - """ - Plot the dataset using the (very fast) "datashader" library. - - Requires `datashader`... use `conda install -c conda-forge datashader` - - - This method is intended for extremely large datasets - (up to millions of datapoints)! - - A dynamically updated "shaded" map will be generated. - Note that the datapoints in this case are NOT represented by the shapes - defined as `m.set_shape`! - - - By default, the shading is performed using a "mean"-value aggregation hook - - kwargs : - kwargs passed to `datashader.mpl_ext.dsshow` - - """ - _log.info( - "EOmaps: Plotting " - f"{self._data_manager.z_data.size} datapoints ({self.shape.name})" - ) - - ds, mpl_ext, pd, xar = register_modules( - "datashader", "datashader.mpl_ext", "pandas", "xarray" - ) - - # remove previously fetched backgrounds for the used layer - if dynamic is False: - self.BM._refetch_layer(layer) - - # in case the aggregation does not represent data-values - # (e.g. count, std, var ... ) use an automatic "linear" normalization - - # get the name of the used aggretation reduction - aggname = self.shape.aggregator.__class__.__name__ - - if aggname in ["first", "last", "max", "min", "mean", "mode"]: - kwargs.setdefault("norm", self.classify_specs._norm) - else: - kwargs.setdefault("norm", "linear") - - zdata = self._data_manager.z_data - if len(zdata) == 0: - _log.error("EOmaps: there was no data to plot") - return - - plot_width, plot_height = self._get_shade_axis_size() - - # get rid of unnecessary dimensions in the numpy arrays - zdata = zdata.squeeze() - x0 = self._data_manager.x0.squeeze() - y0 = self._data_manager.y0.squeeze() - - # the shape is always set after _prepare data! - if self.shape.name == "shade_points" and self._data_manager.x0_1D is None: - # fill masked-values with None to avoid issues with numba not being - # able to deal with numpy-arrays - # TODO report this to datashader to get it fixed properly? - if isinstance(zdata, np.ma.masked_array): - zdata = zdata.filled(None) - - df = pd.DataFrame( - dict( - x=x0.ravel(), - y=y0.ravel(), - val=zdata.ravel(), - ), - copy=False, - ) - - else: - if len(zdata.shape) == 2: - if (zdata.shape == x0.shape) and (zdata.shape == y0.shape): - # 2D coordinates and 2D raster - - # use a curvilinear QuadMesh - if self.shape.name == "shade_raster": - self.shape.glyph = ds.glyphs.QuadMeshCurvilinear( - "x", "y", "val" - ) - - df = xar.Dataset( - data_vars=dict(val=(["xx", "yy"], zdata)), - # dims=["x", "y"], - coords=dict( - x=(["xx", "yy"], x0), - y=(["xx", "yy"], y0), - ), - ) - - elif ( - ((zdata.shape[1],) == x0.shape) - and ((zdata.shape[0],) == y0.shape) - and (x0.shape != y0.shape) - ): - raise AssertionError( - "EOmaps: it seems like you need to transpose your data! \n" - + f"the dataset has a shape of {zdata.shape}, but the " - + f"coordinates suggest ({x0.shape}, {y0.shape})" - ) - elif (zdata.T.shape == x0.shape) and (zdata.T.shape == y0.shape): - raise AssertionError( - "EOmaps: it seems like you need to transpose your data! \n" - + f"the dataset has a shape of {zdata.shape}, but the " - + f"coordinates suggest {x0.shape}" - ) - - elif ((zdata.shape[0],) == x0.shape) and ( - (zdata.shape[1],) == y0.shape - ): - # 1D coordinates and 2D data - - # use a rectangular QuadMesh - if self.shape.name == "shade_raster": - self.shape.glyph = ds.glyphs.QuadMeshRectilinear( - "x", "y", "val" - ) - - df = xar.DataArray( - data=zdata, - dims=["x", "y"], - coords=dict(x=x0, y=y0), - ) - df = xar.Dataset(dict(val=df)) - else: - try: - # try if reprojected coordinates can be used as 2d grid and if yes, - # directly use a curvilinear QuadMesh based on the reprojected - # coordinates to display the data - idx = pd.MultiIndex.from_arrays( - [x0.ravel(), y0.ravel()], names=["x", "y"] - ) - - df = pd.DataFrame( - data=dict(val=zdata.ravel()), index=idx, copy=False - ) - df = df.to_xarray() - xg, yg = np.meshgrid(df.x, df.y) - except Exception: - # first convert original coordinates of the 1D inputs to 2D, - # then reproject the grid and use a curvilinear QuadMesh to display - # the data - _log.warning( - "EOmaps: 1D data is converted to 2D prior to reprojection... " - "Consider using 'shade_points' as plot-shape instead!" - ) - xorig = self._data_manager.xorig.ravel() - yorig = self._data_manager.yorig.ravel() - - idx = pd.MultiIndex.from_arrays([xorig, yorig], names=["x", "y"]) - - df = pd.DataFrame( - data=dict(val=zdata.ravel()), index=idx, copy=False - ) - df = df.to_xarray() - xg, yg = np.meshgrid(df.x, df.y) - - # transform the grid from input-coordinates to the plot-coordinates - crs1 = CRS.from_user_input(self.data_specs.crs) - crs2 = CRS.from_user_input(self._crs_plot) - if crs1 != crs2: - transformer = self._get_transformer( - crs1, - crs2, - ) - xg, yg = transformer.transform(xg, yg) - - # use a curvilinear QuadMesh - if self.shape.name == "shade_raster": - self.shape.glyph = ds.glyphs.QuadMeshCurvilinear("x", "y", "val") - - df = xar.Dataset( - data_vars=dict(val=(["xx", "yy"], df.val.values.T)), - coords=dict(x=(["xx", "yy"], xg), y=(["xx", "yy"], yg)), - ) - - if self.shape.name == "shade_points": - df = df.to_dataframe().reset_index() - - if set_extent is True and self._set_extent_on_plot is True: - # convert to a numpy-array to support 2D indexing with boolean arrays - x, y = np.asarray(df.x), np.asarray(df.y) - xf, yf = np.isfinite(x), np.isfinite(y) - x_range = (np.nanmin(x[xf]), np.nanmax(x[xf])) - y_range = (np.nanmin(y[yf]), np.nanmax(y[yf])) - else: - # update here to ensure bounds are set - self.BM.update() - x0, x1, y0, y1 = self.get_extent(self.crs_plot) - x_range = (x0, x1) - y_range = (y0, y1) - - coll = mpl_ext.dsshow( - df, - glyph=self.shape.glyph, - aggregator=self.shape.aggregator, - shade_hook=self.shape.shade_hook, - agg_hook=self.shape.agg_hook, - # norm="eq_hist", - # norm=plt.Normalize(vmin, vmax), - cmap=self._cbcmap, - ax=self.ax, - plot_width=plot_width, - plot_height=plot_height, - # x_range=(x0, x1), - # y_range=(y0, y1), - # x_range=(df.x.min(), df.x.max()), - # y_range=(df.y.min(), df.y.max()), - x_range=x_range, - y_range=y_range, - vmin=self._vmin, - vmax=self._vmax, - **kwargs, - ) - - coll.set_label("Dataset " f"({self.shape.name} | {zdata.shape})") - - self._coll = coll - - if dynamic is True: - self.BM.add_artist(coll, layer=layer) - else: - self.BM.add_bg_artist(coll, layer=layer) - - if dynamic is True: - self.BM.update(clear=False) - - def set_shade_dpi(self, dpi=None): - """ - Set the dpi used by "shade shapes" to aggregate datasets. - - This only affects the plot-shapes "shade_raster" and "shade_points". - - Note - ---- - If dpi=None is used (the default), datasets in exported figures will be - re-rendered with respect to the requested dpi of the exported image! - - Parameters - ---------- - dpi : int or None, optional - The dpi to use for data aggregation with shade shapes. - If None, the figure-dpi is used. - - The default is None. - - """ - self._shade_dpi = dpi - self._update_shade_axis_size() - - def _get_shade_axis_size(self, dpi=None, flush=True): - if flush: - # flush events before evaluating shade sizes to make sure axes dimensions have - # been properly updated - self.f.canvas.flush_events() - - if self._shade_dpi is not None: - dpi = self._shade_dpi - - fig_dpi = self.f.dpi - w, h = self.ax.bbox.width, self.ax.bbox.height - - # TODO for now, only handle numeric dpi-values to avoid issues. - # (savefig also seems to support strings like "figure" etc.) - if isinstance(dpi, (int, float, np.number)): - width = int(w / fig_dpi * dpi) - height = int(h / fig_dpi * dpi) - else: - width = int(w) - height = int(h) - - return width, height - - def _update_shade_axis_size(self, dpi=None, flush=True): - # method to update all shade-dpis - # NOTE: provided dpi value is only used if no explicit "_shade_dpi" is set! - - # set the axis-size that is used to determine the number of pixels used - # when using "shade" shapes for ALL maps objects of a figure - for m in (self.parent, *self.parent._children): - if m.coll is not None and m.shape.name.startswith("shade_"): - w, h = m._get_shade_axis_size(dpi=dpi, flush=flush) - m.coll.plot_width = w - m.coll.plot_height = h - - def make_dataset_pickable( - self, - ): - """ - Make the associated dataset pickable **without plotting** it first. - - After executing this function, `m.cb.pick` callbacks can be attached to the - `Maps` object. - - NOTE - ---- - This function is ONLY necessary if you want to use pick-callbacks **without** - actually plotting the data**! Otherwise a call to `m.plot_map()` is sufficient! - - - Each `Maps` object can always have only one pickable dataset. - - The used data is always the dataset that was assigned in the last call to - `m.plot_map()` or `m.make_dataset_pickable()`. - - To get multiple pickable datasets, use an individual layer for each of the - datasets (e.g. first `m2 = m.new_layer()` and then assign the data to `m2`) - - Examples - -------- - >>> m = Maps() - >>> m.add_feature.preset.coastline() - >>> ... - >>> # a dataset that should be pickable but NOT visible... - >>> m2 = m.new_layer() - >>> m2.set_data(*np.linspace([0, -180,-90,], [100, 180, 90], 100).T) - >>> m2.make_dataset_pickable() - >>> m2.cb.pick.attach.annotate() # get an annotation for the invisible dataset - >>> # ...call m2.plot_map() to make the dataset visible... - """ - if self.coll is not None: - _log.error( - "EOmaps: There is already a dataset plotted on this Maps-object. " - "You MUST use a new layer (`m2 = m.new_layer()`) to use " - "`m2.make_dataset_pickable()`!" - ) - return - - # ---------------------- prepare the data - self._data_manager = DataManager(self._proxy(self)) - self._data_manager.set_props(layer=self.layer, only_pick=True) - - x0, x1 = self._data_manager.x0.min(), self._data_manager.x0.max() - y0, y1 = self._data_manager.y0.min(), self._data_manager.y0.max() - - # use a transparent rectangle of the data-extent as artist for picking - (art,) = self.ax.fill([x0, x1, x1, x0], [y0, y0, y1, y1], fc="none", ec="none") - - self._coll = art - - self.tree = SearchTree(m=self._proxy(self)) - self.cb.pick._set_artist(art) - self.cb.pick._init_cbs() - self.cb._methods.add("pick") - - self._coll_kwargs = dict() - self._coll_dynamic = True - - # set _data_plotted to True to trigger updates in the data-manager - self._data_plotted = True - - def copy( - self, - data_specs=False, - classify_specs=True, - shape=True, - **kwargs, - ): - """ - Create a (deep)copy of the Maps object that shares selected specifications. - - -> useful to quickly create plots with similar configurations + -> useful to quickly create plots with similar configurations Parameters ---------- @@ -3498,23 +849,19 @@ def copy( getattr(copy_cls.set_shape, self.shape.name)(**self.shape._initargs) if classify_specs is True: - classify_specs = list(self.classify_specs.keys()) + classify_specs = list(self._classify_specs.keys()) copy_cls.set_classify_specs( - scheme=self.classify_specs.scheme, **self.classify_specs + scheme=self._classify_specs.scheme, **self._classify_specs ) return copy_cls - def redraw(self, *args): - self._data_manager.last_extent = None - super().redraw(*args) + def redraw(self, *args, **kwargs): + super().redraw(*args, **kwargs) + @wraps(MapsBase.snapshot) def snapshot(self, *args, **kwargs): - # hide companion-widget indicator - for m in (self.parent, *self.parent._children): - # hide companion-widget indicator - m._indicate_companion_map(False) - + self._hide_all_companion_widget_indicators() super().snapshot(*args, **kwargs) @_add_to_docstring( @@ -3537,13 +884,14 @@ def savefig(self, *args, refetch_wms=False, rasterize_data=True, **kwargs): with ExitStack() as stack: # re-fetch webmap services if required if refetch_wms is False: - if _cx_refetch_wms_on_size_change is not None: - stack.enter_context(_cx_refetch_wms_on_size_change(refetch_wms)) + if getattr(self, "add_wms", None) is not None: + stack.enter_context( + self.add_wms._cx_refetch_wms_on_size_change(refetch_wms) + ) - for m in (self.parent, *self.parent._children): - # hide companion-widget indicator - m._indicate_companion_map(False) + self._hide_all_companion_widget_indicators() + for m in self._bm._children: # handle colorbars for cb in m._colorbars: for a in (cb.ax_cb, cb.ax_cb_plot): @@ -3582,9 +930,8 @@ def cleanup(self): ONLY execute this if you do not need to do anything with the layer """ - # close the pyqt widget if there is one - if self._companion_widget is not None: - self._companion_widget.close() + # close the companion-widget + self._close_companion_widget() # de-register colormaps for cmap in self._registered_cmaps: @@ -3624,746 +971,10 @@ def cleanup(self): super().cleanup() - def _save_to_clipboard(self, **kwargs): - """ - Export the figure to the clipboard. - - Parameters - ---------- - kwargs : - Keyword-arguments passed to :py:meth:`Maps.savefig` - """ - import io - import mimetypes - from qtpy.QtCore import QMimeData - from qtpy.QtWidgets import QApplication - from qtpy.QtGui import QImage - - # guess the MIME type from the provided file-extension - fmt = kwargs.get("format", "png") - mimetype, _ = mimetypes.guess_type(f"dummy.{fmt}") - - message = f"EOmaps: Exporting figure as '{fmt}' to clipboard..." - _log.info(message) - - # TODO remove dependency on companion widget here - if getattr(self, "_companion_widget", None) is not None: - self._companion_widget.window().statusBar().showMessage(message, 2000) - - with io.BytesIO() as buffer: - self.savefig(buffer, **kwargs) - data = QMimeData() - - cb = QApplication.clipboard() - - # TODO check why files copied with setMimeData(...) cannot be pasted - # properly in other apps - if fmt in ["svg", "svgz", "pdf", "eps"]: - data.setData(mimetype, buffer.getvalue()) - cb.clear(mode=cb.Clipboard) - cb.setMimeData(data, mode=cb.Clipboard) - else: - cb.setImage(QImage.fromData(buffer.getvalue())) - def _on_keypress(self, event): if plt.get_backend().lower() == "webagg": return # NOTE: callback is only attached to the parent Maps object! - if event.key == self._companion_widget_key: - try: - self._open_companion_widget((event.x, event.y)) - except Exception: - _log.exception( - "EOmaps: Encountered a problem while trying to open " - "the companion widget", - exc_info=_log.getEffectiveLevel() <= logging.DEBUG, - ) - elif event.key == "ctrl+c": - try: - self._save_to_clipboard(**Maps._clipboard_kwargs) - except Exception: - _log.exception( - "EOmaps: Encountered a problem while trying to export the figure " - "to the clipboard.", - exc_info=_log.getEffectiveLevel() <= logging.DEBUG, - ) - - def _classify_data( - self, - z_data=None, - cmap=None, - vmin=None, - vmax=None, - classify_specs=None, - ): - - if self._inherit_classification is not None: - try: - return ( - self._inherit_classification._cbcmap, - self._inherit_classification._norm, - self._inherit_classification._bins, - self._inherit_classification._classified, - ) - except AttributeError: - raise AssertionError( - "EOmaps: A Maps object can only inherit the classification " - "if the parent Maps object called `m.plot_map()` first!!" - ) - - if z_data is None: - z_data = self._data_manager.z_data - - if isinstance(cmap, str): - cmap = plt.get_cmap(cmap).copy() - else: - cmap = cmap.copy() - - # evaluate classification - if classify_specs is not None and classify_specs.scheme is not None: - (mapclassify,) = register_modules("mapclassify") - - classified = True - if self.classify_specs.scheme == "UserDefined": - bins = self.classify_specs.bins - else: - # use "np.ma.compressed" to make sure values excluded via - # masked-arrays are not used to evaluate classification levels - # (normal arrays are passed through!) - mapc = getattr(mapclassify, classify_specs.scheme)( - np.ma.compressed(z_data[~np.isnan(z_data)]), **classify_specs - ) - bins = mapc.bins - - bins = np.unique(np.clip(bins, vmin, vmax)) - - if vmin < min(bins): - bins = [vmin, *bins] - - if vmax > max(bins): - bins = [*bins, vmax] - - # TODO Always use resample once mpl>3.6 is pinned - if hasattr(cmap, "resampled") and len(bins) > cmap.N: - # Resample colormap to contain enough color-values - # as needed by the boundary-norm. - cbcmap = cmap.resampled(len(bins)) - else: - cbcmap = cmap - - norm = mpl.colors.BoundaryNorm(bins, cbcmap.N) - - self._emit_signal("cmapsChanged") - - if cmap._rgba_bad: - cbcmap.set_bad(cmap._rgba_bad) - if cmap._rgba_over: - cbcmap.set_over(cmap._rgba_over) - if cmap._rgba_under: - cbcmap.set_under(cmap._rgba_under) - - else: - classified = False - bins = None - cbcmap = cmap - norm = None - - return cbcmap, norm, bins, classified - - def _get_mcl_subclass(self, s): - # get a subclass that inherits the docstring from the corresponding - # mapclassify classifier - - class scheme: - @wraps(s) - def __init__(_, *args, **kwargs): - pass - - def __new__(cls, **kwargs): - if "y" in kwargs: - _log.error( - "EOmaps: The values (e.g. the 'y' parameter) are " - + "assigned internally... only provide additional " - + "parameters that specify the classification scheme!" - ) - kwargs.pop("y") - - self.classify_specs._set_scheme_and_args(scheme=s.__name__, **kwargs) - - scheme.__doc__ = s.__doc__ - return scheme - - def _set_default_shape(self): - if self.data is not None: - # size = np.size(self.data) - size = np.size(self._data_manager.z_data) - shape = np.shape(self._data_manager.z_data) - - if len(shape) == 2 and size > 200_000: - self.set_shape.raster() - else: - if size > 500_000: - if all( - register_modules( - "datashader", "datashader.mpl_ext", raise_exception=False - ) - ): - # shade_points should work for any dataset - self.set_shape.shade_points() - else: - _log.warning( - "EOmaps: Attempting to plot a large dataset " - f"({size} datapoints) but the 'datashader' library " - "could not be imported! The plot might take long " - "to finish! ... defaulting to 'ellipses' " - "as plot-shape." - ) - self.set_shape.ellipses() - else: - self.set_shape.ellipses() - else: - self.set_shape.ellipses() - - def _find_ID(self, ID): - # explicitly treat range-like indices (for very large datasets) - ids = self._data_manager.ids - if isinstance(ids, range): - ind, mask = [], [] - for i in np.atleast_1d(ID): - if i in ids: - - found = ids.index(i) - ind.append(found) - mask.append(found) - else: - ind.append(None) - - elif isinstance(ids, (list, np.ndarray)): - mask = np.isin(ids, ID) - ind = np.where(mask)[0] - - return mask, ind - - @lru_cache() - def _get_nominatim_response(self, q, user_agent=None): - import requests - - _log.info(f"Querying {q}") - if user_agent is None: - user_agent = f"EOMaps v{Maps.__version__}" - - headers = { - "User-Agent": user_agent, - } - - resp = requests.get( - rf"https://nominatim.openstreetmap.org/search?q={q}&format=json&addressdetails=1&limit=1", - headers=headers, - ).json() - - if len(resp) == 0: - raise TypeError(f"Unable to resolve the location: {q}") - - return resp[0] - - def _indicate_companion_map(self, visible): - if hasattr(self, "_companion_map_indicator"): - self.BM.remove_artist(self._companion_map_indicator) - try: - self._companion_map_indicator.remove() - except ValueError: - # ignore errors resulting from the fact that the artist - # has already been removed! - pass - del self._companion_map_indicator - - if self._companion_widget is None: - return - - # don't draw an indicator if only one map is present in the figure - if all(m.ax == self.ax for m in (self.parent, *self.parent._children)): - return - - if visible: - path = self.ax.patch.get_path() - self._companion_map_indicator = mpatches.PathPatch( - path, fc="none", ec="g", lw=5, zorder=9999 - ) - - self.ax.add_artist(self._companion_map_indicator) - self.BM.add_artist(self._companion_map_indicator, layer="all") - - self.BM.update() - - def _identify_maps_object(self, xy): - clicked_map = None - if xy is not None: - for m in (self.parent, *self.parent._children): - if not m._new_axis_map: - # only search for Maps-object that initialized new axes - continue - - if m.ax.contains_point(xy): - clicked_map = m - break - - return clicked_map - - def _open_companion_widget(self, xy=None): - """ - Open the companion-widget. - - Parameters - ---------- - xy : tuple, optional - The click position to identify the relevant Maps-object - (in figure coordinates). - If None, the calling Maps-object is used - - The default is None. - - """ - - clicked_map = self._identify_maps_object(xy) - - if clicked_map is None: - _log.error( - "EOmaps: To activate the 'Companion Widget' you must " - "position the mouse on top of an EOmaps Map!" - ) - return - - # hide all other companion-widgets - for m in (self.parent, *self.parent._children): - if m == clicked_map: - continue - if m._companion_widget is not None and m._companion_widget.isVisible(): - m._companion_widget.hide() - m._indicate_companion_map(False) - - if clicked_map._companion_widget is None: - clicked_map._init_companion_widget() - - if clicked_map._companion_widget is not None: - if clicked_map._companion_widget.isVisible(): - clicked_map._companion_widget.hide() - clicked_map._indicate_companion_map(False) - else: - clicked_map._companion_widget.show() - clicked_map._indicate_companion_map(True) - # execute all actions that should trigger before opening the widget - # (e.g. update tabs to show visible layers etc.) - for f in clicked_map._on_show_companion_widget: - f() - - # Do NOT activate the companion widget in here!! - # Activating the window during the callback steals focus and - # as a consequence the key-released-event is never triggered - # on the figure and "w" would remain activated permanently. - - _key_release_event(clicked_map.f.canvas, "w") - clicked_map._companion_widget.activateWindow() - - def _init_companion_widget(self, show_hide_key="w"): - """ - Create and show the EOmaps Qt companion widget. - - Note - ---- - The companion-widget requires using matplotlib with the Qt5Agg backend! - To activate, use: `plt.switch_backend("Qt5Agg")` - - Parameters - ---------- - show_hide_key : str or None, optional - The keyboard-shortcut that is assigned to show/hide the widget. - The default is "w". - """ - try: - from .qtcompanion.app import MenuWindow - - if self._companion_widget is not None: - _log.error( - "EOmaps: There is already an existing companinon widget for this" - " Maps-object!" - ) - return - if plt.get_backend().lower() in ["qtagg", "qt5agg"]: - # only pass parent if Qt is used as a backend for matplotlib! - self._companion_widget = MenuWindow(m=self, parent=self.f.canvas) - else: - self._companion_widget = MenuWindow(m=self) - self._companion_widget.toggle_always_on_top() - self._companion_widget.hide() # hide on init - - # connect any pending signals - for key, funcs in getattr(self, "_connect_signals_on_init", dict()).items(): - while len(funcs) > 0: - self._connect_signal(key, funcs.pop()) - - # make sure that we clear the colormap-pixmap cache on startup - self._emit_signal("cmapsChanged") - - except Exception: - _log.exception( - "EOmaps: Unable to initialize companion widget.", - exc_info=_log.getEffectiveLevel() <= logging.DEBUG, - ) - - def _connect_signal(self, name, func): - parent = self.parent - widget = parent._companion_widget - - # NOTE: use Maps.config(log_level=5) to get signal log messages! - if widget is None: - if not hasattr(parent, "_connect_signals_on_init"): - parent._connect_signals_on_init = dict() - - parent._connect_signals_on_init.setdefault(name, set()).add(func) - - if widget is not None: - try: - getattr(parent._signal_container, name).connect(func) - _log.log(1, f"Signal connected: {name} ({func.__name__})") - - except Exception: - _log.log( - 1, - f"There was a problem while trying to connect the function {func} " - f"to the signal {name} ", - exc_info=True, - ) - - def _emit_signal(self, name, *args): - parent = self.parent - widget = parent._companion_widget - - # NOTE: use Maps.config(log_level=5) to get signal log messages! - if widget is not None: - try: - getattr(parent._signal_container, name).emit(*args) - _log.log(1, f"Signal emitted: {name} {args}") - except Exception: - _log.log( - 1, - f"There was a problem while trying to emit the signal {name} " - f"with the args {args}", - exc_info=True, - ) - - def _get_always_on_top(self): - try: - if "qt" in plt.get_backend().lower(): - from qtpy import QtCore - - w = self.f.canvas.window() - return bool(w.windowFlags() & QtCore.Qt.WindowStaysOnTopHint) - except Exception: - _log.debug("Error while trying to get 'always_on_top' flag") - return False - return False - - def _set_always_on_top(self, q): - # keep pyqt window on top - try: - from qtpy import QtCore - - if q: - # only do this if necessary to avoid flickering - # see https://stackoverflow.com/a/40007740/9703451 - if not self._get_always_on_top(): - # in case pyqt is used as backend, also keep the figure on top - if "qt" in plt.get_backend().lower(): - w = self.f.canvas.window() - ws = w.size() - w.setWindowFlags( - w.windowFlags() | QtCore.Qt.WindowStaysOnTopHint - ) - w.resize(ws) - w.show() - - # handle the widget in case it was activated (possible also for - # backends other than qt) - if self._companion_widget is not None: - cw = self._companion_widget.window() - cws = cw.size() - cw.setWindowFlags( - cw.windowFlags() | QtCore.Qt.WindowStaysOnTopHint - ) - cw.resize(cws) - cw.show() - - else: - if self._get_always_on_top(): - if "qt" in plt.get_backend().lower(): - w = self.f.canvas.window() - ws = w.size() - w.setWindowFlags( - w.windowFlags() & ~QtCore.Qt.WindowStaysOnTopHint - ) - w.resize(ws) - w.show() - - if self._companion_widget is not None: - cw = self._companion_widget.window() - cws = cw.size() - cw.setWindowFlags( - cw.windowFlags() & ~QtCore.Qt.WindowStaysOnTopHint - ) - cw.resize(cws) - cw.show() - except Exception: - pass - - @staticmethod - def _make_rect_poly(x0, y0, x1, y1, crs=None, npts=100): - """ - Return a geopandas.GeoDataFrame with a rectangle in the given crs. - - Parameters - ---------- - x0, y0, y1, y1 : float - the boundaries of the shape - npts : int, optional - The number of points used to draw the polygon-lines. The default is 100. - crs : any, optional - a coordinate-system identifier. (e.g. output of `m.get_crs(crs)`) - The default is None. - - Returns - ------- - gdf : geopandas.GeoDataFrame - the geodataframe with the shape and crs defined - - """ - (gpd,) = register_modules("geopandas") - - from shapely.geometry import Polygon - - xs, ys = np.linspace([x0, y0], [x1, y1], npts).T - x0, y0, x1, y1, xs, ys = np.broadcast_arrays(x0, y0, x1, y1, xs, ys) - verts = np.column_stack(((x0, ys), (xs, y1), (x1, ys[::-1]), (xs[::-1], y0))).T - - gdf = gpd.GeoDataFrame(geometry=[Polygon(verts)]) - gdf.set_crs(crs, inplace=True) - - return gdf - - def fetch_companion_wms_layers(self, refetch=True): - """ - Fetch (and cache) WebMap layer names for the companion-widget. - - The cached layers are stored at the following location: - - >>> from eomaps import _data_dir - >>> print(_data_dir) - - Parameters - ---------- - refetch : bool, optional - If True, the layers will be re-fetched and the cache will be updated. - If False, the cached dict is loaded and returned. - The default is True. - """ - from .qtcompanion.widgets.wms import AddWMSMenuButton - - return AddWMSMenuButton.fetch_all_wms_layers(self, refetch=refetch) - - if refetch_wms_on_size_change is not None: - - @wraps(refetch_wms_on_size_change) - def refetch_wms_on_size_change(self, *args, **kwargs): - """Set the behavior for WebMap services on axis or figure size changes.""" - refetch_wms_on_size_change(*args, **kwargs) - - def _get_alpha_cmap_name(self, alpha): - # get a unique name for the colormap - try: - ncmaps = max( - [ - int(i.rsplit("_", 1)[1]) - for i in plt.colormaps() - if i.startswith("EOmaps_alpha_") - ] - ) - except Exception: - ncmaps = 0 - - return f"EOmaps_alpha_{ncmaps + 1}" - - def _encode_values(self, val): - """ - Encode values with respect to the provided "scale_factor" and "add_offset". - - Encoding is performed via the formula: - - `encoded_value = val / scale_factor - add_offset` - - NOTE: the data-type is not altered!! - (e.g. no integer-conversion is performed, only values are adjusted) - - Parameters - ---------- - val : array-like - The data-values to encode - - Returns - ------- - encoded_values - The encoded data values - """ - encoding = self.data_specs.encoding - - if encoding is not None and encoding is not False: - try: - scale_factor = encoding.get("scale_factor", None) - add_offset = encoding.get("add_offset", None) - fill_value = encoding.get("_FillValue", None) - - if val is None: - return fill_value - - if add_offset: - val = val - add_offset - if scale_factor: - val = val / scale_factor - - return val - except Exception: - _log.exception(f"EOmaps: Error while trying to encode the data: {val}") - return val - else: - return val - - def _decode_values(self, val): - """ - Decode data-values with respect to the provided "scale_factor" and "add_offset". - - Decoding is performed via the formula: - - `actual_value = add_offset + scale_factor * val` - - The encoding is defined in `m.data_specs.encoding` - - Parameters - ---------- - val : array-like - The encoded data-values - - Returns - ------- - decoded_values - The decoded data values - """ - if val is None: - return None - - encoding = self.data_specs.encoding - if not any(encoding is i for i in (None, False)): - try: - scale_factor = encoding.get("scale_factor", None) - add_offset = encoding.get("add_offset", None) - - if scale_factor: - val = val * scale_factor - if add_offset: - val = val + add_offset - - return val - except Exception: - _log.exception(f"EOmaps: Error while trying to decode the data {val}.") - return val - else: - return val - - def _calc_vmin_vmax(self, vmin=None, vmax=None): - if self.data is None: - return vmin, vmax - - calc_min, calc_max = vmin is None, vmax is None - - # ignore fill_values when evaluating vmin/vmax on integer-encoded datasets - if ( - self.data_specs.encoding is not None - and isinstance(self._data_manager.z_data, np.ndarray) - and issubclass(self._data_manager.z_data.dtype.type, np.integer) - ): - - # note the specific way how to check for integer-dtype based on issubclass - # since isinstance() fails to identify all integer dtypes!! - # isinstance(np.dtype("uint8"), np.integer) (incorrect) False - # issubclass(np.dtype("uint8").type, np.integer) (correct) True - # for details, see https://stackoverflow.com/a/934652/9703451 - - fill_value = self.data_specs.encoding.get("_FillValue", None) - if fill_value and any([calc_min, calc_max]): - # find values that are not fill-values - use_vals = self._data_manager.z_data[ - self._data_manager.z_data != fill_value - ] - - if calc_min: - vmin = np.min(use_vals) - if calc_max: - vmax = np.max(use_vals) - - return vmin, vmax - - # use nanmin/nanmax for all other arrays - if calc_min: - vmin = np.nanmin(self._data_manager.z_data) - if calc_max: - vmax = np.nanmax(self._data_manager.z_data) - - return vmin, vmax - - def _set_vmin_vmax(self, vmin=None, vmax=None): - # don't encode nan-vailes to avoid setting the fill-value as vmin/vmax - if vmin is not None: - vmin = self._encode_values(vmin) - if vmax is not None: - vmax = self._encode_values(vmax) - - # handle inherited bounds - if self._inherit_classification is not None: - if not (vmin is None and vmax is None): - raise TypeError( - "EOmaps: 'vmin' and 'vmax' cannot be set explicitly " - "if the classification is inherited!" - ) - - # in case data is NOT inherited, warn if vmin/vmax is None - # (different limits might cause a different appearance of the data!) - if self.data_specs._m == self: - if self._vmin is None: - _log.warning("EOmaps: Inherited value for 'vmin' is None!") - if self._vmax is None: - _log.warning( - "EOmaps: Inherited inherited value for 'vmax' is None!" - ) - - self._vmin = self._inherit_classification._vmin - self._vmax = self._inherit_classification._vmax - return - - if not self.shape.name.startswith("shade_"): - # ignore fill_values when evaluating vmin/vmax on integer-encoded datasets - self._vmin, self._vmax = self._calc_vmin_vmax(vmin=vmin, vmax=vmax) - else: - # get the name of the used aggretation reduction - aggname = self.shape.aggregator.__class__.__name__ - if aggname in ["first", "last", "max", "min", "mean", "mode"]: - # set vmin/vmax in case the aggregation still represents data-values - self._vmin, self._vmax = self._calc_vmin_vmax(vmin=vmin, vmax=vmax) - else: - # set vmin/vmax for aggregations that do NOT represent data values - # allow vmin/vmax = None (e.g. autoscaling) - self._vmin, self._vmax = vmin, vmax - if "count" in aggname: - # if the reduction represents a count, don't count empty pixels - if vmin and vmin <= 0: - _log.warning( - "EOmaps: setting vmin=1 to avoid counting empty pixels..." - ) - self._vmin = 1 + self._ClipboardMixin__on_keypress(event) + self._CompanionMixin__on_keypress(event) diff --git a/eomaps/grid.py b/eomaps/grid.py index 5ec4d1dd6..de9c968e6 100644 --- a/eomaps/grid.py +++ b/eomaps/grid.py @@ -13,6 +13,8 @@ from matplotlib.collections import LineCollection +from .helpers import _proxy + _log = logging.getLogger(__name__) @@ -63,7 +65,7 @@ class GridLines: def __init__( self, m, d=None, auto_n=10, layer=None, bounds=None, n=100, dynamic=False ): - self.m = m._proxy(m) + self.m = _proxy(m) self._d = d self._auto_n = auto_n @@ -366,15 +368,19 @@ def _add_grid(self, **kwargs): # don't trigger draw since this would result in a recursion! # (_redraw is called on each fetch-bg event) if self._dynamic: - self.m.BM.add_artist(self._coll, layer=self.layer) + self.m.l[self.layer].add_artist(self._coll) else: - self.m.BM.add_bg_artist(self._coll, layer=self.layer, draw=False) + self.m.l[self.layer].add_bg_artist(self._coll, draw=False) def _redraw(self): self._get_lines.cache_clear() try: self._remove() except Exception as ex: + _log.debug( + f"Encountered exception {ex} while trying to remove gridlines", + exc_info=True, + ) # catch exceptions to avoid issues with dynamic re-drawing of # invisible grids pass @@ -391,14 +397,9 @@ def _remove(self): # don't trigger draw since this would result in a recursion! # (_redraw is called on each fetch-bg event) if self._dynamic: - self.m.BM.remove_artist(self._coll, layer=self.layer) + self.m.l[self.layer].remove_artist(self._coll) else: - self.m.BM.remove_bg_artist(self._coll, layer=self.layer, draw=False) - - try: - self._coll.remove() - except ValueError: - pass + self.m.l[self.layer].remove_bg_artist(self._coll, draw=False) self._coll = None @@ -575,7 +576,7 @@ def __init__( self._kwargs.setdefault("clip_box", self._g.m.ax.bbox) if not self._g._dynamic: - self._g.m.BM._before_fetch_bg_actions.append(self._redraw) + self._g.m._bm.add_hook("before_fetch_bg", self._redraw, True) def _set_exclude(self, exclude): # a list of tick values to exclude @@ -619,23 +620,18 @@ def _remove(self): while len(self._texts) > 0: try: t = self._texts.pop(-1) - try: - t.remove() - except ValueError: - pass if self._g._dynamic: - self._g.m.BM.remove_artist(t) + self._g.m._bm.remove_artist(t) else: - self._g.m.BM.remove_bg_artist(t, draw=False) + self._g.m.l[self._g.layer].remove_bg_artist(t, draw=False) except Exception: _log.exception("EOmaps: Problem while trying to remove a grid-label:") pass def remove(self): """Remove the grid-labels from the map.""" - if self._redraw in self._g.m.BM._before_fetch_bg_actions: - self._g.m.BM._before_fetch_bg_actions.remove(self._redraw) + self._g.m._bm.remove_hook("before_fetch_bg", self._redraw, True) self._remove() @@ -728,7 +724,6 @@ def _get_spine_intersections(self, lines, axis=None): lines_fig[:, 0, 0 if axis == 1 else 1] -= 0.01 lines_fig[:, -1, 0 if axis == 1 else 1] += 0.01 - tr = m.ax.transData.inverted() tr_ax = m.ax.transAxes.inverted() # TODO would be nice to vectorize over gridlines as well @@ -914,18 +909,16 @@ def _add_axis_labels(self, lines, axis): t.set_label("__EOmaps_exclude") if self._g._dynamic: - m.BM.add_artist(t, layer=self._g.layer) + m.l[self._g.layer].add_artist(t) else: - m.BM.add_bg_artist(t, layer=self._g.layer, draw=False) + m.l[self._g.layer].add_bg_artist(t, draw=False) self._texts.append(t) def add_labels(self): """ Add labels to the grid. """ - m = self._g.m lines = self._g._get_lines() - aspect = m.ax.bbox.height / m.ax.bbox.width if self._where == "all": use_axes = (0, 1) @@ -946,7 +939,7 @@ class GridFactory: def __init__(self, m): self.m = m self._gridlines = [] - self.m.BM._before_fetch_bg_actions.append(self._update_autogrid) + self.m._bm.add_hook("before_fetch_bg", self._update_autogrid, True) def add_grid( self, @@ -1075,7 +1068,7 @@ def add_grid( else: raise TypeError(f"{labels} is not a valid input for labels") - self.m.f.canvas.draw_idle() + self.m.redraw(self.m.layer if layer is None else layer) return g def _update_autogrid(self, *args, **kwargs): @@ -1083,7 +1076,7 @@ def _update_autogrid(self, *args, **kwargs): if g.d is None: try: g._redraw() - except Exception as ex: + except Exception: # catch exceptions to avoid issues with dynamic re-drawing of # invisible grids continue diff --git a/eomaps/helpers.py b/eomaps/helpers.py index b90b8cba8..47b1f0359 100644 --- a/eomaps/helpers.py +++ b/eomaps/helpers.py @@ -13,6 +13,7 @@ from textwrap import indent, dedent from functools import wraps, lru_cache import warnings +import weakref import numpy as np import matplotlib.pyplot as plt @@ -28,6 +29,18 @@ _log = logging.getLogger(__name__) +def _proxy(obj): + # None cannot be weak-referenced! + if obj is None: + return None + + # create a proxy if the object is not yet a proxy + if type(obj) is not weakref.ProxyType: + return weakref.proxy(obj) + else: + return obj + + def _parse_log_level(level): """ Get the numerical log-level from string (or number). @@ -564,3 +577,26 @@ def query(self, x, k=1, d=None, pick_relative_to_closest=True): i = None return i + + +def _get_rect_poly_verts(x0, y0, x1, y1, npts=100): + """ + Return vertices of a rectangle with npts number of points. + + Parameters + ---------- + x0, y0, y1, y1 : float + the boundaries of the shape + npts : int, optional + The number of points used to draw the polygon-lines. The default is 100. + + Returns + ------- + gdf : geopandas.GeoDataFrame + the geodataframe with the shape and crs defined + + """ + xs, ys = np.linspace([x0, y0], [x1, y1], npts).T + x0, y0, x1, y1, xs, ys = np.broadcast_arrays(x0, y0, x1, y1, xs, ys) + verts = np.column_stack(((x0, ys), (xs, y1), (x1, ys[::-1]), (xs[::-1], y0))).T + return verts diff --git a/eomaps/inset_maps.py b/eomaps/inset_maps.py index d4a3ea150..f9809f1ea 100755 --- a/eomaps/inset_maps.py +++ b/eomaps/inset_maps.py @@ -11,6 +11,7 @@ from . import Maps from .grid import _intersect, _get_intersect +from .helpers import _proxy class InsetMaps(Maps): @@ -28,7 +29,7 @@ class InsetMaps(Maps): def __init__( self, - parent, + parent_m, crs=4326, layer=None, xy=(45, 45), @@ -45,7 +46,7 @@ def __init__( **kwargs, ): - self._parent_m = self._proxy(parent) + self._parent_m = _proxy(parent_m) self._indicators = [] # inherit the layer from the parent Maps-object if not explicitly # provided @@ -54,9 +55,9 @@ def __init__( # put all inset-map artists on dedicated layers # NOTE: all artists of inset-map axes are put on a dedicated layer - # with a "__inset_" prefix to ensure they appear on top of other artists + # with a "**inset_" prefix to ensure they appear on top of other artists # (AND on top of spines of normal maps)! - # layer = "__inset_" + str(layer) + # layer = "**inset_" + str(layer) possible_shapes = ["ellipses", "rectangles", "geod_circles"] assert ( @@ -159,7 +160,7 @@ def __init__( self._bg_patch = None # attach callback to update indicator patches - self.BM._before_fetch_bg_actions.append(self._update_indicator) + self._bm.add_hook("before_fetch_bg", self._update_indicator, True) def _get_spine_verts(self): s = self.ax.spines["geo"] @@ -179,7 +180,7 @@ def _update_indicator(self, *args, **kwargs): while len(self._patches) > 0: patch = self._patches.pop() - self.BM.remove_bg_artist(patch, draw=False) + self.all.remove_bg_artist(patch, draw=False) try: patch.remove() except ValueError: @@ -197,13 +198,13 @@ def _update_indicator(self, *args, **kwargs): # all buttons since they will not work on dynamically re-created artists... p.set_label("__EOmaps_deactivated InsetMap indicator") art = m.ax.add_patch(p) - self.BM.add_bg_artist(art, layer=m.layer, draw=False) + self.all.add_bg_artist(art, draw=False) self._patches.add(art) def _handle_spines(self): spine = self.ax.spines["geo"] - if spine not in self.BM._bg_artists.get("__inset___SPINES__", []): - self.BM.add_bg_artist(spine, layer="__inset___SPINES__") + if spine not in self._bm._bg_artists["**inset_**SPINES**"]: + self._bm._bg_artists.add("**inset_**SPINES**", spine) def _get_ax_label(self): return "inset_map" @@ -289,7 +290,7 @@ def add_indicator_line(self, m=None, **kwargs): l = self._parent.ax.add_artist(l) l.set_clip_on(False) - self.BM.add_bg_artist(l, layer=self.layer, draw=False) + self.all.add_bg_artist(l, draw=False) self._indicator_lines.append((l, m)) if isinstance(m, InsetMaps): @@ -311,11 +312,11 @@ def add_indicator_line(self, m=None, **kwargs): l2.set_clip_on(True) l2 = m.ax.add_artist(l2) - self.BM.add_bg_artist(l2, layer=self.layer) + self.add_bg_artist(l2, draw=False) self._indicator_lines.append((l2, m)) self._update_indicator_lines() - self.BM._before_fetch_bg_actions.append(self._update_indicator_lines) + self._bm.add_hook("before_fetch_bg", self._update_indicator_lines, True) def _update_indicator_lines(self, *args, **kwargs): spine_verts = self._get_spine_verts() @@ -384,7 +385,7 @@ def set_inset_position(self, x=None, y=None, size=None): y = (y0 + y1) / 2 self.ax.set_position((x - size / 2, y - size / 2, size, size)) - self.redraw("__inset_" + self.layer, "__inset___SPINES__") + self.redraw("**inset_" + self.layer, "**inset_**SPINES**") # a convenience-method to get the position based on the center of the axis def get_inset_position(self, precision=3): diff --git a/eomaps/layout_editor.py b/eomaps/layout_editor.py index cb7fcc9b2..af2d2d214 100644 --- a/eomaps/layout_editor.py +++ b/eomaps/layout_editor.py @@ -119,15 +119,15 @@ def modifier_pressed(self, val): self.m.cb.execute_callbacks(not val) if self._modifier_pressed: - self.m.BM._disable_draw = True - self.m.BM._disable_update = True + self.m._bm._disable_draw = True + self.m._bm._disable_update = True else: - self.m.BM._disable_draw = False - self.m.BM._disable_update = False + self.m._bm._disable_draw = False + self.m._bm._disable_update = False @property def ms(self): - return [self.m.parent, *self.m.parent._children] + return list(self.m._bm._children) @property def maxes(self): @@ -385,21 +385,21 @@ def cb_pick(self, event): def fetch_current_background(self): # clear the renderer to avoid drawing on existing backgrounds - renderer = self.m.BM.canvas.get_renderer() + renderer = self.m._bm.canvas.get_renderer() renderer.clear() with ExitStack() as stack: for ax in self._ax_picked: stack.enter_context(ax._cm_set(visible=False)) - self.m.BM.blit_artists(self.axes, None, False) + self.m._bm.blit_artists(self.axes, None, False) grid = getattr(self, "_snap_grid_artist", None) if grid is not None: - self.m.BM.blit_artists([grid], None, False) + self.m._bm.blit_artists([grid], None, False) - self.m.BM.canvas.blit() - self._current_bg = self.m.BM.canvas.copy_from_bbox(self.m.f.bbox) + self.m._bm.canvas.blit() + self._current_bg = self.m._bm.canvas.copy_from_bbox(self.m.f.bbox) def cb_move_with_key(self, event): if not self.modifier_pressed: @@ -463,7 +463,7 @@ def blit_artists(self): if getattr(self, "_info_text", None) is not None: artists.append(self._info_text) - self.m.BM.blit_artists(artists, self._current_bg) + self.m._bm.blit_artists(artists, self._current_bg) def cb_scroll(self, event): if (self.f.canvas.toolbar is not None) and self.f.canvas.toolbar.mode != "": @@ -609,26 +609,32 @@ def _snap(self): return snap def ax_on_layer(self, ax): - if ax in self.m.BM._get_unmanaged_axes(): + if ax in self.m._bm._get_unmanaged_axes(): return True elif ax in self.maxes: return True else: - for layer in (*self.m.BM._get_active_layers_alphas[0], "__SPINES__", "all"): + for layer in ( + *self.m._bm._get_active_layers_alphas[0], + "**SPINES**", + "all", + ): # logos are put on the spines-layer to appear on top of spines! - if ax in self.m.BM.get_bg_artists(layer): + if ax in self.m._bm.get_bg_artists(layer): return True - elif ax in self.m.BM.get_artists(layer): + elif ax in self.m._bm.get_artists(layer): return True return False def _make_draggable(self, filepath=None): + self.m._hide_all_companion_widget_indicators() + # Uncheck active pan/zoom actions of the matplotlib toolbar. # use a try-except block to avoid issues with ipympl in jupyter notebooks # (see https://github.com/matplotlib/ipympl/issues/530#issue-1780919042) try: - toolbar = getattr(self.m.BM.canvas, "toolbar", None) + toolbar = getattr(self.m._bm.canvas, "toolbar", None) if toolbar is not None: for key in ["pan", "zoom"]: val = toolbar._actions.get(key, None) @@ -735,6 +741,7 @@ def _make_draggable(self, filepath=None): self._info_text = self.add_info_text() self._color_axes() + self._attach_callbacks() self.m._emit_signal("layoutEditorActivated") @@ -812,6 +819,7 @@ def _undo_draggable(self): # remove snap-grid (if it's still visible) self._remove_snap_grid() + self.m._show_all_companion_widget_indicators() self.m._emit_signal("layoutEditorDeactivated") diff --git a/eomaps/mapsgrid.py b/eomaps/mapsgrid.py index 24ac6b61c..8e2d6659e 100644 --- a/eomaps/mapsgrid.py +++ b/eomaps/mapsgrid.py @@ -3,536 +3,187 @@ # This file is part of EOmaps and is released under the BSD 3-clause license. # See LICENSE in the root of the repository for full licensing details. -"""Mapsgrid class definition (helper for initialization of regular Maps-grids).""" +"""MapsGrid class definition (helper to work with regular grids of maps).""" -from functools import wraps, lru_cache +from itertools import chain import numpy as np + from matplotlib.gridspec import GridSpec import matplotlib.pyplot as plt -from .shapes import Shapes from .eomaps import Maps - -from .ne_features import NaturalEarthFeatures - -try: - from .webmap_containers import WebMapContainer -except ImportError: - WebMapContainer = None +from ._maps_base import MultiCaller -class MapsGrid: +class MapsGrid(MultiCaller): """ Initialize a grid of Maps objects + Any action performed on the MapsGrid accessor will be executed on ALL + Maps of the grid! + + You can access individual Maps-objects via the m__ properties: + + >>> mgrid.m_0_0 # access first map of first row + + or via indexing/slicing: + + >>> mgrid[0,0] # access first map of first row + >>> mgrid[0] # access first map of flattened grid + >>> mgrid[[0, 1]] # get accessor to run actions on first and second map + >>> mgrid[:4] # get accessor to run actions on first 4 maps + + You can also iterate over the MapsGrid: + + >>> for m in mg: + >>> ... # loop over all maps of the grid + + Parameters ---------- - r : int, optional + nrows : int, optional The number of rows. The default is 2. - c : int, optional + ncols : int, optional The number of columns. The default is 2. - crs : int or a cartopy-projection, optional + crs : int or a cartopy-projection or a list, optional The projection that will be assigned to all Maps objects. (you can still change the projection of individual Maps objects later!) - See the doc of "Maps" for details. + See the doc of "Maps" for details. If a list is provided, it is used + to assign individual crs to each Maps-object of the grid. The default is 4326. - m_inits : dict, optional - A dictionary that is used to customize the initialization the Maps-objects. - - The keys of the dictionaries are used as names for the Maps-objects, - (accessible via `mgrid.m_` or `mgrid[m_]`) and the values are used to - identify the position of the axes in the grid. - - Possible values are: - - a tuple of (row, col) - - an integer representing (row + col) - - Note: If either `m_inits` or `ax_inits` is provided, ONLY objects with the - specified properties are initialized! - - The default is None in which case a unique Maps-object will be created - for each grid-cell (accessible via `mgrid.m__`) - ax_inits : dict, optional - Completely similar to `m_inits` but instead of `Maps` objects, ordinary - matplotlib axes will be initialized. They are accessible via `mg.ax_`. - - Note: If you iterate over the MapsGrid object, ONLY the initialized Maps - objects will be returned! figsize : (float, float) The width and height of the figure. layer : int or str The default layer to assign to all Maps-objects of the grid. - The default is 0. - f : matplotlib.Figure or None - The matplotlib figure to use. If None, a new figure will be created. - The default is None. + The default is "base" kwargs Additional keyword-arguments passed to the `matplotlib.gridspec.GridSpec()` function that is used to initialize the grid. Attributes ---------- - m_ : eomaps.Maps objects - The individual Maps-objects can be accessed via `mgrid.m_` - The identifiers are hereby `_` or the keys of the `m_inits` - dictionary (if provided) - ax_ : matplotlib.axes - The individual (ordinary) matplotlib axes can be accessed via - `mgrid.ax_`. The identifiers are hereby the keys of the - `ax_inits` dictionary (if provided). - Note: if `ax_inits` is not specified, NO ordinary axes will be created! - - - Methods - ------- - join_limits : - join the axis-limits of maps that share the same projection - share_click_events : - share click-callback events between the Maps-objects - share_pick_events : - share pick-callback events between the Maps-objects - create_axes : - create a new (ordinary) matplotlib axes - add_<...> : - call the underlying `add_<...>` method on all Maps-objects of the grid - set_<...> : - set the corresponding property on all Maps-objects of the grid - subplots_adjust : - Dynamically adjust the layout of the subplots, e.g: - - >>> mg.subplots_adjust(left=0.1, right=0.9, - >>> top=0.8, bottom=0.1, - >>> wspace=0.05, hspace=0.25) + m__ : eomaps.Maps objects + The individual Maps-objects can be accessed via + `mgrid.m_0_0` -> the first Maps object of the first row + Examples -------- To initialize a 2 by 2 grid with a large map on top, a small map on the bottom-left and an ordinary matplotlib plot on the bottom-right, use: - >>> m_inits = dict(top = (0, slice(0, 2)), - >>> bottom_left=(1, 0)) - >>> ax_inits = dict(bottom_right=(1, 1)) - >>> mg = MapsGrid(2, 2, m_inits=m_inits, ax_inits=ax_inits) + >>> mg = MapsGrid(2, 2, crs=4326) + >>> mg.add_feature.preset.coastline() + >>> mg.set_data(data=[1,2,3], x=[1,2,3], y=[1,2,3]) >>> mg.m_top.plot_map() - >>> mg.m_bottom_left.plot_map() - >>> mg.ax_bottom_right.plot([1,2,3]) Returns ------- eomaps.MapsGrid Accessor to the Maps objects "m_{row}_{column}". - Notes - ----- - - - To perform actions on all Maps-objects of the grid, simply iterate over - the MapsGrid object! """ - def __init__( - self, - r=2, - c=2, - crs=None, - m_inits=None, - ax_inits=None, - figsize=None, - layer="base", - f=None, - **kwargs, - ): - - self._Maps = [] - self._names = dict() - - if WebMapContainer is not None: - self._wms_container = WebMapContainer(self) - - gskwargs = dict(bottom=0.01, top=0.99, left=0.01, right=0.99) - gskwargs.update(kwargs) - self.gridspec = GridSpec(nrows=r, ncols=c, **gskwargs) - - if m_inits is None and ax_inits is None: + __parent_only_attrs = ( + "f", + "parent", + "savefig", + "redraw", + "snapshot", + "show", + "show_layer", + "edit_layout", + "get_layout", + "apply_layout", + "subplots_adjust", + "CRS", + "fetch_layers", + "new_inset_map", + "new_map", + "new_subplot", + "util", + ) + + def __init__(self, nrows=2, ncols=2, crs=None, figsize=None, layer=None, **kwargs): + self.__nrows = nrows + self.__ncols = ncols + + if crs is None: + crs = [Maps.CRS.PlateCarree()] * nrows * ncols + else: if isinstance(crs, list): - crs = np.array(crs).reshape((r, c)) + crs = [Maps._get_cartopy_crs(i) for i in np.ravel(crs)] else: - crs = np.broadcast_to(crs, (r, c)) - - self._custom_init = False - for i in range(r): - for j in range(c): - crsij = crs[i, j] - if isinstance(crsij, np.generic): - crsij = crsij.item() - - if i == 0 and j == 0: - # use crs[i, j].item() to convert to native python-types - # (instead of numpy-dtypes) ... check numpy.ndarray.item - mij = Maps( - crs=crsij, - ax=self.gridspec[0, 0], - figsize=figsize, - layer=layer, - f=f, - ) - mij.ax.set_label("mg_map_0_0") - self.parent = mij - else: - mij = Maps( - crs=crsij, - f=self.parent.f, - ax=self.gridspec[i, j], - layer=layer, - ) - mij.ax.set_label(f"mg_map_{i}_{j}") - self._Maps.append(mij) - name = f"{i}_{j}" - self._names.setdefault("Maps", []).append(name) - setattr(self, "m_" + name, mij) - else: - self._custom_init = True - if m_inits is not None: - if not isinstance(crs, dict): - if isinstance(crs, np.generic): - crs = crs.item() - - crs = {key: crs for key in m_inits} - - assert self._test_unique_str_keys( - m_inits - ), "EOmaps: there are duplicated keys in m_inits!" - - for i, [key, val] in enumerate(m_inits.items()): - if ax_inits is not None: - q = set(m_inits).intersection(set(ax_inits)) - assert ( - len(q) == 0 - ), f"You cannot provide duplicate keys! Check: {q}" - - if i == 0: - mi = Maps( - crs=crs[key], - ax=self.gridspec[val], - figsize=figsize, - layer=layer, - f=f, - ) - mi.ax.set_label(f"mg_map_{key}") - self.parent = mi - else: - mi = Maps( - crs=crs[key], - ax=self.gridspec[val], - layer=layer, - f=self.parent.f, - ) - mi.ax.set_label(f"mg_map_{key}") - - name = str(key) - self._names.setdefault("Maps", []).append(name) - - self._Maps.append(mi) - setattr(self, f"m_{name}", mi) - - if ax_inits is not None: - assert self._test_unique_str_keys( - ax_inits - ), "EOmaps: there are duplicated keys in ax_inits!" - for key, val in ax_inits.items(): - self.create_axes(val, name=key) - - def new_layer(self, layer=None): - if layer is None: - layer = self.parent.layer - - mg = MapsGrid(m_inits=dict()) # initialize an empty MapsGrid - mg.gridspec = self.gridspec - - for name, m in zip(self._names.get("Maps", []), self._Maps): - newm = m.new_layer(layer) - mg._Maps.append(newm) - mg._names["Maps"].append(name) - setattr(mg, "m_" + name, newm) - - if m is self.parent: - mg.parent = newm - - for name in self._names.get("Axes", []): - ax = getattr(self, f"ax_{name}") - mg._names["Axes"].append(name) - setattr(mg, f"ax_{name}", ax) - - return mg - - def cleanup(self): - for m in self: - m.cleanup() - - @staticmethod - def _test_unique_str_keys(x): - # check if all keys are unique (as strings) - seen = set() - return not any(str(i) in seen or seen.add(str(i)) for i in x) - - def __iter__(self): - return iter(self._Maps) - - def __getitem__(self, key): - try: - if self._custom_init is False: - if isinstance(key, str): - r, c = map(int, key.split("_")) - elif isinstance(key, (list, tuple)): - r, c = key - else: - raise IndexError(f"{key} is not a valid indexer for MapsGrid") - - return getattr(self, f"m_{r}_{c}") + crs = [Maps._get_cartopy_crs(crs)] * nrows * ncols + + f = plt.figure(figsize=figsize) + aspect = f.get_figheight() / f.get_figwidth() + + d = 0.02 + gs = GridSpec( + nrows, + ncols, + bottom=d * aspect, + top=1 - d * aspect, + left=d, + right=1 - d, + hspace=d * aspect, + wspace=d, + ) + + m_parent = Maps(f=f, ax=list(gs)[0], crs=crs[0], layer=layer, **kwargs) + + mg = [ + m_parent, + *( + Maps(f=f, ax=g, crs=c, layer=layer, parent=m_parent, **kwargs) + for g, c in zip(list(gs)[1:], crs[1:]) + ), + ] + + return MultiCaller.__init__(self, elements=mg) + + def __dir__(self): + return [i for i in dir(Maps) if not i.startswith("_")] + + def __getitem__(self, idx): + if isinstance(idx, int): + # implement 1d indexing, e.g. mg[1] + return self._elements[idx] + elif isinstance(idx, tuple) and len(idx) == 2: + # implement 2d indexing, e.g. mg[1,2] + idx = np.ravel_multi_index(idx, (self.__nrows, self.__ncols)).item() + return self._elements[idx] + elif isinstance(idx, slice): + # implement slicing, e.g.: mg[1:-2] + start, stop, step = idx.indices(len(self._elements)) + return sum([self.__getitem__(i) for i in range(start, stop, step)]) + elif isinstance(idx, list): + # implement multi-seletion, e.g.: mg[[1,2,5]] + return sum([self.__getitem__(i) for i in idx]) + + def __getattribute__(self, name): + if name in dir(MapsGrid): + return object.__getattribute__(self, name) + + if name.startswith("m_"): + i, *j = map(int, name.removeprefix("m_").split("_")) + if j: + idx = (i, j[0]) else: - if str(key) in self._names.get("Maps", []): - return getattr(self, "m_" + str(key)) - elif str(key) in self._names.get("Axes", []): - return getattr(self, "ax_" + str(key)) - else: - raise IndexError(f"{key} is not a valid indexer for MapsGrid") - except: - raise IndexError(f"{key} is not a valid indexer for MapsGrid") + idx = i + return self.__getitem__(idx) - @property - def _preferred_wms_service(self): - return self.parent._preferred_wms_service - - def create_axes(self, ax_init, name=None): - """ - Create (and return) an ordinary matplotlib axes. - - Note: If you intend to use both ordinary axes and Maps-objects, it is - recommended to use explicit "m_inits" and "ax_inits" dicts in the - initialization of the MapsGrid to avoid the creation of overlapping axes! - - Parameters - ---------- - ax_init : set - The GridSpec specifications for the axis. - use `ax_inits = (, )` to get an axis in a given grid-cell - use `slice(, )` for `` or `` to get an axis - that spans over multiple rows/columns. - - Returns - ------- - ax : matplotlib.axist - The matplotlib axis instance - - Examples - -------- - - >>> ax_inits = dict(top = (0, slice(0, 2)), - >>> bottom_left=(1, 0)) - - >>> mg = MapsGrid(2, 2, ax_inits=ax_inits) - >>> mg.m_top.plot_map() - >>> mg.m_bottom_left.plot_map() - - >>> mg.create_axes((1, 1), name="bottom_right") - >>> mg.ax_bottom_right.plot([1,2,3], [1,2,3]) - - """ - - if name is None: - # get all existing axes - axes = [key for key in self.__dict__ if key.startswith("ax_")] - name = str(len(axes)) + if name in object.__getattribute__(self, "_MapsGrid__parent_only_attrs"): + return object.__getattribute__(self.__getitem__(0), name) else: - assert ( - name.isidentifier() - ), f"the provided name {name} is not a valid identifier" - - ax = self.f.add_subplot(self.gridspec[ax_init], label=f"mg_ax_{name}") - - self._names.setdefault("Axes", []).append(name) - setattr(self, f"ax_{name}", ax) - return ax - - _doc_prefix = ( - "This will execute the corresponding action on ALL Maps " - + "objects of the MapsGrid!\n" - ) - - @property - def children(self): - return [i for i in self if i is not self.parent] - - @property - def f(self): - return self.parent.f - - @wraps(Maps.plot_map) - def plot_map(self, **kwargs): - for m in self: - m.plot_map(**kwargs) - - plot_map.__doc__ = _doc_prefix + plot_map.__doc__ + return super().__getattribute__(name) @property - @lru_cache() - @wraps(Shapes) - def set_shape(self): - s = Shapes(self) - s.__doc__ = self._doc_prefix + s.__doc__ - - return s - - @wraps(Maps.set_data) - def set_data(self, *args, **kwargs): - for m in self: - m.set_data(*args, **kwargs) - - set_data.__doc__ = _doc_prefix + set_data.__doc__ - - @wraps(Maps.set_classify_specs) - def set_classify_specs(self, scheme=None, **kwargs): - for m in self: - m.set_classify_specs(scheme=scheme, **kwargs) - - set_classify_specs.__doc__ = _doc_prefix + set_classify_specs.__doc__ - - @wraps(Maps.add_annotation) - def add_annotation(self, *args, **kwargs): - for m in self: - m.add_annotation(*args, **kwargs) - - add_annotation.__doc__ = _doc_prefix + add_annotation.__doc__ - - @wraps(Maps.add_marker) - def add_marker(self, *args, **kwargs): - for m in self: - m.add_marker(*args, **kwargs) - - add_marker.__doc__ = _doc_prefix + add_marker.__doc__ - - if hasattr(Maps, "add_wms"): - - @property - @wraps(Maps.add_wms) - def add_wms(self): - return self._wms_container - - @property - @wraps(Maps.add_feature) - def add_feature(self): - x = NaturalEarthFeatures(self) - return x - - @wraps(Maps.add_gdf) - def add_gdf(self, *args, **kwargs): - for m in self: - m.add_gdf(*args, **kwargs) - - add_gdf.__doc__ = _doc_prefix + add_gdf.__doc__ - - @wraps(Maps.add_line) - def add_line(self, *args, **kwargs): - for m in self: - m.add_line(*args, **kwargs) - - add_line.__doc__ = _doc_prefix + add_line.__doc__ - - @wraps(Maps.add_scalebar) - def add_scalebar(self, *args, **kwargs): - for m in self: - m.add_scalebar(*args, **kwargs) - - add_scalebar.__doc__ = _doc_prefix + add_scalebar.__doc__ - - @wraps(Maps.add_compass) - def add_compass(self, *args, **kwargs): - for m in self: - m.add_compass(*args, **kwargs) - - add_compass.__doc__ = _doc_prefix + add_compass.__doc__ - - @wraps(Maps.add_colorbar) - def add_colorbar(self, *args, **kwargs): - for m in self: - m.add_colorbar(*args, **kwargs) - - add_colorbar.__doc__ = _doc_prefix + add_colorbar.__doc__ - - @wraps(Maps.add_logo) - def add_logo(self, *args, **kwargs): - for m in self: - m.add_logo(*args, **kwargs) - - add_colorbar.__doc__ = _doc_prefix + add_logo.__doc__ - - def share_click_events(self): - """ - Share click events between all Maps objects of the grid - """ - self.parent.cb.click.share_events(*self.children) - - def share_move_events(self): - """ - Share move events between all Maps objects of the grid - """ - self.parent.cb.move.share_events(*self.children) - - def share_pick_events(self, name="default"): - """ - Share pick events between all Maps objects of the grid - """ - if name == "default": - self.parent.cb.pick.share_events(*self.children) - else: - self.parent.cb.pick[name].share_events(*self.children) - - def join_limits(self): + def on_all_layers(self): """ - Join axis limits between all Maps objects of the grid - (only possible if all maps share the same crs!) + Accessor to run actions on **all layers** of **all maps** of the grid. """ - self.parent.join_limits(*self.children) - - @wraps(Maps.redraw) - def redraw(self, *args): - self.parent.redraw(*args) - - @wraps(plt.savefig) - def savefig(self, *args, **kwargs): - - # clear all cached background layers before saving to make sure they - # are re-drawn with the correct dpi-settings - self.parent.BM._refetch_bg = True - - self.parent.savefig(*args, **kwargs) - - @property - @wraps(Maps.util) - def util(self): - return self.parent.util - - @wraps(Maps.subplots_adjust) - def subplots_adjust(self, **kwargs): - return self.parent.subplots_adjust(**kwargs) - - @wraps(Maps.get_layout) - def get_layout(self, *args, **kwargs): - return self.parent.get_layout(*args, **kwargs) - - @wraps(Maps.apply_layout) - def apply_layout(self, *args, **kwargs): - return self.parent.apply_layout(*args, **kwargs) - - @wraps(Maps.edit_layout) - def edit_layout(self, *args, **kwargs): - return self.parent.edit_layout(*args, **kwargs) - - @wraps(Maps.show) - def show(self, *args, **kwargs): - return self.parent.show(*args, **kwargs) - - @wraps(Maps.snapshot) - def snapshot(self, *args, **kwargs): - return self.parent.snapshot(*args, **kwargs) + return sum(chain(*self.l)) diff --git a/eomaps/mixins/__init__.py b/eomaps/mixins/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/eomaps/mixins/add_mixin.py b/eomaps/mixins/add_mixin.py new file mode 100644 index 000000000..5fa8166b7 --- /dev/null +++ b/eomaps/mixins/add_mixin.py @@ -0,0 +1,858 @@ +import logging + +_log = logging.getLogger(__name__) + +from itertools import repeat, chain, pairwise +from functools import wraps +from pathlib import Path +import weakref + +from pyproj import CRS +import numpy as np + +import matplotlib as mpl +import matplotlib.pyplot as plt +from matplotlib.patches import Polygon +from matplotlib.colors import to_rgb + +from ..ne_features import NaturalEarthFeatures +from ..grid import GridFactory +from ..helpers import _TransformedBoundsLocator, _get_rect_poly_verts +from ..compass import Compass +from ..scalebar import ScaleBar + +try: + from .._webmap import _cx_refetch_wms_on_size_change + from ..webmap_containers import WebMapContainer +except ImportError as ex: + _log.error(f"EOmaps: Unable to import dependencies required for WebMaps: {ex}") + _cx_refetch_wms_on_size_change = None + WebMapContainer = None + + +class AddMixin: + add_feature = NaturalEarthFeatures + + if WebMapContainer is not None: + _preferred_wms_service = "wms" + add_wms = WebMapContainer + + def __init__(self, *args, **kwargs): + if WebMapContainer is not None: + self.add_wms = self.add_wms(weakref.proxy(self)) + self._wms_legend = dict() + + self.add_feature = self.add_feature(weakref.proxy(self)) + + if self.parent == self: + self._grid = GridFactory(self) + + # a set to hold references to the compass objects + self._compass = set() + + super().__init__(*args, **kwargs) + + @property + def __lazy_attrs(self): + # list of attributes that support lazy-evaluation + return [i for i in dir(AddMixin) if not i.startswith("_")] + + @wraps(GridFactory.add_grid) + def add_gridlines(self, *args, **kwargs): + """Add gridlines to the Map.""" + return self.parent._grid.add_grid(m=self, *args, **kwargs) + + @wraps(Compass.__call__) + def add_compass(self, *args, **kwargs): + """Add a compass (or north-arrow) to the map.""" + c = Compass(weakref.proxy(self)) + c(*args, **kwargs) + # store a reference to the object (required for callbacks)! + self._compass.add(c) + return c + + @wraps(ScaleBar.__init__) + def add_scalebar( + self, + pos=None, + rotation=0, + scale=None, + n=10, + preset=None, + autoscale_fraction=0.25, + auto_position=(0.8, 0.25), + scale_props=None, + patch_props=None, + label_props=None, + line_props=None, + layer=None, + size_factor=1, + pickable=True, + ): + """Add a scalebar to the map.""" + s = ScaleBar( + m=self, + preset=preset, + scale=scale, + n=n, + autoscale_fraction=autoscale_fraction, + auto_position=auto_position, + scale_props=scale_props, + patch_props=patch_props, + label_props=label_props, + line_props=line_props, + layer=layer, + size_factor=size_factor, + ) + + # add the scalebar to the map at the desired position + s._add_scalebar(pos=pos, azim=rotation, pickable=pickable) + self._bm.update() + return s + + def add_logo( + self, + filepath=None, + position="lr", + size=0.12, + pad=0.1, + layer=None, + fix_position=False, + **kwargs, + ): + """ + Add a small image (png, jpeg etc.) to the map. + + The position of the image is dynamically updated if the plot is resized or + zoomed. + + Parameters + ---------- + filepath : str, optional + if str: The path to the image-file. + The default is None in which case an EOmaps logo is added to the map. + position : str, optional + The position of the logo. + - "ul", "ur" : upper left, upper right + - "ll", "lr" : lower left, lower right + The default is "lr". + size : float, optional + The size of the logo as a fraction of the axis-width. + The default is 0.15. + pad : float, tuple optional + Padding between the axis-edge and the logo as a fraction of the logo-width. + If a tuple is passed, (x-pad, y-pad) + The default is 0.1. + layer : str or None, optional + The layer at which the logo should be visible. + If None, the logo will be added to ALL layers and will be drawn on + top of ALL background artists. The default is None. + fix_position : bool, optional + If True, the relative position of the logo (with respect to the map-axis) + is fixed (and dynamically updated on zoom / resize events) + + NOTE: If True, the logo can NOT be moved with the layout_editor! + The default is False. + kwargs : + Additional kwargs are passed to plt.imshow + """ + if layer is None: + layer = "**SPINES**" + + if filepath is None: + filepath = Path(__file__).parent.parent / "logo.png" + + im = mpl.image.imread(filepath) + + # replace default rgba colors of transparent regions with the + # color used by the axes background patch + try: + im[..., :3][im[..., 3] == 0] = to_rgb(self.ax.patch.get_facecolor()) + except Exception as ex: + _log.debug( + "Encountered a problem while trying to adjust color of " + f"transparent logo regions with axes background color: {ex}", + ) + + def getpos(pos): + s = size + if isinstance(pad, tuple): + pwx, pwy = (s * pad[0], s * pad[1]) + else: + pwx, pwy = (s * pad, s * pad) + + if position == "lr": + p = dict(rect=[pos.x1 - s - pwx, pos.y0 + pwy, s, s], anchor="SE") + elif position == "ll": + p = dict(rect=[pos.x0 + pwx, pos.y0 + pwy, s, s], anchor="SW") + elif position == "ur": + p = dict(rect=[pos.x1 - s - pwx, pos.y1 - s - pwy, s, s], anchor="NE") + elif position == "ul": + p = dict(rect=[pos.x0 + pwx, pos.y1 - s - pwy, s, s], anchor="NW") + return p + + figax = self.f.add_axes( + **getpos(self.ax.get_position()), label="logo", zorder=999, animated=True + ) + + figax.set_navigate(False) + figax.set_axis_off() + + kwargs.setdefault("aspect", "equal") + kwargs.setdefault("zorder", 999) + kwargs.setdefault("interpolation_stage", "rgba") + + _ = figax.imshow(im, **kwargs) + + self.l[layer].add_bg_artist(figax) + + if fix_position: + fixed_pos = ( + figax.get_position() + .transformed(self.f.transFigure) + .transformed(self.ax.transAxes.inverted()) + ) + + figax.set_axes_locator( + _TransformedBoundsLocator(fixed_pos.bounds, self.ax.transAxes) + ) + + def add_line( + self, + xy, + xy_crs=4326, + connect="geod", + n=None, + del_s=None, + mark_points=None, + layer=None, + **kwargs, + ): + """ + Draw a line by connecting a set of anchor-points. + + The points can be connected with either "geodesic-lines", "straight lines" or + "projected straight lines with respect to a given crs" (see `connect` kwarg). + + Parameters + ---------- + xy : list, set or numpy.ndarray + The coordinates of the anchor-points that define the line. + Expected shape: [(x0, y0), (x1, y1), ...] + xy_crs : any, optional + The crs of the anchor-point coordinates. + (can be any crs definition supported by PyProj) + The default is 4326 (e.g. lon/lat). + connect : str, optional + The connection-method used to draw the segments between the anchor-points. + + - "geod": Connect the anchor-points with geodesic lines + - "straight": Connect the anchor-points with straight lines + - "straight_crs": Connect the anchor-points with straight lines in the + `xy_crs` projection and reproject those lines to the plot-crs. + + The default is "geod". + n : int, list or None optional + The number of intermediate points to use for each line-segment. + + - If an integer is provided, each segment is equally divided into n parts. + - If a list is provided, it is used to specify "n" for each line-segment + individually. + + (NOTE: The number of segments is 1 less than the number of anchor-points!) + + If both n and del_s is None, n=100 is used by default! + + The default is None. + del_s : int, float or None, optional + Only relevant if `connect="geod"`! + + The target-distance in meters between the subdivisions of the line-segments. + + - If a number is provided, each segment is equally divided. + - If a list is provided, it is used to specify "del_s" for each line-segment + individually. + + (NOTE: The number of segments is 1 less than the number of anchor-points!) + + The default is None. + mark_points : str, dict or None, optional + Set the marker-style for the anchor-points. + + - If a string is provided, it is identified as a matplotlib "format-string", + e.g. "r." for red dots, "gx" for green x markers etc. + - if a dict is provided, it will be used to set the style of the markers + e.g.: dict(marker="o", facecolor="orange", edgecolor="g") + + See https://matplotlib.org/stable/gallery/lines_bars_and_markers/marker_reference.html + for more details + + The default is "o" + + layer : str, int or None + The name of the layer at which the line should be drawn. + If None, the layer associated with the used Maps-object (e.g. m.layer) + is used. Use "all" to add the line to all layers! + The default is None. + kwargs : + additional keyword-arguments passed to plt.plot(), e.g. + "c" (or "color"), "lw" (or "linewidth"), "ls" (or "linestyle"), + "markevery", etc. + + See https://matplotlib.org/stable/api/_as_gen/matplotlib.axes.Axes.plot.html + for more details. + + Returns + ------- + out_d_int : list + Only relevant for `connect="geod"`! (An empty list is returned otherwise.) + A list of the subdivision distances of the line-segments (in meters). + out_d_tot : list + Only relevant for `connect="geod"` (An empty list is returned otherwise.) + A list of total distances of the line-segments (in meters). + + """ + if layer is None: + layer = self.layer + + # intermediate and total distances + out_d_int, out_d_tot = [], [] + + if len(xy) <= 1: + _log.error("you must provide at least 2 points") + + if n is not None: + assert del_s is None, "EOmaps: Provide either `del_s` or `n`, not both!" + del_s = 0 # pyproj's geod uses 0 as identifier! + + if not isinstance(n, int): + assert len(n) == len(xy) - 1, ( + "EOmaps: The number of subdivisions per line segment (n) must be" + + " 1 less than the number of points!" + ) + + elif del_s is not None: + assert n is None, "EOmaps: Provide either `del_s` or `n`, not both!" + n = 0 # pyproj's geod uses 0 as identifier! + + assert connect in ["geod"], ( + "EOmaps: Setting a fixed subdivision-distance (e.g. `del_s`) is only " + + "possible for `geod` lines! Use `n` instead!" + ) + + if not isinstance(del_s, (int, float, np.number)): + assert len(del_s) == len(xy) - 1, ( + "EOmaps: The number of subdivision-distances per line segment " + + "(`del_s`) must be 1 less than the number of points!" + ) + else: + # use 100 subdivisions by default + n = 100 + del_s = 0 + + t_xy_plot = self._get_transformer( + self.get_crs(xy_crs), + self.crs_plot, + ) + xplot, yplot = t_xy_plot.transform(*zip(*xy)) + + if connect == "geod": + # connect points via geodesic lines + if xy_crs != 4326: + t = self._get_transformer( + self.get_crs(xy_crs), + self.get_crs(4326), + ) + x, y = t.transform(*zip(*xy)) + else: + x, y = zip(*xy) + + geod = self.crs_plot.get_geod() + + if n is None or isinstance(n, int): + n = repeat(n) + + if del_s is None or isinstance(del_s, (int, float, np.number)): + del_s = repeat(del_s) + + xs, ys = [], [] + for (x0, x1), (y0, y1), ni, di in zip(pairwise(x), pairwise(y), n, del_s): + npts, d_int, d_tot, lon, lat, _ = geod.inv_intermediate( + lon1=x0, + lat1=y0, + lon2=x1, + lat2=y1, + del_s=di, + npts=ni, + initial_idx=0, + terminus_idx=0, + ) + + out_d_int.append(d_int) + out_d_tot.append(d_tot) + + lon, lat = lon.tolist(), lat.tolist() + xi, yi = self._transf_lonlat_to_plot.transform(lon, lat) + xs += xi + ys += yi + (art,) = self.ax.plot(xs, ys, **kwargs) + + elif connect == "straight": + (art,) = self.ax.plot(xplot, yplot, **kwargs) + + elif connect == "straight_crs": + # draw a straight line that is defined in a given crs + + x, y = zip(*xy) + if isinstance(n, int): + # use same number of points for all segments + xs = np.linspace(x[:-1], x[1:], n).T.ravel() + ys = np.linspace(y[:-1], y[1:], n).T.ravel() + else: + # use different number of points for individual segments + + xs = list( + chain( + *(np.linspace(a, b, ni) for (a, b), ni in zip(pairwise(x), n)) + ) + ) + ys = list( + chain( + *(np.linspace(a, b, ni) for (a, b), ni in zip(pairwise(y), n)) + ) + ) + + x, y = t_xy_plot.transform(xs, ys) + + (art,) = self.ax.plot(x, y, **kwargs) + else: + raise TypeError(f"EOmaps: '{connect}' is not a valid connection-method!") + + art.set_label(f"Line ({connect})") + self.l[layer].add_bg_artist(art) + + if mark_points: + zorder = kwargs.get("zorder", 10) + + if isinstance(mark_points, dict): + # only use zorder of the line if no explicit zorder is provided + mark_points["zorder"] = mark_points.get("zorder", zorder) + + art2 = self.ax.scatter(xplot, yplot, **mark_points) + + elif isinstance(mark_points, str): + # use matplotlib's single-string style identifiers, + # (e.g. "r.", "go", "C0x" etc.) + (art2,) = self.ax.plot(xplot, yplot, mark_points, zorder=zorder, lw=0) + + art2.set_label(f"Line Marker ({connect})") + self.l[layer].add_bg_artist(art2) + + return out_d_int, out_d_tot + + def add_title(self, title, x=0.5, y=1.01, **kwargs): + """ + Convenience function to add a title to the map. + + (The title will be visible at the assigned layer.) + + Parameters + ---------- + title : str + The title. + x, y : float, optional + The position of the text in axis-coordinates (0-1). + The default is 0.5, 1.01. + kwargs : + Additional kwargs are passed to `m.add_text()` + The defaults are: + + - `"fontsize": "large"` + - `horizontalalignment="center"` + - `verticalalignment="bottom"` + + See Also + -------- + + :py:meth:`Maps.text` : General function to add text to the figure. + + """ + kwargs.setdefault("fontsize", "large") + kwargs.setdefault("horizontalalignment", "center") + kwargs.setdefault("verticalalignment", "bottom") + kwargs.setdefault("transform", self.ax.transAxes) + + self.add_text(x, y, title, layer=self.layer, **kwargs) + + @wraps(plt.Figure.text) + def add_text(self, *args, layer=None, **kwargs): + """Add text to the map.""" + kwargs.setdefault("animated", True) + kwargs.setdefault("horizontalalignment", "center") + kwargs.setdefault("verticalalignment", "center") + kwargs.setdefault("transform", self.ax.transAxes) + + a = self.f.text(*args, **kwargs) + + if layer is None: + layer = self.layer + self.l[layer].add_artist(a) + self._bm.update() + + return a + + def add_extent_indicator(self, x0, y0, x1, y1, crs=4326, npts=100, **kwargs): + """ + Indicate a rectangular extent in a given crs on the map. + + Parameters + ---------- + x0, y0, y1, y1 : float + the boundaries of the shape + npts : int, optional + The number of points used to draw the polygon-lines. + (e.g. to correctly display the distortion of the extent-rectangle when + it is re-projected to another coordinate-system) + The default is 100. + crs : any, optional + A coordinate-system identifier. + The default is 4326 (e.g. lon/lat). + kwargs : + Additional keyword-arguments are forwarded to the matplotlib-patch. + """ + + verts = _get_rect_poly_verts(x0, y0, x1, y1, npts) + + t = self._get_transformer(self.get_crs(crs), self.crs_plot) + verts = np.column_stack(t.transform(*verts.T)) + + p = Polygon(verts, **kwargs) + + artist = self.ax.add_patch(p) + self.add_bg_artist(artist) + + def add_marker( + self, + ID=None, + xy=None, + xy_crs=None, + radius=None, + radius_crs=None, + shape="ellipses", + buffer=1, + n=100, + layer=None, + update=True, + **kwargs, + ): + """ + Add a marker to the plot. + + Parameters + ---------- + ID : any + The index-value of the pixel in m.data_specs.data. + xy : tuple + A tuple of the position of the pixel provided in "xy_crs". + If "xy_crs" is None, xy must be provided in the plot-crs! + The default is None + xy_crs : any + the identifier of the coordinate-system for the xy-coordinates + radius : float or "pixel", optional + - If float: The radius of the marker. + - If "pixel": It will represent the dimensions of the selected pixel. + (check the `buffer` kwarg!) + + The default is None in which case "pixel" is used if a dataset is + present and otherwise a shape with 1/10 of the axis-size is plotted + radius_crs : str or a crs-specification + The crs specification in which the radius is provided. + Either "in", "out", or a crs specification (e.g. an epsg-code, + a PROJ or wkt string ...) + The default is "in" (e.g. the crs specified via `m.data_specs.crs`). + (only relevant if radius is NOT specified as "pixel") + shape : str, optional + Indicator which shape to draw. Currently supported shapes are: + - geod_circles + - ellipses + - rectangles + + The default is "circle". + buffer : float, optional + A factor to scale the size of the shape. The default is 1. + n : int + The number of points to calculate for the shape. + The default is 100. + layer : str, int or None + The name of the layer at which the marker should be drawn. + If None, the layer associated with the used Maps-object (e.g. m.layer) + is used. The default is None. + kwargs : + kwargs passed to the matplotlib patch. + (e.g. `zorder`, `facecolor`, `edgecolor`, `linewidth`, `alpha` etc.) + update : bool, optional + If True, call m._bm.update() to immediately show dynamic annotations + If False, dynamic annotations will only be shown at the next update + + Examples + -------- + >>> m.add_marker(ID=1, buffer=5) + >>> m.add_marker(ID=1, radius=2, radius_crs=4326, shape="rectangles") + >>> m.add_marker(xy=(4, 3), xy_crs=4326, radius=20000, shape="geod_circles") + """ + if ID is not None: + assert xy is None, "You can only provide 'ID' or 'pos' not both!" + else: + if isinstance(radius, str) and radius != "pixel": + raise TypeError(f"I don't know what to do with radius='{radius}'") + + if xy is not None: + ID = None + if xy_crs is not None: + # get coordinate transformation + transformer = self._get_transformer( + self.get_crs(xy_crs), + self.crs_plot, + ) + # transform coordinates + xy = transformer.transform(*xy) + + if layer is None: + layer = self.layer + + # using permanent=None results in permanent makers that are NOT + # added to the "m.cb.click.get.permanent_markers" list that is + # used to manage callback-markers + + permanent = kwargs.pop("permanent", None) + + # call the "mark" callback function to add the marker + marker = self.cb.click._attach.mark( + self.cb.click.attach, + ID=ID, + pos=xy, + radius=radius, + radius_crs=radius_crs, + ind=None, + shape=shape, + buffer=buffer, + n=n, + layer=layer, + permanent=permanent, + **kwargs, + ) + + if permanent is False and update: + self._bm.update() + + return marker + + def add_annotation( + self, + ID=None, + xy=None, + xy_crs=None, + text=None, + update=True, + **kwargs, + ): + """ + Add an annotation to the plot. + + Parameters + ---------- + ID : str, int, float or array-like + The index-value of the pixel in m.data. + xy : tuple of float or array-like + A tuple of the position of the pixel provided in "xy_crs". + If None, xy must be provided in the coordinate-system of the plot! + The default is None. + xy_crs : any + the identifier of the coordinate-system for the xy-coordinates + text : callable or str, optional + if str: the string to print + if callable: A function that returns the string that should be + printed in the annotation with the following call-signature: + + >>> def text(m, ID, val, pos, ind): + >>> # m ... the Maps object + >>> # ID ... the ID + >>> # pos ... the position + >>> # val ... the value + >>> # ind ... the index of the clicked pixel + >>> + >>> return "the string to print" + + The default is None. + update : bool, optional + If True, call m._bm.update() to immediately show dynamic annotations + If False, dynamic annotations will only be shown at the next update + **kwargs + kwargs passed to m.cb.annotate + + Examples + -------- + >>> 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 + + >>> m.add_annotation(ID=1, text="some text") + + or a callable that returns a string with the following signature: + + >>> 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) + + **Customizing the appearance** + + For the full set of possibilities, see: + https://matplotlib.org/stable/tutorials/text/annotations.html + + >>> 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 + >>> ) + >>> ) + + """ + inp_ID = ID + + if xy is None and ID is None: + x = self.ax.bbox.x0 + self.ax.bbox.width / 2 + y = self.ax.bbox.y0 + self.ax.bbox.height / 2 + xy = self.ax.transData.inverted().transform((x, y)) + + if ID is not None: + assert xy is None, "You can only provide 'ID' or 'pos' not both!" + # avoid using np.isin directly since it needs a lot of ram + # for very large datasets! + mask, ind = self._find_ID(ID) + + xy = ( + self._data_manager.xorig.ravel()[mask], + self._data_manager.yorig.ravel()[mask], + ) + val = self._data_manager.z_data.ravel()[mask] + ID = np.atleast_1d(ID) + xy_crs = self.data_specs.crs + + is_ID_annotation = False + else: + val = repeat(None) + ind = repeat(None) + ID = repeat(None) + + is_ID_annotation = True + + assert ( + xy is not None + ), "EOmaps: you must provide either ID or xy to position the annotation!" + + xy = (np.atleast_1d(xy[0]), np.atleast_1d(xy[1])) + + if xy_crs is not None: + # get coordinate transformation + transformer = self._get_transformer( + CRS.from_user_input(xy_crs), + self.crs_plot, + ) + # transform coordinates + xy = transformer.transform(*xy) + else: + transformer = None + + kwargs.setdefault("permanent", None) + + if isinstance(text, str) or callable(text): + usetext = repeat(text) + else: + try: + usetext = iter(text) + except TypeError: + usetext = repeat(text) + + for x, y, texti, vali, indi, IDi in zip(xy[0], xy[1], usetext, val, ind, ID): + ann = self.cb.click._attach.annotate( + self.cb.click.attach, + ID=IDi, + pos=(x, y), + val=vali, + ind=indi, + text=texti, + **kwargs, + ) + + if kwargs.get("permanent", False) is not False: + self._edit_annotations._add( + a=ann, + kwargs={ + "ID": inp_ID, + "xy": (x, y), + "xy_crs": xy_crs, + "text": text, + **kwargs, + }, + transf=transformer, + drag_coords=is_ID_annotation, + ) + + if update: + self._bm.update(clear=False) + return ann + + def add_background_patch(self, color, layer=None, **kwargs): + """ + Add a background-patch for the map. + + Useful for overlapping axes if you don't want to "see-through" + the top map. + + Parameters + ---------- + color : str, rgba tuple + The color of the patch. + layer : str, optional + The layer to use. + If None, the layer assigned to the Maps-object is used. + The default is None. + kwargs : + All additional kwargs are passed to the created Patch. + (e.g. alpha, hatch, ...) + + Returns + ------- + art : TYPE + DESCRIPTION. + + """ + if layer is None: + layer = self.layer + + (art,) = self.ax.fill( + [0, 0, 1, 1], + [0, 1, 1, 0], + fc=color, + ec="none", + zorder=-9999, + transform=self.ax.transAxes, + **kwargs, + ) + + art.set_label("Background patch") + + self.l[layer].add_bg_artist(art) + return art diff --git a/eomaps/mixins/callback_mixin.py b/eomaps/mixins/callback_mixin.py new file mode 100644 index 000000000..82a8467b9 --- /dev/null +++ b/eomaps/mixins/callback_mixin.py @@ -0,0 +1,19 @@ +from ..cb_container import CallbackContainer + + +class CallbackMixin: + cb = CallbackContainer + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # initialize accessor for callbacks + self.cb = CallbackContainer(self) + self.cb._init_cbs() + + if not hasattr(self.parent, "_execute_callbacks"): + self.parent._execute_callbacks = True + + @property + def __lazy_attrs(self): + # list of attributes that support lazy-evaluation + return ["cb"] diff --git a/eomaps/mixins/clipboard_mixin.py b/eomaps/mixins/clipboard_mixin.py new file mode 100644 index 000000000..87654239b --- /dev/null +++ b/eomaps/mixins/clipboard_mixin.py @@ -0,0 +1,127 @@ +import logging +import matplotlib.pyplot as plt + +# arguments passed to m.savefig when using "ctrl+c" to export figure to clipboard +_clipboard_kwargs = {} + +_log = logging.getLogger(__name__) + + +def _set_clipboard_kwargs(**kwargs): + # use Maps to make sure InsetMaps do the same thing! + global _clipboard_kwargs + _clipboard_kwargs = kwargs + + +def _get_clipboard_kwargs(): + # use Maps to make sure InsetMaps do the same thing! + global _clipboard_kwargs + return _clipboard_kwargs + + +class ClipboardMixin: + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def __on_keypress(self, event): + if event.key == "ctrl+c": + try: + self._save_to_clipboard(**self._get_clipboard_kwargs()) + except Exception: + _log.exception( + "EOmaps: Encountered a problem while trying to export the figure " + "to the clipboard.", + exc_info=_log.getEffectiveLevel() <= logging.DEBUG, + ) + + def _get_clipboard_kwargs(self): + return _get_clipboard_kwargs() + + @staticmethod + def _set_clipboard_kwargs(**kwargs): + """ + Set GLOBAL savefig parameters for all Maps objects on export to the clipboard. + + - press "control + c" to export the figure to the clipboard + + All arguments are passed to :meth:`Maps.savefig` + + Useful options are + + - dpi : the dots-per-inch of the figure + - refetch_wms: re-fetch webmaps with respect to the export-`dpi` + - bbox_inches: use "tight" to export figure with a tight boundary + - pad_inches: the size of the boundary if `bbox_inches="tight"` + - transparent: if `True`, export with a transparent background + - facecolor: the background color + + + Parameters + ---------- + kwargs : + Keyword-arguments passed to :meth:`Maps.savefig`. + + Note + ---- + This function sets the clipboard kwargs for all Maps-objects! + + Exporting to the clipboard only works if `PyQt5` is used as matplotlib backend! + (the default if `PyQt` is installed) + + See Also + -------- + Maps.savefig : Save the figure as jpeg, png, etc. + + """ + # use Maps to make sure InsetMaps do the same thing! + _set_clipboard_kwargs(**kwargs) + # trigger companion-widget setter for all open figures that contain maps + for i in plt.get_fignums(): + try: + m = getattr(plt.figure(i), "_EOmaps_parent", None) + if m is not None: + if m._companion_widget is not None: + m._emit_signal("clipboardKwargsChanged") + except Exception: + _log.exception("UPS") + + def _save_to_clipboard(self, **kwargs): + """ + Export the figure to the clipboard. + + Parameters + ---------- + kwargs : + Keyword-arguments passed to :py:meth:`Maps.savefig` + """ + import io + import mimetypes + from qtpy.QtCore import QMimeData + from qtpy.QtWidgets import QApplication + from qtpy.QtGui import QImage + + # guess the MIME type from the provided file-extension + fmt = kwargs.get("format", "png") + mimetype, _ = mimetypes.guess_type(f"dummy.{fmt}") + + message = f"EOmaps: Exporting figure as '{fmt}' to clipboard..." + _log.info(message) + + # TODO remove dependency on companion widget here + if getattr(self, "_companion_widget", None) is not None: + self._companion_widget.window().statusBar().showMessage(message, 2000) + + with io.BytesIO() as buffer: + self.savefig(buffer, **kwargs) + data = QMimeData() + + cb = QApplication.clipboard() + + # TODO check why files copied with setMimeData(...) cannot be pasted + # properly in other apps + if fmt in ["svg", "svgz", "pdf", "eps"]: + data.setData(mimetype, buffer.getvalue()) + cb.clear(mode=cb.Clipboard) + cb.setMimeData(data, mode=cb.Clipboard) + else: + cb.setImage(QImage.fromData(buffer.getvalue())) diff --git a/eomaps/mixins/companion_mixin.py b/eomaps/mixins/companion_mixin.py new file mode 100644 index 000000000..f1b438ad3 --- /dev/null +++ b/eomaps/mixins/companion_mixin.py @@ -0,0 +1,294 @@ +import logging + +_log = logging.getLogger(__name__) + +import matplotlib.pyplot as plt +import matplotlib.patches as mpatches + +from ..helpers import _key_release_event + + +class CompanionMixin: + # the keyboard shortcut to activate the companion-widget + __companion_widget_key = "w" + # max. number of layers to show all layers as tabs in the widget + # (otherwise only recently active layers are shown as tabs) + _companion_widget_n_layer_tabs = 50 + + def __init__(self, *args, **kwargs): + + super().__init__(*args, **kwargs) + + try: + from ..qtcompanion.signal_container import _SignalContainer + + # initialize the signal container (MUST be done before init of the widget!) + self._signal_container = _SignalContainer() + except Exception: + _log.debug("SignalContainer could not be initialized", exc_info=True) + self._signal_container = None + + # slot for the pyqt widget + self._companion_widget = None + # a list of actions that are executed whenever the widget is shown + self._on_show_companion_widget = [] + + @staticmethod + def _if_companion_exists(f): + # decorator to run method only if companion-widget has been initialized + def inner(self, *args, **kwargs): + if self._companion_widget is None: + return + return f(self, *args, **kwargs) + + return inner + + def __on_keypress(self, event): + # NOTE: callback is only attached to the parent Maps object! + if event.key == self.__companion_widget_key: + try: + self._open_companion_widget((event.x, event.y)) + except Exception: + _log.exception( + "EOmaps: Encountered a problem while trying to open " + "the companion widget", + exc_info=_log.getEffectiveLevel() <= logging.DEBUG, + ) + + def _hide_all_companion_widget_indicators(self): + # hide companion-widget indicator + for m in self._bm._children: + # hide companion-widget indicator + m._indicate_companion_map(False) + + def _show_all_companion_widget_indicators(self): + # hide companion-widget indicator + for m in self._bm._children: + if (w := getattr(m, "_companion_widget", None)) is not None: + if w.isVisible(): + # hide companion-widget indicator + m._indicate_companion_map(True) + + @_if_companion_exists + def __set_always_on_top(self, q): + from qtpy import QtCore + + cw = self._companion_widget.window() + cws = cw.size() + if q: + cw.setWindowFlags(cw.windowFlags() | QtCore.Qt.WindowStaysOnTopHint) + else: + cw.setWindowFlags(cw.windowFlags() & ~QtCore.Qt.WindowStaysOnTopHint) + cw.resize(cws) + cw.show() + + @_if_companion_exists + def _close_companion_widget(self): + self._companion_widget.close() + + @_if_companion_exists + def _show_companion_statusbar_message(self, message, time=2000): + self._companion_widget.window().statusBar().showMessage(message, time) + + @_if_companion_exists + def _indicate_companion_map(self, visible): + if hasattr(self, "_companion_map_indicator"): + try: + self.all.remove_artist(self._companion_map_indicator) + except ValueError: + # ignore errors resulting from the fact that the artist + # has already been removed! + pass + del self._companion_map_indicator + + # don't draw an indicator if only one map is present in the figure + if all(m.ax == self.ax for m in self._bm._children): + return + + if visible: + path = self.ax.patch.get_path() + self._companion_map_indicator = mpatches.PathPatch( + path, fc="none", ec="g", lw=5, zorder=9999 + ) + + self.ax.add_artist(self._companion_map_indicator) + self.all.add_artist(self._companion_map_indicator) + + self._bm.update() + + def _identify_maps_object(self, xy): + clicked_map = None + if xy is not None: + for m in self._bm._children: + if not m._new_axis_map: + # only search for Maps-object that initialized new axes + continue + + if m.ax.contains_point(xy): + clicked_map = m + break + + return clicked_map + + def _open_companion_widget(self, xy=None): + """ + Open the companion-widget. + + Parameters + ---------- + xy : tuple, optional + The click position to identify the relevant Maps-object + (in figure coordinates). + If None, the calling Maps-object is used + + The default is None. + + """ + + clicked_map = self._identify_maps_object(xy) + + if clicked_map is None: + _log.error( + "EOmaps: To activate the 'Companion Widget' you must " + "position the mouse on top of an EOmaps Map!" + ) + return + + # hide all other companion-widgets + for m in self._bm._children: + if m == clicked_map: + continue + if m._companion_widget is not None and m._companion_widget.isVisible(): + m._companion_widget.hide() + m._indicate_companion_map(False) + + if clicked_map._companion_widget is None: + clicked_map._init_companion_widget() + + if clicked_map._companion_widget is not None: + if clicked_map._companion_widget.isVisible(): + clicked_map._companion_widget.hide() + clicked_map._indicate_companion_map(False) + else: + clicked_map._companion_widget.show() + clicked_map._indicate_companion_map(True) + # execute all actions that should trigger before opening the widget + # (e.g. update tabs to show visible layers etc.) + for f in clicked_map._on_show_companion_widget: + f() + + # Do NOT activate the companion widget in here!! + # Activating the window during the callback steals focus and + # as a consequence the key-released-event is never triggered + # on the figure and "w" would remain activated permanently. + + _key_release_event(clicked_map.f.canvas, "w") + clicked_map._companion_widget.activateWindow() + + def _init_companion_widget(self, show_hide_key="w"): + """ + Create and show the EOmaps Qt companion widget. + + Note + ---- + The companion-widget requires using matplotlib with the Qt5Agg backend! + To activate, use: `plt.switch_backend("Qt5Agg")` + + Parameters + ---------- + show_hide_key : str or None, optional + The keyboard-shortcut that is assigned to show/hide the widget. + The default is "w". + """ + try: + from ..qtcompanion.app import MenuWindow + + if self._companion_widget is not None: + _log.error( + "EOmaps: There is already an existing companinon widget for this" + " Maps-object!" + ) + return + if plt.get_backend().lower() in ["qtagg", "qt5agg"]: + # only pass parent if Qt is used as a backend for matplotlib! + self._companion_widget = MenuWindow(m=self, parent=self.f.canvas) + else: + self._companion_widget = MenuWindow(m=self) + self._companion_widget.toggle_always_on_top() + self._companion_widget.hide() # hide on init + + # connect any pending signals + for key, funcs in getattr(self, "_connect_signals_on_init", dict()).items(): + while len(funcs) > 0: + self._connect_signal(key, funcs.pop()) + + # make sure that we clear the colormap-pixmap cache on startup + self._emit_signal("cmapsChanged") + + except Exception: + _log.exception( + "EOmaps: Unable to initialize companion widget.", + exc_info=_log.getEffectiveLevel() <= logging.DEBUG, + ) + + def fetch_companion_wms_layers(self, refetch=True): + """ + Fetch (and cache) WebMap layer names for the companion-widget. + + The cached layers are stored at the following location: + + >>> from eomaps import _data_dir + >>> print(_data_dir) + + Parameters + ---------- + refetch : bool, optional + If True, the layers will be re-fetched and the cache will be updated. + If False, the cached dict is loaded and returned. + The default is True. + """ + from ..qtcompanion.widgets.wms import AddWMSMenuButton + + return AddWMSMenuButton.fetch_all_wms_layers(self, refetch=refetch) + + def _connect_signal(self, name, func): + parent = self.parent + widget = parent._companion_widget + + # NOTE: use Maps.config(log_level=5) to get signal log messages! + if widget is None: + if not hasattr(parent, "_connect_signals_on_init"): + parent._connect_signals_on_init = dict() + + parent._connect_signals_on_init.setdefault(name, set()).add(func) + + if widget is not None: + try: + getattr(parent._signal_container, name).connect(func) + _log.log(1, f"Signal connected: {name} ({func.__name__})") + + except Exception: + _log.log( + 1, + f"There was a problem while trying to connect the function {func} " + f"to the signal {name} ", + exc_info=True, + ) + + # TODO how to deal with calls on _emit_signal? + def _emit_signal(self, name, *args): + parent = self.parent + widget = parent._companion_widget + + # NOTE: use Maps.config(log_level=5) to get signal log messages! + if widget is not None: + try: + getattr(parent._signal_container, name).emit(*args) + _log.log(1, f"Signal emitted: {name} {args}") + except Exception: + _log.log( + 1, + f"There was a problem while trying to emit the signal {name} " + f"with the args {args}", + exc_info=True, + ) diff --git a/eomaps/mixins/data_mixin.py b/eomaps/mixins/data_mixin.py new file mode 100644 index 000000000..d472d2caa --- /dev/null +++ b/eomaps/mixins/data_mixin.py @@ -0,0 +1,1414 @@ +import logging + +_log = logging.getLogger(__name__) + +from types import SimpleNamespace +from functools import wraps +import weakref + +from pyproj import CRS +import numpy as np + +import matplotlib.pyplot as plt +import matplotlib as mpl + +from ..helpers import cmap_alpha, SearchTree, register_modules, _proxy +from ..shapes import Shapes +from ..colorbar import ColorBar +from .._containers import DataSpecs, ClassifySpecs +from ..reader import read_file, from_file, new_layer_from_file +from .._data_manager import DataManager + + +class DataMixin: + """Mixin to handle data visualization""" + + from_file = from_file + new_layer_from_file = new_layer_from_file + read_file = read_file + + # to make namespace accessible for sphinx + set_shape = Shapes + + data_specs = DataSpecs + + def __init__( + self, + *args, + **kwargs, + ): + + self._inherit_classification = None + + self._colorbars = [] + self._coll = None # slot for the collection created by m.plot_map() + + # a list to remember newly registered colormaps + self._registered_cmaps = [] + + # default classify specs + self._classify_specs = ClassifySpecs(weakref.proxy(self)) + + # initialize the data-manager + self.data_specs = DataSpecs(weakref.proxy(self), x=None, y=None, crs=4326) + + self._data_manager = DataManager(_proxy(self)) + self._data_plotted = False + self._set_extent_on_plot = True + + self.new_layer_from_file = new_layer_from_file(weakref.proxy(self)) + + self.set_shape = self.set_shape(weakref.proxy(self)) + self._shape = None + # the dpi used for shade shapes + self._shade_dpi = None + + # the radius is estimated when plot_map is called + self._estimated_radius = None + + # evaluate and cache crs boundary bounds (for extent clipping) + self._crs_boundary_bounds = self.crs_plot.boundary.bounds + super().__init__(*args, **kwargs) + + @property + def __lazy_attrs(self): + # list of attributes that support lazy-evaluation + exclude = [ + "coll", + "shape", + "colorbar", + "data", + "data_specs", + "set_shade_dpi", + ] + return [i for i in dir(DataMixin) if not (i.startswith("_") or i in exclude)] + + @property + def coll(self): + """The collection representing the dataset plotted by m.plot_map().""" + return self._coll + + @property + def shape(self): + """ + The shape that is used to represent the dataset if `m.plot_map()` is called. + + By default "ellipses" is used for datasets < 500k datapoints and for plots + where no explicit data is assigned, and otherwise "shade_raster" is used + for 2D datasets and "shade_points" is used for unstructured datasets. + + """ + + if not self._shape_assigned: + self._set_default_shape() + self._shape._is_default = True + + return self._shape + + @property + def colorbar(self): + """ + Get the **most recently added** colorbar of this Maps-object. + + Returns + ------- + ColorBar + EOmaps colorbar object. + """ + if len(self._colorbars) > 0: + return self._colorbars[-1] + + def set_data( + self, + data=None, + x=None, + y=None, + crs=None, + encoding=None, + cpos="c", + cpos_radius=None, + parameter=None, + ): + """ + Set the properties of the dataset you want to plot. + + Use this function to update multiple data-specs in one go + Alternatively you can set the data-specifications via + + >>> m.data_specs.< property > = ...` + + Parameters + ---------- + data : array-like + The data of the Maps-object. + Accepted inputs are: + + - a pandas.DataFrame with the coordinates and the data-values + - a pandas.Series with only the data-values + - a 1D or 2D numpy-array with the data-values + - a 1D list of data values + + x, y : array-like or str, optional + Specify the coordinates associated with the provided data. + Accepted inputs are: + + - a string (corresponding to the column-names of the `pandas.DataFrame`) + + - ONLY if "data" is provided as a pandas.DataFrame! + + - a pandas.Series + - a 1D or 2D numpy-array + - a 1D list + + The default is "lon" and "lat". + crs : int, dict or str + The coordinate-system of the provided coordinates. + Can be one of: + + - PROJ string + - Dictionary of PROJ parameters + - PROJ keyword arguments for parameters + - JSON string with PROJ parameters + - CRS WKT string + - An authority string [i.e. 'epsg:4326'] + - An EPSG integer code [i.e. 4326] + - A tuple of ("auth_name": "auth_code") [i.e ('epsg', '4326')] + - An object with a `to_wkt` method. + - A :class:`pyproj.crs.CRS` class + + (see `pyproj.CRS.from_user_input` for more details) + + The default is 4326 (e.g. geographic lon/lat crs) + parameter : str, optional + MANDATORY IF a pandas.DataFrame that specifies both the coordinates + and the data-values is provided as `data`! + + The name of the column that should be used as parameter. + + If None, the first column (despite of the columns assigned as "x" and "y") + will be used. The default is None. + encoding : dict or False, optional + A dict containing the encoding information in case the data is provided as + encoded values (useful to avoid decoding large integer-encoded datasets). + + If provided, the data will be decoded "on-demand" with respect to the + provided "scale_factor" and "add_offset" according to the formula: + + >>> actual_value = encoding["add_offset"] + encoding["scale_factor"] * value + + Note: Colorbars and pick-callbakcs will use the encoding-information to + display the actual data-values! + + If False, no value-transformation is performed. + The default is False + cpos : str, optional + Indicator if the provided x-y coordinates correspond to the center ("c"), + upper-left corner ("ul"), lower-left corner ("ll") etc. of the pixel. + If any value other than "c" is provided, a "cpos_radius" must be set! + The default is "c". + cpos_radius : int or tuple, optional + The pixel-radius (in the input-crs) that will be used to set the + center-position of the provided data. + If a number is provided, the pixels are treated as squares. + If a tuple (rx, ry) is provided, the pixels are treated as rectangles. + The default is None. + + Examples + -------- + - using a single `pandas.DataFrame` + + >>> data = pd.DataFrame(dict(lon=[...], lat=[...], a=[...], b=[...])) + >>> m.set_data(data, x="lon", y="lat", parameter="a", crs=4326) + + - using individual `pandas.Series` + + >>> lon, lat, vals = pd.Series([...]), pd.Series([...]), pd.Series([...]) + >>> m.set_data(vals, x=lon, y=lat, crs=4326) + + - using 1D lists + + >>> lon, lat, vals = [...], [...], [...] + >>> m.set_data(vals, x=lon, y=lat, crs=4326) + + - using 1D or 2D numpy.arrays + + >>> lon, lat, vals = np.array([[...]]), np.array([[...]]), np.array([[...]]) + >>> m.set_data(vals, x=lon, y=lat, crs=4326) + + - integer-encoded datasets + + >>> lon, lat, vals = [...], [...], [1, 2, 3, ...] + >>> encoding = dict(scale_factor=0.01, add_offset=1) + >>> # colorbars and pick-callbacks will now show values as (1 + 0.01 * value) + >>> # e.g. the "actual" data values are [0.01, 0.02, 0.03, ...] + >>> m.set_data(vals, x=lon, y=lat, crs=4326, encoding=encoding) + + """ + if data is not None: + self.data_specs.data = data + + if x is not None: + self.data_specs.x = x + + if y is not None: + self.data_specs.y = y + + if crs is not None: + self.data_specs.crs = crs + + if encoding is not None: + self.data_specs.encoding = encoding + + if cpos is not None: + self.data_specs.cpos = cpos + + if cpos_radius is not None: + self.data_specs.cpos_radius = cpos_radius + + if parameter is not None: + self.data_specs.parameter = parameter + + @property + def set_classify(self): + """ + Interface to the classifiers provided by the 'mapclassify' module. + + To set a classification scheme for a given Maps-object, simply use: + + >>> m.set_classify.< SCHEME >(...) + + Where `< SCHEME >` is the name of the desired classification and additional + parameters are passed in the call. (check docstrings for more info!) + + A list of available classification-schemes is accessible via + `mapclassify.CLASSIFIERS` + + - BoxPlot (hinge) + - EqualInterval (k) + - FisherJenks (k) + - FisherJenksSampled (k, pct, truncate) + - HeadTailBreaks () + - JenksCaspall (k) + - JenksCaspallForced (k) + - JenksCaspallSampled (k, pct) + - MaxP (k, initial) + - MaximumBreaks (k, mindiff) + - NaturalBreaks (k, initial) + - Quantiles (k) + - Percentiles (pct) + - StdMean (multiples) + - UserDefined (bins) + + Examples + -------- + >>> m.set_classify.Quantiles(k=5) + + >>> m.set_classify.EqualInterval(k=5) + + >>> m.set_classify.UserDefined(bins=[5, 10, 25, 50]) + + """ + (mapclassify,) = register_modules("mapclassify") + + s = SimpleNamespace( + **{ + i: self._get_mcl_subclass(getattr(mapclassify, i)) + for i in mapclassify.CLASSIFIERS + } + ) + + s.__doc__ = DataMixin.set_classify.__doc__ + + return s + + def set_classify_specs(self, scheme=None, **kwargs): + """ + Set classification specifications for the data. + + The classification is ultimately performed by the `mapclassify` module! + + Note + ---- + The following calls have the same effect: + + >>> m.set_classify.Quantiles(k=5) + >>> m.set_classify_specs(scheme="Quantiles", k=5) + + Using `m.set_classify()` is the same as using `m.set_classify_specs()`! + However, `m.set_classify()` will provide autocompletion and proper + docstrings once the Maps-object is initialized which greatly enhances + the usability. + + Parameters + ---------- + scheme : str + The classification scheme to use. + (the list is accessible via `mapclassify.CLASSIFIERS`) + + E.g. one of (possible kwargs in brackets): + + - BoxPlot (hinge) + - EqualInterval (k) + - FisherJenks (k) + - FisherJenksSampled (k, pct, truncate) + - HeadTailBreaks () + - JenksCaspall (k) + - JenksCaspallForced (k) + - JenksCaspallSampled (k, pct) + - MaxP (k, initial) + - MaximumBreaks (k, mindiff) + - NaturalBreaks (k, initial) + - Quantiles (k) + - Percentiles (pct) + - StdMean (multiples) + - UserDefined (bins) + + kwargs : + kwargs passed to the call to the respective mapclassify classifier + (dependent on the selected scheme... see above) + + """ + register_modules("mapclassify") + self._classify_specs._set_scheme_and_args(scheme, **kwargs) + + def set_shade_dpi(self, dpi=None): + """ + Set the dpi used by "shade shapes" to aggregate datasets. + + This only affects the plot-shapes "shade_raster" and "shade_points". + + Note + ---- + If dpi=None is used (the default), datasets in exported figures will be + re-rendered with respect to the requested dpi of the exported image! + + Parameters + ---------- + dpi : int or None, optional + The dpi to use for data aggregation with shade shapes. + If None, the figure-dpi is used. + + The default is None. + + """ + self._shade_dpi = dpi + self._update_shade_axis_size() + + def inherit_data(self, m): + """ + Use the data of another Maps-object (without copying). + + NOTE + ---- + If the data is inherited, any change in the data of the parent + Maps-object will be reflected in this Maps-object as well! + + Parameters + ---------- + m : eomaps.Maps or None + The Maps-object that provides the data. + """ + if m is not None: + self.data_specs = m.data_specs + + def set_data(*args, **kwargs): + raise AssertionError( + "EOmaps: You cannot set data for a Maps object that " + "inherits data!" + ) + + self.set_data = set_data + + def inherit_classification(self, m): + """ + Use the classification of another Maps-object when plotting the data. + + NOTE + ---- + If the classification is inherited, the following arguments + for `m.plot_map()` will have NO effect (they are inherited): + + - "cmap" + - "vmin" + - "vmax" + + Parameters + ---------- + m : eomaps.Maps or None + The Maps-object that provides the classification specs. + """ + if m is not None: + self._inherit_classification = _proxy(m) + else: + self._inherit_classification = None + + def plot_map( + self, + layer=None, + dynamic=False, + set_extent=True, + assume_sorted=True, + indicate_masked_points=False, + **kwargs, + ): + """ + Plot the dataset assigned to this Maps-object. + + - To set the data, see `m.set_data()` + - To change the "shape" that is used to represent the datapoints, see + `m.set_shape`. + - To classify the data, see `m.set_classify` or `m.set_classify_specs()` + + NOTE + ---- + Each call to `plot_map(...)` will override the previously plotted dataset! + + If you want to plot multiple datasets, use a new layer for each dataset! + (e.g. via `m2 = m.new_layer()`) + + Parameters + ---------- + layer : str or None + The layer at which the dataset will be plotted. + ONLY relevant if `dynamic = False`! + + - If "all": the corresponding feature will be added to ALL layers + - If None, the layer assigned to the Maps object is used (e.g. `m.layer`) + + The default is None. + dynamic : bool + If True, the collection will be dynamically updated. + set_extent : bool + Set the plot-extent to the data-extent. + + - if True: The plot-extent will be set to the extent of the data-coordinates + - if False: The plot-extent is kept as-is + + The default is True + assume_sorted : bool, optional + ONLY relevant for the shapes "raster" and "shade_raster" + (and only if coordinates are provided as 1D arrays and data is a 2D array) + + Sort values with respect to the coordinates prior to plotting + (required for QuadMesh if unsorted coordinates are provided) + + The default is True. + indicate_masked_points : bool or dict + If False, masked points are not indicated. + + If True, any datapoints that could not be properly plotted + with the currently assigned shape are indicated with a + circle with a red boundary. + + If a dict is provided, it can be used to update the appearance of the + masked points (arguments are passed to matplotlibs `plt.scatter()`) + ('s': markersize, 'marker': the shape of the marker, ...) + + The default is False + + Other Parameters + ---------------- + 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 matplotlib collection + (dependent on the plot-shape) [linewidth, edgecolor, facecolor, ...] + + For "shade_points" or "shade_raster" shapes, kwargs are passed to + `datashader.mpl_ext.dsshow` + + """ + verbose = kwargs.pop("verbose", None) + if verbose is not None: + _log.error("EOmaps: The parameter verbose is ignored.") + + # make sure zorder is set to 1 by default + # (by default shading would use 0 while ordinary collections use 1) + if self.shape.name != "contour": + kwargs.setdefault("zorder", 1) + else: + # put contour lines by default at level 10 + if self.shape._filled: + kwargs.setdefault("zorder", 1) + else: + kwargs.setdefault("zorder", 10) + + if getattr(self, "coll", None) is not None and len(self.cb.pick.get.cbs) > 0: + _log.info( + "EOmaps: Calling `m.plot_map()` or " + "`m.make_dataset_pickable()` more than once on the " + "same Maps-object overrides the assigned PICK-dataset!" + ) + + if layer is None: + layer = self.layer + else: + if not isinstance(layer, str): + _log.info("EOmaps: The layer-name has been converted to a string!") + layer = str(layer) + + useshape = self.shape # invoke the setter to set the default shape + shade_q = useshape.name.startswith("shade_") # indicator if shading is used + + # make sure the colormap is properly set and transparencies are assigned + cmap = kwargs.pop("cmap", "viridis") + + if "alpha" in kwargs and kwargs["alpha"] < 1: + # get a unique name for the colormap + cmapname = self._get_alpha_cmap_name(kwargs["alpha"]) + + cmap = cmap_alpha( + cmap=cmap, + alpha=kwargs["alpha"], + name=cmapname, + ) + + plt.colormaps.register(name=cmapname, cmap=cmap) + self._emit_signal("cmapsChanged") + # remember registered colormaps (to de-register on close) + self._registered_cmaps.append(cmapname) + + # ---------------------- prepare the data + + _log.debug("EOmaps: Preparing dataset") + + # ---------------------- assign the data to the data_manager + + # shade shapes use datashader to update the data of the collections! + update_coll_on_fetch = False if shade_q else True + + self._data_manager.set_props( + layer=layer, + assume_sorted=assume_sorted, + update_coll_on_fetch=update_coll_on_fetch, + indicate_masked_points=indicate_masked_points, + dynamic=dynamic, + ) + + # ---------------------- classify the data + self._set_vmin_vmax( + vmin=kwargs.pop("vmin", None), vmax=kwargs.pop("vmax", None) + ) + + if not self._inherit_classification: + if self._classify_specs.scheme is not None: + _log.debug("EOmaps: Classifying...") + elif self.shape.name == "contour" and kwargs.get("levels", None) is None: + # TODO use custom contour-levels as UserDefined classification? + self.set_classify.EqualInterval(k=5) + + cbcmap, norm, bins, classified = self._classify_data( + vmin=self._vmin, + vmax=self._vmax, + cmap=cmap, + classify_specs=self._classify_specs, + ) + + if norm is not None: + if "norm" in kwargs: + raise TypeError( + "EOmaps: You cannot provide an explicit norm for the dataset if a " + "classification scheme is used!" + ) + else: + if "norm" in kwargs: + norm = kwargs.pop("norm") + if not isinstance(norm, str): # to allow datashader "eq_hist" norm + norm.vmin = self._vmin + norm.vmax = self._vmax + else: + norm = plt.Normalize(vmin=self._vmin, vmax=self._vmax) + + # todo remove duplicate attributes + self._classify_specs._cbcmap = cbcmap + self._classify_specs._norm = norm + self._classify_specs._bins = bins + self._classify_specs._classified = classified + + self._cbcmap = cbcmap + self._norm = norm + self._bins = bins + self._classified = classified + + # ---------------------- plot the data + + if shade_q: + self._shade_map( + layer=layer, + dynamic=dynamic, + set_extent=set_extent, + assume_sorted=assume_sorted, + **kwargs, + ) + self.f.canvas.draw_idle() + else: + # dont set extent if "m.set_extent" was called explicitly + if set_extent and self._set_extent_on_plot: + # note bg-layers are automatically triggered for re-draw + # if the extent changes! + self._data_manager._set_lims() + + self._plot_map( + layer=layer, + dynamic=dynamic, + set_extent=set_extent, + assume_sorted=assume_sorted, + **kwargs, + ) + + self._bm._refetch_layer(layer) + + if getattr(self, "_data_mask", None) is not None and not np.all( + self._data_mask + ): + _log.info("EOmaps: Some datapoints could not be drawn!") + + self._data_plotted = True + + self._emit_signal("dataPlotted") + + self._bm.update() + + @wraps(ColorBar._new_colorbar) + def add_colorbar(self, *args, **kwargs): + """Add a colorbar to the map.""" + if self.coll is None: + raise AttributeError( + "EOmaps: You must plot a dataset before " "adding a colorbar!" + ) + colorbar = ColorBar._new_colorbar(self, *args, **kwargs) + + self._colorbars.append(colorbar) + self._bm._refetch_layer(self.layer) + self._bm._refetch_layer("**SPINES**") + + return colorbar + + def make_dataset_pickable( + self, + ): + """ + Make the associated dataset pickable **without plotting** it first. + + After executing this function, `m.cb.pick` callbacks can be attached to the + `Maps` object. + + NOTE + ---- + This function is ONLY necessary if you want to use pick-callbacks **without** + actually plotting the data**! Otherwise a call to `m.plot_map()` is sufficient! + + - Each `Maps` object can always have only one pickable dataset. + - The used data is always the dataset that was assigned in the last call to + `m.plot_map()` or `m.make_dataset_pickable()`. + - To get multiple pickable datasets, use an individual layer for each of the + datasets (e.g. first `m2 = m.new_layer()` and then assign the data to `m2`) + + Examples + -------- + >>> m = Maps() + >>> m.add_feature.preset.coastline() + >>> ... + >>> # a dataset that should be pickable but NOT visible... + >>> m2 = m.new_layer() + >>> m2.set_data(*np.linspace([0, -180,-90,], [100, 180, 90], 100).T) + >>> m2.make_dataset_pickable() + >>> m2.cb.pick.attach.annotate() # get an annotation for the invisible dataset + >>> # ...call m2.plot_map() to make the dataset visible... + """ + if self.coll is not None: + _log.error( + "EOmaps: There is already a dataset plotted on this Maps-object. " + "You MUST use a new layer (`m2 = m.new_layer()`) to use " + "`m2.make_dataset_pickable()`!" + ) + return + + # ---------------------- prepare the data + self._data_manager = DataManager(_proxy(self)) + self._data_manager.set_props(layer=self.layer, only_pick=True) + + x0, x1 = self._data_manager.x0.min(), self._data_manager.x0.max() + y0, y1 = self._data_manager.y0.min(), self._data_manager.y0.max() + + # use a transparent rectangle of the data-extent as artist for picking + (art,) = self.ax.fill([x0, x1, x1, x0], [y0, y0, y1, y1], fc="none", ec="none") + + self._coll = art + + self.tree = SearchTree(m=_proxy(self)) + self.cb.pick._set_artist(art) + self.cb.pick._init_cbs() + self.cb._methods.add("pick") + + self._coll_kwargs = dict() + self._coll_dynamic = True + + # set _data_plotted to True to trigger updates in the data-manager + self._data_plotted = True + + def _plot_map( + self, + layer=None, + dynamic=False, + set_extent=True, + assume_sorted=True, + **kwargs, + ): + _log.info( + "EOmaps: Plotting " + f"{self._data_manager.z_data.size} datapoints ({self.shape.name})" + ) + + for key in ("array",): + assert ( + key not in kwargs + ), f"The key '{key}' is assigned internally by EOmaps!" + + try: + self._set_extent = set_extent + + # ------------- plot the data + self._coll_kwargs = kwargs + self._coll_dynamic = dynamic + + # NOTE: the actual plot is performed by the data-manager + # at the next call to m._bm.fetch_bg() for the corresponding layer + # this is called to make sure m.coll is properly set + self._data_manager.on_fetch_bg(check_redraw=False) + + except Exception as ex: + raise ex + + def _shade_map( + self, + layer=None, + dynamic=False, + set_extent=True, + assume_sorted=True, + **kwargs, + ): + """ + Plot the dataset using the (very fast) "datashader" library. + + Requires `datashader`... use `conda install -c conda-forge datashader` + + - This method is intended for extremely large datasets + (up to millions of datapoints)! + + A dynamically updated "shaded" map will be generated. + Note that the datapoints in this case are NOT represented by the shapes + defined as `m.set_shape`! + + - By default, the shading is performed using a "mean"-value aggregation hook + + kwargs : + kwargs passed to `datashader.mpl_ext.dsshow` + + """ + _log.info( + "EOmaps: Plotting " + f"{self._data_manager.z_data.size} datapoints ({self.shape.name})" + ) + + ds, mpl_ext, pd, xar = register_modules( + "datashader", "datashader.mpl_ext", "pandas", "xarray" + ) + + # remove previously fetched backgrounds for the used layer + if dynamic is False: + self._bm._refetch_layer(layer) + + # in case the aggregation does not represent data-values + # (e.g. count, std, var ... ) use an automatic "linear" normalization + + # get the name of the used aggretation reduction + aggname = self.shape.aggregator.__class__.__name__ + + if aggname in ["first", "last", "max", "min", "mean", "mode"]: + kwargs.setdefault("norm", self._classify_specs._norm) + else: + kwargs.setdefault("norm", "linear") + + zdata = self._data_manager.z_data + if len(zdata) == 0: + _log.error("EOmaps: there was no data to plot") + return + + plot_width, plot_height = self._get_shade_axis_size() + + # get rid of unnecessary dimensions in the numpy arrays + zdata = zdata.squeeze() + x0 = self._data_manager.x0.squeeze() + y0 = self._data_manager.y0.squeeze() + + # the shape is always set after _prepare data! + if self.shape.name == "shade_points" and self._data_manager.x0_1D is None: + # fill masked-values with None to avoid issues with numba not being + # able to deal with numpy-arrays + # TODO report this to datashader to get it fixed properly? + if isinstance(zdata, np.ma.masked_array): + zdata = zdata.filled(None) + + df = pd.DataFrame( + dict( + x=x0.ravel(), + y=y0.ravel(), + val=zdata.ravel(), + ), + copy=False, + ) + + else: + if len(zdata.shape) == 2: + if (zdata.shape == x0.shape) and (zdata.shape == y0.shape): + # 2D coordinates and 2D raster + + # use a curvilinear QuadMesh + if self.shape.name == "shade_raster": + self.shape.glyph = ds.glyphs.QuadMeshCurvilinear( + "x", "y", "val" + ) + + df = xar.Dataset( + data_vars=dict(val=(["xx", "yy"], zdata)), + # dims=["x", "y"], + coords=dict( + x=(["xx", "yy"], x0), + y=(["xx", "yy"], y0), + ), + ) + + elif ( + ((zdata.shape[1],) == x0.shape) + and ((zdata.shape[0],) == y0.shape) + and (x0.shape != y0.shape) + ): + raise AssertionError( + "EOmaps: it seems like you need to transpose your data! \n" + + f"the dataset has a shape of {zdata.shape}, but the " + + f"coordinates suggest ({x0.shape}, {y0.shape})" + ) + elif (zdata.T.shape == x0.shape) and (zdata.T.shape == y0.shape): + raise AssertionError( + "EOmaps: it seems like you need to transpose your data! \n" + + f"the dataset has a shape of {zdata.shape}, but the " + + f"coordinates suggest {x0.shape}" + ) + + elif ((zdata.shape[0],) == x0.shape) and ( + (zdata.shape[1],) == y0.shape + ): + # 1D coordinates and 2D data + + # use a rectangular QuadMesh + if self.shape.name == "shade_raster": + self.shape.glyph = ds.glyphs.QuadMeshRectilinear( + "x", "y", "val" + ) + + df = xar.DataArray( + data=zdata, + dims=["x", "y"], + coords=dict(x=x0, y=y0), + ) + df = xar.Dataset(dict(val=df)) + else: + try: + # try if reprojected coordinates can be used as 2d grid and if yes, + # directly use a curvilinear QuadMesh based on the reprojected + # coordinates to display the data + idx = pd.MultiIndex.from_arrays( + [x0.ravel(), y0.ravel()], names=["x", "y"] + ) + + df = pd.DataFrame( + data=dict(val=zdata.ravel()), index=idx, copy=False + ) + df = df.to_xarray() + xg, yg = np.meshgrid(df.x, df.y) + except Exception: + # first convert original coordinates of the 1D inputs to 2D, + # then reproject the grid and use a curvilinear QuadMesh to display + # the data + _log.warning( + "EOmaps: 1D data is converted to 2D prior to reprojection... " + "Consider using 'shade_points' as plot-shape instead!" + ) + xorig = self._data_manager.xorig.ravel() + yorig = self._data_manager.yorig.ravel() + + idx = pd.MultiIndex.from_arrays([xorig, yorig], names=["x", "y"]) + + df = pd.DataFrame( + data=dict(val=zdata.ravel()), index=idx, copy=False + ) + df = df.to_xarray() + xg, yg = np.meshgrid(df.x, df.y) + + # transform the grid from input-coordinates to the plot-coordinates + crs1 = CRS.from_user_input(self.data_specs.crs) + crs2 = CRS.from_user_input(self._crs_plot) + if crs1 != crs2: + transformer = self._get_transformer( + crs1, + crs2, + ) + xg, yg = transformer.transform(xg, yg) + + # use a curvilinear QuadMesh + if self.shape.name == "shade_raster": + self.shape.glyph = ds.glyphs.QuadMeshCurvilinear("x", "y", "val") + + df = xar.Dataset( + data_vars=dict(val=(["xx", "yy"], df.val.values.T)), + coords=dict(x=(["xx", "yy"], xg), y=(["xx", "yy"], yg)), + ) + + if self.shape.name == "shade_points": + df = df.to_dataframe().reset_index() + + if set_extent is True and self._set_extent_on_plot is True: + # convert to a numpy-array to support 2D indexing with boolean arrays + x, y = np.asarray(df.x), np.asarray(df.y) + xf, yf = np.isfinite(x), np.isfinite(y) + x_range = (np.nanmin(x[xf]), np.nanmax(x[xf])) + y_range = (np.nanmin(y[yf]), np.nanmax(y[yf])) + else: + # update here to ensure bounds are set + self._bm.update() + x0, x1, y0, y1 = self.get_extent(self.crs_plot) + x_range = (x0, x1) + y_range = (y0, y1) + + coll = mpl_ext.dsshow( + df, + glyph=self.shape.glyph, + aggregator=self.shape.aggregator, + shade_hook=self.shape.shade_hook, + agg_hook=self.shape.agg_hook, + # norm="eq_hist", + # norm=plt.Normalize(vmin, vmax), + cmap=self._cbcmap, + ax=self.ax, + plot_width=plot_width, + plot_height=plot_height, + # x_range=(x0, x1), + # y_range=(y0, y1), + # x_range=(df.x.min(), df.x.max()), + # y_range=(df.y.min(), df.y.max()), + x_range=x_range, + y_range=y_range, + vmin=self._vmin, + vmax=self._vmax, + **kwargs, + ) + + coll.set_label( + f" Dataset ({self.shape.name} | {zdata.shape})" f" on layer {self.layer}" + ) + + self._coll = coll + + if dynamic is True: + self.l[layer].add_artist(coll) + else: + self.l[layer].add_bg_artist(coll) + + if dynamic is True: + self._bm.update(clear=False) + + @property + def _shape_assigned(self): + """Return True if the shape is explicitly assigned and False otherwise""" + # the shape is considered assigned if an explicit shape is set + # or if the data has been plotted with the default shape + + q = getattr(self, "_shape", None) is None or ( + getattr(self._shape, "_is_default", False) and not self._data_plotted + ) + + return not q + + def _classify_data( + self, + z_data=None, + cmap=None, + vmin=None, + vmax=None, + classify_specs=None, + ): + + if self._inherit_classification is not None: + try: + return ( + self._inherit_classification._cbcmap, + self._inherit_classification._norm, + self._inherit_classification._bins, + self._inherit_classification._classified, + ) + except AttributeError: + raise AssertionError( + "EOmaps: A Maps object can only inherit the classification " + "if the parent Maps object called `m.plot_map()` first!!" + ) + + if z_data is None: + z_data = self._data_manager.z_data + + if isinstance(cmap, str): + cmap = plt.get_cmap(cmap).copy() + else: + cmap = cmap.copy() + + # evaluate classification + if classify_specs is not None and classify_specs.scheme is not None: + (mapclassify,) = register_modules("mapclassify") + + classified = True + if self._classify_specs.scheme == "UserDefined": + bins = self._classify_specs.bins + else: + # use "np.ma.compressed" to make sure values excluded via + # masked-arrays are not used to evaluate classification levels + # (normal arrays are passed through!) + mapc = getattr(mapclassify, classify_specs.scheme)( + np.ma.compressed(z_data[~np.isnan(z_data)]), **classify_specs + ) + bins = mapc.bins + + bins = np.unique(np.clip(bins, vmin, vmax)) + + if vmin < min(bins): + bins = [vmin, *bins] + + if vmax > max(bins): + bins = [*bins, vmax] + + # TODO Always use resample once mpl>3.6 is pinned + if hasattr(cmap, "resampled") and len(bins) > cmap.N: + # Resample colormap to contain enough color-values + # as needed by the boundary-norm. + cbcmap = cmap.resampled(len(bins)) + else: + cbcmap = cmap + + norm = mpl.colors.BoundaryNorm(bins, cbcmap.N) + + self._emit_signal("cmapsChanged") + + if cmap._rgba_bad: + cbcmap.set_bad(cmap._rgba_bad) + if cmap._rgba_over: + cbcmap.set_over(cmap._rgba_over) + if cmap._rgba_under: + cbcmap.set_under(cmap._rgba_under) + + else: + classified = False + bins = None + cbcmap = cmap + norm = None + + return cbcmap, norm, bins, classified + + def _get_mcl_subclass(self, s): + # get a subclass that inherits the docstring from the corresponding + # mapclassify classifier + + class scheme: + @wraps(s) + def __init__(_, *args, **kwargs): + pass + + if "y" in kwargs: + _log.error( + "EOmaps: The values (e.g. the 'y' parameter) are " + + "assigned internally... only provide additional " + + "parameters that specify the classification scheme!" + ) + kwargs.pop("y") + + self._classify_specs._set_scheme_and_args(scheme=s.__name__, **kwargs) + + scheme.__doc__ = s.__doc__ + return scheme + + def _set_default_shape(self): + if self.data_specs.data is not None: + size = np.size(self._data_manager.z_data) + shape = np.shape(self._data_manager.z_data) + + if len(shape) == 2 and size > 200_000: + self.set_shape.raster() + else: + if size > 500_000: + if all( + register_modules( + "datashader", "datashader.mpl_ext", raise_exception=False + ) + ): + # shade_points should work for any dataset + self.set_shape.shade_points() + else: + _log.warning( + "EOmaps: Attempting to plot a large dataset " + f"({size} datapoints) but the 'datashader' library " + "could not be imported! The plot might take long " + "to finish! ... defaulting to 'ellipses' " + "as plot-shape." + ) + self.set_shape.ellipses() + else: + self.set_shape.ellipses() + else: + self.set_shape.ellipses() + + def _find_ID(self, ID): + # explicitly treat range-like indices (for very large datasets) + ids = self._data_manager.ids + if isinstance(ids, range): + ind, mask = [], [] + for i in np.atleast_1d(ID): + if i in ids: + + found = ids.index(i) + ind.append(found) + mask.append(found) + else: + ind.append(None) + + elif isinstance(ids, (list, np.ndarray)): + mask = np.isin(ids, ID) + ind = np.where(mask)[0] + + return mask, ind + + def _get_alpha_cmap_name(self, alpha): + # get a unique name for the colormap + try: + ncmaps = max( + [ + int(i.rsplit("_", 1)[1]) + for i in plt.colormaps() + if i.startswith("EOmaps_alpha_") + ] + ) + except Exception: + ncmaps = 0 + + return f"EOmaps_alpha_{ncmaps + 1}" + + def _encode_values(self, val): + """ + Encode values with respect to the provided "scale_factor" and "add_offset". + + Encoding is performed via the formula: + + `encoded_value = val / scale_factor - add_offset` + + NOTE: the data-type is not altered!! + (e.g. no integer-conversion is performed, only values are adjusted) + + Parameters + ---------- + val : array-like + The data-values to encode + + Returns + ------- + encoded_values + The encoded data values + """ + encoding = self.data_specs.encoding + + if encoding is not None and encoding is not False: + try: + scale_factor = encoding.get("scale_factor", None) + add_offset = encoding.get("add_offset", None) + fill_value = encoding.get("_FillValue", None) + + if val is None: + return fill_value + + if add_offset: + val = val - add_offset + if scale_factor: + val = val / scale_factor + + return val + except Exception: + _log.exception(f"EOmaps: Error while trying to encode the data: {val}") + return val + else: + return val + + def _decode_values(self, val): + """ + Decode data-values with respect to the provided "scale_factor" and "add_offset". + + Decoding is performed via the formula: + + `actual_value = add_offset + scale_factor * val` + + The encoding is defined in `m.data_specs.encoding` + + Parameters + ---------- + val : array-like + The encoded data-values + + Returns + ------- + decoded_values + The decoded data values + """ + if val is None: + return None + + encoding = self.data_specs.encoding + if not any(encoding is i for i in (None, False)): + try: + scale_factor = encoding.get("scale_factor", None) + add_offset = encoding.get("add_offset", None) + + if scale_factor: + val = val * scale_factor + if add_offset: + val = val + add_offset + + return val + except Exception: + _log.exception(f"EOmaps: Error while trying to decode the data {val}.") + return val + else: + return val + + def _calc_vmin_vmax(self, vmin=None, vmax=None): + if self.data_specs.data is None: + return vmin, vmax + + calc_min, calc_max = vmin is None, vmax is None + + # ignore fill_values when evaluating vmin/vmax on integer-encoded datasets + if ( + self.data_specs.encoding is not None + and isinstance(self._data_manager.z_data, np.ndarray) + and issubclass(self._data_manager.z_data.dtype.type, np.integer) + ): + + # note the specific way how to check for integer-dtype based on issubclass + # since isinstance() fails to identify all integer dtypes!! + # isinstance(np.dtype("uint8"), np.integer) (incorrect) False + # issubclass(np.dtype("uint8").type, np.integer) (correct) True + # for details, see https://stackoverflow.com/a/934652/9703451 + + fill_value = self.data_specs.encoding.get("_FillValue", None) + if fill_value and any([calc_min, calc_max]): + # find values that are not fill-values + use_vals = self._data_manager.z_data[ + self._data_manager.z_data != fill_value + ] + + if calc_min: + vmin = np.min(use_vals) + if calc_max: + vmax = np.max(use_vals) + + return vmin, vmax + + # use nanmin/nanmax for all other arrays + if calc_min: + vmin = np.nanmin(self._data_manager.z_data) + if calc_max: + vmax = np.nanmax(self._data_manager.z_data) + + return vmin, vmax + + def _set_vmin_vmax(self, vmin=None, vmax=None): + # don't encode nan-vailes to avoid setting the fill-value as vmin/vmax + if vmin is not None: + vmin = self._encode_values(vmin) + if vmax is not None: + vmax = self._encode_values(vmax) + + # handle inherited bounds + if self._inherit_classification is not None: + if not (vmin is None and vmax is None): + raise TypeError( + "EOmaps: 'vmin' and 'vmax' cannot be set explicitly " + "if the classification is inherited!" + ) + + # in case data is NOT inherited, warn if vmin/vmax is None + # (different limits might cause a different appearance of the data!) + if self.data_specs._m == self: + if self._vmin is None: + _log.warning("EOmaps: Inherited value for 'vmin' is None!") + if self._vmax is None: + _log.warning( + "EOmaps: Inherited inherited value for 'vmax' is None!" + ) + + self._vmin = self._inherit_classification._vmin + self._vmax = self._inherit_classification._vmax + return + + if not self.shape.name.startswith("shade_"): + # ignore fill_values when evaluating vmin/vmax on integer-encoded datasets + self._vmin, self._vmax = self._calc_vmin_vmax(vmin=vmin, vmax=vmax) + else: + # get the name of the used aggretation reduction + aggname = self.shape.aggregator.__class__.__name__ + if aggname in ["first", "last", "max", "min", "mean", "mode"]: + # set vmin/vmax in case the aggregation still represents data-values + self._vmin, self._vmax = self._calc_vmin_vmax(vmin=vmin, vmax=vmax) + else: + # set vmin/vmax for aggregations that do NOT represent data values + # allow vmin/vmax = None (e.g. autoscaling) + self._vmin, self._vmax = vmin, vmax + if "count" in aggname: + # if the reduction represents a count, don't count empty pixels + if vmin and vmin <= 0: + _log.warning( + "EOmaps: setting vmin=1 to avoid counting empty pixels..." + ) + self._vmin = 1 + + def _get_shade_axis_size(self, dpi=None, flush=True): + if flush: + # flush events before evaluating shade sizes to make sure axes dimensions have + # been properly updated + self.f.canvas.flush_events() + + if self._shade_dpi is not None: + dpi = self._shade_dpi + + fig_dpi = self.f.dpi + w, h = self.ax.bbox.width, self.ax.bbox.height + + # TODO for now, only handle numeric dpi-values to avoid issues. + # (savefig also seems to support strings like "figure" etc.) + if isinstance(dpi, (int, float, np.number)): + width = int(w / fig_dpi * dpi) + height = int(h / fig_dpi * dpi) + else: + width = int(w) + height = int(h) + + return width, height + + def _update_shade_axis_size(self, dpi=None, flush=True): + # method to update all shade-dpis + # NOTE: provided dpi value is only used if no explicit "_shade_dpi" is set! + return + # set the axis-size that is used to determine the number of pixels used + # when using "shade" shapes for ALL maps objects of a figure + for m in self._bm._children: + if m.coll is not None and m.shape.name.startswith("shade_"): + w, h = m._get_shade_axis_size(dpi=dpi, flush=flush) + m.coll.plot_width = w + m.coll.plot_height = h diff --git a/eomaps/mixins/gpd_mixin.py b/eomaps/mixins/gpd_mixin.py new file mode 100644 index 000000000..bc2de509b --- /dev/null +++ b/eomaps/mixins/gpd_mixin.py @@ -0,0 +1,487 @@ +import logging + +_log = logging.getLogger(__name__) + +from difflib import get_close_matches +from pathlib import Path + +import numpy as np +import matplotlib.path as mpath + +from ..cb_container import GeoDataFramePicker +from ..helpers import _get_rect_poly_verts, register_modules, progressbar + + +class GeopandasMixin: + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + @property + def __lazy_attrs(self): + # list of attributes that support lazy-evaluation + return [i for i in dir(GeopandasMixin) if not i.startswith("_")] + + def _make_rect_poly(self, x0, y0, x1, y1, crs=None, npts=100): + """ + Return a geopandas.GeoDataFrame with a rectangle in the given crs. + + Parameters + ---------- + x0, y0, y1, y1 : float + the boundaries of the shape + npts : int, optional + The number of points used to draw the polygon-lines. The default is 100. + crs : any, optional + a coordinate-system identifier. (e.g. output of `m.get_crs(crs)`) + The default is None. + + Returns + ------- + gdf : geopandas.GeoDataFrame + the geodataframe with the shape and crs defined + + """ + (gpd,) = register_modules("geopandas") + + from shapely.geometry import Polygon + + verts = _get_rect_poly_verts(x0=x0, y0=y0, x1=x1, y1=y1, npts=npts) + gdf = gpd.GeoDataFrame(geometry=[Polygon(verts)]) + gdf.set_crs(crs, inplace=True) + + return gdf + + def add_gdf( + self, + gdf, + picker_name=None, + pick_method="contains", + val_key=None, + layer=None, + temporary_picker=None, + clip=False, + reproject="gpd", + verbose=False, + only_valid=False, + set_extent=False, + permanent=True, + **kwargs, + ): + """ + Plot a `geopandas.GeoDataFrame` on the map. + + Parameters + ---------- + gdf : geopandas.GeoDataFrame, str or pathlib.Path + A GeoDataFrame that should be added to the plot. + + If a string (or pathlib.Path) is provided, it is identified as the path to + a file that should be read with `geopandas.read_file(gdf)`. + + picker_name : str or None + A unique name that is used to identify the pick-method. + + If a `picker_name` is provided, a new pick-container will be + created that can be used to pick geometries of the GeoDataFrame. + + The container can then be accessed via: + >>> m.cb.pick__ + or + >>> m.cb.pick[picker_name] + and it can be used in the same way as `m.cb.pick...` + + pick_method : str or callable + if str : + The operation that is executed on the GeoDataFrame to identify + the picked geometry. + Possible values are: + + - "contains": + pick a geometry only if it contains the clicked point + (only works with polygons! (not with lines and points)) + - "centroids": + pick the closest geometry with respect to the centroids + (should work with any geometry whose centroid is defined) + + The default is "centroids" + + if callable : + A callable that is used to identify the picked geometry. + The call-signature is: + + >>> def picker(artist, mouseevent): + >>> # if the pick is NOT successful: + >>> return False, dict() + >>> ... + >>> # if the pick is successful: + >>> return True, dict(ID, pos, val, ind) + + The default is "contains" + + val_key : str + The dataframe-column used to identify values for pick-callbacks. + The default is the value provided via `column=...` or None. + layer : int, str or None + The name of the layer at which the dataset will be plotted. + + - If "all": the corresponding feature will be added to ALL layers + - If None, the layer assigned to the Maps-object is used (e.g. `m.layer`) + + The default is None. + temporary_picker : str, optional + The name of the picker that should be used to make the geometry + temporary (e.g. remove it after each pick-event) + clip : str or False + This feature can help with re-projection issues for non-global crs. + (see example below) + + Indicator if geometries should be clipped prior to plotting or not. + + - if "crs": clip with respect to the boundary-shape of the crs + - if "crs_bounds" : clip with respect to a rectangular crs boundary + - if "extent": clip with respect to the current extent of the plot-axis. + + >>> mg = MapsGrid(2, 3, crs=3035) + >>> mg.m_0_0.add_feature.preset.ocean(use_gpd=True) + >>> mg.m_0_1.add_feature.preset.ocean(use_gpd=True, clip="crs") + >>> mg.m_0_2.add_feature.preset.ocean(use_gpd=True, clip="extent") + >>> mg.m_1_0.add_feature.preset.ocean(use_gpd=False) + >>> mg.m_1_1.add_feature.preset.ocean(use_gpd=False, clip="crs") + >>> mg.m_1_2.add_feature.preset.ocean(use_gpd=False, clip="extent") + + reproject : str, optional + Similar to "clip" this feature mainly addresses issues in the way how + re-projected geometries are displayed in certain coordinate-systems. + (see example below) + + - if "gpd": re-project geometries geopandas + - if "cartopy": re-project geometries with cartopy (slower but more robust) + + The default is "gpd". + + >>> mg = MapsGrid(2, 1, crs=Maps.CRS.Stereographic()) + >>> mg.m_0_0.add_feature.preset.ocean(reproject="gpd") + >>> mg.m_1_0.add_feature.preset.ocean(reproject="cartopy") + + verbose : bool, optional + Indicator if a progressbar should be printed when re-projecting + geometries with "use_gpd=False". The default is False. + only_valid : bool, optional + - If True, only valid geometries (e.g. `gdf.is_valid`) are plotted. + - If False, all geometries are attempted to be plotted + (this might result in errors for infinite geometries etc.) + + The default is True + set_extent: bool, optional + - if True, set map extent to the extent of the geometries with +-5% margin. + - if float, use the value as margin (0-1). + + The default is True. + permanent : bool, optional + If True, all created artists are added as "permanent" background + artists. If False, artists are added as dynamic artists. + The default is True. + kwargs : + all remaining kwargs are passed to `geopandas.GeoDataFrame.plot(**kwargs)` + + Returns + ------- + new_artists : matplotlib.Artist + The matplotlib-artists added to the plot + + """ + (gpd,) = register_modules("geopandas") + + if val_key is None: + val_key = kwargs.get("column", None) + + gdf = self._handle_gdf( + gdf, + val_key=val_key, + only_valid=only_valid, + clip=clip, + reproject=reproject, + verbose=verbose, + ) + + # plot gdf and identify newly added collections + # (geopandas always uses collections) + colls = [id(i) for i in self.ax.collections] + artists, prefixes = [], [] + + # drop all invalid geometries + if only_valid: + valid = gdf.is_valid + n_invald = np.count_nonzero(~valid) + gdf = gdf[valid] + if len(gdf) == 0: + _log.error("EOmaps: GeoDataFrame contains only invalid geometries!") + return + elif n_invald > 0: + _log.warning( + "EOmaps: {n_invald} invalid GeoDataFrame geometries are ignored!" + ) + + if set_extent: + extent = np.array( + [ + gdf.bounds["minx"].min(), + gdf.bounds["maxx"].max(), + gdf.bounds["miny"].min(), + gdf.bounds["maxy"].max(), + ] + ) + + if isinstance(set_extent, (int, float, np.number)): + margin = set_extent + else: + margin = 0.05 + + dx = extent[1] - extent[0] + dy = extent[3] - extent[2] + + d = max(dx, dy) * margin + extent[[0, 2]] -= d + extent[[1, 3]] += d + + self.set_extent(extent, crs=gdf.crs) + + for geomtype, geoms in gdf.groupby(gdf.geom_type): + gdf.plot(ax=self.ax, aspect=self.ax.get_aspect(), **kwargs) + artists = [i for i in self.ax.collections if id(i) not in colls] + for i in artists: + prefixes.append(f"_{i.__class__.__name__.replace('Collection', '')}") + + if picker_name is not None: + if isinstance(pick_method, str): + picker_cls = GeoDataFramePicker( + gdf=gdf, pick_method=pick_method, val_key=val_key + ) + picker = picker_cls.get_picker() + elif callable(pick_method): + picker = pick_method + picker_cls = None + else: + _log.error( + "EOmaps: The provided pick_method is invalid." + "Please provide either a string or a function." + ) + return + + if len(artists) > 1: + log_names = [picker_name + prefix for prefix in np.unique(prefixes)] + _log.warning( + "EOmaps: Multiple geometry types encountered in `m.add_gdf`. " + + "The pick containers are re-named to" + + f"{log_names}" + ) + else: + prefixes = [""] + + for artist, prefix in zip(artists, prefixes): + # make the newly added collection pickable + self.cb.add_picker(picker_name + prefix, artist, picker=picker) + # attach the re-projected GeoDataFrame to the pick-container + self.cb.pick[picker_name + prefix].data = gdf + self.cb.pick[picker_name + prefix].val_key = val_key + self.cb.pick[picker_name + prefix]._picker_cls = picker_cls + + if layer is None: + layer = self.layer + + if temporary_picker is not None: + if temporary_picker == "default": + for art, prefix in zip(artists, prefixes): + self.cb.pick.add_temporary_artist(art) + else: + for art, prefix in zip(artists, prefixes): + self.cb.pick[temporary_picker].add_temporary_artist(art) + else: + for art, prefix in zip(artists, prefixes): + art.set_label(f"EOmaps GeoDataframe ({prefix.lstrip('_')}, {len(gdf)})") + if permanent is True: + self.l[layer].add_bg_artist(art) + else: + self.l[layer].add_artist(art) + return artists + + def _handle_gdf( + self, + gdf, + val_key=None, + only_valid=True, + clip=False, + reproject="gpd", + verbose=False, + ): + (gpd,) = register_modules("geopandas") + + if isinstance(gdf, (str, Path)): + gdf = gpd.read_file(gdf) + + if only_valid: + gdf = gdf[gdf.is_valid] + + try: + # explode the GeoDataFrame to avoid picking multi-part geometries + gdf = gdf.explode(index_parts=False) + except Exception: + # geopandas sometimes has problems exploding geometries... + # if it does not work, just continue with the Multi-geometries! + _log.error("EOmaps: Exploding geometries did not work!") + pass + + if clip: + gdf = self._clip_gdf(gdf, clip) + if reproject == "gpd": + gdf = gdf.to_crs(self.crs_plot) + elif reproject == "cartopy": + # optionally use cartopy's re-projection routines to re-project + # geometries + + cartopy_crs = self._get_cartopy_crs(gdf.crs) + if self.ax.projection != cartopy_crs: + geoms = gdf.geometry + if len(geoms) > 0: + proj_geoms = [] + + if verbose: + for g in progressbar(geoms, "EOmaps: re-projecting... ", 20): + proj_geoms.append( + self.ax.projection.project_geometry(g, cartopy_crs) + ) + else: + for g in geoms: + proj_geoms.append( + self.ax.projection.project_geometry(g, cartopy_crs) + ) + gdf = gdf.set_geometry(proj_geoms) + gdf = gdf.set_crs(self.ax.projection, allow_override=True) + gdf = gdf[~gdf.is_empty] + else: + raise AssertionError( + f"EOmaps: '{reproject}' is not a valid reproject-argument." + ) + + return gdf + + def _clip_gdf(self, gdf, how="crs"): + """ + Clip the shapes of a GeoDataFrame with respect to the given boundaries. + + Parameters + ---------- + gdf : geopandas.GeoDataFrame + The GeoDataFrame containing the geometries. + how : str, optional + Identifier how the clipping should be performed. + + - clipping with geopandas: + - "crs" : use the actual crs boundary polygon + - "crs_bounds" : use the boundary-envelope of the crs + - "extent" : use the current plot-extent + + The default is "crs". + + Returns + ------- + gdf + A GeoDataFrame with the clipped geometries + + """ + (gpd,) = register_modules("geopandas") + + if how == "crs" or how == "crs_invert": + clip_shp = gpd.GeoDataFrame( + geometry=[self.ax.projection.domain], crs=self.crs_plot + ).to_crs(gdf.crs) + elif how == "extent" or how == "extent_invert": + self._bm.update() + x0, x1, y0, y1 = self.get_extent(crs=self.crs_plot) + clip_shp = self._make_rect_poly(x0, y0, x1, y1, self.crs_plot).to_crs( + gdf.crs + ) + elif how == "crs_bounds" or how == "crs_bounds_invert": + x0, x1, y0, y1 = self.get_extent(crs=self.crs_plot) + clip_shp = self._make_rect_poly( + *self.crs_plot.boundary.bounds, self.crs_plot + ).to_crs(gdf.crs) + else: + raise TypeError(f"EOmaps: '{how}' is not a valid clipping method") + + clip_shp = clip_shp.buffer(0) # use this to make sure the geometry is valid + + # add 1% of the extent-diameter as buffer + bnd = clip_shp.boundary.bounds + d = np.sqrt((bnd.maxx - bnd.minx) ** 2 + (bnd.maxy - bnd.miny) ** 2) + clip_shp = clip_shp.buffer(d / 100) + + # clip the geo-dataframe with the buffered clipping shape + clipgdf = gdf.clip(clip_shp) + + return clipgdf + + def _set_gdf_path_boundary(self, gdf, set_extent=True): + geom = gdf.to_crs(self.crs_plot).union_all() + if "Polygon" in geom.geom_type: + geom = geom.boundary + + if geom.geom_type == "MultiLineString": + boundary_linestrings = geom.geoms + elif geom.geom_type == "LineString": + boundary_linestrings = [geom] + else: + raise TypeError( + f"Geometries of type {geom.type} cannot be used as map-boundary." + ) + + vertices, codes = [], [] + for g in boundary_linestrings: + x, y = g.xy + codes.extend( + [mpath.Path.MOVETO, *[mpath.Path.LINETO] * len(x), mpath.Path.CLOSEPOLY] + ) + vertices.extend([(x[0], y[0]), *zip(x, y), (x[-1], y[-1])]) + + path = mpath.Path(vertices, codes) + + self.ax.set_boundary(path, self.ax.transData) + if set_extent: + x0, y0 = np.min(vertices, axis=0) + x1, y1 = np.max(vertices, axis=0) + + self.set_extent([x0, x1, y0, y1], gdf.crs) + + def _get_country_frame(self, countries, scale=50): + """ + Get the map-frame to one (or more) country boarders defined by + the NaturalEarth admin_0_countries dataset. + + For more details, see: + + https://www.naturalearthdata.com/downloads/10m-cultural-vectors/10m-admin-0-countries/ + + Parameters + ---------- + countries : str or list of str + The countries who should be included in the map-frame. + scale : int, optional + The scale factor of the used NaturalEarth dataset. + One of 10, 50, 110. + The default is 50. + """ + countries = [i.lower() for i in np.atleast_1d(countries)] + gdf = self.add_feature.cultural.admin_0_countries.get_gdf(scale=scale) + names = gdf.NAME.str.lower().values + + q = np.isin(names, countries) + + if np.count_nonzero(q) == len(countries): + return gdf[q] + else: + for c in countries: + if c not in names: + print( + f"Unable to identify the country '{c}'. " + f"Fid you mean {get_close_matches(c, gdf.NAME)}" + ) diff --git a/eomaps/mixins/tools_mixin.py b/eomaps/mixins/tools_mixin.py new file mode 100644 index 000000000..035bea850 --- /dev/null +++ b/eomaps/mixins/tools_mixin.py @@ -0,0 +1,30 @@ +import weakref +from functools import wraps + +from ..utilities import Utilities +from ..draw import ShapeDrawer +from ..annotation_editor import AnnotationEditor + + +class ToolsMixin: + draw = ShapeDrawer + util = Utilities + + def __init__(self, *args, **kwargs): + if self.parent == self: + self.__util = Utilities(self) + self.__edit_annotations = AnnotationEditor(self) + + self.draw = ShapeDrawer(weakref.proxy(self)) + self.util = self.parent._ToolsMixin__util + + super().__init__(*args, **kwargs) + + @property + def _edit_annotations(self): + return self.parent._ToolsMixin__edit_annotations + + @wraps(AnnotationEditor.__call__) + def edit_annotations(self, b=True, **kwargs): + # self.parent._edit_annotations(b, **kwargs) + return self._edit_annotations(b, **kwargs) diff --git a/eomaps/ne_features.py b/eomaps/ne_features.py index 19f34346b..67ccd291a 100644 --- a/eomaps/ne_features.py +++ b/eomaps/ne_features.py @@ -219,6 +219,7 @@ def __call__(self, layer=None, scale="auto", **kwargs): from . import MapsGrid # do this here to avoid circular imports! for m in self._m if isinstance(self._m, MapsGrid) else [self._m]: + if layer is None: uselayer = m.layer else: @@ -261,7 +262,7 @@ def __call__(self, layer=None, scale="auto", **kwargs): """ art._EOmaps_source_code = source_code - m.BM.add_bg_artist(art, layer=uselayer) + m.l[uselayer].add_bg_artist(art) def _set_scale(self, scale): if scale == "auto": diff --git a/eomaps/qtcompanion/app.py b/eomaps/qtcompanion/app.py index 88dcfa938..1c9e799df 100644 --- a/eomaps/qtcompanion/app.py +++ b/eomaps/qtcompanion/app.py @@ -20,11 +20,11 @@ from .widgets.editor import LayerTabBar from .widgets.layer import AutoUpdateLayerMenuButton -# TODO make sure a QApplication has been instantiated -app = QtWidgets.QApplication.instance() -if app is None: - # if it does not exist then a QApplication is created - app = QtWidgets.QApplication([]) +# # TODO make sure a QApplication has been instantiated +# app = QtWidgets.QApplication.instance() +# if app is None: +# # if it does not exist then a QApplication is created +# app = QtWidgets.QApplication([]) class CompareTab(QtWidgets.QWidget): @@ -196,7 +196,9 @@ def show(self): # make sure show/hide shortcut also works if the widget is active # we need to re-assign this on show to make sure it is always assigned # when the window is shown - self.shortcut = QtWidgets.QShortcut(QKeySequence("w"), self) + self.shortcut = QtWidgets.QShortcut( + QKeySequence(self.m._CompanionMixin__companion_widget_key), self + ) self.shortcut.setContext(Qt.WindowShortcut) self.shortcut.activated.connect(self.toggle_show) self.shortcut.activatedAmbiguously.connect(self.toggle_show) diff --git a/eomaps/qtcompanion/base.py b/eomaps/qtcompanion/base.py index 803c74c3f..c5f5a6d0b 100644 --- a/eomaps/qtcompanion/base.py +++ b/eomaps/qtcompanion/base.py @@ -139,7 +139,7 @@ def leaveEvent(self, event): if self.active_icon: self.setIcon(self.active_icon) - return super().enterEvent(event) + return super().leaveEvent(event) def swap_icon(self, *args, **kwargs): if self.normal_icon and self.hoover_icon: @@ -472,8 +472,9 @@ def __init__(self, *args, m=None, **kwargs): self.out_alpha = 0.25 self.m = m - # get the current PyQt app and connect the focus-change callback - self.app = QtWidgets.QApplication([]).instance() + self.app = QtWidgets.QApplication.instance() + if self.app is None: + self.app = QtWidgets.QApplication([]) # make sure the window does not steal focus from the matplotlib-canvas # on show (otherwise callbacks are inactive as long as the window is focused!) diff --git a/eomaps/qtcompanion/signal_container.py b/eomaps/qtcompanion/signal_container.py index 6b63dbe7e..edbbad9aa 100644 --- a/eomaps/qtcompanion/signal_container.py +++ b/eomaps/qtcompanion/signal_container.py @@ -30,3 +30,6 @@ class _SignalContainer(QObject): # -------- layout editor layoutEditorActivated = Signal() layoutEditorDeactivated = Signal() + + # -------- layer handling + lazyLayerActivated = Signal() diff --git a/eomaps/qtcompanion/widgets/annotate.py b/eomaps/qtcompanion/widgets/annotate.py index ccd481a07..83e11c153 100644 --- a/eomaps/qtcompanion/widgets/annotate.py +++ b/eomaps/qtcompanion/widgets/annotate.py @@ -416,20 +416,20 @@ def update_selected_text(self, *args, **kwargs): text = self.text_inp.toPlainText() ann.set_text(text) - self.m.BM.update(artists=[ann]) + self.m._bm.update(artists=[ann]) def update_selected_text_props(self, *args, **kwargs): ann = self.selected_annotation if ann: ann.set_color(self.annotate_props.get("color", "k")) - self.m.BM.update(artists=[ann]) + self.m._bm.update(artists=[ann]) def update_selected_rotation(self, r): ann = self.selected_annotation if ann: # update the rotation of the currently selected annotation ann.set_rotation(r) - self.m.BM.update(artists=[ann]) + self.m._bm.update(artists=[ann]) def update_selected_patch(self, fc, ec, lw): ann = self.selected_annotation @@ -469,7 +469,7 @@ def update_selected_patch(self, fc, ec, lw): else: ann.arrow_patch.set(arrowstyle=None) - self.m.BM.update(artists=[ann]) + self.m._bm.update(artists=[ann]) def enterEvent(self, e): if self.window().showhelp is True: @@ -513,9 +513,8 @@ def do_add_annotation(self): def remove_selected_annotation(self): ann = self.selected_annotation if ann: - self.m.BM.remove_artist(ann) - ann.remove() - self.m.BM.update() + self.m._bm.remove_artist(ann) + self.m._bm.update() else: self.window().statusBar().showMessage("There is no annotation to remove!") diff --git a/eomaps/qtcompanion/widgets/click_callbacks.py b/eomaps/qtcompanion/widgets/click_callbacks.py index 5c24ef17e..1e01109cf 100644 --- a/eomaps/qtcompanion/widgets/click_callbacks.py +++ b/eomaps/qtcompanion/widgets/click_callbacks.py @@ -120,7 +120,7 @@ def __init__(self, *args, **kwargs): # long layer names... (full name is shown in dropdown) self.setMinimumWidth(150) self.setMaximumWidth(150) - self.setSizeAdjustPolicy(self.AdjustToContents) + self.setSizeAdjustPolicy(QtWidgets.QComboBox.AdjustToContents) def enterEvent(self, e): if self.window().showhelp is True: @@ -311,7 +311,7 @@ def __init__(self, *args, m=None, **kwargs): self.set_pick_map(0) # make sure we re-attach pick-callback on a layer change - self.m.BM.on_layer(self.on_layer_change, persistent=True) + self.m._bm.on_layer(self.on_layer_change, persistent=True) self.m._connect_signal("dataPlotted", self.populate_dropdown) self.m._connect_signal("dataPlotted", self.update_buttons) @@ -320,11 +320,11 @@ def showEvent(self, event): self.widgetShown.emit() def identify_pick_map(self): - layers, _ = self.m.BM._get_active_layers_alphas + layers, _ = self.m._bm._get_active_layers_alphas layers.extend(("all", "inset_all")) pickm = list() - for m in (self.m.parent, *self.m.parent._children): + for m in self.m._bm._children: if m.coll is not None and m.ax == self.m.ax and m.layer in layers: pickm.append(m) @@ -334,14 +334,14 @@ def identify_pick_map(self): def clear_annotations_and_markers(self): # clear all annotations and markers from this axis # (irrespective of the visible layer!) - for m in (self.m.parent, *self.m.parent._children): + for m in self.m._bm._children: if m.ax == self.m.ax: m.cb.click._attach.clear_annotations(m.cb.click.attach) m.cb.click._attach.clear_markers(m.cb.click.attach) m.cb.pick._attach.clear_annotations(m.cb.pick.attach) m.cb.pick._attach.clear_markers(m.cb.pick.attach) - self.m.BM.update() + self.m._bm.update() def reattach_pick_callbacks(self): # re-attach all "pick" callbacks (e.g. if the pick_map changed) @@ -370,8 +370,8 @@ def populate_dropdown(self, *args, **kwargs): name = f"{i}" # indicate map-layer name if combined layer is visible - if "|" in m.BM.bg_layer: - if m.layer != m.BM.bg_layer: + if "|" in m._bm.bg_layer: + if m.layer != m._bm.bg_layer: name += f" ({m.layer})" self.map_dropdown.addItem(name, m) @@ -468,7 +468,7 @@ def remove_callback(self, key): m.cb.click.remove(cid) self.cids[key] = None - self.m.BM.update() + self.m._bm.update() def attach_callback(self, key): # remove existing callback diff --git a/eomaps/qtcompanion/widgets/draw.py b/eomaps/qtcompanion/widgets/draw.py index a3ad76d02..41a8d8ce9 100644 --- a/eomaps/qtcompanion/widgets/draw.py +++ b/eomaps/qtcompanion/widgets/draw.py @@ -111,7 +111,7 @@ def __init__(self, *args, m=None, **kwargs): self.addTab(newtabwidget, "+") # don't show the close button for this tab - self.tabBar().setTabButton(self.count() - 1, self.tabBar().RightSide, None) + self.tabBar().setTabButton(self.count() - 1, QtWidgets.QTabBar.RightSide, None) self.tabBarClicked.connect(self.tabbar_clicked) self.setCurrentIndex(0) @@ -183,7 +183,7 @@ def close_handler(self, index): exc_info=_log.getEffectiveLevel() <= logging.DEBUG, ) - self.m.BM.update() + self.m._bm.update() self.removeTab(index) if index == curridx: @@ -422,7 +422,7 @@ def remove_last_shape(self): try: self.drawer.remove_last_shape() # update to make sure the changes are reflected on the map immediately - self.m.BM.update() + self.m._bm.update() except Exception: _log.error( "EOmaps: Encountered a problem while trying to remove " diff --git a/eomaps/qtcompanion/widgets/editor.py b/eomaps/qtcompanion/widgets/editor.py index 84393575f..0121b73f0 100644 --- a/eomaps/qtcompanion/widgets/editor.py +++ b/eomaps/qtcompanion/widgets/editor.py @@ -10,14 +10,12 @@ from qtpy.QtCore import Qt, Signal, Slot, QPointF from qtpy.QtGui import QFont -from matplotlib.colors import to_rgba_array - from ...inset_maps import InsetMaps from ...helpers import _key_release_event from ..common import iconpath from ..base import BasicCheckableToolButton, NewWindow from .wms import AddWMSMenuButton -from .utils import ColorWithSlidersWidget, GetColorWidget, AlphaSlider +from .utils import ColorWithSlidersWidget, AlphaSlider from .annotate import AddAnnotationWidget from .draw import DrawerTabs from .files import OpenDataStartTab @@ -151,7 +149,7 @@ def menu_callback_factory(self, featuretype, feature): def cb(): # TODO set the layer !!!! if self.layer is None: - layer = self.m.BM.bg_layer + layer = self.m._bm.bg_layer else: layer = self.layer @@ -162,13 +160,15 @@ def cb(): return try: - f = getattr(getattr(self.m.add_feature, featuretype), feature) + # f = getattr(getattr(self.m.add_feature, featuretype), feature) + f = getattr(getattr(self.m.ll[layer].add_feature, featuretype), feature) + if featuretype == "preset": - f(layer=layer, **f.kwargs) + f(**f.kwargs) else: - f(layer=layer, **self.props) + f(**self.props) - self.m.f.canvas.draw_idle() + # self.m.f.canvas.draw_idle() self.FeatureAdded.emit(str(layer)) except Exception: _log.error( @@ -549,7 +549,7 @@ def __init__(self, *args, m=None, **kwargs): def move_plus_button(self, *args, **kwargs): """Move the plus button to the correct location.""" # Set the plus button location in a visible area - h = self.geometry().top() + # h = self.geometry().top() w = self.window().width() self.plus_button.move(w - self.margin_right, -3) @@ -557,7 +557,7 @@ def move_plus_button(self, *args, **kwargs): def move_layer_button(self, *args, **kwargs): """Move the plus button to the correct location.""" # Set the plus button location in a visible area - h = self.geometry().top() + # h = self.geometry().top() self.layer_button.move(-5, 2) @@ -673,7 +673,7 @@ def __init__(self, m=None, populate=False, *args, **kwargs): # NOTE this is done by the TabWidget if tabs have content!! self.populate() # re-populate on show to make sure currently active layers are shown - self.m.BM.on_layer(self.populate_on_layer, persistent=True) + self.m._bm.on_layer(self.populate_on_layer, persistent=True) self.m._after_add_child.append(self.populate) self.m._on_show_companion_widget.append(self.populate) @@ -799,7 +799,9 @@ def repopulate_and_activate_current(self, *args, **kwargs): # activate the currently visible layer tab try: idx = next( - i for i in range(self.count()) if self.tabText(i) == self.m.BM._bg_layer + i + for i in range(self.count()) + if self.tabText(i) == self.m._bm._bg_layer ) self.setCurrentIndex(idx) except StopIteration: @@ -808,7 +810,7 @@ def repopulate_and_activate_current(self, *args, **kwargs): @Slot() def tab_moved(self): # get currently active layers - active_layers, alphas = self.m.BM._get_active_layers_alphas + active_layers, alphas = self.m._bm._get_active_layers_alphas # get the name of the layer that was moved layer = self.tabText(self.currentIndex()) @@ -866,13 +868,14 @@ def _do_close_tab(self, index): return # get currently active layers - active_layers, alphas = self.m.BM._get_active_layers_alphas + active_layers, alphas = self.m._bm._get_active_layers_alphas + # TODO this should call a unified "cleanup layer method on the blit-manager!" # cleanup the layer and remove any artists etc. for m in list(self.m._children): if layer == m.layer: m.cleanup() - m.BM._bg_layers.pop(layer, None) + m._bm._bg_layers.pop(layer, None) # in case the layer was visible, try to activate a suitable replacement if layer in active_layers: @@ -893,8 +896,8 @@ def _do_close_tab(self, index): switchlayer = next( ( i - for i in self.m.BM._bg_artists - if layer not in self.m.BM._parse_multi_layer_str(i)[0] + for i in self.m._bm._bg_artists + if layer not in self.m._bm._parse_multi_layer_str(i)[0] ) ) self.m.show_layer(switchlayer) @@ -903,25 +906,18 @@ def _do_close_tab(self, index): _log.error("EOmaps: Unable to delete the last available layer!") return - if layer in list(self.m.BM._bg_artists): - for a in self.m.BM._bg_artists[layer]: - self.m.BM.remove_bg_artist(a) + if layer in list(self.m._bm._bg_artists): + for a in self.m._bm._bg_artists[layer]: + self.m._bm.remove_bg_artist(a) a.remove() - del self.m.BM._bg_artists[layer] - - if layer in self.m.BM._bg_layers: - del self.m.BM._bg_layers[layer] + del self.m._bm._bg_artists[layer] - # also remove the layer from any layer-change/layer-activation triggers - # (e.g. to deal with not-yet-fetched WMS services) + if layer in self.m._bm._bg_layers: + del self.m._bm._bg_layers[layer] - for permanent, d in self.m.BM._on_layer_activation.items(): - if layer in d: - del d[layer] - - for permanent, d in self.m.BM._on_layer_change.items(): - if layer in d: - del d[layer] + self.m._bm.remove_hook( + "layer_activation", method=None, permanent=None, layer=layer + ) self.populate() @@ -932,7 +928,7 @@ def color_active_tab(self, m=None, layer=None, adjust_order=True): multicolor = QtGui.QColor(50, 150, 50) # QtGui.QColor(0, 128, 0) # get currently active layers - active_layers, alphas = self.m.BM._get_active_layers_alphas + active_layers, alphas = self.m._bm._get_active_layers_alphas for i in range(self.count()): selected_layer = self.tabText(i) @@ -972,10 +968,10 @@ def color_active_tab(self, m=None, layer=None, adjust_order=True): @Slot() def populate_on_layer(self, *args, **kwargs): lastlayer = getattr(self, "_last_populated_layer", "") - currlayer = self.m.BM.bg_layer + currlayer = self.m._bm.bg_layer # only populate if the current layer is not part of the last set of layers # (e.g. to allow show/hide of selected layers without removing the tabs) - if not self.m.BM._layer_is_subset(currlayer, lastlayer): + if not self.m._bm._layer_is_subset(currlayer, lastlayer): self.populate(*args, **kwargs) self._last_populated_layer = currlayer else: @@ -1004,7 +1000,7 @@ def populate(self, *args, **kwargs): # if more than max_n_layers layers are available, show only active tabs to # avoid performance issues when too many tabs are created - alllayers = [i for i in self.m.BM._bg_layer.split("|") if i in alllayers] + alllayers = [i for i in self.m._bm._bg_layer.split("|") if i in alllayers] for i in range(self.count(), -1, -1): self.removeTab(i) else: @@ -1037,7 +1033,7 @@ def populate(self, *args, **kwargs): if layer == "all" or layer == self.m.layer: # don't show the close button for this tab - self.setTabButton(self.count() - 1, self.RightSide, None) + self.setTabButton(self.count() - 1, QtWidgets.QTabBar.RightSide, None) self.color_active_tab() @@ -1047,7 +1043,7 @@ def populate(self, *args, **kwargs): @Slot(str) def set_current_tab_by_name(self, layer): if layer is None: - layer = self.m.BM.bg_layer + layer = self.m._bm.bg_layer found = False ntabs = self.count() @@ -1098,10 +1094,10 @@ def tabchanged(self, index): return # get currently active layers - active_layers, alphas = self.m.BM._get_active_layers_alphas + active_layers, alphas = self.m._bm._get_active_layers_alphas for x in ( - i for i in self.m.BM._parse_multi_layer_str(layer)[0] if i != "_" + i for i in self.m._bm._parse_multi_layer_str(layer)[0] if i != "_" ): if x not in active_layers: active_layers.append(x) @@ -1136,11 +1132,14 @@ def __init__(self, m=None): # re-populate tabs if a new layer is created self.populate() self.m._after_add_child.append(self.populate) - self.m.BM.on_layer(self.populate_on_layer, persistent=True) + self.m._bm.on_layer(self.populate_on_layer, persistent=True) self.currentChanged.connect(self.populate_layer) - self.m.BM._on_add_bg_artist.append(self.populate) - self.m.BM._on_remove_bg_artist.append(self.populate) + + self.m._bm.add_hook("add_bg_artist", self.populate_layer, True) + self.m._bm.add_hook("remove_bg_artist", self.populate_layer, True) + + self.m._bm.add_hook("on_layer_callback_added", self.populate_layer, True) self.m._on_show_companion_widget.append(self.populate) self.m._on_show_companion_widget.append(self.populate_layer) @@ -1176,6 +1175,7 @@ def new_layer_button_clicked(self, *args, **kwargs): if len(layer) > 0: self.m.new_layer(layer) + self.repopulate_and_activate_current() inp.deleteLater() def repopulate_and_activate_current(self, *args, **kwargs): @@ -1184,7 +1184,9 @@ def repopulate_and_activate_current(self, *args, **kwargs): # activate the currently visible layer tab try: idx = next( - i for i in range(self.count()) if self.tabText(i) == self.m.BM._bg_layer + i + for i in range(self.count()) + if self.tabText(i) == self.m._bm._bg_layer ) self.setCurrentIndex(idx) @@ -1239,7 +1241,7 @@ def _get_artist_layout(self, a, layer): b_sh = ShowHideToolButton() b_sh.setAutoRaise(True) - if a in self.m.BM._hidden_artists: + if a in self.m._bm._hidden_artists: b_sh.setIcon(QtGui.QIcon(str(iconpath / "eye_closed.png"))) else: b_sh.setIcon(QtGui.QIcon(str(iconpath / "eye_open.png"))) @@ -1380,8 +1382,8 @@ def populate_on_layer(self, *args, **kwargs): # only populate if the current layer is not part of the last set of layers # (e.g. to allow show/hide of selected layers without removing the tabs) - if not self.m.BM._layer_visible(lastlayer): - self._last_populated_layer = self.m.BM.bg_layer + if not self.m._bm._layer_visible(lastlayer): + self._last_populated_layer = self.m._bm.bg_layer self.populate(*args, **kwargs) else: # TODO check why adjusting the tab-order causes recursions if multiple @@ -1412,7 +1414,7 @@ def populate(self, *args, **kwargs): # if more than max_n_layers layers are available, show only active tabs to # avoid performance issues when too many tabs are created - alllayers = self.m.BM._get_active_layers_alphas[0] + alllayers = self.m._bm._get_active_layers_alphas[0] for i in range(self.count(), -1, -1): self.removeTab(i) else: @@ -1447,7 +1449,7 @@ def populate(self, *args, **kwargs): if layer == "all" or layer == self.m.layer: # don't show the close button for this tab - tabbar.setTabButton(self.count() - 1, tabbar.RightSide, None) + tabbar.setTabButton(self.count() - 1, QtWidgets.QTabBar.RightSide, None) tabbar.color_active_tab() @@ -1455,7 +1457,7 @@ def populate(self, *args, **kwargs): tabbar.set_current_tab_by_name(self._current_tab_name) def get_layer_alpha(self, layer): - layers, alphas = self.m.BM._get_active_layers_alphas + layers, alphas = self.m._bm._get_active_layers_alphas if layer in layers: idx = layers.index(layer) alpha = alphas[idx] @@ -1477,9 +1479,9 @@ def populate_layer(self, layer=None): layer = self.tabText(self.currentIndex()) # make sure we fetch artists of inset-maps from the layer with - # the "__inset_" prefix - if isinstance(self.m, InsetMaps) and not layer.startswith("__inset_"): - layer = "__inset_" + layer + # the "**inset_" prefix + if isinstance(self.m, InsetMaps) and not layer.startswith("**inset_"): + layer = "**inset_" + layer widget = self.currentWidget() if widget is None: @@ -1489,15 +1491,7 @@ def populate_layer(self, layer=None): edit_layout = QtWidgets.QGridLayout() edit_layout.setAlignment(Qt.AlignTop | Qt.AlignLeft) - # make sure that we don't create an empty entry ! - # TODO the None check is to address possible race-conditions - # with Maps objects that have no axes defined. - if layer in self.m.BM._bg_artists and self.m.ax is not None: - artists = [ - a for a in self.m.BM.get_bg_artists(layer) if a.axes is self.m.ax - ] - else: - artists = [] + artists = self.m._bm.get_bg_artists(layer) for i, a in enumerate(artists): for art, pos in self._get_artist_layout(a, layer): @@ -1554,8 +1548,16 @@ def update_layerslider(alpha): layout.addLayout(layer_actions_layout) - for text in self.m.BM._pending_webmaps.get(layer, []): - layout.addWidget(QtWidgets.QLabel(f"PENDING WebMap: {text}")) + # indicate all pending methods (e.g. layer-activation callbacks) in the widget tab + for method in self.m._bm._get_hooks( + "layer_activation", layer=layer, permanent=False + ): + layout.addWidget( + QtWidgets.QLabel( + f"PENDING Method:" + f"   {method.__qualname__}" + ) + ) layout.addWidget(scroll) layout.addStretch(1) @@ -1576,8 +1578,8 @@ def cb(): artist.set_fc(colorwidget.facecolor.getRgbF()) artist.set_edgecolor(colorwidget.edgecolor.getRgbF()) - self.m.BM._refetch_layer(layer) - self.m.BM.update() + self.m._bm._refetch_layer(layer) + self.m._bm.update() return cb @@ -1585,7 +1587,7 @@ def _do_remove(self, artist, layer): if self._msg.standardButton(self._msg.clickedButton()) != self._msg.Yes: return - self.m.BM.remove_bg_artist(artist, layer) + self.m._bm.remove_bg_artist(artist, layer) try: artist.remove() except Exception: @@ -1627,11 +1629,11 @@ def cb(): def show_hide(self, artist, layer): @Slot() def cb(): - if artist in self.m.BM._hidden_artists: - self.m.BM._hidden_artists.remove(artist) + if artist in self.m._bm._hidden_artists: + self.m._bm._hidden_artists.remove(artist) artist.set_visible(True) else: - self.m.BM._hidden_artists.add(artist) + self.m._bm._hidden_artists.add(artist) artist.set_visible(False) self.m.redraw(layer) @@ -1685,7 +1687,7 @@ def cb(): @Slot() def set_layer_alpha(self, layer, alpha): - layers, alphas = self.m.BM._get_active_layers_alphas + layers, alphas = self.m._bm._get_active_layers_alphas if layer in layers: idx = layers.index(layer) alphas[idx] = alpha @@ -1701,6 +1703,9 @@ def __init__(self, *args, m=None, show_editor=False, **kwargs): self.m = m self.artist_tabs = ArtistEditorTabs(m=self.m) + self.m._connect_signal( + "lazyLayerActivated", self.artist_tabs.repopulate_and_activate_current + ) self.artist_tabs.tabBar().setStyleSheet( """ diff --git a/eomaps/qtcompanion/widgets/files.py b/eomaps/qtcompanion/widgets/files.py index 8e889618b..deb131c99 100644 --- a/eomaps/qtcompanion/widgets/files.py +++ b/eomaps/qtcompanion/widgets/files.py @@ -363,7 +363,7 @@ def __init__( # layer self.layer_label = QtWidgets.QLabel("Layer:") self.layer = LayerInput() - self.layer.setPlaceholderText(str(self.m.BM.bg_layer)) + self.layer.setPlaceholderText(str(self.m._bm.bg_layer)) setlayername = QtWidgets.QWidget() layername = QtWidgets.QHBoxLayout() @@ -836,7 +836,7 @@ def do_open_file(self, file_path): # set default layer-name to current layer if a single layer is selected, # else use the filename - use_layer = self.m.BM.bg_layer + use_layer = self.m._bm.bg_layer if "|" in use_layer: use_layer = self.file_path.stem else: @@ -912,7 +912,7 @@ def do_open_file(self, file_path): # set default layer-name to current layer if a single layer is selected, # else use the filename - use_layer = self.m.BM.bg_layer + use_layer = self.m._bm.bg_layer if "|" in use_layer: use_layer = self.file_path.stem else: @@ -1008,7 +1008,7 @@ def do_open_file(self, file_path): # set default layer-name to current layer if a single layer is selected, # else use the filename - use_layer = self.m.BM.bg_layer + use_layer = self.m._bm.bg_layer if "|" in use_layer: use_layer = self.file_path.stem else: @@ -1231,7 +1231,7 @@ def do_open_file(self, file_path=None): # set default layer-name to current layer if a single layer is selected, # else use the filename - use_layer = self.m.BM.bg_layer + use_layer = self.m._bm.bg_layer if "|" in use_layer: use_layer = self.file_path.stem else: @@ -1375,7 +1375,7 @@ def __init__(self, *args, m=None, **kwargs): self.addTab(self.starttab, "NEW") # don't show the close button for this tab - self.tabBar().setTabButton(self.count() - 1, self.tabBar().RightSide, None) + self.tabBar().setTabButton(self.count() - 1, QtWidgets.QTabBar.RightSide, None) self.setStyleSheet( """ @@ -1431,8 +1431,8 @@ def do_close_tab(self, index): widget = self.widget(index) try: - if widget.m2.coll in self.m.BM._bg_artists[widget.m2.layer]: - self.m.BM.remove_bg_artist(widget.m2.coll, layer=widget.m2.layer) + if widget.m2.coll in widget.m2._bg_artists: + widget.m2.remove_bg_artist(widget.m2.coll) widget.m2.coll.remove() except Exception: _log.error("EOmaps_companion: unable to remove dataset artist.") @@ -1440,7 +1440,7 @@ def do_close_tab(self, index): widget.m2.cleanup() # redraw if the layer was currently visible - if widget.m2.layer in self.m.BM.bg_layer: + if widget.m2.layer in self.m._bm.bg_layer: self.m.redraw(widget.m2.layer) del widget.m2 diff --git a/eomaps/qtcompanion/widgets/layer.py b/eomaps/qtcompanion/widgets/layer.py index 6baf48441..618c60691 100644 --- a/eomaps/qtcompanion/widgets/layer.py +++ b/eomaps/qtcompanion/widgets/layer.py @@ -34,7 +34,9 @@ def __init__( self.update_layers() - self.setSizeAdjustPolicy(self.AdjustToMinimumContentsLengthWithIcon) + self.setSizeAdjustPolicy( + QtWidgets.QComboBox.AdjustToMinimumContentsLengthWithIcon + ) self.activated.connect(self.set_last_active) @@ -97,7 +99,7 @@ def update_layers(self): if self._use_active: # set current index to active layer if _use_active - currindex = self.findText(str(self.m.BM.bg_layer)) + currindex = self.findText(str(self.m._bm.bg_layer)) self.setCurrentIndex(currindex) elif self._last_active is not None: # set current index to last active layer otherwise @@ -114,14 +116,14 @@ def __init__(self, *args, m=None, max_length=60, **kwargs): self._max_length = max_length # update layers on every change of the Maps-object background layer - self.m.BM.on_layer(self.update, persistent=True) + self.m._bm.on_layer(self.update, persistent=True) self.setText(self.get_text()) # turn text interaction off to "click through" the label self.setTextInteractionFlags(Qt.NoTextInteraction) def get_text(self): - layers, alphas = self.m.BM._get_active_layers_alphas + layers, alphas = self.m._bm._get_active_layers_alphas prefix = "    " "" suffix = "<\font>" @@ -169,7 +171,7 @@ def __init__( self.setMenu(menu) # update layers on every change of the Maps-object background layer - self.m.BM.on_layer(self.update_visible_layer, persistent=True) + self.m._bm.on_layer(self.update_visible_layer, persistent=True) # update layers before the widget is shown to make sure they always # represent the currently visible layers on startup of the widget # (since "update_visible_layer" only triggers if the widget is actually visible) @@ -239,7 +241,7 @@ def leaveEvent(self, event): if self.active_icon: self.setIcon(self.active_icon) - return super().enterEvent(event) + return super().leaveEvent(event) def enterEvent(self, e): if self.hoover_icon and not self.isChecked(): @@ -272,7 +274,7 @@ def get_uselayer(self): uselayer = "???" if len(active_layers) > 1: - uselayer = self.m.BM._get_combined_layer_name(*active_layers) + uselayer = self.m._bm._get_combined_layer_name(*active_layers) elif len(active_layers) == 1: uselayer = active_layers[0] @@ -321,7 +323,7 @@ def update_visible_layer(self, *args, **kwargs): return # make sure to re-fetch layers first self.update_layers() - self.update_display_text(self.m.BM._bg_layer) + self.update_display_text(self.m._bm._bg_layer) @Slot() def actionClicked(self): @@ -335,7 +337,7 @@ def actionClicked(self): actionwidget = action.defaultWidget() # just split here to keep transparency-assignments in tact! - active_layers = self.m.BM.bg_layer.split("|") + active_layers = self.m._bm.bg_layer.split("|") checked_layers = [l for l in active_layers if l != "_"] selected_layer = action.data() @@ -368,7 +370,7 @@ def actionClicked(self): uselayer = "???" if len(checked_layers) > 1: - uselayer = self.m.BM._get_combined_layer_name(*checked_layers) + uselayer = self.m._bm._get_combined_layer_name(*checked_layers) elif len(checked_layers) == 1: uselayer = checked_layers[0] @@ -379,8 +381,8 @@ def actionClicked(self): self.m.show_layer(selected_layer) def update_checkstatus(self): - currlayer = str(self.m.BM.bg_layer) - layers, alphas = self.m.BM._get_active_layers_alphas + currlayer = str(self.m._bm.bg_layer) + layers, alphas = self.m._bm._get_active_layers_alphas if "|" in currlayer: active_layers = [i for i in layers if not i.startswith("_")] active_layers.append(currlayer) @@ -442,7 +444,7 @@ def update_layers(self): action.triggered.connect(self.menu().show) - self.update_display_text(self.m.BM._bg_layer) + self.update_display_text(self.m._bm._bg_layer) self._last_layers = layers self.update_checkstatus() diff --git a/eomaps/qtcompanion/widgets/peek.py b/eomaps/qtcompanion/widgets/peek.py index f9b035106..c0a2cc55e 100644 --- a/eomaps/qtcompanion/widgets/peek.py +++ b/eomaps/qtcompanion/widgets/peek.py @@ -442,7 +442,7 @@ def add_peek_cb(self): self.m.all.cb._click_move._execute_cbs( self.m.all.cb._click_move._event, [self.cid] ) - self.m.BM.update() + self.m._bm.update() def remove_peek_cb(self): if self.cid is not None: @@ -510,7 +510,7 @@ def __init__(self, *args, m=None, **kwargs): self.addTab(newtabwidget, "+") # don't show the close button for this tab - self.tabBar().setTabButton(self.count() - 1, self.tabBar().RightSide, None) + self.tabBar().setTabButton(self.count() - 1, QtWidgets.QTabBar.RightSide, None) self.tabBarClicked.connect(self.tabbar_clicked) self.setCurrentIndex(0) diff --git a/eomaps/qtcompanion/widgets/save.py b/eomaps/qtcompanion/widgets/save.py index 8373a97c5..0f284fe6c 100644 --- a/eomaps/qtcompanion/widgets/save.py +++ b/eomaps/qtcompanion/widgets/save.py @@ -247,7 +247,7 @@ def __init__(self, *args, m=None, **kwargs): # set current widget export parameters as copy-to-clipboard args self.m._connect_signal("clipboardKwargsChanged", self.set_export_props) - # set export props to current state of Maps._clipboard_kwargs + # set export props to current state of the _clipboard_kwargs self.set_export_props() @Slot() @@ -321,7 +321,7 @@ def update_clipboard_kwargs(self, *args, **kwargs): def set_export_props(self, *args, **kwargs): # callback that is triggered on Maps.set_clipboard_kwargs - clipboard_kwargs = self.m.__class__._clipboard_kwargs + clipboard_kwargs = self.m._get_clipboard_kwargs() filetype = clipboard_kwargs.get("format", "png") i = self.filetype_dropdown.findText(filetype) diff --git a/eomaps/reader.py b/eomaps/reader.py index 8fc15a666..3d64d6ebc 100644 --- a/eomaps/reader.py +++ b/eomaps/reader.py @@ -671,7 +671,7 @@ def _from_file( >>> m = Maps(crs=..., layer=...) >>> m.set_data(**m.read_GeoTIFF(...)) - >>> m.set_classify_specs(...) + >>> m.set_classify.(...) >>> m.plot_map(**kwargs) Parameters @@ -689,7 +689,7 @@ def _from_file( A dict of keyword-arguments passed to `xarray.Dataset.isel()`. The default is None. classify_specs : dict, optional - A dict of keyword-arguments passed to `m.set_classify_specs()`. + A dict of keyword-arguments passed to `m.set_classify`. The default is None. val_transform : None or callable A function that is used to transform the data-values. @@ -766,7 +766,9 @@ def _from_file( m.set_data(**data) if classify_specs: - m.set_classify_specs(**classify_specs) + classify_specs = {**classify_specs} + scheme = classify_specs.pop("scheme") + getattr(m.set_classify, scheme)(**classify_specs) if shape is not None: # use the provided shape @@ -845,7 +847,7 @@ def NetCDF( >>> m = Maps(crs=...) >>> m.set_data(**m.read_file.NetCDF(...)) - >>> m.set_classify_specs(...) + >>> m.set_classify.(...) >>> m.plot_map(**kwargs) Parameters @@ -898,7 +900,7 @@ def NetCDF( >>> dict(shape="rectangles", radius=1, radius_crs=.5) classify_specs : dict, optional - A dict of keyword-arguments passed to `m.set_classify_specs()`. + A dict of keyword-arguments passed to `m.set_classify`. The default is None. val_transform : None or callable A function that is used to transform the data-values. @@ -1057,7 +1059,7 @@ def GeoTIFF( >>> m = Maps(crs=...) >>> m.set_data(**m.read_file.GeoTIFF(...)) - >>> m.set_classify_specs(...) + >>> m.set_classify.(...) >>> m.plot_map(**kwargs) Parameters @@ -1098,7 +1100,7 @@ def GeoTIFF( >>> dict(shape="rectangles", radius=1, radius_crs=.5) classify_specs : dict, optional - A dict of keyword-arguments passed to `m.set_classify_specs()`. + A dict of keyword-arguments passed to `m.set_classify`. The default is None. val_transform : None or callable A function that is used to transform the data-values. @@ -1275,7 +1277,7 @@ def CSV( >>> m = Maps(crs=...) >>> m.set_data(**m.read_file.CSV(...)) - >>> m.set_classify_specs(...) + >>> m.set_classify..(...) >>> m.plot_map(**kwargs) @@ -1305,7 +1307,7 @@ def CSV( >>> dict(shape="rectangles", radius=1, radius_crs=.5) classify_specs : dict, optional - A dict of keyword-arguments passed to `m.set_classify_specs()`. + A dict of keyword-arguments passed to `m.set_classify`. The default is None. val_transform : None or callable A function that is used to transform the data-values. diff --git a/eomaps/scalebar.py b/eomaps/scalebar.py index 65018b3e6..7724859c5 100644 --- a/eomaps/scalebar.py +++ b/eomaps/scalebar.py @@ -1151,7 +1151,7 @@ def _get_line_verts(self, pts, lon, lat, ang, d): return lines # cache this to avoid re-evaluating the text-size when dragging the scalebar - @lru_cache(1) + @lru_cache def _get_maxw(self, sscale, sn, lscale, lrotation, levery): # arguments are only used for caching! @@ -1221,7 +1221,7 @@ def _set_minitxt(self, d, pts): patch.set_clip_on(False) self._artists[f"text_{i}"] = self._m.ax.add_artist(patch) self._texts[f"text_{i}"] = txt - self._m.BM.add_artist(self._artists[f"text_{i}"], layer=self._layer) + self._m.l[self._layer].add_artist(self._artists[f"text_{i}"]) def _redraw_minitxt(self): # re-draw the text patches in case the number of texts changed @@ -1232,10 +1232,12 @@ def _redraw_minitxt(self): for key in list(self._artists): if key.startswith("text_"): - self._artists[key].remove() - self._m.BM.remove_artist(self._artists[key]) - del self._artists[key] - + try: + self._m.l[self._layer].remove_artist(self._artists[key]) + except KeyError: + _log.debug( + f"Scalebar Text Artist {self._artists[key]} tagged for removal not found" + ) pts = self._get_pts(self._lon, self._lat, self._azim) d = self._get_d() self._set_minitxt(d, pts) @@ -1244,7 +1246,6 @@ def _update_minitxt(self, d, pts): angs = np.arctan2(*np.array([p[0] - p[-1] for p in pts]).T[::-1]) angs = [*angs, angs[-1]] pts = self._get_base_pts(self._lon, self._lat, self._azim, npts=self._n + 2) - for i, (lon, lat, ang) in enumerate(zip(pts.lons, pts.lats, angs)): if i not in self._every: continue @@ -1338,7 +1339,7 @@ def _add_scalebar(self, pos, azim, pickable=True): line_verts = self._get_line_verts(pts, lon, lat, self._azim, d) lc = LineCollection(line_verts, **self._line_props) self._artists["patch_lines"] = self._m.ax.add_artist(lc) - self._m.BM.add_artist(self._artists["patch_lines"], layer=self._layer) + self._m.l[self._layer].add_artist(self._artists["patch_lines"]) # -------------- add the scalebar coll = LineCollection(pts) @@ -1356,12 +1357,12 @@ def _add_scalebar(self, pos, azim, pickable=True): self._artists["scale"].set_zorder(1) self._artists["patch"].set_zorder(0) - self._m.BM.add_artist(self._artists["scale"], layer=self._layer) - self._m.BM.add_artist(self._artists["patch"], layer=self._layer) + self._m.l[self._layer].add_artist(self._artists["scale"]) + self._m.l[self._layer].add_artist(self._artists["patch"]) # update scalebar props whenever new backgrounds are fetched # (e.g. to take care of updates on pan/zoom/resize) - self._m.BM._before_fetch_bg_actions.append(self._update) + self._m._bm.add_hook("before_fetch_bg", self._update, True) if pickable is True: self._make_pickable() @@ -1596,20 +1597,27 @@ def _remove_cbs(self): self._m.f.canvas.mpl_disconnect(cid) setattr(self, cidname, None) + def _in_visible_extent(self): + # auto-positioned scalebars are treated as "always in visible extent" + if self._auto_position is False: + bbox = self._artists["patch"].get_extents() + if not self._m.ax.bbox.overlaps(bbox): + return False + return True + def _update(self, lon=None, lat=None, azim=None, BM_update=False, **kwargs): + in_extent = self._in_visible_extent() # only do this if the extent changed (to avoid performance issues) if self._extent_changed(): # check if the scalebar is in the current field-of-view # if not, avoid updating it and make it invisible - if self._auto_position is False: - bbox = self._artists["patch"].get_extents() - if not self._m.ax.bbox.overlaps(bbox): - for a in self._artists.values(): - a.set_visible(False) - return - else: - for a in self._artists.values(): - a.set_visible(True) + if in_extent: + for a in self._artists.values(): + a.set_visible(True) + else: + for a in self._artists.values(): + a.set_visible(False) + return # clear the cache to re-evaluate the text-width if label # props have changed @@ -1623,6 +1631,9 @@ def _update(self, lon=None, lat=None, azim=None, BM_update=False, **kwargs): except Exception: self._scale = prev_scale + if not in_extent: + return + # make sure scalebars are not positioned out of bounds if lon is not None and lat is not None: lon = np.clip(lon, -179, 179) @@ -1638,19 +1649,17 @@ def _update(self, lon=None, lat=None, azim=None, BM_update=False, **kwargs): if BM_update: # note: when using this function as before_fetch_bg action, updates # would cause a recursion! - self._m.BM.update() + self._m._bm.update() def remove(self): """Remove the scalebar from the map.""" self._unpick() for a in self._artists.values(): - self._m.BM.remove_artist(a) - a.remove() + self._m._bm.remove_artist(a) # remove trigger to update scalebar properties on fetch_bg - if self._update in self._m.BM._before_fetch_bg_actions: - self._m.BM._before_fetch_bg_actions.remove(self._update) + self._m._bm.remove_hook("before_fetch_bg", self._update) self._renderer = None - self._m.BM.update() + self._m._bm.update() diff --git a/eomaps/scripts/open.py b/eomaps/scripts/open.py index 4f2abe48a..f02fd79e9 100644 --- a/eomaps/scripts/open.py +++ b/eomaps/scripts/open.py @@ -235,5 +235,5 @@ def on_close(*args, **kwargs): else: os._exit(0) - m.BM.canvas.mpl_connect("close_event", on_close) + m._bm.canvas.mpl_connect("close_event", on_close) m.show() diff --git a/eomaps/shapes.py b/eomaps/shapes.py index d7fff5568..a10e97c7d 100644 --- a/eomaps/shapes.py +++ b/eomaps/shapes.py @@ -270,7 +270,7 @@ class _CollectionAccessor: >>> >>> labels = m3_1.ax.clabel(m.coll.contour_set) >>> for i in labels: - >>> m.BM.add_bg_artist(i, layer=m.layer) + >>> m.add_bg_artist(i) """ @@ -429,8 +429,10 @@ def _get_radius(m, radius, radius_crs): # check if the first element of x0 is nonzero... # (to avoid slow performance of np.any for large arrays) - if not np.any(m._data_manager.x0.take(0)): - return None + # TODO... why do we need this? + # it results in no proper radius estimation for x0[0] = 0 + # if not np.any(m._data_manager.x0.take(0)): + # return None _log.info("EOmaps: Estimating shape radius...") radiusx, radiusy = Shapes._estimate_radius(m, radius_crs) @@ -658,8 +660,7 @@ def _calc_geod_circle_points(self, lon, lat, radius, n=20, start_angle=0): """ size = lon.size - - if isinstance(radius, (int, float)): + if isinstance(radius, (int, float, np.number)): radius = np.full((size, n), radius) else: if radius.size != lon.size: @@ -672,7 +673,11 @@ def _calc_geod_circle_points(self, lon, lat, radius, n=20, start_angle=0): lons=np.broadcast_to(lon[:, None], (size, n)), lats=np.broadcast_to(lat[:, None], (size, n)), az=np.linspace( - [start_angle] * size, [360 - start_angle] * size, n, axis=1 + [start_angle] * size, + [360 - start_angle] * size, + n, + axis=1, + endpoint=False, ), dist=radius, radians=False, @@ -792,7 +797,7 @@ def _get_points(self, x, y, crs, radius, radius_crs="in", n=20): # transform from crs to the radius_crs t_radius_plot = self._m._get_transformer(radius_crs, self._m.crs_plot) - if isinstance(radius, (int, float, np.number)): + if isinstance(radius, (int, float, np.number, list, np.ndarray)): rx, ry = radius, radius else: rx, ry = radius @@ -2204,7 +2209,7 @@ def _get_contourf_colls(self, x, y, crs, **kwargs): # if manual levels were specified, use them, otherwise check for # classification values if "levels" not in kwargs: - bins = getattr(self._m.classify_specs, "_bins", None) + bins = getattr(self._m._classify_specs, "_bins", None) if bins is not None: # in order to ensure that values above or below vmin/vmax are # colored with the appropriate "under" and "over" colors, @@ -2287,7 +2292,7 @@ def get_coll(self, x, y, crs, **kwargs): # TODO remove this once mpl >= 3.10 is required if isinstance(coll, _CollectionAccessor): for c in coll.collections: - self._m.BM._ignored_unmanaged_artists.add(c) + self._m._bm._ignored_unmanaged_artists.add(c) return coll diff --git a/eomaps/utilities.py b/eomaps/utilities.py index cfb95098e..e9a8dbc1b 100644 --- a/eomaps/utilities.py +++ b/eomaps/utilities.py @@ -149,7 +149,7 @@ def on_motion(self, evt): dy = evt.y - self.mouse_y self.update_offset(dx, dy) self.legend.stale = True - self._m.BM.update() + self._m._bm.update() def on_pick(self, evt): if self._check_still_parented() and evt.artist == self.ref_artist: @@ -263,7 +263,7 @@ def __init__( uselayers = [] for l in layers: if not isinstance(l, str): - uselayers.append(m.BM._get_combined_layer_name(*l)) + uselayers.append(m._bm._get_combined_layer_name(*l)) else: uselayers.append(l) layers = uselayers @@ -283,7 +283,7 @@ def __init__( self.figure = self._m.f # make sure the figure is set for the artist self.set_animated(True) - self._m.BM.add_artist(self.leg, layer="all") + self._m.all.add_artist(self.leg) # keep a reference to the buttons to make sure they stay interactive if name is None: @@ -305,10 +305,10 @@ def __init__( def on_clicked(self, val): l = self.labels[int(val)] - self._m.BM.bg_layer = l + self._m._bm.bg_layer = l - self._m.BM.update(blit=False) - self._m.BM.canvas.draw_idle() + self._m._bm.update(blit=False) + self._m._bm.canvas.draw_idle() def _reinit(self): """ @@ -334,18 +334,17 @@ def _reinit(self): self.__init__(m=self._m, **self._init_args) self._m.util._update_widgets() - self._m.BM.update() + self._m._bm.update() def remove(self): """ Remove the widget from the map """ - self._m.BM.remove_artist(self.leg) - self.leg.remove() + self._m.all.remove_artist(self.leg) del self._m.util._selectors[self._init_args["name"]] - self._m.BM.update() + self._m._bm.update() class LayerSlider(Slider): @@ -455,7 +454,7 @@ def __init__( uselayers = [] for l in layers: if not isinstance(l, str): - uselayers.append(m.BM._get_combined_layer_name(*l)) + uselayers.append(m._bm._get_combined_layer_name(*l)) else: uselayers.append(l) layers = uselayers @@ -515,7 +514,7 @@ def fmt(val): self.track.set_height(h) self.track.set_y(self.track.get_y() + h / 2) - self._m.BM.add_artist(ax_slider, layer="all") + self._m.all.add_artist(ax_slider) self.on_changed(self._on_changed) @@ -539,8 +538,8 @@ def set_layers(self, layers): self.valmax = max(len(layers) - 1, 0.01) self.ax.set_xlim(self.valmin, self.valmax) - if self._m.BM.bg_layer in self._layers: - currval = self._layers.index(self._m.BM.bg_layer) + if self._m._bm.bg_layer in self._layers: + currval = self._layers.index(self._m._bm.bg_layer) self.set_val(currval) else: self.set_val(0) @@ -548,7 +547,7 @@ def set_layers(self, layers): self._on_changed(self.val) self._m.util._update_widgets() - self._m.BM.update() + self._m._bm.update() def _reinit(self): """ @@ -564,25 +563,23 @@ def _reinit(self): self.__init__(m=self._m, pos=self.ax.get_position(), **self._init_args) self._m.util._update_widgets() - self._m.BM.update() + self._m._bm.update() def _on_changed(self, val): l = self._layers[int(val)] - self._m.BM.bg_layer = l - self._m.BM.update() + self._m._bm.bg_layer = l + self._m._bm.update() def remove(self): """ Remove the widget from the map """ - - self._m.BM.remove_artist(self.ax) self.disconnect_events() - self.ax.remove() + self._m._bm.remove_artist(self.ax) del self._m.util._sliders[self._init_args["name"]] - self._m.BM.update() + self._m._bm.update() class Utilities: @@ -603,17 +600,15 @@ def __init__(self, m): self._sliders = dict() # register a function to update all associated widgets on a layer-chance - self._m.BM.on_layer( - lambda m, layer: self._update_widgets(layer), persistent=True - ) + self._m._bm.on_layer(lambda layer: self._update_widgets(layer), persistent=True) def _update_widgets(self, l=None): if l is None: - l = self._m.BM._bg_layer + l = self._m._bm._bg_layer # this function is called whenever the background-layer changed # to synchronize changes across all selectors and sliders - # see setter for helpers.BM._bg_layer + # see setter for helpers._bm._bg_layer for s in self._sliders.values(): try: s.eventson = False @@ -622,11 +617,11 @@ def _update_widgets(self, l=None): s.valtext.set_color(rcParams["text.color"]) s.eventson = True except ValueError: - s.valtext.set_text(self._m.BM._bg_layer) + s.valtext.set_text(self._m._bm._bg_layer) s.valtext.set_color("r") pass except IndexError: - s.valtext.set_text(self._m.BM._bg_layer) + s.valtext.set_text(self._m._bm._bg_layer) s.valtext.set_color("r") pass finally: diff --git a/eomaps/webmap_containers.py b/eomaps/webmap_containers.py index e8e7a9474..eb0b3f412 100644 --- a/eomaps/webmap_containers.py +++ b/eomaps/webmap_containers.py @@ -23,12 +23,16 @@ def _register_imports(): global RestApiServices global _XyzTileService global _XyzTileServiceNonEarth + global refetch_wms_on_size_change + global _cx_refetch_wms_on_size_change from ._webmap import ( _WebServiceCollection, RestApiServices, _XyzTileService, _XyzTileServiceNonEarth, + refetch_wms_on_size_change, + _cx_refetch_wms_on_size_change, ) @@ -62,6 +66,8 @@ def __init__(self, m): _register_imports() self._m = m + self.refetch_wms_on_size_change = refetch_wms_on_size_change + self._cx_refetch_wms_on_size_change = _cx_refetch_wms_on_size_change class _ISRIC: """ @@ -208,7 +214,7 @@ def ESA_WorldCover(self): WMS._EOmaps_info = type(self).ESA_WorldCover.__doc__ WMS._EOmaps_source_code = ( - "m.add_wms.ESA_WorldCover.add_layer." f"(transparent=True)" + "m.add_wms.ESA_WorldCover.add_layer." "(transparent=True)" ) WMS.__doc__ = type(self).ESA_WorldCover.__doc__ @@ -301,7 +307,7 @@ def GMRT(self): ) WMS._EOmaps_info = type(self).GMRT.__doc__ WMS._EOmaps_source_code = ( - "m.add_wms.GMRT.add_layer." f"(transparent=True)" + "m.add_wms.GMRT.add_layer." "(transparent=True)" ) WMS.__doc__ = type(self).GMRT.__doc__ return WMS @@ -329,7 +335,7 @@ def GLAD(self): ) WMS._EOmaps_info = type(self).GLAD.__doc__ WMS._EOmaps_source_code = ( - "m.add_wms.GLAD.add_layer." f"(transparent=True)" + "m.add_wms.GLAD.add_layer." "(transparent=True)" ) WMS.__doc__ = type(self).GLAD.__doc__ return WMS @@ -373,7 +379,7 @@ def NASA_GIBS(self): ) WMS._EOmaps_info = type(self).NASA_GIBS.__doc__ WMS._EOmaps_source_code = ( - "m.add_wms.NASA_GIBS.add_layer." f"(transparent=True)" + "m.add_wms.NASA_GIBS.add_layer." "(transparent=True)" ) WMS.__doc__ = type(self).NASA_GIBS.__doc__ @@ -394,7 +400,7 @@ def EPSG_4326(self): ) WMS._EOmaps_info = WebMapContainer.NASA_GIBS.__doc__ WMS._EOmaps_source_code = ( - "m.add_wms.NASA_GIBS.EPSG_4326.add_layer." f"(transparent=True)" + "m.add_wms.NASA_GIBS.EPSG_4326.add_layer." "(transparent=True)" ) WMS.__doc__ = WebMapContainer.NASA_GIBS.__doc__ return WMS @@ -409,7 +415,7 @@ def EPSG_3857(self): ) WMS._EOmaps_info = WebMapContainer.NASA_GIBS.__doc__ WMS._EOmaps_source_code = ( - "m.add_wms.NASA_GIBS.EPSG_3857.add_layer." f"(transparent=True)" + "m.add_wms.NASA_GIBS.EPSG_3857.add_layer." "(transparent=True)" ) WMS.__doc__ = WebMapContainer.NASA_GIBS.__doc__ return WMS @@ -424,7 +430,7 @@ def EPSG_3413(self): ) WMS._EOmaps_info = WebMapContainer.NASA_GIBS.__doc__ WMS._EOmaps_source_code = ( - "m.add_wms.NASA_GIBS.EPSG_3413.add_layer." f"(transparent=True)" + "m.add_wms.NASA_GIBS.EPSG_3413.add_layer." "(transparent=True)" ) WMS.__doc__ = WebMapContainer.NASA_GIBS.__doc__ return WMS @@ -439,7 +445,7 @@ def EPSG_3031(self): ) WMS._EOmaps_info = WebMapContainer.NASA_GIBS.__doc__ WMS._EOmaps_source_code = ( - "m.add_wms.NASA_GIBS.EPSG_3031.add_layer." f"(transparent=True)" + "m.add_wms.NASA_GIBS.EPSG_3031.add_layer." "(transparent=True)" ) WMS.__doc__ = WebMapContainer.NASA_GIBS.__doc__ return WMS @@ -2076,7 +2082,7 @@ def S2_cloudless(self): WMS._EOmaps_info = WebMapContainer.S2_cloudless.__doc__ WMS._EOmaps_source_code = ( - f"m.add_wms.S2_cloudless.add_layer.(transparent=True)" + "m.add_wms.S2_cloudless.add_layer.(transparent=True)" ) WMS.__doc__ = WebMapContainer.S2_cloudless.__doc__ @@ -2118,7 +2124,7 @@ def CAMS(self): url="https://eccharts.ecmwf.int/wms/?token=public", ) WMS._EOmaps_info = WebMapContainer.CAMS.__doc__ - WMS._EOmaps_source_code = f"m.add_wms.CAMS.add_layer.(transparent=True)" + WMS._EOmaps_source_code = "m.add_wms.CAMS.add_layer.(transparent=True)" WMS.__doc__ = WebMapContainer.CAMS.__doc__ return WMS diff --git a/eomaps/widgets.py b/eomaps/widgets.py index 50fd5e86f..2f658b843 100644 --- a/eomaps/widgets.py +++ b/eomaps/widgets.py @@ -14,13 +14,13 @@ @contextmanager def _force_full(m): """A contextmanager to force a full update of the figure (to avoid glitches)""" - force_full = getattr(m.BM, "_mpl_backend_force_full", False) + force_full = getattr(m._bm, "_mpl_backend_force_full", False) try: - m.BM._mpl_backend_force_full = True + m._bm._mpl_backend_force_full = True yield finally: - m.BM._mpl_backend_force_full = force_full + m._bm._mpl_backend_force_full = force_full from textwrap import dedent, indent @@ -100,7 +100,7 @@ def __init__(self, m, layers=None, **kwargs): # add a callback to update the widget values if the map-layer changes if hasattr(self, "_cb_on_layer_change"): - self._m.BM.on_layer(self._cb_on_layer_change, persistent=True) + self._m._bm.on_layer(self._cb_on_layer_change, persistent=True) def _set_layers_options(self, layers): # _layers is a list of the actual layer-names @@ -151,8 +151,8 @@ def _unobserve_change_handler(self): class _SingleLayerSelectionWidget(_LayerSelectionWidget): def _set_default_kwargs(self, kwargs): kwargs.setdefault("description", self._description) - if self._m.BM.bg_layer in self._layers: - kwargs.setdefault("value", self._m.BM.bg_layer) + if self._m._bm.bg_layer in self._layers: + kwargs.setdefault("value", self._m._bm.bg_layer) def change_handler(self, change): try: @@ -166,13 +166,13 @@ def change_handler(self, change): def _cb_on_layer_change(self, **kwargs): """A callback that is executed on all layer changes to update the widget-value.""" try: - layer = self._m.BM.bg_layer + layer = self._m._bm.bg_layer if layer in self._layers: with self._unobserve_change_handler(): self.value = layer except Exception: - _log.exception(f"Unable to update widget value to {self._m.BM.bg_layer}") + _log.exception(f"Unable to update widget value to {self._m._bm.bg_layer}") @_add_docstring( @@ -219,8 +219,8 @@ class _MultiLayerSelectionWidget(_LayerSelectionWidget): def _set_default_kwargs(self, kwargs): kwargs.setdefault("description", self._description) - if self._m.BM.bg_layer in self._layers: - kwargs.setdefault("value", (self._m.BM.bg_layer, self._m.BM.bg_layer)) + if self._m._bm.bg_layer in self._layers: + kwargs.setdefault("value", (self._m._bm.bg_layer, self._m._bm.bg_layer)) @_add_docstring( @@ -242,7 +242,7 @@ def _cb_on_layer_change(self, **kwargs): try: # Identify all layers that are part of the currently visible layer # TODO transparencies are currently ignored (e.g. treated as selected) - active_layers = self._m.BM._get_active_layers_alphas[0] + active_layers = self._m._bm._get_active_layers_alphas[0] found = [l for l in self._layers if l in active_layers] if len(found) > 0: @@ -250,7 +250,7 @@ def _cb_on_layer_change(self, **kwargs): self.value = found except Exception: - _log.exception(f"Unable to update widget value to {self._m.BM.bg_layer}") + _log.exception(f"Unable to update widget value to {self._m._bm.bg_layer}") @_add_docstring( @@ -283,7 +283,7 @@ def _cb_on_layer_change(self, **kwargs): # TODO properly handle case where intermediate layers are not selected # (right now only start- and stop determines the range independent # of the selected layers in between) - active_layers = self._m.BM._get_active_layers_alphas[0] + active_layers = self._m._bm._get_active_layers_alphas[0] found_idx = [ self._layers.index(l) for l in self._layers if l in active_layers ] @@ -295,7 +295,7 @@ def _cb_on_layer_change(self, **kwargs): self.value = (self._layers[mi], self._layers[ma]) except Exception: - _log.exception(f"Unable to update widget value to {self._m.BM.bg_layer}") + _log.exception(f"Unable to update widget value to {self._m._bm.bg_layer}") # %% Layer Overlay Widgets @@ -387,7 +387,7 @@ def __init__(self, m, layer, **kwargs): def change_handler(self, change): try: - layers, alphas = LayerParser._parse_multi_layer_str(self._m.BM.bg_layer) + layers, alphas = LayerParser._parse_multi_layer_str(self._m._bm.bg_layer) # in case the active layer has the overlay on top, strip off the overlay # from the active layer! @@ -396,7 +396,7 @@ def change_handler(self, change): *zip(layers[:-1], alphas[:-1]) ) else: - base = self._m.BM.bg_layer + base = self._m._bm.bg_layer with _force_full(self._m): self._m.show_layer(base, (self._layer, self.value)) diff --git a/examples/01-Maps/agg_filter.py b/examples/01-Maps/agg_filter.py new file mode 100644 index 000000000..3c9746805 --- /dev/null +++ b/examples/01-Maps/agg_filter.py @@ -0,0 +1,127 @@ +from eomaps import Maps +from scipy.ndimage import gaussian_filter +from matplotlib.colors import to_rgb +import numpy as np + + +def scale_to_range(a, mi, ma): + """ + Scale values of an array between 2 values. + + Parameters + ---------- + a : array-like + The values that should be re-scaled. + mi, ma: float + The minimum and maximum value to which the array "a" should be scaled. + + Returns + ------- + a : array-like + The values scaled to the range [mi, ma] + + """ + amax = np.max(a) + if amax == 0: + return a + a += -(np.min(a)) + a /= amax / (ma - mi) + a += mi + return a + + +# Define an agg-filter function to get a blurry map boundary +def gaussian_blur(sigma=1, blurrcolor="r", truncate=4, radius=None, **kwargs): + """ + An agg-filter function to apply a 'gaussian-blurr' to an artist. + + All kwargs are passed to `scipy.ndimage.gaussian_filter`. + + Parameters + ---------- + sigma : int, optional + The standard-deviation of the gaussian kernel. + The default is 1. + blurrcolor : str or rgb-tuple, optional + The color to use as background-color for the artist when applying + the gaussian-filter. The default is "r". + truncate : int, optional + Truncate the filter at this many standard deviations. + The default is 4. + radius : int, or sequence of int, optional + The radius of the gaussian kernel. If provided, truncate is ignored + and the size of the kernel is 2*radius + 1. + The default is None. + kwargs : + Additional kwargs are passed to `scipy.ndimage.gaussian_filter`. + + Returns + ------- + callable + A agg-filter-function for matpltolib artists. + + """ + + def agg_filter(im, dpi): + # make sure filter properties scale with dpi changes + s, r = int(sigma / 72 * dpi), int(radius / 72 * dpi) if radius else None + # pad the image to avoid artefacts on the boundary + pad = (2 * r + 1) if r else 2 * (s * truncate) + padded_src = np.pad(im, [(pad, pad), (pad, pad), (0, 0)], "constant") + # set all transparent image parts to the blurrcolor + padded_src[..., :3][padded_src[..., 3] == 0] = to_rgb(blurrcolor) + # apply gaussian filter to all channels + padded_src = gaussian_filter(padded_src, (s, s, 0), radius=r, **kwargs) + # maintain the alpha value-range after filtering + mi, ma = (getattr(im[..., 3], f)() for f in ("min", "max")) + if mi != ma: + padded_src[..., 3] = scale_to_range(padded_src[..., 3], mi, ma) + return padded_src, -pad, -pad + + return agg_filter + + +# %% +countries = ["Austria"] + +# Create a new map with a black figure background +m = Maps(3857, figsize=(8, 4.5), facecolor="k") +# Make the map frame black and let the boarder fade into black +m.set_frame(rounded=1, lw=1, ec="k", fc="k", agg_filter=gaussian_blur(10, "k")) + +# Get a geo-data frame with the NaturalEarth county-borders +gdf_all_countries = m.add_feature.cultural.admin_0_countries.get_gdf(scale=50) +gdf_other = gdf_all_countries[~gdf_all_countries.NAME.isin(countries)] +gdf_country = gdf_all_countries[gdf_all_countries.NAME.isin(countries)] + +# Add blurry white lines for the country-borders +m.add_gdf(gdf_other, fc="none", ec="w", alpha=0.1, agg_filter=gaussian_blur(3, "w")) +m.add_gdf(gdf_other, fc="none", lw=0.25, ec="w", alpha=0.8) + +# Highlight the country +m.add_gdf( + gdf_country, fc="none", lw=2, ec="w", alpha=0.5, agg_filter=gaussian_blur(5, "w") +) +m.add_gdf(gdf_country, fc="none", lw=0.5, ec="w", alpha=0.8) + +# Add features and webmap services +m.add_wms.ESA_WorldCover.add_layer.WORLDCOVER_2021_S2_TCC(alpha=0.5) +m.add_feature.preset.ocean(scale=10, zorder=1) +m.add_text(0.5, 0.95, "Austria / Europe / Earth", c="w", fontsize=15, weight="bold") + +# --------------- add a second map for the inset +m2 = m.new_map(crs=m.crs_plot, layer="overlay") +# Make sure the maps share axes limits +m2.join_limits(m) +# Set the map frame to the country-border +m2.set_frame(gdf=gdf_country, lw=1, ec="w", alpha=0.25, set_extent=False) +# Add webmap service +m2.add_wms.ESA_WorldCover.add_layer.WORLDCOVER_2021_S2_TCC() + + +# Set the map-extent +m.set_extent_to_location("Austria", buffer=0.2) +# Adjust subplots +m.subplots_adjust(top=0.95, bottom=0.05, left=0.05, right=0.95) +m.show_layer("base", "overlay") +m.add_logo() diff --git a/examples/01-Maps/agg_filter.rst b/examples/01-Maps/agg_filter.rst new file mode 100644 index 000000000..dd3c224f2 --- /dev/null +++ b/examples/01-Maps/agg_filter.rst @@ -0,0 +1,24 @@ +=================================================== +AGG filters - visual effects for your map-features! +=================================================== + +This more advanced example shows how to use the `AGG filter`_ feature of +matplotlib to get nice blurry country-boarders. + +- A custom AGG filter is defined to apply a "gaussian blur" to artists +- The filter is applied to the country-boundaries and map-frames + + + +(requires EOmaps >= v9.0) + + +.. image:: /_static/example_images/example_agg_filters.png + :width: 75% + :align: center + + +.. literalinclude:: /../../examples/01-Maps/agg_filter.py + + +.. _AGG filter: https://matplotlib.org/stable/gallery/misc/demo_agg_filter.html diff --git a/examples/01-Maps/inset_maps.py b/examples/01-Maps/inset_maps.py index 5721f87db..e831c250d 100644 --- a/examples/01-Maps/inset_maps.py +++ b/examples/01-Maps/inset_maps.py @@ -74,16 +74,15 @@ # add some additional text to the inset-maps for m_i, txt, color in zip([mi1, mi2], ["epsg: 4326", "epsg: 3035"], ["r", "g"]): - txt = m_i.ax.text( + txt = m_i.add_text( 0.5, 0, txt, - transform=m_i.ax.transAxes, horizontalalignment="center", bbox=dict(facecolor=color), ) - # add the text-objects as artists to the blit-manager - m_i.BM.add_artist(txt) + # add the text-objects as artists + m_i.add_artist(txt) mi2.add_colorbar(hist_bins=20, margin=dict(bottom=-0.2), label="some parameter") # move the inset map (and the colorbar) to a different location diff --git a/examples/01-Maps/multiple_maps.py b/examples/01-Maps/multiple_maps.py index d26ea0ff1..f7353ce10 100644 --- a/examples/01-Maps/multiple_maps.py +++ b/examples/01-Maps/multiple_maps.py @@ -18,16 +18,16 @@ m3 = m.new_map(ax=133, crs=3035) # --------- set specs for the first map -m.text(0.5, 1.1, "epsg=4326", transform=m.ax.transAxes) +m.add_text(0.5, 1.1, "epsg=4326") m.set_classify.EqualInterval(k=10) # --------- set specs for the second map -m2.text(0.5, 1.1, "Stereographic", transform=m2.ax.transAxes) +m2.add_text(0.5, 1.1, "Stereographic") m2.set_shape.rectangles() m2.set_classify.Quantiles(k=8) # --------- set specs for the third map -m3.text(0.5, 1.1, "epsg=3035", transform=m3.ax.transAxes) +m3.add_text(0.5, 1.1, "epsg=3035") m3.set_classify_specs( scheme="StdMean", multiples=[-1, -0.75, -0.5, -0.25, 0.25, 0.5, 0.75, 1], diff --git a/examples/05-custom/customization.py b/examples/05-custom/customization.py new file mode 100644 index 000000000..fa76ab4c9 --- /dev/null +++ b/examples/05-custom/customization.py @@ -0,0 +1,59 @@ +# EOmaps example: Customize the appearance of the plot + +from eomaps import Maps +import pandas as pd +import numpy as np + +# ----------- create some example-data +lon, lat = np.meshgrid(np.arange(-30, 60, 0.25), np.arange(30, 60, 0.3)) +data = pd.DataFrame( + dict(lon=lon.flat, lat=lat.flat, data_variable=np.sqrt(lon**2 + lat**2).flat) +) +data = data.sample(3000) # take 3000 random datapoints from the dataset +# ------------------------------------ + +m = Maps(crs=3857, figsize=(9, 5)) +m.set_frame(rounded=0.2, lw=1.5, ec="midnightblue", fc="ivory") +m.add_text(0.5, 1.04, "What a nice figure", fontsize=12) + +m.add_feature.preset.ocean(fc="lightsteelblue") +m.add_feature.preset.coastline(lw=0.25) + +m.set_data(data=data, x="lon", y="lat", crs=4326) +m.set_shape.geod_circles(radius=30000) # plot geodesic-circles with 30 km radius +m.set_classify_specs( + scheme="UserDefined", bins=[35, 36, 37, 38, 45, 46, 47, 48, 55, 56, 57, 58] +) +m.plot_map( + edgecolor="k", # give shapes a black edgecolor + linewidth=0.5, # with a linewidth of 0.5 + cmap="RdYlBu", # use a red-yellow-blue colormap + vmin=35, # map colors to values between 35 and 60 + vmax=60, + alpha=0.75, # add some transparency +) + +# add a colorbar +m.add_colorbar( + label="some parameter", + hist_bins="bins", + hist_size=1, + hist_kwargs=dict(density=True), +) + +# add a y-label to the histogram +m.colorbar.ax_cb_plot.set_ylabel("The Y label") + +# add a logo to the plot +m.add_logo() + +m.apply_layout( + { + "figsize": [9.0, 5.0], + "0_map": [0.10154, 0.2475, 0.79692, 0.6975], + "1_cb": [0.20125, 0.0675, 0.6625, 0.135], + "1_cb_histogram_size": 1, + "2_logo": [0.87501, 0.09, 0.09999, 0.07425], + } +) +m.show() diff --git a/examples/05-custom/customization.rst b/examples/05-custom/customization.rst index 3af149721..0a2d435a3 100644 --- a/examples/05-custom/customization.rst +++ b/examples/05-custom/customization.rst @@ -15,64 +15,4 @@ Customize the appearance of the plot :align: center -.. code-block:: - - # EOmaps example: Customize the appearance of the plot - - from eomaps import Maps - import pandas as pd - import numpy as np - - # ----------- create some example-data - lon, lat = np.meshgrid(np.arange(-30, 60, 0.25), np.arange(30, 60, 0.3)) - data = pd.DataFrame( - dict(lon=lon.flat, lat=lat.flat, data_variable=np.sqrt(lon**2 + lat**2).flat) - ) - data = data.sample(3000) # take 3000 random datapoints from the dataset - # ------------------------------------ - - m = Maps(crs=3857, figsize=(9, 5)) - m.set_frame(rounded=0.2, lw=1.5, ec="midnightblue", fc="ivory") - m.text(0.5, 0.97, "What a nice figure", fontsize=12) - - m.add_feature.preset.ocean(fc="lightsteelblue") - m.add_feature.preset.coastline(lw=0.25) - - m.set_data(data=data, x="lon", y="lat", crs=4326) - m.set_shape.geod_circles(radius=30000) # plot geodesic-circles with 30 km radius - m.set_classify_specs( - scheme="UserDefined", bins=[35, 36, 37, 38, 45, 46, 47, 48, 55, 56, 57, 58] - ) - m.plot_map( - edgecolor="k", # give shapes a black edgecolor - linewidth=0.5, # with a linewidth of 0.5 - cmap="RdYlBu", # use a red-yellow-blue colormap - vmin=35, # map colors to values between 35 and 60 - vmax=60, - alpha=0.75, # add some transparency - ) - - # add a colorbar - m.add_colorbar( - label="some parameter", - hist_bins="bins", - hist_size=1, - hist_kwargs=dict(density=True), - ) - - # add a y-label to the histogram - m.colorbar.ax_cb_plot.set_ylabel("The Y label") - - # add a logo to the plot - m.add_logo() - - m.apply_layout( - { - "figsize": [9.0, 5.0], - "0_map": [0.10154, 0.2475, 0.79692, 0.6975], - "1_cb": [0.20125, 0.0675, 0.6625, 0.135], - "1_cb_histogram_size": 1, - "2_logo": [0.87501, 0.09, 0.09999, 0.07425], - } - ) - m.show() +.. literalinclude:: /../../examples/05-custom/customization.py diff --git a/examples/06-overlays/overlays.rst b/examples/06-overlays/overlays.rst index 05befa6cb..9adce3ddf 100644 --- a/examples/06-overlays/overlays.rst +++ b/examples/06-overlays/overlays.rst @@ -74,7 +74,7 @@ The data displayed in the above gif is taken from: def callback(m, **kwargs): # NOTE: Since we change the array of a dynamic collection, the changes will be # reverted as soon as the background is re-drawn (e.g. on pan/zoom events) - selection = np.random.randint(0, len(m.data), 1000) + selection = np.random.randint(0, len(m.data_specs.data), 1000) m.coll.set_array(data_OK.param.iloc[selection]) @@ -103,7 +103,7 @@ The data displayed in the above gif is taken from: framealpha=1, ) # add the legend as artist to keep it on top - m.BM.add_artist(leg) + m.add_artist(leg) # --------- add some fancy (static) indicators for selected pixels mark_id = 6060 @@ -125,7 +125,7 @@ The data displayed in the above gif is taken from: ) m.add_annotation( ID=mark_id, - text=f"Here's Vienna!\n... the data-value is={m.data.param.loc[mark_id]:.2f}", + text=f"Here's Vienna!\n... the data-value is={m.data_specs.data.param.loc[mark_id]:.2f}", xytext=(80, 70), textcoords="offset points", bbox=dict(boxstyle="round", fc="w", ec="r"), diff --git a/examples/callbacks/callbacks.py b/examples/callbacks/callbacks.py new file mode 100644 index 000000000..a7f2e133f --- /dev/null +++ b/examples/callbacks/callbacks.py @@ -0,0 +1,184 @@ +# EOmaps example: Turn your maps into a powerful widgets + +from eomaps import Maps +import pandas as pd +import numpy as np + +# create some data +lon, lat = np.meshgrid(np.linspace(-20, 40, 50), np.linspace(30, 60, 50)) + +data = pd.DataFrame( + dict(lon=lon.flat, lat=lat.flat, data=np.sqrt(lon**2 + lat**2).flat) +) + +# --------- initialize a Maps object and plot a basic map +m = Maps(crs=3035, figsize=(10, 8)) +m.set_data(data=data, x="lon", y="lat", crs=4326) +m.ax.set_title("A clickable widget!") +m.set_shape.rectangles() + +m.set_classify_specs(scheme="EqualInterval", k=5) +m.add_feature.preset.coastline() +m.add_feature.preset.ocean() +m.plot_map() + +# add some static text +m.add_text( + 0.66, + 0.92, + ( + "Left-click: temporary annotations\n" + "Right-click: permanent annotations\n" + "Middle-click: clear permanent annotations" + ), + fontsize=10, + horizontalalignment="left", + verticalalignment="top", + color="k", + fontweight="bold", + bbox=dict(facecolor="w", alpha=0.75), +) + + +# --------- attach pre-defined CALLBACK functions --------- + +### add a temporary annotation and a marker if you left-click on a pixel +m.cb.pick.attach.mark( + button=1, + permanent=False, + fc=[0, 0, 0, 0.5], + ec="w", + ls="--", + buffer=2.5, + shape="ellipses", + zorder=1, +) +m.cb.pick.attach.annotate( + button=1, + permanent=False, + bbox=dict(boxstyle="round", fc="w", alpha=0.75), + zorder=999, +) +### save all picked values to a dict accessible via m.cb.get.picked_vals +m.cb.pick.attach.get_values(button=1) + +### add a permanent marker if you right-click on a pixel +m.cb.pick.attach.mark( + button=3, + permanent=True, + facecolor=[1, 0, 0, 0.5], + edgecolor="k", + buffer=1, + shape="rectangles", + zorder=1, +) + + +### add a customized permanent annotation if you right-click on a pixel +def text(m, ID, val, pos, ind): + return f"ID={ID}" + + +m.cb.pick.attach.annotate( + button=3, + permanent=True, + bbox=dict(boxstyle="round", fc="r"), + text=text, + xytext=(10, 10), + zorder=2, # use zorder=2 to put the annotations on top of the markers +) + +### remove all permanent markers and annotations if you middle-click anywhere on the map +m.cb.pick.attach.clear_annotations(button=2) +m.cb.pick.attach.clear_markers(button=2) + +# --------- define a custom callback to update some text to the map +# (use a high zorder to draw the texts above all other things) +txt = m.add_text( + 0.5, + 0.35, + "You clicked on 0 pixels so far", + fontsize=15, + horizontalalignment="center", + verticalalignment="top", + color="w", + fontweight="bold", + animated=True, + zorder=99, +) +txt2 = m.add_text( + 0.18, + 0.9, + " lon / lat " + "\n", + fontsize=12, + horizontalalignment="right", + verticalalignment="top", + fontweight="bold", + animated=True, + zorder=99, +) + + +def cb1(m, pos, ID, val, **kwargs): + # update the text that indicates how many pixels we've clicked + nvals = len(m.cb.pick.get.picked_vals["ID"]) + txt.set_text( + f"You clicked on {nvals} pixel" + + ("s" if nvals > 1 else "") + + "!\n... and the " + + ("average " if nvals > 1 else "") + + f"value is {np.mean(m.cb.pick.get.picked_vals['val']):.3f}" + ) + + # update the list of lon/lat coordinates on the top left of the figure + d = m.data_specs.data.loc[ID] + lonlat_list = txt2.get_text().splitlines() + if len(lonlat_list) > 10: + lonlat_txt = lonlat_list[0] + "\n" + "\n".join(lonlat_list[-10:]) + "\n" + else: + lonlat_txt = txt2.get_text() + txt2.set_text(lonlat_txt + f"{d['lon']:.2f} / {d['lat']:.2f}" + "\n") + + +m.cb.pick.attach(cb1, button=1, m=m) + + +def cb2(m, pos, val, **kwargs): + # plot a marker at the pixel-position + (l,) = m.ax.plot(*pos, marker="*", animated=True) + # add the custom marker to the blit-manager! + m.add_artist(l) + + # print the value at the pixel-position + # use a low zorder so the text will be drawn below the temporary annotations + m.add_text( + pos[0], + pos[1] - 150000, + f"{val:.2f}", + horizontalalignment="center", + verticalalignment="bottom", + color=l.get_color(), + zorder=1, + ) + + +m.cb.pick.attach(cb2, button=3, m=m) + +# add a "target-indicator" on mouse-movement +m.cb.move.attach.mark(fc="r", ec="none", radius=10000, shape="geod_circles") +m.cb.move.attach.mark(fc="none", ec="r", radius=50000, shape="geod_circles") + +# add a colorbar +m.add_colorbar(hist_bins="bins", label="A classified dataset") +m.add_logo() + +m.apply_layout( + { + "figsize": [10.0, 8.0], + "0_map": [0.04375, 0.27717, 0.9125, 0.69566], + "1_cb": [0.01, 0.0, 0.98, 0.23377], + "1_cb_histogram_size": 0.8, + "2_logo": [0.825, 0.29688, 0.12, 0.06188], + } +) +m.show() diff --git a/examples/callbacks/callbacks.rst b/examples/callbacks/callbacks.rst index eb2fbf8ab..f52c6bb10 100644 --- a/examples/callbacks/callbacks.rst +++ b/examples/callbacks/callbacks.rst @@ -20,192 +20,5 @@ Callbacks : turn your maps into interactive widgets .. image:: /_static/example_images/example_callbacks.gif :align: center -.. code-block:: python - # EOmaps example: Turn your maps into a powerful widgets - - from eomaps import Maps - import pandas as pd - import numpy as np - - # create some data - lon, lat = np.meshgrid(np.linspace(-20, 40, 50), np.linspace(30, 60, 50)) - - data = pd.DataFrame( - dict(lon=lon.flat, lat=lat.flat, data=np.sqrt(lon**2 + lat**2).flat) - ) - - # --------- initialize a Maps object and plot a basic map - m = Maps(crs=3035, figsize=(10, 8)) - m.set_data(data=data, x="lon", y="lat", crs=4326) - m.ax.set_title("A clickable widget!") - m.set_shape.rectangles() - - m.set_classify_specs(scheme="EqualInterval", k=5) - m.add_feature.preset.coastline() - m.add_feature.preset.ocean() - m.plot_map() - - # add some static text - m.text( - 0.66, - 0.92, - ( - "Left-click: temporary annotations\n" - "Right-click: permanent annotations\n" - "Middle-click: clear permanent annotations" - ), - fontsize=10, - horizontalalignment="left", - verticalalignment="top", - color="k", - fontweight="bold", - bbox=dict(facecolor="w", alpha=0.75), - ) - - - # --------- attach pre-defined CALLBACK functions --------- - - ### add a temporary annotation and a marker if you left-click on a pixel - m.cb.pick.attach.mark( - button=1, - permanent=False, - fc=[0, 0, 0, 0.5], - ec="w", - ls="--", - buffer=2.5, - shape="ellipses", - zorder=1, - ) - m.cb.pick.attach.annotate( - button=1, - permanent=False, - bbox=dict(boxstyle="round", fc="w", alpha=0.75), - zorder=999, - ) - ### save all picked values to a dict accessible via m.cb.get.picked_vals - m.cb.pick.attach.get_values(button=1) - - ### add a permanent marker if you right-click on a pixel - m.cb.pick.attach.mark( - button=3, - permanent=True, - facecolor=[1, 0, 0, 0.5], - edgecolor="k", - buffer=1, - shape="rectangles", - zorder=1, - ) - - - ### add a customized permanent annotation if you right-click on a pixel - def text(m, ID, val, pos, ind): - return f"ID={ID}" - - - m.cb.pick.attach.annotate( - button=3, - permanent=True, - bbox=dict(boxstyle="round", fc="r"), - text=text, - xytext=(10, 10), - zorder=2, # use zorder=2 to put the annotations on top of the markers - ) - - ### remove all permanent markers and annotations if you middle-click anywhere on the map - m.cb.pick.attach.clear_annotations(button=2) - m.cb.pick.attach.clear_markers(button=2) - - # --------- define a custom callback to update some text to the map - # (use a high zorder to draw the texts above all other things) - txt = m.text( - 0.5, - 0.35, - "You clicked on 0 pixels so far", - fontsize=15, - horizontalalignment="center", - verticalalignment="top", - color="w", - fontweight="bold", - animated=True, - zorder=99, - transform=m.ax.transAxes, - ) - txt2 = m.text( - 0.18, - 0.9, - " lon / lat " + "\n", - fontsize=12, - horizontalalignment="right", - verticalalignment="top", - fontweight="bold", - animated=True, - zorder=99, - transform=m.ax.transAxes, - ) - - - def cb1(m, pos, ID, val, **kwargs): - # update the text that indicates how many pixels we've clicked - nvals = len(m.cb.pick.get.picked_vals["ID"]) - txt.set_text( - f"You clicked on {nvals} pixel" - + ("s" if nvals > 1 else "") - + "!\n... and the " - + ("average " if nvals > 1 else "") - + f"value is {np.mean(m.cb.pick.get.picked_vals['val']):.3f}" - ) - - # update the list of lon/lat coordinates on the top left of the figure - d = m.data.loc[ID] - lonlat_list = txt2.get_text().splitlines() - if len(lonlat_list) > 10: - lonlat_txt = lonlat_list[0] + "\n" + "\n".join(lonlat_list[-10:]) + "\n" - else: - lonlat_txt = txt2.get_text() - txt2.set_text(lonlat_txt + f"{d['lon']:.2f} / {d['lat']:.2f}" + "\n") - - - m.cb.pick.attach(cb1, button=1, m=m) - - - def cb2(m, pos, val, **kwargs): - # plot a marker at the pixel-position - (l,) = m.ax.plot(*pos, marker="*", animated=True) - # add the custom marker to the blit-manager! - m.BM.add_artist(l) - - # print the value at the pixel-position - # use a low zorder so the text will be drawn below the temporary annotations - m.text( - pos[0], - pos[1] - 150000, - f"{val:.2f}", - horizontalalignment="center", - verticalalignment="bottom", - color=l.get_color(), - zorder=1, - transform=m.ax.transData, - ) - - - m.cb.pick.attach(cb2, button=3, m=m) - - # add a "target-indicator" on mouse-movement - m.cb.move.attach.mark(fc="r", ec="none", radius=10000, shape="geod_circles") - m.cb.move.attach.mark(fc="none", ec="r", radius=50000, shape="geod_circles") - - # add a colorbar - m.add_colorbar(hist_bins="bins", label="A classified dataset") - m.add_logo() - - m.apply_layout( - { - "figsize": [10.0, 8.0], - "0_map": [0.04375, 0.27717, 0.9125, 0.69566], - "1_cb": [0.01, 0.0, 0.98, 0.23377], - "1_cb_histogram_size": 0.8, - "2_logo": [0.825, 0.29688, 0.12, 0.06188], - } - ) - m.show() +.. literalinclude:: /../../examples/callbacks/callbacks.py diff --git a/examples/widgets/row_col_selector.rst b/examples/widgets/row_col_selector.rst index 1c0cda8bf..97700b3b6 100644 --- a/examples/widgets/row_col_selector.rst +++ b/examples/widgets/row_col_selector.rst @@ -30,7 +30,7 @@ Use custom callback functions to perform arbitrary tasks on the data when clicki m = Maps(crs=Maps.CRS.InterruptedGoodeHomolosine(), ax=(2, 2, (1, 3)), figsize=(8, 5)) m.add_feature.preset.coastline() m.set_data(data, lon, lat, parameter=name) - m.set_classify_specs(Maps.CLASSIFIERS.NaturalBreaks, k=5) + m.set_classify.NaturalBreaks(k=5) m.plot_map() # create 2 ordinary matplotlib axes to show the selected data @@ -62,7 +62,7 @@ Use custom callback functions to perform arbitrary tasks on the data when clicki def cb(m, ind, ID, *args, **kwargs): # get row and column from the data # NOTE: "ind" always represents the index of the flattened array! - r, c = np.unravel_index(ind, m.data.shape) + r, c = np.unravel_index(ind, m.data_specs.data.shape) # ---- highlight the picked column # use "dynamic=True" to avoid re-drawing the background on each pick @@ -97,8 +97,7 @@ Use custom callback functions to perform arbitrary tasks on the data when clicki ) # make all artists temporary (e.g. remove them on next pick) - # "m2.coll" represents the collection created by "m2.plot_map()" - for a in [art0, art01, art1, art11, m2.coll, m3.coll]: + for a in [art0, art01, art1, art11]: m.cb.pick.add_temporary_artist(a) @@ -108,7 +107,7 @@ Use custom callback functions to perform arbitrary tasks on the data when clicki # ---- add a pick-annotation with a custom text def text(ind, val, **kwargs): - r, c = np.unravel_index(ind, m.data.shape) + r, c = np.unravel_index(ind, m.data_specs.data.shape) return ( f"row/col = {r}/{c}\n" f"lon/lat = {m.data_specs.x[r, c]:.2f}/{m.data_specs.y[r, c]:.2f}\n" diff --git a/examples/widgets/timeseries.rst b/examples/widgets/timeseries.rst index 7fe5e8721..abe3219ca 100644 --- a/examples/widgets/timeseries.rst +++ b/examples/widgets/timeseries.rst @@ -63,8 +63,7 @@ This example shows how to use EOmaps to analyze a database that is associated wi # -------- assign data to the map and plot it m.set_data(data=data, x="lon", y="lat", crs=4326) - m.set_classify_specs( - scheme=Maps.CLASSIFIERS.UserDefined, + m.set_classify.UserDefined( bins=[50, 100, 200, 400, 800], ) m.set_shape.ellipses(radius=0.5) diff --git a/pyproject.toml b/pyproject.toml index e6b028986..1a92cf41b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires = ["setuptools>=61.0"] build-backend = "setuptools.build_meta" [tool.setuptools.packages.find] -include = ["eomaps", "eomaps.scripts", "eomaps.qtcompanion", "eomaps.qtcompanion.widgets"] +include = ["eomaps", "eomaps.mixins", "eomaps.scripts", "eomaps.qtcompanion", "eomaps.qtcompanion.widgets"] [tool.setuptools.package-data] eomaps = ["logo.png", "NE_features.json", "qtcompanion/icons/*"] @@ -76,9 +76,14 @@ gui = [ ] test = [ - "eomaps[io, classify, wms, shade, gui, test]", + "eomaps[io, classify, wms, shade, gui]", + "docutils", + "nbformat", + "ipywidgets", "pytest", - "pytest-mpl" + "pytest-mpl", + "pytest-qt", + "pytest-cov" ] docs = [ diff --git a/snapshot1.png b/snapshot1.png new file mode 100644 index 000000000..f56bd22a7 Binary files /dev/null and b/snapshot1.png differ diff --git a/tests/test_add_gdf.py b/tests/test_add_gdf.py new file mode 100644 index 000000000..953a1c7c0 --- /dev/null +++ b/tests/test_add_gdf.py @@ -0,0 +1,15 @@ +import pytest +from eomaps import Maps + + +@pytest.mark.parametrize("reproject", ["gpd", "cartopy"]) +@pytest.mark.parametrize("clip", ["crs", "crs_bounds", "extent"]) +def test_gdf_reproject(reproject, clip): + + m = Maps(3035) + m.set_extent( + (5.2197051759523285, 15.049503639569611, 38.13009442774602, 43.90360564611554) + ) + gdf = m.add_feature.physical.coastline.get_gdf() + + m.add_gdf(gdf, reproject=reproject, clip=clip) diff --git a/tests/test_basic_functions.py b/tests/test_basic_functions.py index d14051fb9..0c3d6b76d 100644 --- a/tests/test_basic_functions.py +++ b/tests/test_basic_functions.py @@ -66,8 +66,7 @@ def setUp(self): def test_simple_map(self): m = Maps(4326) - m.data = self.data - m.set_data(x="x", y="y", crs=3857) + m.set_data(self.data, x="x", y="y", crs=3857) m.plot_map() plt.close(m.f) @@ -78,7 +77,7 @@ def test_simple_map(self): m.add_feature.preset.coastline() m.set_data(data=self.data, x="x", y="y", crs=3857, cpos="ur", cpos_radius=1) m.plot_map() - m.indicate_extent(20, 10, 60, 76, crs=4326, fc="r", ec="k", alpha=0.5) + m.add_extent_indicator(20, 10, 60, 76, crs=4326, fc="r", ec="k", alpha=0.5) plt.close(m.f) def test_simple_plot_shapes(self): @@ -247,10 +246,9 @@ def test_cpos(self): def test_alpha_and_splitbins(self): m = Maps(4326) - m.data = self.data - m.set_data(x="x", y="y", crs=3857) + m.set_data(self.data, x="x", y="y", crs=3857) m.set_shape.rectangles() - m.set_classify_specs(scheme="Percentiles", pct=[0.1, 0.2]) + m.set_classify.Percentiles(pct=[0.1, 0.2]) m.plot_map(alpha=0.4) @@ -258,11 +256,10 @@ def test_alpha_and_splitbins(self): def test_classification(self): m = Maps(4326) - m.data = self.data - m.set_data(x="x", y="y", crs=3857) + m.set_data(self.data, x="x", y="y", crs=3857) m.set_shape.rectangles(radius=1, radius_crs="out") - m.set_classify_specs(scheme="Quantiles", k=5) + m.set_classify.Quantiles(k=5) m.plot_map() @@ -270,8 +267,7 @@ def test_classification(self): def test_add_callbacks(self): m = Maps(3857, layer="layername") - m.data = self.data.sample(10) - m.set_data(x="x", y="y", crs=3857) + m.set_data(self.data.sample(10), x="x", y="y", crs=3857) m.set_shape.ellipses(radius=200000) m.plot_map() @@ -350,20 +346,19 @@ def test_add_callbacks(self): def test_add_annotate(self): m = Maps() - m.data = self.data - m.set_data(x="x", y="y", crs=3857) + m.set_data(self.data, x="x", y="y", crs=3857) m.plot_map() - m.add_annotation(ID=m.data["value"].idxmax(), fontsize=15, text="adsf") + m.add_annotation(ID=self.data["value"].idxmax(), fontsize=15, text="adsf") def customtext(m, ID, val, pos, ind): return f"{m.data_specs}\n {val}\n {pos}\n {ID} \n {ind}" - m.add_annotation(ID=m.data["value"].idxmin(), text=customtext) + m.add_annotation(ID=self.data["value"].idxmin(), text=customtext) m.add_annotation( - xy=(m.data.x[0], m.data.y[0]), xy_crs=3857, fontsize=15, text="adsf" + xy=(self.data.x[0], self.data.y[0]), xy_crs=3857, fontsize=15, text="adsf" ) plt.close(m.f) @@ -371,8 +366,7 @@ def customtext(m, ID, val, pos, ind): def test_add_marker(self): crs = Maps.CRS.Orthographic(central_latitude=45, central_longitude=45) m = Maps(crs) - m.data = self.data - m.set_data(x="x", y="y", crs=3857) + m.set_data(self.data, x="x", y="y", crs=3857) m.plot_map(set_extent=True) m.add_marker( @@ -449,7 +443,7 @@ def test_add_marker(self): ) m.add_marker( - xy=(m.data.x[10], m.data.y[10]), + xy=(self.data.x[10], self.data.y[10]), xy_crs=3857, facecolor="none", edgecolor="r", @@ -466,10 +460,9 @@ def test_add_marker(self): def test_copy(self): m = Maps(3857) - m.data = self.data - m.set_data(x="x", y="y", crs=3857) + m.set_data(self.data, x="x", y="y", crs=3857) - m.set_classify_specs(scheme="Quantiles", k=5) + m.set_classify.Quantiles(k=5) m2 = m.copy() @@ -477,8 +470,8 @@ def test_copy(self): m2.data_specs[["x", "y", "parameter", "crs"]] == {"x": None, "y": None, "parameter": None, "crs": 4326} ) - self.assertTrue([*m.classify_specs] == [*m2.classify_specs]) - self.assertTrue(m2.data == None) + self.assertTrue([*m._classify_specs] == [*m2._classify_specs]) + self.assertTrue(m2.data_specs.data == None) m3 = m.copy(data_specs=True) @@ -486,19 +479,18 @@ def test_copy(self): m.data_specs[["x", "y", "parameter", "crs"]] == m3.data_specs[["x", "y", "parameter", "crs"]] ) - self.assertTrue([*m.classify_specs] == [*m3.classify_specs]) - self.assertFalse(m3.data is m.data) - self.assertTrue(m3.data.equals(m.data)) + self.assertTrue([*m._classify_specs] == [*m3._classify_specs]) + self.assertFalse(m3.data_specs.data is m.data_specs.data) + self.assertTrue(m3.data_specs.data.equals(m.data_specs.data)) m3.plot_map() plt.close(m3.f) def test_copy_connect(self): m = Maps(3857) - m.data = self.data - m.set_data(x="x", y="y", crs=3857) + m.set_data(self.data, x="x", y="y", crs=3857) m.set_shape.rectangles() - m.set_classify_specs(scheme="Quantiles", k=5) + m.set_classify.Quantiles(k=5) m.plot_map() # plot on the same axes @@ -536,8 +528,7 @@ def test_join_limits(self): def test_prepare_data(self): m = Maps() - m.data = self.data - m.set_data(x="x", y="y", crs=3857, parameter="value") + m.set_data(self.data, x="x", y="y", crs=3857, parameter="value") data = m._data_manager._prepare_data() # TODO add proper checks here! @@ -630,12 +621,12 @@ def test_add_colorbar(self): m.redraw() m.show_layer("asdf") - self.assertTrue(len(m.BM._hidden_artists) == 5) + self.assertTrue(len(m._bm._hidden_artists) == 5) for cb in m._colorbars: - self.assertTrue(cb in m.BM._hidden_artists) + self.assertTrue(cb in m._bm._hidden_artists) m.show_layer("base") for cb in m2._colorbars: - self.assertTrue(cb in m.BM._hidden_artists) + self.assertTrue(cb in m._bm._hidden_artists) self.assertTrue(len(m2._colorbars) == 1) self.assertTrue(m2.colorbar is cb5) @@ -681,13 +672,15 @@ def test_MapsGrid(self): mg.set_data( data=self.data, x="x", y="y", crs=3857, encoding=dict(scale_factor=1e-7) ) - mg.set_classify_specs(scheme=Maps.CLASSIFIERS.EqualInterval, k=4) + mg.set_classify.EqualInterval(k=4) mg.set_shape.rectangles() mg.plot_map() mg.add_annotation(ID=520) mg.add_marker(ID=5, fc="r", radius=10, radius_crs=4326) mg.add_colorbar() + mg.cb.click.attach.annotate() + self.assertTrue(mg.m_0_0 is mg[0, 0]) self.assertTrue(mg.m_0_1 is mg[0, 1]) self.assertTrue(mg.m_1_0 is mg[1, 0]) @@ -695,47 +688,6 @@ def test_MapsGrid(self): plt.close("all") - def test_MapsGrid2(self): - mg = MapsGrid( - 2, - 2, - m_inits={"a": (0, slice(0, 2)), 2: (1, 0)}, - crs={"a": 4326, 2: 3857}, - ax_inits=dict(c=(1, 1)), - ) - - mg.set_data(data=self.data, x="x", y="y", crs=3857) - mg.set_classify_specs(scheme=Maps.CLASSIFIERS.EqualInterval, k=4) - - for m in mg: - m.plot_map() - - mg.add_annotation(ID=520) - mg.add_marker(ID=5, fc="r", radius=10, radius_crs=4326) - - self.assertTrue(mg.m_a is mg["a"]) - self.assertTrue(mg.m_2 is mg[2]) - self.assertTrue(mg.ax_c is mg["c"]) - - plt.close(mg.f) - - with self.assertRaises(AssertionError): - MapsGrid( - 2, - 2, - m_inits={"2": (0, slice(0, 2)), 2: (1, 0)}, - ax_inits=dict(c=(1, 1)), - ) - - with self.assertRaises(AssertionError): - MapsGrid( - 2, - 2, - m_inits={1: (0, slice(0, 2)), 2: (1, 0)}, - ax_inits={"2": (1, 1), 2: 2}, - ) - plt.close("all") - def test_compass(self): m = Maps(Maps.CRS.Stereographic()) m.add_feature.preset.coastline(ec="k", scale="110m") @@ -1006,8 +958,7 @@ def test_adding_maps_to_existing_figures(self): def test_combine_layers(self): m = Maps(4326) - m.data = self.data - m.set_data(x="x", y="y", crs=3857) + m.set_data(self.data, x="x", y="y", crs=3857) m.plot_map() m2 = m.new_layer("ocean") @@ -1136,7 +1087,7 @@ def test_a_complex_figure(self): m.add_feature.preset.coastline(lw=0.5) m.add_colorbar() - mgrid.share_click_events() + mgrid.cb.click.share_events(*mgrid) m.subplots_adjust(left=0.05, top=0.95, bottom=0.05, right=0.95) plt.close("all") @@ -1307,8 +1258,8 @@ def test_cleanup(self): m.cb.pick.attach.annotate() m.cb.keypress.attach.fetch_layers() m.f.canvas.draw() # redraw since otherwise the map might not yet be created! - self.assertTrue(len(m.BM._artists[m.layer]) == 1) - self.assertTrue(len(m.BM._bg_artists[m.layer]) == 2) + self.assertTrue(len(m._bm._artists[m.layer]) == 1) + self.assertTrue(len(m._bm._bg_artists[m.layer]) == 2) self.assertTrue(m._data_manager.x0.size == 3) self.assertTrue(hasattr(m, "tree")) @@ -1329,18 +1280,26 @@ def test_cleanup(self): m2.on_layer_activation(lambda m: print("temporary", m.layer)) m2.on_layer_activation(lambda m: print("permanent", m.layer), persistent=True) - self.assertTrue(len(m.BM._on_layer_activation[True][m2.layer]) == 1) - self.assertTrue(len(m.BM._on_layer_activation[False][m2.layer]) == 1) + self.assertTrue( + len(m._bm._Hooks__hooks["layer_activation"][True][m2.layer]) == 1 + ) + self.assertTrue( + len(m._bm._Hooks__hooks["layer_activation"][False][m2.layer]) == 1 + ) m.show_layer(m2.layer) # show the layer to draw the artists! m.f.canvas.draw() # redraw since otherwise the map might not yet be created! - self.assertTrue(len(m.BM._on_layer_activation[True][m2.layer]) == 1) - self.assertTrue(len(m.BM._on_layer_activation[False][m2.layer]) == 0) + self.assertTrue( + len(m._bm._Hooks__hooks["layer_activation"][True][m2.layer]) == 1 + ) + self.assertTrue( + len(m._bm._Hooks__hooks["layer_activation"][False][m2.layer]) == 0 + ) - self.assertTrue(len(m.BM._artists[m.layer]) == 1) - self.assertTrue(len(m.BM._bg_artists[m.layer]) == 2) - self.assertTrue(len(m.BM._artists[m2.layer]) == 1) - self.assertTrue(len(m.BM._bg_artists[m2.layer]) == 2) + self.assertTrue(len(m._bm._artists[m.layer]) == 1) + self.assertTrue(len(m._bm._bg_artists[m.layer]) == 2) + self.assertTrue(len(m._bm._artists[m2.layer]) == 1) + self.assertTrue(len(m._bm._bg_artists[m2.layer]) == 2) self.assertTrue(m2._data_manager.x0.size == 3) self.assertTrue(hasattr(m2, "tree")) @@ -1350,12 +1309,13 @@ def test_cleanup(self): m2.cleanup() - self.assertTrue(m2.layer not in m.BM._on_layer_activation) + self.assertTrue(m2.layer not in m._bm._Hooks__hooks["layer_activation"][True]) + self.assertTrue(m2.layer not in m._bm._Hooks__hooks["layer_activation"][False]) - self.assertTrue(len(m.BM._artists[m.layer]) == 1) - self.assertTrue(len(m.BM._bg_artists[m.layer]) == 2) - self.assertTrue(m2.layer not in m.BM._artists) - self.assertTrue(m2.layer not in m.BM._bg_artists) + self.assertTrue(len(m._bm._artists[m.layer]) == 1) + self.assertTrue(len(m._bm._bg_artists[m.layer]) == 2) + self.assertTrue(m2.layer not in m._bm._artists) + self.assertTrue(m2.layer not in m._bm._bg_artists) # m should still be OK self.assertTrue(m._data_manager.x0.size == 3) @@ -1373,8 +1333,8 @@ def test_cleanup(self): m.cleanup() - self.assertTrue(m.layer not in m.BM._artists) - self.assertTrue(m.layer not in m.BM._bg_artists) + self.assertTrue(m.layer not in m._bm._artists) + self.assertTrue(m.layer not in m._bm._bg_artists) self.assertTrue(m._data_manager.x0 is None) self.assertTrue(not hasattr(m, "tree")) @@ -1389,7 +1349,7 @@ def test_blit_artists(self): line = plt.Line2D( [0, 0.25, 1], [0, 0.63, 1], c="k", lw=3, transform=m.ax.transAxes ) - m.BM.blit_artists([line]) + m._bm.blit_artists([line]) plt.close("all") def test_set_frame(self): diff --git a/tests/test_callbacks.py b/tests/test_callbacks.py index 1f9613251..7493f4a93 100644 --- a/tests/test_callbacks.py +++ b/tests/test_callbacks.py @@ -669,28 +669,28 @@ def test_overlay_layer(self): key_press_event(m.f.canvas, "0") key_release_event(m.f.canvas, "0") - self.assertTrue(m.BM._bg_layer == m.BM._get_combined_layer_name(m.layer, "A")) + self.assertTrue(m._bm._bg_layer == m._bm._get_combined_layer_name(m.layer, "A")) key_press_event(m.f.canvas, "0") key_release_event(m.f.canvas, "0") - self.assertTrue(m.BM._bg_layer == m.layer) + self.assertTrue(m._bm._bg_layer == m.layer) key_press_event(m.f.canvas, "1") key_release_event(m.f.canvas, "1") self.assertTrue( - m.BM._bg_layer == m.BM._get_combined_layer_name(m.layer, ("B", 0.5)) + m._bm._bg_layer == m._bm._get_combined_layer_name(m.layer, ("B", 0.5)) ) key_press_event(m.f.canvas, "1") key_release_event(m.f.canvas, "1") - self.assertTrue(m.BM._bg_layer == m.layer) + self.assertTrue(m._bm._bg_layer == m.layer) key_press_event(m.f.canvas, "2") key_release_event(m.f.canvas, "2") self.assertTrue( - m.BM._bg_layer == m.BM._get_combined_layer_name(m.layer, "A", ("B", 0.5)) + m._bm._bg_layer == m._bm._get_combined_layer_name(m.layer, "A", ("B", 0.5)) ) key_press_event(m.f.canvas, "2") key_release_event(m.f.canvas, "2") - self.assertTrue(m.BM._bg_layer == m.layer) + self.assertTrue(m._bm._bg_layer == m.layer) def test_switch_layer(self): # ---------- test as CLICK callback @@ -709,23 +709,23 @@ def test_switch_layer(self): # switch to layer 2 key_press_event(m.f.canvas, "2") key_release_event(m.f.canvas, "2") - self.assertTrue(m.BM._bg_layer == "2") + self.assertTrue(m._bm._bg_layer == "2") # the 3rd callback should not trigger key_press_event(m.f.canvas, "3") key_release_event(m.f.canvas, "3") - self.assertTrue(m.BM._bg_layer == "2") + self.assertTrue(m._bm._bg_layer == "2") # switch to the "base" layer key_press_event(m.f.canvas, "0") key_release_event(m.f.canvas, "0") - self.assertTrue(m.BM._bg_layer == "base") + self.assertTrue(m._bm._bg_layer == "base") # now the 3rd callback should trigger key_press_event(m.f.canvas, "3") key_release_event(m.f.canvas, "3") self.assertTrue( - m.BM._bg_layer == m.BM._get_combined_layer_name("2", ("3", 0.5)) + m._bm._bg_layer == m._bm._get_combined_layer_name("2", ("3", 0.5)) ) m.all.cb.keypress.remove(cid0) @@ -770,11 +770,11 @@ def cb(key): key_press_event(m.f.canvas, "0") key_release_event(m.f.canvas, "0") - self.assertTrue(m.BM._bg_layer == "0") + self.assertTrue(m._bm._bg_layer == "0") key_press_event(m.f.canvas, "1") key_release_event(m.f.canvas, "1") - self.assertTrue(m.BM._bg_layer == "1") + self.assertTrue(m._bm._bg_layer == "1") plt.close("all") def test_geodataframe_contains_picking(self): diff --git a/tests/test_config.py b/tests/test_config.py index ca2937cbe..e16edfcf9 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -19,9 +19,9 @@ def test_config_options(self): m.add_feature.preset.coastline() m.f.canvas.draw() - self.assertTrue(m._companion_widget_key == "x") + self.assertTrue(m._CompanionMixin__companion_widget_key == "x") self.assertTrue(m._always_on_top is True) - self.assertTrue(m.BM._snapshot_on_update is False) + self.assertTrue(m._bm._snapshot_on_update is False) self.assertTrue(m._use_interactive_mode is True) self.assertTrue(_log.getEffectiveLevel() == 10) @@ -38,9 +38,9 @@ def test_config_options(self): m.add_feature.preset.coastline() m.f.canvas.draw() - self.assertTrue(m._companion_widget_key == "w") + self.assertTrue(m._CompanionMixin__companion_widget_key == "w") self.assertTrue(m._always_on_top is False) - self.assertTrue(m.BM._snapshot_on_update is True) + self.assertTrue(m._bm._snapshot_on_update is True) self.assertTrue(m._use_interactive_mode is False) self.assertTrue(_log.getEffectiveLevel() == 30) diff --git a/tests/test_env.yml b/tests/test_env.yml index 583e9de4d..073853ebd 100644 --- a/tests/test_env.yml +++ b/tests/test_env.yml @@ -34,6 +34,7 @@ dependencies: - coveralls - pytest - pytest-cov + - pytest-xdist # --------------for testing the docs # (e.g. parsing .rst code-blocks and Jupyter Notebooks) - docutils diff --git a/tests/test_layout_editor.py b/tests/test_layout_editor.py index 49de71f69..bd28c3149 100644 --- a/tests/test_layout_editor.py +++ b/tests/test_layout_editor.py @@ -158,6 +158,7 @@ def test_layout_editor(self): x6 = (mg.m_1_1.colorbar.ax_cb.bbox.x1 + mg.m_1_1.colorbar.ax_cb.bbox.x0) / 2 y6 = (mg.m_1_1.colorbar.ax_cb.bbox.y1 + mg.m_1_1.colorbar.ax_cb.bbox.y0) / 2 button_press_event(cv, x6, y6, 1, False) + button_release_event(cv, x6, y6, 1, False) # undo the last 5 events nhist = len(mg.parent._layout_editor._history) diff --git a/tests/test_plot_shapes.py b/tests/test_plot_shapes.py index 6ca77a49c..fdc39753d 100644 --- a/tests/test_plot_shapes.py +++ b/tests/test_plot_shapes.py @@ -125,7 +125,7 @@ def test_contour(data): # arts = m3_1.ax.clabel(m3_1.coll.contour_set) # for a in arts: - # m3_1.BM.add_bg_artist(a, layer=m3_1.layer) + # m3_1._bm.add_bg_artist(a, layer=m3_1.layer) m.show_layer("base", "contours") diff --git a/tests/test_widgets.py b/tests/test_widgets.py index 85f111a56..8d74c2502 100644 --- a/tests/test_widgets.py +++ b/tests/test_widgets.py @@ -35,7 +35,7 @@ def test_selector_widgets(widget, use_layers): if use_layers is None: assert layers == m._get_layers(), "layers not correctly identified" else: - assert layers == [m.BM._get_combined_layer_name(*i[1]) for i in use_layers] + assert layers == [m._bm._get_combined_layer_name(*i[1]) for i in use_layers] state = w.get_state() @@ -59,7 +59,7 @@ def test_selector_widgets(widget, use_layers): w.set_state(state) m.redraw() - found_layer = m.BM.bg_layer + found_layer = m._bm.bg_layer if widget in (widgets.LayerSelectMultiple,): if layers[i] == found_layer: @@ -67,9 +67,9 @@ def test_selector_widgets(widget, use_layers): # so the expected layer is NOT an overlay! expected_layer = layers[i] else: - expected_layer = m.BM._get_combined_layer_name(layers[0], layers[i]) + expected_layer = m._bm._get_combined_layer_name(layers[0], layers[i]) elif widget in (widgets.LayerSelectionRangeSlider,): - expected_layer = m.BM._get_combined_layer_name(*layers[0 : i + 1]) + expected_layer = m._bm._get_combined_layer_name(*layers[0 : i + 1]) else: expected_layer = layers[i] @@ -142,10 +142,10 @@ def test_overlay_widgets(widget): state["value"] = val w.set_state(state) if val > 0: - expected = m.BM._get_combined_layer_name("coast", ("ocean", val)) + expected = m._bm._get_combined_layer_name("coast", ("ocean", val)) else: expected = "coast" - found = m.BM.bg_layer + found = m._bm.bg_layer assert ( found == expected ), f"Overlay not properly assigned, expected {expected}, found {found}" @@ -164,4 +164,4 @@ def test_layer_button(layer): b = widgets.LayerButton(m, layer=layer) layername = b._parse_layer(layer) b.click() - assert m.BM.bg_layer == layername, "layer not correctly switched" + assert m._bm.bg_layer == layername, "layer not correctly switched"