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/.readthedocs.yaml b/.readthedocs.yaml index 79a8b6ad2..331451c03 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -4,9 +4,9 @@ sphinx: configuration: docs/source/conf.py build: - os: "ubuntu-20.04" + os: "ubuntu-lts-latest" tools: - python: "mambaforge-22.9" + python: "miniforge3-latest" conda: environment: docs/docs_env.yml diff --git a/docs/docs_env.yml b/docs/docs_env.yml index 568448f9e..83a356960 100755 --- a/docs/docs_env.yml +++ b/docs/docs_env.yml @@ -4,15 +4,15 @@ channels: dependencies: - - python =3.11 + - python =3.12 # --------------for building the docs - - docutils =0.20.1 - - sphinx =8.0.2 + - docutils =0.22.4 + - sphinx =9.1.0 - sphinx-copybutton =0.5.2 - - myst-nb =1.1.1 - - sphinx-design =0.6.1 - - pydata-sphinx-theme + - myst-nb =1.4.0 + - sphinx-design =0.7.0 + - pydata-sphinx-theme =0.16.1 - myst-sphinx-gallery = 0.2.2 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..6e23eb636 100755 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -84,24 +84,17 @@ def setup(app): Maps.cb.click.__name__ = "click" Maps.cb.click.attach.__name__ = "attach" - Maps.cb.click.get.__name__ = "get" Maps.cb.pick.__name__ = "pick" Maps.cb.pick.attach.__name__ = "attach" - Maps.cb.pick.get.__name__ = "get" Maps.cb.keypress.__name__ = "keypress" Maps.cb.keypress.attach.__name__ = "attach" - Maps.cb.keypress.get.__name__ = "get" Maps.cb.move.__name__ = "move" 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..70d075528 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"): @@ -89,8 +79,6 @@ def make_feature_toctree_file(): "cb.keypress.attach", ): members.extend(get_members(Maps, key, True)) - for key in ("cb.click.get", "cb.pick.get", "cb.move.get", "cb.keypress.get"): - members.extend(get_members(Maps, key, False)) # create a page that will be used for sphinx-autodoc to create stub-files s = ":orphan:\n\n" @@ -103,9 +91,7 @@ def make_feature_toctree_file(): s += get_autosummary("eomaps.compass", ["Compass"], "custom-class-template") s += get_autosummary("eomaps.scalebar", ["ScaleBar"], "custom-class-template") s += get_autosummary( - "eomaps.callbacks", - ["ClickCallbacks", "PickCallbacks", "KeypressCallbacks"], - "custom-class-template", + "eomaps.callback_methods", ["_CallbackMixin"], "custom-class-template" ) s += get_autosummary( 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..b6f52446b 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: @@ -276,7 +276,7 @@ The visible layer can be a **single layer-name**, or a **combination of multiple :icon: info :color: info - .. currentmodule:: eomaps.callbacks.ClickCallbacks + .. currentmodule:: eomaps.callback_methods._CallbackMixin If you want to interactively overlay a part of the screen with a different layer, have a look at :py:meth:`peek_layer` callbacks! @@ -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/interactivity/api_callbacks.rst b/docs/source/user_guide/interactivity/api_callbacks.rst index 3a134869a..00fdae3c4 100755 --- a/docs/source/user_guide/interactivity/api_callbacks.rst +++ b/docs/source/user_guide/interactivity/api_callbacks.rst @@ -157,7 +157,7 @@ Click, pick and move Callbacks that can be used with ``m.cb.click``, ``m.cb.pick`` and ``m.cb.move``: -.. currentmodule:: eomaps.callbacks.ClickCallbacks +.. currentmodule:: eomaps.callback_methods._CallbackMixin .. autosummary:: :nosignatures: @@ -171,7 +171,7 @@ Callbacks that can be used with ``m.cb.click``, ``m.cb.pick`` and ``m.cb.move``: Callbacks that can be used with ``m.cb.click`` and ``m.cb.pick``: -.. currentmodule:: eomaps.callbacks.ClickCallbacks +.. currentmodule:: eomaps.callback_methods._CallbackMixin .. autosummary:: :nosignatures: @@ -183,7 +183,7 @@ Callbacks that can be used with ``m.cb.click`` and ``m.cb.pick``: Callbacks that can be used only with ``m.cb.pick``: -.. currentmodule:: eomaps.callbacks.PickCallbacks +.. currentmodule:: eomaps.callback_methods._CallbackMixin .. autosummary:: :nosignatures: @@ -197,7 +197,7 @@ Keypress Callbacks that can be used with ``m.cb.keypress`` -.. currentmodule:: eomaps.callbacks.KeypressCallbacks +.. currentmodule:: eomaps.callback_methods._CallbackMixin .. autosummary:: :nosignatures: 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..ff0b4154d 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, WeakOrderedCollection + _log = logging.getLogger(__name__) @@ -98,33 +100,36 @@ 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): + combnames.append(arg) + 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 + + 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 +140,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 +171,220 @@ 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, WeakOrderedCollection()).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 __iter__(self): + return iter(chain(*self._children.values())) + + def add(self, m): + self._children.setdefault(m.layer, WeakOrderedCollection()).add(m) + + def remove(self, m): + self._children[m.layer].remove(m) + if len(self._children[m.layer]) == 0: + del self._children[m.layer] + + 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 self._children.get(layer, []) + + def get_layers(self): + return list(self._children) + + 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 {action}: {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 {action}: {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 +402,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 = WeakOrderedCollection() # the name of the layer at which all "unmanaged" artists are drawn self._unmanaged_artists_layer = "base" @@ -206,9 +416,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 +436,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 = WeakOrderedCollection() - 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 +530,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 +544,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) - def on_layer(self, func, layer=None, persistent=False, m=None): + return artists + + def on_layer(self, func, layer=None, persistent=False, **kwargs): """ Add callables that are executed whenever the visible layer changes. @@ -496,151 +660,273 @@ 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 - - def cb(*args, **kwargs): - func(m=m, *args, **kwargs) + method_evaluated = False + # in case the layer is currently visible, directly execute the callback + if layer == "all" or layer in self._get_active_layers_alphas[0]: + ret = func(layer, **kwargs) + method_evaluated = True + if persistent is False: + return ret + + @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) - - for l in self._bg_layers: - sublayers, _ = self._parse_multi_layer_str(l) - if layer in sublayers: - self._layers_to_refetch.add(l) + self.add_hook("layer_activation", layer_callback, persistent, layer=layer) - def _bg_artists_sort(self, art): - sortp = [] + self.run_hook("on_layer_callback_added") - # 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) + # 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) - sortp.append(getattr(art, "zorder", -1)) - return sortp + # return the return-value of the callback in case it is submitted + # as persistent callback and immediately evaluated + if method_evaluated: + return ret - def get_bg_artists(self, layer): + def fetch_bg(self, layer=None, bbox=None): """ - Get all (sorted) background artists assigned to a given layer-name. + Trigger fetching (and caching) the background for 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). + 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. """ - 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.get(l, [])) + if layer is None: + 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()) + if layer in self._bg_layers: + # don't re-fetch existing layers + # (layers get cleared automatically if re-draw is necessary) + return - # 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) + with self._disconnect_draw(): + # execute actions on layer-changes + # (to make sure all lazy WMS services are properly added) + self._do_on_layer_change(layer=layer, new=False) - return artists + self._do_fetch_bg(layer, bbox) - def get_artists(self, layer): + def update( + self, + layers=None, + bbox_bounds=None, + bg_layer=None, + artists=None, + clear=False, + blit=True, + clear_snapshot=True, + ): """ - Get all (sorted) dynamically updated artists assigned to a given layer-name. + 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. """ + if self._disable_update: + # don't update during layout-editing + return + cv = self.canvas - 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 bg_layer is None: + bg_layer = self.bg_layer - # 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, [])) + 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 _layer_visible(self, layer): - """ - Return True if the layer is currently visible. + if show_layer not in self._bg_layers: + # make sure the background is properly fetched + self.fetch_bg(show_layer) - - layer is considered visible if all sub-layers of a combined layer are visible - - transparency assignments do not alter the layer visibility + 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): + """ + Blit artists (optionally on top of a given background) Parameters ---------- - layer : str - The combined layer-name to check. (e.g. 'A|B{.4}|C{.3}') + 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 - ------- - visible: bool - True if the layer is currently visible, False otherwise + # restore the background + if bg is not None: + if bg == "active": + bg = self._get_active_bg() + cv.restore_region(bg) - """ - return layer == "all" or self._layer_is_subset(layer, self.bg_layer) + 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_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 + + try: + return self.canvas.get_renderer() + except Exception: + return None + + def _get_all_map_axes(self): + maxes = {m.ax for m in (self._children) if getattr(m, "_new_axis_map", False)} + return maxes + + def _get_managed_axes(self): + return (*self._get_all_map_axes(), *self._managed_axes) + + 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) + + # EOmaps axes + managed_axes = self._get_managed_axes() + allaxes = set(self.figure.axes) + + unmanaged_axes = allaxes.difference(managed_axes) + return unmanaged_axes + + 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 +941,217 @@ 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_restore_bg_img( + self, + layer, + bbox=None, + ): + """ + Update a part of the screen with a different background + (intended as after-restore action) + + bbox_bounds = (x, y, width, height) + """ + + if bbox is None: + bbox = self.figure.bbox + + if layer in self._bg_layers: + buffer = self._bg_layers[layer] + else: + renderer = self._get_renderer() + if renderer is None: + raise RuntimeError("No renderer available?") + + # make sure to restore the initial background + init_bg = renderer.copy_from_bbox(bbox) + buffer = self._get_background(layer, bbox=bbox, cache=True) + self.canvas.restore_region(init_bg) + + return buffer + + 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 +1174,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 +1185,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]) @@ -788,47 +1221,7 @@ 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) + self.run_hook("after_fetch_bg", layer=layer, bbox=None) def _on_draw_cb(self, event): """Callback to register with 'draw_event'.""" @@ -892,234 +1285,137 @@ 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! + 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: + 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,16 +1434,17 @@ 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 = [] # always redraw artists from the "all" layer - layers.append("all") + # (all 'all' layer artists before all other artists to make sure that they + # are drawn below explicit layer artists) + # This is useful for peek-layer callbacks defined on the all layer that + # otherwise interfere with explicit layer callbacks (e.g. annotate) + layers.insert(0, "all") # make the list unique but maintain order (dicts keep order for python>3.7) layers = list(dict.fromkeys(layers)) @@ -1163,7 +1460,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 +1475,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 +1505,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 +1512,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 +1519,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 +1552,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 +1568,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..865e10ed5 100644 --- a/eomaps/_maps_base.py +++ b/eomaps/_maps_base.py @@ -4,25 +4,30 @@ # See LICENSE in the root of the repository for full licensing details. """Base class for Maps objects.""" +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .eomaps import Maps -import gc import logging -from contextlib import ExitStack -from pyproj import CRS, Transformer -from functools import lru_cache, wraps -from itertools import chain -import weakref -import numpy as np +_log = logging.getLogger(__name__) -from cartopy import crs as ccrs +from contextlib import contextmanager, ExitStack +from functools import lru_cache, wraps +from textwrap import fill +import importlib.metadata +import weakref +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, WeakOrderedCollection from .layout_editor import LayoutEditor from ._blit_manager import BlitManager from .projections import Equi7Grid_projection # import also supercharges cartopy.ccrs @@ -62,12 +67,63 @@ def _handle_backends(): ) +class LazyCx: + """ + A contextmanager to temporarily change if methods are executed lazily. + + Examples + -------- + + Set global behavior + + >>> Maps.lazy = True # or False + + + Temporarily execute methods lazily: + + >>> with Maps.lazy: + >>> m["my_layer"].add_feature.preset.coastline() + + + Temporarily execute methods immediately: + + >>> with Maps.lazy(False): + >>> m["my_layer"].add_feature.preset.coastline() + + """ + + def __init__(self): + self._lazy = True + + def __call__(self, lazy=True): + if not isinstance(lazy, bool): + raise TypeError("lazy must be either True or False.") + self._lazy = lazy + return self + + def __enter__(self): + self._init_lazy = MapsBase._lazy + MapsBase._lazy = self._lazy + + def __exit__(self, type, value, tb): + MapsBase._lazy = self._init_lazy + + class _MapsMeta(type): _use_interactive_mode = None _always_on_top = False - _backend_warning_shown = False + # a contextmanager to set the "lazy" attribute on all Maps objects + lazy = LazyCx() + + # allow setting "lazy" without overriding the contextmanager + def __setattr__(cls, name, value): + if name == "lazy": + MapsBase._lazy = value + else: + super().__setattr__(name, value) + def config( cls, snapshot_on_update=None, @@ -130,7 +186,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 +289,328 @@ 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( + [getattr(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 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.name + + self._layers[name] = m + super().__setattr__(name, m) + + def _remove_layer(self, layer): + # NOTE it is important to first delete the attribute and then + # delete the entry from the dict in order to avoid re-creating + # the layer when checking for attribute-existence! + try: + delattr(self, layer) + except AttributeError: + pass + self._layers.pop(layer, None) + + 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): + # NOTE: convert args to string since layer-names are always strings + if isinstance(name, tuple): + return MultiMaps([getattr(self, str(n)) for n in name]) + else: + return getattr(self, str(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 __getattribute__(self, name) -> "Maps": + # private attributes are handled in ordinary manner. + # only public attribute names will trigger layer-creation! + if name.startswith("_"): + return super().__getattribute__(name) + + # get the maps-object associated with the name (create if it does not exist) + # Note: new_layer calls "LayerNamespace._ingest_layer" to ingest the layer + m = self._layers.get(name) + if m is None: + return self._m.new_layer(name) + + return m + + +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) + + # the "full" layer-name including sublayer-(__) suffix + self._name = layer + + # the layer at which the artists should be visible + # TODO write a proper parser method + self._layer = layer.split("__", 1)[0] + + super().__init__(*args, **kwargs) + + @property + def layer(self): + """Name of the layer at which artists of this Maps-object are visible.""" + return self._layer + + @property + def name(self): + """ + The name associated with this Maps-object. + + __ + + """ + return self._name + + @property + def parent(self): + """ + The parent-object to which this Maps-object is connected to. + """ + return self._parent + + @property + @wraps(LayerNamespace) + def l(self): + """LayerNamespace accessor to create/access layers on the map.""" + return self._l + + def new_layer( + self, + layer=None, + inherit_data=False, + inherit_classification=False, + inherit_shape=False, + **kwargs, + ): + """ + Create a new Maps-object that shares the same plot-axes. + + Parameters + ---------- + 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 + - If None, the layer of the parent object is used. + + The default is None. + inherit_data, inherit_classification, inherit_shape : bool + Indicator if the corresponding properties should be inherited from + the parent Maps-object. + + By default only the shape is inherited. + + For more details, see :py:meth:`Maps.inherit_data` and + :py:meth:`Maps.inherit_classification` + + Returns + ------- + eomaps.Maps + A connected copy of the Maps-object that shares the same plot-axes. + + Examples + -------- + Create a new Maps-object **on an existing layer** + + >>> from eomaps import Maps + >>> m = Maps(layer="base") # m.layer == "base" + >>> m2 = m.new_layer() # m2.layer == "base" + + + Create a new Maps-object representing a **new layer** + + >>> from eomaps import Maps + >>> m = Maps(layer="base") # m.layer == "base" + >>> m2 = m.new_layer("a new layer") # m2.layer == "a new layer" + + + Create a new layer and immediately delete it after it has been exported. + (useful to free memory if a lot of layers are be exported) + + >>> from eomaps import Maps + >>> m = Maps(layer="base") + >>> with m.new_layer("a new layer") as m2: + >>> ... + >>> m2.show() # make the layer visible + >>> m2.savefig(...) # save it as an image + + + See Also + -------- + Maps.copy : general way for copying Maps objects + + """ + + if layer is None: + layer = self.layer + else: + layer = str(layer) + if len(layer) == 0: + raise SyntaxError( + "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: + m.inherit_data(self) + if inherit_classification: + m.inherit_classification(self) + if inherit_shape and self._shape_assigned: + getattr(m.set_shape, self.shape.name)(**self.shape._initargs) + + # make sure the new layer does not attempt to reset the extent if + # it has already been set on the parent layer + m._set_extent_on_plot = self._set_extent_on_plot + + # re-initialize all sliders and buttons to include the new layer + self.parent.util._reinit_widgets() + + # share the companion-widget with the parent + m._companion_widget = self._companion_widget + + return m + + class MapsBase(metaclass=_MapsMeta): + _lazy = None + def __init__( self, crs=None, - layer="base", f=None, ax=None, **kwargs, ): + self._view_transparency = 1 + self._figure_closed = False + + self._artists = WeakOrderedCollection() + self._bg_artists = WeakOrderedCollection() - self._BM = None 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 +624,8 @@ 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 +644,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.name in self._l._layers: + name = self.layer + 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.name}' already exists!\n" + f"It has been re-named to {name} in the LayerNamespace!" + ) + self._name = name - 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__") + 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["**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 +684,88 @@ 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) + + def remove_artist(self, artist): + self._remove_artist(artist) + self._bm.run_hook("remove_artist") + artist.remove() + + def _remove_bg_artist(self, artist): + self._bg_artists.remove(artist) + + def remove_bg_artist(self, artist, draw=True): + self._remove_bg_artist(artist) + self._bm.run_hook("remove_bg_artist") + artist.remove() + + if draw: + self.redraw(self.layer) + + def __mul__(self, value): + self._view_transparency = value + return self + + def __rmul__(self, value): + self._view_transparency = value + return self + + def __add__(self, value): + return MultiMaps([self, value]) + + # to add support for sum() + def __radd__(self, value): + if value == 0: + return MultiMaps([self]) + else: + return self.__add__(value) + + def _ipython_key_completions_(self, *args, **kwargs): + # to allow auto-completion for __getitem__ in ipython + return list(self.l._layers) + + def __getitem__(self, name) -> "Maps": + # NOTE: convert args to string since layer-names are always strings + if isinstance(name, tuple): + return MultiMaps([getattr(self._l, str(n)) for n in name]) + elif isinstance(name, slice): + return sum([*self.l][name]) + else: + return getattr(self._l, str(name)) + def __repr__(self): try: return f"" @@ -332,7 +782,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)!" ) @@ -340,76 +790,23 @@ def __enter__(self): return self def __exit__(self, type, value, traceback): + # all action on Maps-objects must be performed BEFORE cleanup! + is_parent = self.parent == self self.cleanup() - if self.parent == self: + if is_parent: 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 - - @property - def ax(self): - """The matplotlib (cartopy) GeoAxes associated with this Maps-object.""" - return self._ax - @property def f(self): - """The matplotlib Figure associated with this Maps-object.""" + """Matplotlib Figure associated with this Maps-object.""" # always return the figure of the parent object return self._f @property - def layer(self): - """The layer-name associated with this Maps-object.""" - return self._layer + def ax(self): + """Cartopy GeoAxes associated with this Maps-object.""" + return self._ax @property def all(self): @@ -421,203 +818,20 @@ def all(self): >>> m.all.cb.click.attach.annotate() """ - if not hasattr(self, "_all"): - self._all = self.new_layer("all") - return self._all + return self.l["all"] - @property - def parent(self): + def redraw(self, *args, force_data_redraw=False): """ - The parent-object to which this Maps-object is connected to. + Force a re-draw of cached background layers. - If None, `self` is returned! - """ - if self._parent is None: - self._set_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! - return self._parent - - 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.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) - - 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.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! + Parameters + ---------- + forece_data_redraw : bool + Force a re-draw of already plotted datasets. + The default is False. Note ---- @@ -626,7 +840,7 @@ def redraw(self, *args): To dynamically re-draw an artist whenever you interact with the map, use: - >>> m.BM.add_artist(artist) + >>> m.add_artist(artist) To make an artist temporary (e.g. remove it on the next event), use one of : @@ -647,11 +861,19 @@ def redraw(self, *args): 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 + self._bm._refetch_bg = True + if force_data_redraw and getattr(self, "_data_manager", None) is not None: + self._data_manager.last_extent = None + 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 +921,37 @@ 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 +977,8 @@ def show(self, clear=True): show_layer : Set the currently visible layer. """ + self.show_layer(self.layer) + try: __IPYTHON__ except NameError: @@ -764,6 +992,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 +1086,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 +1108,11 @@ 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 = self._bm._children.get_layers() # exclude private layers if exclude_private: @@ -838,8 +1122,10 @@ 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.extend(("**BG**", "**SPINES**")) if exclude: for i in exclude: @@ -898,25 +1184,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 +1233,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 +1269,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 +1289,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 +1300,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 +1313,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 +1346,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 +1357,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 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 +1387,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 self._bm._managed_axes: stack.enter_context(a._cm_set(animated=False)) # trigger a redraw of all savelayers to make sure unmanaged artists @@ -1136,8 +1421,30 @@ def cleanup(self): """ try: + # 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 + self._bm._children.remove(self) + # disconnect callback on xlim-change (only relevant for parent) - if not self._is_sublayer: + if self.parent == self: try: if hasattr(self, "_cid_xlim"): self.ax.callbacks.disconnect(self._cid_xlim) @@ -1148,13 +1455,6 @@ 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) - - # remove the child from the parent Maps object - if self in self.parent._children: - self.parent._children.remove(self) except Exception: _log.error( "EOmaps: Cleanup problem!", @@ -1166,12 +1466,223 @@ def crs_plot(self): """The crs used for plotting.""" return self._crs_plot_cartopy - @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 + 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 == 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) + + # 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 = next((m._l 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:"): @@ -1214,6 +1725,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 +1756,44 @@ 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): + self._figure_closed = True - Parameters - ---------- - lon, lat : float or array-like - The longitude and latitude values to transform. + # 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 - Returns - ------- - x, y : The coordinates transformed to the plot-coordinate system. + m.cleanup() - """ - return self._transf_lonlat_to_plot.transform(lon, lat) + # 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 def on_layer_activation(self, func, layer=None, persistent=False, **kwargs): """ @@ -1327,81 +1862,124 @@ def on_layer_activation(self, func, layer=None, persistent=False, **kwargs): layer = str(layer) m = self.new_layer(layer) - def cb(m, layer): - func(m=m, **kwargs) + @wraps(func) + def cb(layer): + return func(m=m, **kwargs) - self.BM.on_layer(func=cb, layer=layer, persistent=persistent, m=m) + return 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. - - 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. + Return a MultiMaps that executes action on all layers defined + on the map at the moment of execution. - - 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. - - - if None, the extent is provided in epsg=4326 (e.g. lon/lat projection) + 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 - The default is None. + Examples + -------- + >>> m = Maps() + >>> m.set_extent_to_location("Austria") + >>> m.add_feature.preset.countries() - 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 +1991,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 +2045,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 +2093,111 @@ 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() + + +class MultiMaps(MultiCaller): + """ + Wrapper around Maps-objects to run methods on multiple Maps objects in one go. + """ + + @wraps(MapsBase.show) + def show(self, **kwargs): + layers = [(m._layer, m._view_transparency) for m in self._elements] + self._elements[0].show_layer(*layers) + + @wraps(MapsBase.snapshot) + def snapshot(self, *args, **kwargs): + layers = [(m._layer, m._view_transparency) for m in self._elements] + self._elements[0].snapshot(*layers, **kwargs) + + @wraps(MapsBase.savefig) + def savefig(self, *args, **kwargs): + self.show() + self._elements[0].savefig(*args, **kwargs) + + def __getattribute__(self, name): + if name in ("show", "snapshot", "savefig"): + return object.__getattribute__(self, name) + + return super().__getattribute__(name) diff --git a/eomaps/_webmap.py b/eomaps/_webmap.py index fce409f75..533373c6c 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 @@ -26,16 +26,11 @@ from cartopy.io.img_tiles import GoogleWTS from cartopy.io import RasterSource -from .helpers import _sanitize +from .helpers import _sanitize, _submit_on_activation _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 @@ -353,6 +347,7 @@ class _WMTSLayer(_WebMapLayer): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + @_submit_on_activation(maps_attr="_m", label="{_EOmaps_source_code}") def __call__(self, layer=None, zorder=0, alpha=1, **kwargs): """ Add the WMTS layer to the map @@ -397,29 +392,13 @@ 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): - # add the layer immediately if the layer is already active - self._do_add_layer( - self._m, - layer=self._layer, - wms_kwargs=kwargs, - zorder=zorder, - alpha=alpha, - ) - 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, - ), - layer=self._layer, - persistent=False, - m=m, - ) + self._do_add_layer( + m=self._m, + layer=self._layer, + wms_kwargs=kwargs, + zorder=zorder, + alpha=alpha, + ) # ------------------------ # The following is very much a copy of "cartopy.mpl.geoaxes.GeoAxes.add_raster" @@ -439,7 +418,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,13 +435,14 @@ 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): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + @_submit_on_activation(maps_attr="_m", label="{_EOmaps_source_code}") def __call__(self, layer=None, zorder=0, alpha=1, **kwargs): """ Add the WMS layer to the map @@ -508,29 +488,13 @@ def __call__(self, layer=None, zorder=0, alpha=1, **kwargs): else: self._layer = layer - if m.BM._layer_visible(self._layer): - # add the layer immediately if the layer is already active - self._do_add_layer( - m=m, - layer=self._layer, - wms_kwargs=kwargs, - zorder=zorder, - alpha=alpha, - ) - 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, - ), - layer=self._layer, - persistent=False, - m=m, - ) + self._do_add_layer( + m=m, + layer=self._layer, + wms_kwargs=kwargs, + zorder=zorder, + alpha=alpha, + ) # ------------------------ # The following is very much a copy of "cartopy.mpl.geoaxes.GeoAxes.add_raster" @@ -585,7 +549,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 +564,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: @@ -1165,6 +1129,7 @@ def __init__(self, m, url, maxzoom=19, name=None): def _reinit(self, m): return _XyzTileService(m, url=self.url, maxzoom=self._maxzoom, name=self.name) + @_submit_on_activation(maps_attr="_m", label="{_EOmaps_source_code}") def __call__( self, layer=None, @@ -1227,20 +1192,10 @@ def __call__( kwargs.setdefault("alpha", alpha) kwargs.setdefault("origin", "lower") - 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) - 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), - layer=self._layer, - persistent=False, - m=self._m, - ) + # add the layer immediately if the layer is already active + self._do_add_layer(layer=self._layer, m=self._m, **kwargs) - 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 +1226,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): @@ -1374,6 +1329,14 @@ def __init__(self, ax, raster_source, **kwargs): # indicator if WebMaps should be re-fetched if the size of the # axes (e.g. also the figure size or dpi) changes. + def contains(self, *args, **kwargs): + # to avoid issues for empty-images that are not yet fetched + # (because the layer was never visible) + if self.get_array() is None: + return False, {} + else: + return super().contains(*args, **kwargs) + def on_xlim(self, *args, **kwargs): self.stale = True diff --git a/eomaps/annotation_editor.py b/eomaps/annotation_editor.py index 665b6b7b2..7786226f4 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,10 +440,10 @@ 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) + self.m.execute_callbacks = False _log.info("EOmaps: Annotations editable!") else: for ann in self._annotations: @@ -472,8 +461,8 @@ def __call__(self, q=True): self.remove_info_text() self.m._emit_signal("annotationEditorDeactivated") - self.m.BM.update() - self.m.cb.execute_callbacks(True) + self.m._bm.update() + self.m.execute_callbacks = True def _make_ann_editable(self, ann, drag_coords=True): # avoid issues with annotations that are removed during interactive editing @@ -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/cb_container.py b/eomaps/callback_container.py old mode 100755 new mode 100644 similarity index 69% rename from eomaps/cb_container.py rename to eomaps/callback_container.py index 8bfcf469c..2d4a37cdf --- a/eomaps/cb_container.py +++ b/eomaps/callback_container.py @@ -7,18 +7,13 @@ import logging from types import SimpleNamespace -from functools import partial, wraps +from functools import partial, wraps, update_wrapper from contextlib import contextmanager -from itertools import chain +from itertools import chain, permutations from weakref import proxy -from .callbacks import ( - ClickCallbacks, - PickCallbacks, - KeypressCallbacks, - MoveCallbacks, -) -from .helpers import register_modules +from .callback_methods import _CallbackMixin +from .helpers import register_modules, _proxy import matplotlib.pyplot as plt from pyproj import Transformer @@ -27,6 +22,17 @@ _log = logging.getLogger(__name__) +def _try_decorator(func): + @wraps(func) + def inner(*args, **kwargs): + try: + func(*args, **kwargs) + except Exception: + _log.error("problem during callback", exc_info=True) + + return inner + + class GeoDataFramePicker: """Collection of pick-methods for geopandas.GeoDataFrames""" @@ -114,10 +120,10 @@ def _centroids_picker(self, artist, mouseevent): return False, dict() -class _CallbackContainer(object): +class _CallbackContainerBase: """Base-class for callback containers.""" - def __init__(self, m, cb_class=None, method="click", parent_container=None): + def __init__(self, m, method="click", parent_container=None): self._m = m self._parent_container = parent_container @@ -126,6 +132,9 @@ def __init__(self, m, cb_class=None, method="click", parent_container=None): else: self._temporary_artists = self._parent_container._temporary_artists + self._cids = dict() + + self._cbs = dict() self._fwd_cbs = dict() self._method = method @@ -134,80 +143,60 @@ def __init__(self, m, cb_class=None, method="click", parent_container=None): self._execute_on_all_layers = False self._execute_while_toolbar_active = False - def _getobj(self, m): - """Get the equivalent callback container on another maps object.""" - return getattr(m.cb, self._method, None) + def __repr__(self): + txt = "Attached callbacks:\n " + "\n ".join( + f"{key}" for key in self.attached_callbacks + ) + return txt - @property - def _objs(self): - """Get the callback-container objects associated with the event-axes.""" - # Note: it is possible that more than 1 Maps objects are - # assigned to the same axis! - objs = [] - if self._event is not None: - if hasattr(self._event, "mouseevent"): - event = self._event.mouseevent - else: - event = self._event + class _attach: + """ + Attach custom or pre-defined callbacks to the map. - # make sure that "all" layer callbacks are executed before other callbacks - ms, malls = [], [] - for m in reversed((*self._m.parent._children, self._m.parent)): - if m.layer == "all": - malls.append(m) - else: - ms.append(m) - ms = ms + malls + NOTE: any public attribute of the class will be submitted as a callback! + """ - if self._method in ["keypress"]: - for m in ms: - # always execute keypress callbacks irrespective of the mouse-pos - obj = self._getobj(m) + def __init__(self, parent): + self._parent = parent + self.m = parent._m + self._temporary_artists = self._parent._temporary_artists - # only include objects that are on the same layer - # (irrespective if a toolbar mode is active or not) - if ( - obj is not None - and obj._execute_cb(obj._m.layer) - # and not obj._check_toolbar_mode() - ): - objs.append(obj) + def __getattribute__(self, name): + if name.startswith("_") or name not in self._available_callbacks(): + return object.__getattribute__(self, name) else: - for m in ms: - # don't use "is" in here since Maps-children are proxies - # (and so are their attributes)! - if event.inaxes == m.ax: - obj = self._getobj(m) - # only include objects that are on the same layer - # (and only if no toolbar mode is active!) - if ( - obj is not None - and obj._execute_cb(obj._m.layer) - and not obj._check_toolbar_mode() - ): - objs.append(obj) - return objs + method = object.__getattribute__(self, name) + callback = _try_decorator(method) - 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) - - def _sort_cbs(self, cbs): - _cb_list = self._attach._available_callbacks() - if not cbs: - return set() - cbnames = set([i.rsplit("__", 1)[0].rsplit("_", 1)[0] for i in cbs]) - sortp = _cb_list + list(set(_cb_list) ^ cbnames) - return sorted( - list(cbs), key=lambda w: sortp.index(w.rsplit("__", 1)[0].rsplit("_", 1)[0]) - ) + @wraps(method) + def attach_wrapper(*args, **kwargs): + return self._parent._add_callback( + *args, callback=callback, **kwargs + ) - def __repr__(self): - txt = "Attached callbacks:\n " + "\n ".join( - f"{key}" for key in self.get.attached_callbacks - ) - return txt + return attach_wrapper + + @classmethod + def _available_callbacks(cls): + try: + return cls.__available_callbacks + except AttributeError: + cls.__available_callbacks = list( + filter(lambda x: not x.startswith("_"), dir(cls)) + ) + return cls.__available_callbacks + + @property + def attached_callbacks(self): + """Get a list of all IDs of attached callbacks.""" + cbs = [] + for ds, dsdict in self._cbs.items(): + for b, bdict in dsdict.items(): + for k, kdict in bdict.items(): + for name in kdict.keys(): + cbs.append(f"{name}__{ds}__{b}__{k}") + + return cbs def forward_events(self, *args): """ @@ -221,7 +210,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,16 +223,57 @@ 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) + def _get_artists(self, use_artists): + """ + Get a list of artists. + + Parameters + ---------- + use_artists : str or list of Maps + - "background": return all background artists of all Maps. + - "dynamic": return all dynamic artists of all Maps. + - "all": return all artists of the figure. + - iterable: return all dynamic and background artists of the + Maps-objects provided in the list. + + Returns + ------- + artists: a list of all relevant artists + + """ + if use_artists == "all": + # consider all artists (even non-Maps artists) + artists = chain( + *[ax.get_children() for ax in self._m.f.axes], + self._m.f.get_children(), + ) + elif use_artists == "dynamic": + # consider all dynamic artists + artists = chain((m._artists for m in self._m._bm._children)) + elif use_artists == "background": + # consider all background artists + artists = chain((m._bg_artists for m in self._m._bm._children)) + elif isinstance(use_artists, (list, tuple)): + # only consider artists added to specific Maps objects + artists = chain(*(chain(m._bg_artists, m._artists) for m in use_artists)) + return artists + @contextmanager - def make_artists_temporary(self, layer=None): + def make_artists_temporary(self, layer=None, use_artists="all"): """ A contextmanager to make all artists created within the context temporary (e.g. they are removed on the next relevant event) @@ -271,21 +301,12 @@ def make_artists_temporary(self, layer=None): add_temporary_artist: Make one (or more) artists temporary. """ + try: - artists_before = set( - chain( - *[ax.get_children() for ax in self._m.f.axes], - self._m.f.get_children(), - ) - ) + artists_before = set(self._get_artists(use_artists)) yield finally: - artists_after = set( - chain( - *[ax.get_children() for ax in self._m.f.axes], - self._m.f.get_children(), - ) - ) + artists_after = set(self._get_artists(use_artists)) new_artists = artists_after.difference(artists_before) self.add_temporary_artist(*new_artists, layer=layer) @@ -317,40 +338,14 @@ 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): - """ - Get bool if a callback assigned on "layer" should be executed. - - - True if the callback is assigned to the "all" layer - - True if the corresponding layer is currently active - - True if the corresponding layer is part of a currently active "multi-layer" - (e.g. "layer|layer2" or "layer|layer2{0.5}" ) - - Parameters - ---------- - layer : str - The name of the layer to which the callback is attached. - - Returns - ------- - bool - Indicator if the callback should be executed on the currently visible - layer or not. - """ - if self.execute_on_all_layers or layer == "all": - return True - - return self._m.BM._layer_visible(layer) - @property def execute_on_all_layers(self): """Indicator if callbacks of this container are executed on all layers.""" @@ -384,18 +379,6 @@ def set_execute_on_all_layers(self, q): ) self._execute_on_all_layers = q - def _check_toolbar_mode(self): - if self._execute_while_toolbar_active: - return False - - # returns True if a toolbar mode is active and False otherwise - if ( - self._m.f.canvas.toolbar is not None - ) and self._m.f.canvas.toolbar.mode != "": - return True - else: - return False - def set_execute_during_toolbar_action(self, q): """ Set if callbacks should be executed during a toolbar action (e.g. pan/zoom). @@ -413,78 +396,348 @@ def set_execute_during_toolbar_action(self, q): """ self._execute_while_toolbar_active = q - def _try_decorator(self, func): - @wraps(func) - def inner(*args, **kwargs): - try: - func(*args, **kwargs) - except Exception: - _log.error("problem during callback", exc_info=True) + def _execute_cb(self, layer): + """ + Get bool if a callback assigned on "layer" should be executed. - return inner + - True if the callback is assigned to the "all" layer + - True if the corresponding layer is currently active + - True if the corresponding layer is part of a currently active "multi-layer" + (e.g. "layer|layer2" or "layer|layer2{0.5}" ) - def _attach_decorator(self, func): - @wraps(func) - def inner(*args, **kwargs): - return self._add_callback( - callback=self._try_decorator(func), *args, **kwargs - ) + Parameters + ---------- + layer : str + The name of the layer to which the callback is attached. - return inner + Returns + ------- + bool + Indicator if the callback should be executed on the currently visible + layer or not. + """ + if self.execute_on_all_layers or layer == "all": + return True + return self._m._bm._layer_visible(layer) -def _apply_decorator_to_all_public_methods(decorator): - def decorate(cls): - for attr in filter(lambda x: not x.startswith("_"), dir(cls)): - if callable(getattr(cls, attr)): - setattr(cls, attr, decorator(getattr(cls, attr))) - return cls + def _check_toolbar_mode(self): + if self._execute_while_toolbar_active: + return False - return decorate + # returns True if a toolbar mode is active and False otherwise + if ( + self._m.f.canvas.toolbar is not None + ) and self._m.f.canvas.toolbar.mode != "": + return True + else: + return False + def _getobj(self, m): + """Get the equivalent callback container on another maps object.""" + return getattr(m.cb, self._method, None) -class _ClickContainer(_CallbackContainer): - """ - A container for attaching callbacks and accessing return-objects. + @property + def _objs(self): + """Get the callback-container objects associated with the event-axes.""" + # Note: it is possible that more than 1 Maps objects are + # assigned to the same axis! + objs = [] + if self._event is not None: + if hasattr(self._event, "mouseevent"): + event = self._event.mouseevent + else: + event = self._event - attach : accessor for callbacks. - Executing the functions will attach the associated callback to the map! + # make sure that "all" layer callbacks are executed before other callbacks + ms, malls = [], [] + for m in self._m._bm._children: + if m.layer == "all": + malls.append(m) + else: + ms.append(m) + ms = ms + malls - get : accessor for return-objects - A container to provide easy-access to the return-values of the callbacks. + if self._method in ["keypress"]: + for m in ms: + # always execute keypress callbacks irrespective of the mouse-pos + obj = self._getobj(m) - """ + # only include objects that are on the same layer + # (irrespective if a toolbar mode is active or not) + if ( + obj is not None + and obj._execute_cb(obj._m.layer) + # and not obj._check_toolbar_mode() + ): + objs.append(obj) + else: + for m in ms: + # don't use "is" in here since Maps-children are proxies + # (and so are their attributes)! + if event.inaxes == m.ax: + obj = self._getobj(m) + # only include objects that are on the same layer + # (and only if no toolbar mode is active!) + if ( + obj is not None + and obj._execute_cb(obj._m.layer) + and not obj._check_toolbar_mode() + ): + objs.append(obj) + return set(objs) - def __init__(self, m, cb_cls=None, method="pick", default_button=1, **kwargs): - super().__init__(m, cb_cls, method, **kwargs) + def _reset_cids(self): + # clear all temporary artists + self._clear_temporary_artists() + self._m._bm._clear_temp_artists(self._method) - # a dict to identify connected _move callbacks - # (e.g. to remove "_move" and "click" cbs in one go) - self._connected_move_cbs = dict() + # detach all attached callbacks + while len(self._cids) > 0: + name, cid = self._cids.popitem() - self._sticky_modifiers = [] + try: + self._m.f.canvas.mpl_disconnect(cid) + except Exception: + _log.warning( + "There was an issue while trying to remove {name} callback.", + exc_info=_log.getEffectiveLevel() <= logging.DEBUG, + ) - # the default button to use when attaching callbacks - self._default_button = default_button + 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.attach = self._attach(self) - self.attach = _apply_decorator_to_all_public_methods(self._attach_decorator)( - self.attach - ) + def clear_temporary_artists(self, forward=True): + """ + Clear all pending temporary artists of the associated callback method. - self.get = self._get(self) + Parameters + ---------- + forward : bool, optional + If True, also clear all temporary artists of associated events. + (e.g. "peek" events also clear "click" event artists) + The default is True. - class _attach: """ - Attach custom or pre-defined callbacks to the map. + self._clear_temporary_artists() + self._m._bm._clear_temp_artists(self._method, forward=forward) + + # def _sort_cbs(self, cbs): + # return cbs + # TODO sorting callbacks distorts the order of execution! Remove this! + # (or check why it was necessary) + + # _cb_list = self._attach._available_callbacks() + # if not cbs: + # return set() + # cbnames = set([i.rsplit("__", 1)[0].rsplit("_", 1)[0] for i in cbs]) + # sortp = _cb_list + list(set(_cb_list) ^ cbnames) + # return sorted( + # list(cbs), key=lambda w: sortp.index(w.rsplit("__", 1)[0].rsplit("_", 1)[0]) + # ) - Callback-functions accept the following additional keyword-arguments: + def _init_cbs(self): + if self._m.parent == self._m: + self._initialize_callbacks() - double_click : bool - Indicator if the callback should be executed on double-click (True) - or on single-click events (False). The default is False - button : int - The mouse-button to use for executing the callback: + def _ingest_callback(self, callback, button=None, key=None, double_click=None): + if double_click is True: + ds = "double" + elif double_click is False: + ds = "single" + elif double_click == "release": + ds = "release" + else: + ds = "any" + + # check for modifiers + button_modifier = f"{button}__{key}" + + d = ( + self._cbs.setdefault(ds, dict()) + .setdefault(str(button), dict()) + .setdefault(str(key), dict()) + ) + + # get a unique name for the callback + # name_idx__layer + ncb = [ + int(i.split("__")[0].rsplit("_", 1)[1]) + for i in d + if i.startswith(callback.__name__) + ] + cbkey = ( + callback.__name__ + + f"_{max(ncb) + 1 if len(ncb) > 0 else 0}" + + f"__{self._m.layer}" + ) + + d[cbkey] = callback + + # add mouse-button assignment as suffix to the name (with __ separator) + return cbkey + f"__{ds}__{button_modifier}" + + def _parse_cid(self, cid): + """ + Parse a callbac-id. + + Parameters + ---------- + cid : TYPE + DESCRIPTION. + + Returns + ------- + name : str + the callback name. + layer : str + the layer to which the callback is attached. + clicktype : str + indicator if "double", "single" or "any" click is used. + button : str + the mouse button (e.g. 1, 2, 3 for left, middle, right) + key : str + the keyboard key + """ + # do this to allow double-underscores in the layer-name + + name, rest = cid.split("__", 1) + layer, clicktype, button, key = rest.rsplit("__", 3) + + return name, layer, clicktype, button, key + + def remove(self, cid): + """ + Remove an attached callback based on it's callback-id (cid). + + Parameters + ---------- + cid : str + The callback id (returned when attaching a callback). + + """ + name, layer, clicktype, button, key = self._parse_cid(cid) + + cbs = self._cbs.get(clicktype, {}).get(button, {}).get(key, {}) + + if f"{name}__{layer}" in cbs: + cbs.pop(f"{name}__{layer}") + return + _log.error(f"Callback ID {cid} not found for {self._method} callbacks") + + def _execute_cbs(self, event, cids): + """ + Execute a list of callbacks based on an event and the cid + + Parameters + ---------- + event : + The event to use. + cids : list of str + A list of the cids of the callbacks that should be executed. + """ + for cid in cids: + name, layer, ds, button, mod = self._parse_cid(cid) + cbs = self._cbs.get(ds, dict()).get(f"{button}__{mod}", dict()) + cb = cbs.get(f"{name}__{layer}", None) + if cb is not None: + cb(event=event) + + def _execute_cbs_for_event(self, event, dblclick=None, key=None, button=None): + """ + Execute all callbacks relevant for the given event. + + Parameters + ---------- + event : + The event to use. + """ + # add the method name that triggered the callback + # (so we can access the container if necessary) + event._method = self._method + # remember event + # TODO this can be removed since event is now passed to the callbacks + self._event = event + double_click = format(getattr(event, "dblclick", None)) + key = format(getattr(event, "key", None)) + button = format(getattr(event, "button", None)) + + # get callbacks to execute based on single/double click property + cb_keys = ["any"] + if double_click is True: + cb_keys.append("double") + elif double_click is False: + cb_keys.append("single") + + # check for keypress-modifiers + if key is None: + if self._m.cb.keypress._modifier in self._sticky_modifiers: + # in case sticky_modifiers are defined, use the last pressed modifier + key = self._m.cb.keypress._modifier + + for cb_key in cb_keys: + cbs = self._cbs.get(cb_key, None) + if cbs is None: + continue + # get all methods assigned to the pressed button + bcbs = cbs.get(button, {}) + + # get all methods assigned to the pressed key + kcbs = [bcbs.get(key, {})] + # keypress callbacks attached with key=None are executed on "any key" + if event._method == "keypress": + kcbs.append(bcbs.get("None", {})) + + # execute callbacks + for kcb in kcbs: + for cbname, cb in kcb.items(): + layer = cbname.split("__", 1)[1] + if not self._execute_cb(layer): + continue + + cb(event=event) + + +class _MouseCallbackContainer(_CallbackContainerBase): + """ + A container for attaching callbacks and accessing return-objects. + + attach : accessor for callbacks. + Executing the functions will attach the associated callback to the map! + + get : accessor for return-objects + A container to provide easy-access to the return-values of the callbacks. + + """ + + def __init__(self, m, method="click", default_button=1, **kwargs): + super().__init__(m, method, **kwargs) + + # a dict to identify connected _move callbacks + # (e.g. to remove "_move" and "click" cbs in one go) + self._connected_move_cbs = dict() + + self._sticky_modifiers = [] + + # the default button to use when attaching callbacks + self._default_button = default_button + + self.attach = self._attach(self) + + class _attach(_CallbackContainerBase._attach): + """ + Attach custom or pre-defined callbacks to the map. + + Callback-functions accept the following additional keyword-arguments: + + double_click : bool or None + Indicator if the callback should be executed on double-click (True) + or on single-click events (False) or both (None). + The default is None + button : int + The mouse-button to use for executing the callback: - LEFT = 1 - MIDDLE = 2 @@ -534,12 +787,7 @@ class _attach: """ - def __init__(self, parent): - self._parent = parent - self.m = parent._m - self._temporary_artists = self._parent._temporary_artists - - def __call__(self, f, double_click=False, button=None, modifier=None, **kwargs): + def __call__(self, f, double_click=None, button=None, modifier=None, **kwargs): """ Add a custom callback-function to the map. @@ -557,9 +805,10 @@ def __call__(self, f, double_click=False, button=None, modifier=None, **kwargs): >>> >>> m.cb.attach(some_callback, asdf=1) - double_click : bool + double_click : bool or None Indicator if the callback should be executed on double-click (True) - or on single-click events (False) + or on single-click events (False) or both (None) + The default is None button : int The mouse-button to use for executing the callback: @@ -606,104 +855,7 @@ def __call__(self, f, double_click=False, button=None, modifier=None, **kwargs): **kwargs, ) - @classmethod - def _available_callbacks(cls): - return list(filter(lambda x: not x.startswith("_"), dir(cls))) - - class _get: - """Accessor for objects generated/retrieved by callbacks.""" - - def __init__(self, parent): - self._parent = parent - self.m = self._parent._m - - self.cbs = dict() - - @property - def picked_object(self): - """Get the most recent picked object.""" - if hasattr(self._parent.attach, "picked_object"): - return self._parent.attach.picked_object - else: - _log.warning( - "EOmaps: No picked objects found. Attach " - "the 'load' callback first!" - ) - - @property - def picked_vals(self): - """Get a list of all picked values.""" - if hasattr(self._parent.attach, "picked_vals"): - return self._parent.attach.picked_vals - else: - _log.warning( - "EOmaps: No picked values found. Attach " - "the 'get_values' callback first!" - ) - - @property - def permanent_markers(self): - """Get a list of all permanent markers.""" - if hasattr(self._parent.attach, "permanent_markers"): - return self._parent.attach.permanent_markers - else: - _log.warning( - "EOmaps: No permanent markers found. Attach " - "the 'mark' callback with 'permanent=True' first!" - ) - - @property - def permanent_annotations(self): - """Get a list of all permanent annotations.""" - if hasattr(self._parent.attach, "permanent_annotations"): - return self._parent.attach.permanent_annotations - else: - _log.warning( - "EOmaps: No permanent annotations found. Attach " - "the 'annotate' callback with 'permanent=True' first!" - ) - - @property - def attached_callbacks(self): - """Get a list of all IDs of attached callbacks.""" - cbs = [] - for ds, dsdict in self.cbs.items(): - for b, bdict in dsdict.items(): - for name in bdict.keys(): - cbs.append(f"{name}__{ds}__{b}") - - return cbs - - def _parse_cid(self, cid): - """ - Parse a callbac-id. - - Parameters - ---------- - cid : TYPE - DESCRIPTION. - - Returns - ------- - name : str - the callback name. - layer : str - the layer to which the callback is attached. - ds : str - indicator if double- or single-click is used. - b : str - the button (e.g. 1, 2, 3 for left, middle, right) - m : str - the keypress modifier. - """ - # do this to allow double-underscores in the layer-name - - name, rest = cid.split("__", 1) - layer, ds, b, m = rest.rsplit("__", 3) - - return name, layer, ds, b, m - - def remove(self, callback=None): + def remove(self, cid): """ Remove previously attached callbacks from the map. @@ -716,38 +868,12 @@ def remove(self, callback=None): """ # remove motion callbacks connected to click-callbacks if self._method == "click": - if callback in self._connected_move_cbs: - for i in self._connected_move_cbs[callback]: + if cid in self._connected_move_cbs: + for i in self._connected_move_cbs[cid]: self._m.cb._click_move.remove(i) - self._connected_move_cbs.pop(callback) + self._connected_move_cbs.pop(cid) - if callback is not None: - name, layer, ds, b, m = self._parse_cid(callback) - - cbname = name + "__" + layer - bname = f"{b}__{m}" - dsdict = self.get.cbs.get(ds, None) - - if dsdict is not None: - if bname in dsdict: - bdict = dsdict.get(bname) - else: - _log.error(f"EOmaps: There is no callback named {callback}") - return - else: - _log.error(f"EOmaps: There is no callback named {callback}") - return - - if bdict is not None: - if cbname in bdict: - del bdict[cbname] - - # call cleanup methods on removal - fname = name.rsplit("_", 1)[0] - if hasattr(self._attach, f"_{fname}_cleanup"): - getattr(self._attach, f"_{fname}_cleanup")(self.attach) - else: - _log.error(f"EOmaps: There is no callback named {callback}") + super().remove(cid) def set_sticky_modifiers(self, *args): """ @@ -782,31 +908,11 @@ def set_sticky_modifiers(self, *args): if self._method == "click": self._m.cb._click_move._sticky_modifiers = args - def _init_picker(self): - assert ( - self._m.coll is not None - ), "you can only attach pick-callbacks after calling `plot_map()`!" - - try: - # Lazily make a plotted dataset pickable a - if getattr(self._m, "tree", None) is None: - from .helpers import SearchTree - - self._m.tree = SearchTree(m=self._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") - except Exception: - _log.exception( - "EOmaps: There was an error while trying to initialize " - "pick-callbacks!", - ) - def _add_callback( self, *args, callback=None, - double_click=False, + double_click=None, button=None, modifier=None, on_motion=None, @@ -892,13 +998,6 @@ def _add_callback( if button is None: button = self._default_button - if self._method == "pick": - if self._m.coll is None: - # lazily initialize the picker when the layer is fetched - self._m._data_manager._on_next_fetch.append(self._init_picker) - else: - self._init_picker() - cb_name = callback if isinstance(callback, str) else callback.__name__ # attach "on_move" callbacks movecb_name = None @@ -949,43 +1048,174 @@ def _add_callback( ) callback = getattr(self._attach, callback) - if double_click is True: - btn_key = "double" - elif double_click == "release": - btn_key = "release" + cbname = self._ingest_callback( + update_wrapper(partial(callback, *args, **kwargs), callback), + button=button, + key=modifier, + double_click=double_click, + ) + + if movecb_name is not None: + self._connected_move_cbs[cbname] = [movecb_name] + + return cbname + + def _fwd_cb(self, event): + # click container events are MouseEvents! + if event.inaxes != self._m.ax: + return + + for key, m in self._fwd_cbs.items(): + obj = self._getobj(m) + # clear all temporary artists that are still around + obj.clear_temporary_artists() + if obj is None: + continue + + transformer = Transformer.from_crs( + self._m.crs_plot, + m.crs_plot, + always_xy=True, + ) + + # transform the coordinates of the clicked location + xdata, ydata = transformer.transform(event.xdata, event.ydata) + + dummymouseevent = SimpleNamespace( + inaxes=m.ax, + dblclick=event.dblclick, + button=event.button, + xdata=xdata, + ydata=ydata, + key=event.key, + name=event.name, + # x=event.mouseevent.x, + # y=event.mouseevent.y, + ) + + obj._execute_cbs_for_event(dummymouseevent) + + +class _PickCallbackContainer(_MouseCallbackContainer): + def _init_cbs(self): + # Pick callbacks must be added to each map individually + # (not just the parent) so they can pick the right dataset! + self._initialize_callbacks() + + def _add_callback(self, *args, **kwargs): + if self._m.coll is None: + # lazily initialize the picker when the layer is fetched + self._m._data_manager._on_next_fetch.append(self._init_picker) else: - btn_key = "single" + self._init_picker() - # check for modifiers - button_modifier = f"{button}__{modifier}" + return super()._add_callback(*args, **kwargs) - d = self.get.cbs.setdefault(btn_key, dict()).setdefault(button_modifier, dict()) + def _init_picker(self): + assert ( + self._m.coll is not None + ), "you can only attach pick-callbacks after calling `plot_map()`!" - # get a unique name for the callback - # name_idx__layer - ncb = [ - int(i.split("__")[0].rsplit("_", 1)[1]) - for i in d - if i.startswith(callback.__name__) - ] - cbkey = ( - callback.__name__ - + f"_{max(ncb) + 1 if len(ncb) > 0 else 0}" - + f"__{self._m.layer}" + try: + # Lazily make a plotted dataset pickable a + if getattr(self._m, "tree", None) is None: + from .helpers import SearchTree + + 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") + except Exception: + _log.exception( + "EOmaps: There was an error while trying to initialize " + "pick-callbacks!", + ) + + def _default_picker(self, artist, event): + # make sure that objects are only picked if we are on the right layer + if not self._execute_cb(self._m.layer): + return False, None + + try: + # if no pick-callback is attached, don't identify the picked point + if len(self._cbs) == 0: + return False, None + except ReferenceError: + # in case we encounter a reference-error, remove the picker from the artist + # (happens if the picker originates from a no-longer existing Maps object) + self._artist.set_picker(None) + return False, None + + if (event.inaxes != self._m.ax) or not hasattr(self._m, "tree"): + return False, dict(ind=None, dblclick=event.dblclick, button=event.button) + + # make sure non-finite coordinates (resulting from projections in + # forwarded callbacks) don't lead to issues + if not np.isfinite((event.xdata, event.ydata)).all(): + return False, dict(ind=None, dblclick=event.dblclick, button=event.button) + + # update the search-radius if necessary + # (do this here to allow setting a multiplier for the dataset-radius + # without having to plot it first!) + if self._search_radius != self._m.tree._search_radius: + self._m.tree.set_search_radius(self._search_radius) + + # find the closest point to the clicked pixel + index = self._m.tree.query( + (event.xdata, event.ydata), + k=self._n_ids, + pick_relative_to_closest=self._pick_relative_to_closest, ) - d[cbkey] = partial(callback, *args, **kwargs) + if index is not None: + pos = self._m._data_manager._get_xy_from_index(index, reprojected=True) + # decode values in case a encoding is provided + val = self._m._decode_values( + self._m._data_manager._get_val_from_index(index) + ) + ID = self._m._data_manager._get_id_from_index(index) - # add mouse-button assignment as suffix to the name (with __ separator) - cbname = cbkey + f"__{btn_key}__{button}__{modifier}" # TODO + try: + val_color = artist.cmap(artist.norm(val)) + except Exception: + val_color = None - if movecb_name is not None: - self._connected_move_cbs[cbname] = [movecb_name] + return True, dict( + dblclick=event.dblclick, + button=event.button, + ind=index, + ID=ID, + pos=pos, + val=val, + val_color=val_color, + ) + else: + # do this to "unpick" previously picked datapoints if you click + # outside the data-extent + return True, dict(ind=None, dblclick=event.dblclick, button=event.button) - return cbname + return False, None + + def _set_artist(self, artist): + # use a weakref-proxy to make sure the artist can be garbage-collected + # if it is deleted (or if the figure is closed) + self._artist = proxy(artist) + self._artist.set_picker(self._picker) + + def _artist_picked(self, event): + # use == instead of "is" here since self._artist is a weakref proxy! + if self._artist == event.artist: + return True + else: + # handle contour-plot artists explicitly + if self._artist.__class__.__name__ == "_CollectionAccessor": + if any(i is event.artist for i in self._artist.collections): + return True + else: + return False -class ClickContainer(_ClickContainer): +class ClickContainer(_MouseCallbackContainer): """ Callbacks that are executed if you click anywhere on the Map. @@ -999,9 +1229,6 @@ class ClickContainer(_ClickContainer): attach : accessor for callbacks. Executing the functions will attach the associated callback to the map! - get : accessor for return-objects - A container to provide easy-access to the return-values of the callbacks. - remove : remove prviously added callbacks from the map forward_events : forward events to connected maps-objects @@ -1015,237 +1242,135 @@ class ClickContainer(_ClickContainer): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self._cid_button_press_event = None - self._cid_button_release_event = None - self._cid_motion_event = None - + # dict of callback IDs self._event = None - class _attach(_ClickContainer._attach, ClickCallbacks): - __doc__ = _ClickContainer._attach.__doc__ - pass + class _attach(_MouseCallbackContainer._attach): + _popargs = _CallbackMixin._popargs + _get_annotation_text = _CallbackMixin._get_annotation_text + _get_clip_path = _CallbackMixin._get_clip_path - class _get(_ClickContainer._get): - __doc__ = _ClickContainer._get.__doc__ - pass + annotate = _CallbackMixin.annotate + mark = _CallbackMixin.mark + peek_layer = _CallbackMixin.peek_layer + print_to_console = _CallbackMixin.print_to_console + clear_annotations = _CallbackMixin.clear_annotations + clear_markers = _CallbackMixin.clear_markers # to make namespace accessible for sphinx attach = _attach - get = _get - def _init_cbs(self): - if self._m.parent is self._m: - self._add_click_callback() - - def _get_clickdict(self, event): - clickdict = dict( - pos=(event.xdata, event.ydata), - ID=None, - val=None, - ind=None, - ) + def _initialize_callbacks(self): + def clickcb(event): + self._m._bm.run_hook(f"before_callback_{self._method}_event") + if not self._m.execute_callbacks and not self._method == "_always_active": + return - return clickdict + try: + self._event = event + for obj in self._objs: + # 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) - def _execute_cbs(self, event, cids): - """ - Execute a list of callbacks based on an event and the cid + # execute onclick on the maps object that belongs to the clicked axis + # and forward the event to all forwarded maps-objects + for obj in self._objs: + obj._execute_cbs_for_event(event) - Parameters - ---------- - event : - The event to use. - cids : list of str - A list of the cids of the callbacks that should be executed. + # forward callbacks to the connected maps-objects + obj._fwd_cb(event) - """ - clickdict = self._get_clickdict(event) + self._m._bm.update() - for cid in cids: - name, layer, ds, button, mod = self._parse_cid(cid) - cbs = self.get.cbs.get(ds, dict()).get(f"{button}__{mod}", dict()) - cb = cbs.get(f"{name}__{layer}", None) - if cb is not None: - cb(**clickdict) + except ReferenceError: + pass - def _onclick(self, event): - clickdict = self._get_clickdict(event) + self._m._bm.run_hook(f"after_callback_{self._method}_event") - if event.dblclick: - cbs = self.get.cbs.get("double", dict()) - else: - cbs = self.get.cbs.get("single", dict()) + if self._cids.get("button_press", None) is None: + # ------------- add a callback + self._cids["button_press"] = self._m.f.canvas.mpl_connect( + "button_press_event", clickcb + ) - # check for keypress-modifiers - if ( - event.key is None - and self._m.cb.keypress._modifier in self._sticky_modifiers - ): - # in case sticky_modifiers are defined, use the last pressed modifier - event_key = self._m.cb.keypress._modifier - else: - event_key = event.key - button_modifier = f"{event.button}__{event_key}" +class ReleaseContainer(_MouseCallbackContainer): + """ + Callbacks that are executed if you release the mouse anywhere on the Map. + - self._event = event + Methods + ------- + attach : accessor for callbacks. + Executing the functions will attach the associated callback to the map! - if button_modifier in cbs: - bcbs = cbs[button_modifier] + remove : remove prviously added callbacks from the map - for key in self._sort_cbs(bcbs): - layer = key.split("__", 1)[1] - if not self._execute_cb(layer): - return + forward_events : forward events to connected maps-objects - cb = bcbs[key] - if clickdict is not None: - cb(**clickdict) + share_events : share events between connected maps-objects (e.g. forward both ways) - def _onrelease(self, event): - cbs = self.get.cbs.get("release", dict()) + set_sticky_modifiers : define keypress-modifiers that remain active after release - # check for keypress-modifiers - if ( - event.key is None - and self._m.cb.keypress._modifier in self._sticky_modifiers - ): - # in case sticky_modifiers are defined, use the last pressed modifier - event_key = self._m.cb.keypress._modifier - else: - event_key = event.key + """ - button_modifier = f"{event.button}__{event_key}" + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) - if button_modifier in cbs: - clickdict = self._get_clickdict(event) - bcbs = cbs[button_modifier] - for cb in bcbs.values(): - cb(**clickdict) + # dict of callback IDs + self._event = None - def _reset_cids(self): - # clear all temporary artists - self._clear_temporary_artists() - self._m.BM._clear_temp_artists(self._method) + class _attach(_MouseCallbackContainer._attach): + _popargs = _CallbackMixin._popargs + _get_annotation_text = _CallbackMixin._get_annotation_text + _get_clip_path = _CallbackMixin._get_clip_path - if self._cid_button_press_event: - self._m.f.canvas.mpl_disconnect(self._cid_button_press_event) - self._cid_button_press_event = None + annotate = _CallbackMixin.annotate + mark = _CallbackMixin.mark + peek_layer = _CallbackMixin.peek_layer + print_to_console = _CallbackMixin.print_to_console + clear_annotations = _CallbackMixin.clear_annotations + clear_markers = _CallbackMixin.clear_markers - if self._cid_motion_event: - self._m.f.canvas.mpl_disconnect(self._cid_motion_event) - self._cid_motion_event = None + # to make namespace accessible for sphinx + attach = _attach - if self._cid_button_release_event: - self._m.f.canvas.mpl_disconnect(self._cid_button_release_event) - self._cid_button_release_event = None + def _initialize_callbacks(self): + def releasecb(event): + self._m._bm.run_hook(f"before_callback_{self._method}_event") - def _add_click_callback(self): - def clickcb(event): - if ( - not self._m.cb.get_execute_callbacks() - and not self._method == "_always_active" - ): + if not self._m.execute_callbacks and not self._method == "_always_active": return try: self._event = event - - # execute onclick on the maps object that belongs to the clicked axis - # and forward the event to all forwarded maps-objects for obj in self._objs: # clear temporary artists before executing new callbacks to avoid # having old artists around when callbacks are triggered again obj._clear_temporary_artists() - obj._onclick(event) - - # forward callbacks to the connected maps-objects - obj._fwd_cb(event) - - self._m.BM._clear_temp_artists(self._method) - - self._m.parent.BM.update(clear=self._method) - except ReferenceError: - pass - - def releasecb(event): - if ( - not self._m.cb.get_execute_callbacks() - and not self._method == "_always_active" - ): - return - - try: - self._event = event + self._m._bm._clear_temp_artists(self._method) # execute onclick on the maps object that belongs to the clicked axis # and forward the event to all forwarded maps-objects for obj in self._objs: - # don't clear temporary artists in here since we want - # click (or click+move) artists to remain on the plot when the - # button is released! - obj._onrelease(event) + obj._execute_cbs_for_event(event) # forward callbacks to the connected maps-objects obj._fwd_cb(event) + self._m.parent._bm.update() except ReferenceError: - # ignore errors caused by no-longer existing weakrefs pass - if self._cid_button_press_event is None: - # ------------- add a callback - self._cid_button_press_event = self._m.f.canvas.mpl_connect( - "button_press_event", clickcb - ) + self._m._bm.run_hook(f"after_callback_{self._method}_event") - if self._cid_button_release_event is None: + if self._cids.get("button_release", None) is None: # ------------- add a callback - self._cid_button_release_event = self._m.f.canvas.mpl_connect( + self._cids["button_release"] = self._m.f.canvas.mpl_connect( "button_release_event", releasecb ) - def _fwd_cb(self, event): - # click container events are MouseEvents! - if event.inaxes != self._m.ax: - return - - if event.name == "button_release_event": - for key, m in self._fwd_cbs.items(): - obj = self._getobj(m) - if obj is None: - continue - obj._onrelease(event) - - else: - for key, m in self._fwd_cbs.items(): - obj = self._getobj(m) - # clear all temporary artists that are still around - obj._clear_temporary_artists() - if obj is None: - continue - - transformer = Transformer.from_crs( - self._m.crs_plot, - m.crs_plot, - always_xy=True, - ) - - # transform the coordinates of the clicked location - xdata, ydata = transformer.transform(event.xdata, event.ydata) - - dummymouseevent = SimpleNamespace( - inaxes=m.ax, - dblclick=event.dblclick, - button=event.button, - xdata=xdata, - ydata=ydata, - key=event.key - # x=event.mouseevent.x, - # y=event.mouseevent.y, - ) - - obj._onclick(dummymouseevent) - class MoveContainer(ClickContainer): """ @@ -1271,103 +1396,73 @@ class MoveContainer(ClickContainer): # this is just a copy of ClickContainer to manage motion-sensitive callbacks - def __init__(self, button_down=False, *args, **kwargs): - - super().__init__(*args, **kwargs) - - self._cid_motion_event = None - - self._button_down = button_down - - class _attach(_ClickContainer._attach, MoveCallbacks): - __doc__ = _ClickContainer._attach.__doc__ - pass - - class _get(_ClickContainer._get): - __doc__ = _ClickContainer._get.__doc__ - pass - - # to make namespace accessible for sphinx - attach = _attach - get = _get - - def _init_cbs(self): - if self._m.parent is self._m: - self._add_move_callback() + def __init__(self, button_down=False, *args, **kwargs): - def _reset_cids(self): - # clear all temporary artists - self._clear_temporary_artists() - self._m.BM._clear_temp_artists(self._method) + super().__init__(*args, **kwargs) - if self._cid_motion_event: - self._m.f.canvas.mpl_disconnect(self._cid_motion_event) - self._cid_motion_event = None + self._button_down = button_down - def _add_move_callback(self): + def _initialize_callbacks(self): def movecb(event): - if not self._m.cb.get_execute_callbacks(): + button_q = self._button_down == (event.button is None) + + self._m._bm.run_hook(f"before_callback_{self._method}_event") + if self._method == "_click_move" and not button_q: + self._m._bm.run_hook("before_callback_click_event") + + if not self._m.execute_callbacks: return try: self._event = event - # only execute movecb if a mouse-button is held down - # and only if the motion is happening inside the axes - if self._button_down: - if not event.button: # or (event.inaxes != self._m.ax): - # always clear temporary move-artists - if self._method == "move": - for obj in self._objs: - obj._clear_temporary_artists() - self._m.BM._clear_temp_artists(self._method) - return - else: - if event.button: # or (event.inaxes != self._m.ax): - # always clear temporary move-artists - if self._method == "move": - for obj in self._objs: - obj._clear_temporary_artists() - self._m.BM._clear_temp_artists(self._method) - return - # execute onclick on the maps object that belongs to the clicked axis - # and forward the event to all forwarded maps-objects + if button_q: + # clear temporary move-artists (but keep _click_move artists) + # in case button_down is not fulfilled + if self._method == "move": + for obj in self._objs: + obj._clear_temporary_artists() + self._m._bm._clear_temp_artists(self._method) + return + call_update = False for obj in self._objs: # check if there is a reason to update (e.g. an attached callback) - if call_update is False: - if len(obj.get.attached_callbacks) > 0: - call_update = True + # do this also to avoid clearing click artists that do not assign a click-move + if len(obj.attached_callbacks) > 0: + obj._clear_temporary_artists() + call_update = True - # 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) - obj._onclick(event) + self._m._bm._clear_temp_artists(self._method) + # execute onclick on the maps object that belongs to the clicked axis + # and forward the event to all forwarded maps-objects + call_update = True + for obj in self._objs: + obj._execute_cbs_for_event(event) # forward callbacks to the connected maps-objects obj._fwd_cb(event) # only update if a callback is attached # (to avoid lag in webagg backed due to slow updates) if call_update: - if self._button_down: - if event.button: - self._m.parent.BM.update(clear=self._method) - else: - self._m.parent.BM.update(clear=self._method) + self._m.parent._bm.update() except ReferenceError: pass - if self._cid_motion_event is None: + self._m._bm.run_hook(f"after_callback_{self._method}_event") + if self._method == "_click_move" and button_q: + self._m._bm.run_hook("after_callback_click_event") + + if self._cids.get("motion_notify", None) is None: # for click-callbacks, allow motion-detection - self._cid_motion_event = self._m.f.canvas.mpl_connect( + self._cids["motion_notify"] = self._m.f.canvas.mpl_connect( "motion_notify_event", movecb ) -class PickContainer(_ClickContainer): +class PickContainer(_PickCallbackContainer): """ Callbacks that select the nearest datapoint if you click on the map. @@ -1405,7 +1500,6 @@ class PickContainer(_ClickContainer): def __init__(self, picker_name="default", picker=None, *args, **kwargs): super().__init__(*args, **kwargs) - self._cid_pick_event = dict() self._picker_name = picker_name self._artist = None @@ -1423,17 +1517,19 @@ def __init__(self, picker_name="default", picker=None, *args, **kwargs): # indicator how shared pick-events identify the relevant datapoint self._ensure_same_pick_id = False - class _attach(_ClickContainer._attach, PickCallbacks): - __doc__ = _ClickContainer._attach.__doc__ - pass + class _attach(_MouseCallbackContainer._attach): + _popargs = _CallbackMixin._popargs + _get_annotation_text = _CallbackMixin._get_annotation_text - class _get(_ClickContainer._get): - __doc__ = _ClickContainer._get.__doc__ - pass + annotate = _CallbackMixin.annotate + mark = _CallbackMixin.mark + print_to_console = _CallbackMixin.print_to_console + highlight_geometry = _CallbackMixin.highlight_geometry + clear_annotations = _CallbackMixin.clear_annotations + clear_markers = _CallbackMixin.clear_markers # to make namespace accessible for sphinx attach = _attach - get = _get def __getitem__(self, name): name = str(name) @@ -1449,38 +1545,6 @@ def __getitem__(self, name): f"the picker {name} does not exist...", "use `m.cb.add_picker` first!" ) - def share_events(self, *args, ensure_same_id=False): - """ - Share callback-events between this Maps-object and other Maps-objects. - - (e.g. share events both ways) - - Note - ---- - For **pick-events**, you can use the additional keyword-argument - `ensure_same_id` to make sure that all events pick the exact same ID. - - Parameters - ---------- - args : eomaps.Maps - The Maps-objects that should execute the callback. - - ensure_same_id : bool - If True, all pick-events are triggered by the same ID value. - (e.g. the ID of the datapoint that was actually picked) - - If False, each map executes the pick-event with respect to the reprojected - mouse-position and identifies the closest datapoint to use. - - The default is False - """ - self._ensure_same_pick_id = ensure_same_id - - for m in args: - self._getobj(m)._ensure_same_pick_id = ensure_same_id - - super().share_events(*args) - def set_props( self, n=None, @@ -1541,134 +1605,37 @@ def set_props( if search_radius is not None: self._search_radius = search_radius - def _set_artist(self, artist): - # use a weakref-proxy to make sure the artist can be garbage-collected - # if it is deleted (or if the figure is closed) - self._artist = proxy(artist) - self._artist.set_picker(self._picker) - - def _init_cbs(self): - # if self._m.parent is self._m: - self._add_pick_callback() - - def _default_picker(self, artist, event): - # make sure that objects are only picked if we are on the right layer - if not self._execute_cb(self._m.layer): - return False, None - - try: - # if no pick-callback is attached, don't identify the picked point - if len(self.get.cbs) == 0: - return False, None - except ReferenceError: - # in case we encounter a reference-error, remove the picker from the artist - # (happens if the picker originates from a no-longer existing Maps object) - self._artist.set_picker(None) - return False, None - - if (event.inaxes != self._m.ax) or not hasattr(self._m, "tree"): - return False, dict(ind=None, dblclick=event.dblclick, button=event.button) - - # make sure non-finite coordinates (resulting from projections in - # forwarded callbacks) don't lead to issues - if not np.isfinite((event.xdata, event.ydata)).all(): - return False, dict(ind=None, dblclick=event.dblclick, button=event.button) - - # update the search-radius if necessary - # (do this here to allow setting a multiplier for the dataset-radius - # without having to plot it first!) - if self._search_radius != self._m.tree._search_radius: - self._m.tree.set_search_radius(self._search_radius) - - # find the closest point to the clicked pixel - index = self._m.tree.query( - (event.xdata, event.ydata), - k=self._n_ids, - pick_relative_to_closest=self._pick_relative_to_closest, - ) + def share_events(self, *args, ensure_same_id=False): + """ + Share callback-events between this Maps-object and other Maps-objects. - if index is not None: - pos = self._m._data_manager._get_xy_from_index(index, reprojected=True) - val = self._m._data_manager._get_val_from_index(index) - ID = self._m._data_manager._get_id_from_index(index) + (e.g. share events both ways) - try: - val_color = artist.cmap(artist.norm(val)) - except Exception: - val_color = None + Note + ---- + For **pick-events**, you can use the additional keyword-argument + `ensure_same_id` to make sure that all events pick the exact same ID. - return True, dict( - dblclick=event.dblclick, - button=event.button, - ind=index, - ID=ID, - pos=pos, - val=val, - val_color=val_color, - ) - else: - # do this to "unpick" previously picked datapoints if you click - # outside the data-extent - return True, dict(ind=None, dblclick=event.dblclick, button=event.button) + Parameters + ---------- + args : eomaps.Maps + The Maps-objects that should execute the callback. - return False, None + ensure_same_id : bool + If True, all pick-events are triggered by the same ID value. + (e.g. the ID of the datapoint that was actually picked) - def _get_pickdict(self, event): - event_ind = getattr(event, "ind", None) - if event_ind is None: - if _log.getEffectiveLevel() <= logging.DEBUG: - _log.debug(f"Pick-event without index encountered: {event}") - return + If False, each map executes the pick-event with respect to the reprojected + mouse-position and identifies the closest datapoint to use. - n_inds = len(np.atleast_1d(event_ind)) - # mouseevent = event.mouseevent - noval = [None] * n_inds if n_inds > 1 else None - - ID = getattr(event, "ID", noval) - pos = getattr(event, "pos", noval) - val = getattr(event, "val", noval) - ind = getattr(event, "ind", noval) - val_color = getattr(event, "val_color", noval) - - if ind is not None: - if self._consecutive_multipick is False: - # return all picked values as arrays - clickdict = dict( - ID=ID, # convert IDs to numpy-arrays! - pos=pos, - val=val, - ind=ind, - val_color=val_color, - picker_name=self._picker_name, - ) + The default is False + """ + self._ensure_same_pick_id = ensure_same_id - return clickdict - else: - if n_inds > 1: - clickdicts = [] - for i in range(n_inds): - clickdict = dict( - ID=ID[i], - pos=(pos[0][i], pos[1][i]), - val=val[i], - ind=ind[i], - val_color=val_color[i], - picker_name=self._picker_name, - ) - clickdicts.append(clickdict) - else: - clickdicts = [ - dict( - ID=ID, # convert IDs to numpy-arrays! - pos=pos, - val=val, - ind=ind, - val_color=val_color, - picker_name=self._picker_name, - ) - ] + for m in args: + self._getobj(m)._ensure_same_pick_id = ensure_same_id - return clickdicts + super().share_events(*args) def _onpick(self, event): if not self._artist_picked(event): @@ -1679,77 +1646,28 @@ def _onpick(self, event): if not self._execute_cb(self._m.layer): return + event.picker_name = self._picker_name + event.button = event.mouseevent.button + event.dblclick = event.mouseevent.dblclick + event.key = event.mouseevent.key + # 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) - - clickdict = self._get_pickdict(event) + self._m._bm._clear_temp_artists(self._method) # if no data was found, don't execute the callbacks - if clickdict is None: + if getattr(event, "ind", None) is None: return - if event.mouseevent.dblclick: - cbs = self.get.cbs.get("double", dict()) - else: - cbs = self.get.cbs.get("single", dict()) - - # check for keypress-modifiers - if ( - event.mouseevent.key is None - and self._m.cb.keypress._modifier in self._sticky_modifiers - ): - # in case sticky_modifiers are defined, use the last pressed modifier - event_key = self._m.cb.keypress._modifier - else: - event_key = event.mouseevent.key - - button_modifier = f"{event.mouseevent.button}__{event_key}" - - if button_modifier in cbs: - bcbs = cbs[button_modifier] - - for key in self._sort_cbs(bcbs): - layer = key.split("__", 1)[1] - if not self._execute_cb(layer): - # only execute callbacks if the layer name of the associated - # maps-object is active - return - - cb = bcbs[key] - if self._consecutive_multipick is False: - cb(**clickdict) - else: - for c in clickdict: - cb(**c) - - def _reset_cids(self): - # clear all temporary artists - self._clear_temporary_artists() - self._m.BM._clear_temp_artists(self._method) - - for method, cid in self._cid_pick_event.items(): - self._m.f.canvas.mpl_disconnect(cid) - self._cid_pick_event.clear() - - def _artist_picked(self, event): - # use == instead of "is" here since self._artist is a weakref proxy! - if self._artist == event.artist: - return True - else: - # handle contour-plot artists explicitly - if self._artist.__class__.__name__ == "_CollectionAccessor": - if any(i is event.artist for i in self._artist.collections): - return True - else: - return False + self._execute_cbs_for_event(event) - def _add_pick_callback(self): + def _initialize_callbacks(self): # execute onpick and forward the event to all connected Maps-objects - def pickcb(event): - if not self._m.cb.get_execute_callbacks(): + self._m._bm.run_hook(f"before_callback_{self._method}_event") + + if not self._m.execute_callbacks: return try: @@ -1772,9 +1690,11 @@ def pickcb(event): except ReferenceError: pass + self._m._bm.run_hook(f"after_callback_{self._method}_event") + # attach the callbacks (only once per method!) - if self._method not in self._cid_pick_event: - self._cid_pick_event[self._method] = self._m.f.canvas.mpl_connect( + if self._cids.get(f"pick_{self._method}", None) is None: + self._cids[f"pick_{self._method}"] = self._m.f.canvas.mpl_connect( "pick_event", pickcb ) @@ -1806,6 +1726,7 @@ def _fwd_cb(self, event, picker_name): xdata=xdata, ydata=ydata, key=event.mouseevent.key, + name=event.name, # x=event.mouseevent.x, # y=event.mouseevent.y, ) @@ -1815,6 +1736,7 @@ def _fwd_cb(self, event, picker_name): button=event.mouseevent.button, # inaxes=m.ax, mouseevent=dummymouseevent, + name=event.name, # picker_name=picker_name, ) @@ -1860,7 +1782,7 @@ def _fwd_cb(self, event, picker_name): obj._onpick(dummyevent) -class KeypressContainer(_CallbackContainer): +class KeypressContainer(_CallbackContainerBase): """ Callbacks that are executed if you press a key on the keyboard. @@ -1882,37 +1804,17 @@ class KeypressContainer(_CallbackContainer): """ - def __init__(self, m, cb_cls=None, method="keypress"): - super().__init__(m, cb_cls, method) - - self._cid_keypress_event = None + def __init__(self, m, method="keypress"): + super().__init__(m, method) # remember last pressed key (for use as "sticky_modifier") self._modifier = None self.attach = self._attach(self) - self.attach = _apply_decorator_to_all_public_methods(self._attach_decorator)( - self.attach - ) - - self.get = self._get(self) - - def _init_cbs(self): - if self._m.parent is 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) - - if self._cid_keypress_event: - self._m.f.canvas.mpl_disconnect(self._cid_keypress_event) - self._cid_keypress_event = None def _initialize_callbacks(self): def _onpress(event): - if not self._m.cb.get_execute_callbacks(): + if not self._m.execute_callbacks: return try: @@ -1946,35 +1848,20 @@ def _onpress(event): self._modifier = k for obj in self._objs: - # only trigger callbacks on the right layer - if not self._execute_cb(obj._m.layer): - continue - if any(i in obj.get.cbs for i in (event.key, None)): - # do this to allow deleting callbacks with a callback - # otherwise modifying a dict during iteration is problematic! - cbs = { - **obj.get.cbs.get(event.key, dict()), - **obj.get.cbs.get(None, dict()), - } - - names = list(cbs) - for name in names: - if name in cbs: - cbs[name](key=event.key) - - # DO NOT UPDATE in here! - # otherwise keypress modifiers for peek-layer callbacks will - # have glitches! - # self._m.parent.BM.update(clear=self._method) + obj._execute_cbs_for_event(event) + + # do not update to avoid glitches for keypress modifiers + # used with peek-layer callbacks + # self._m.parent._bm.update(clear=self._method) except ReferenceError: pass - if self._m is self._m.parent: - self._cid_keypress_event = self._m.f.canvas.mpl_connect( + if self._cids.get("keypress", None) is None: + self._cids["keypress"] = self._m.f.canvas.mpl_connect( "key_press_event", _onpress ) - class _attach(KeypressCallbacks): + class _attach(_CallbackContainerBase._attach): """ Attach custom or pre-defined callbacks on keypress events. @@ -2003,12 +1890,7 @@ class _attach(KeypressCallbacks): """ - def __init__(self, parent): - self._parent = parent - self._m = parent._m - self._temporary_artists = self._parent._temporary_artists - - def __call__(self, f, key, **kwargs): + def __call__(self, f, key=None, **kwargs): """ Add a custom callback-function to the map. @@ -2043,71 +1925,16 @@ def __call__(self, f, key, **kwargs): "EOmaps: The 'key' for keypress-callbacks must be a string!" ) - return self._parent._add_callback(f, key, **kwargs) - - @classmethod - def _available_callbacks(cls): - return list(filter(lambda x: not x.startswith("_"), dir(cls))) - - class _get: - """Accessor for objects generated/retrieved by callbacks.""" - - def __init__(self, parent): - self.m = parent._m - self.cbs = dict() - - @property - def attached_callbacks(self): - """Get a list of all IDs of attached callbacks.""" - cbs = [] - for key, cbdict in self.cbs.items(): - for name, cb in cbdict.items(): - cbs.append(f"{name}__{key}") + return self._parent._add_callback(callback=f, key=key, **kwargs) - return cbs + switch_layer = _CallbackMixin.switch_layer + overlay_layer = _CallbackMixin.overlay_layer + fetch_layers = _CallbackMixin.fetch_layers # to make namespace accessible for sphinx attach = _attach - get = _get - - def _parse_cid(self, cid): - name, rest = cid.split("__", 1) - layer, key = rest.rsplit("__", 1) - - return name, layer, key - - def remove(self, callback=None): - """ - Remove an attached callback from the figure. - - Parameters - ---------- - callback : int, str or tuple - if str: the name of the callback to remove - (`_____`) - - """ - if callback is not None: - name, layer, key = self._parse_cid(callback) - - cbname = name + "__" + layer - - cbs = self.get.cbs.get(key, None) - - if cbs is not None: - if cbname in cbs: - del cbs[cbname] - - # call cleanup methods on removal - fname = name.rsplit("_", 1)[0] - if hasattr(self._attach, f"_{fname}_cleanup"): - getattr(self._attach, f"_{fname}_cleanup")() - else: - _log.error(f"EOmaps: there is no callback named {callback}") - else: - _log.error(f"EOmaps: there is no callback named {callback}") - def _add_callback(self, callback, key="x", **kwargs): + def _add_callback(self, *args, callback=None, key="x", **kwargs): """ Attach a callback to the plot that will be executed if a key is pressed. @@ -2149,24 +1976,11 @@ def _add_callback(self, callback, key="x", **kwargs): ) callback = getattr(self._attach, callback) - cbdict = self.get.cbs.setdefault(key, dict()) - - # get a unique name for the callback - ncb = [ - int(i.rsplit("__", 1)[0].rsplit("_", 1)[1]) - for i in cbdict - if i.startswith(callback.__name__) - ] - cbkey = ( - callback.__name__ - + f"_{max(ncb) + 1 if len(ncb) > 0 else 0}" - + f"__{self._m.layer}" + cbname = self._ingest_callback( + update_wrapper(partial(callback, *args, **kwargs), callback), key=key ) - # append the callback - cbdict[cbkey] = partial(callback, **kwargs) - - return cbkey + f"__{key}" + return cbname class CallbackContainer: @@ -2186,12 +2000,14 @@ class CallbackContainer: click = ClickContainer move = MoveContainer keypress = KeypressContainer + release = ReleaseContainer def __init__(self, m): - self._m = m + self._m = proxy(m) self._methods = { "click", + "release", "pick", "move", "keypress", @@ -2201,30 +2017,32 @@ def __init__(self, m): self.click = ClickContainer( m=self._m, - cb_cls=ClickCallbacks, method="click", ) + + self.release = ReleaseContainer( + m=self._m, + method="release", + ) + # internal "always_active" click container to handle click-callbacks # that should be executed even if m._execute_callbacks is False. # (used in AnnotationEditor) self._always_active = ClickContainer( m=self._m, - cb_cls=ClickCallbacks, method="_always_active", ) - # a move-container that shares temporary artists with the click-container self._click_move = MoveContainer( m=self._m, - cb_cls=MoveCallbacks, method="_click_move", - parent_container=self.click, button_down=True, ) + # share temporary artists with the click-container + self._click_move._temporary_artists = self.click._temporary_artists self.move = MoveContainer( m=self._m, - cb_cls=MoveCallbacks, method="move", button_down=False, default_button=None, @@ -2232,39 +2050,14 @@ def __init__(self, m): self.pick = PickContainer( m=self._m, - cb_cls=PickCallbacks, method="pick", ) self.keypress = KeypressContainer( m=self._m, - cb_cls=KeypressCallbacks, method="keypress", ) - def get_execute_callbacks(self): - """ - Get if callbacks should be executed or not. - - Returns - ------- - bool - If True, callbacks are executed. - - """ - return self._m.parent._execute_callbacks - - def execute_callbacks(self, val): - """ - Activate / deactivate triggering callbacks. - - Parameters - ---------- - val : bool - If True, callbacks will be executed. - """ - self._m.parent._execute_callbacks = val - def add_picker(self, name, artist, picker): """ Attach a custom picker to an artist. @@ -2319,7 +2112,6 @@ def add_picker(self, name, artist, picker): new_pick = PickContainer( m=self._m, - cb_cls=PickCallbacks, method=method, picker_name=name, picker=picker, @@ -2345,7 +2137,7 @@ def _clear_callbacks(self): # clear all callback containers for method in self._methods: obj = getattr(self, method) - obj.get.cbs.clear() + obj._cbs.clear() def _reset_cids(self): # reset the callback functions (required to re-attach the callbacks diff --git a/eomaps/callbacks.py b/eomaps/callback_methods.py old mode 100755 new mode 100644 similarity index 57% rename from eomaps/callbacks.py rename to eomaps/callback_methods.py index 11ed0a8b9..e4ecf1252 --- a/eomaps/callbacks.py +++ b/eomaps/callback_methods.py @@ -6,13 +6,12 @@ """Collection of pre-defined click/pick/move/keypress callbacks.""" import numpy as np -import matplotlib.pyplot as plt -from matplotlib.patches import PathPatch -from matplotlib.transforms import TransformedPath import warnings import logging import sys +import matplotlib.path as mpath + _log = logging.getLogger(__name__) @@ -23,38 +22,36 @@ def _removesuffix(s, suffix): return s[:] -class _CallbacksBase: - def __init__(self, m, temp_artists): - self.m = m +def _fmt(x, **kwargs): + # make sure to format arrays with "," separator to make them + # copy-pasteable + kwargs.setdefault("separator", ",") + try: + return np.array2string(np.asanyarray(x), **kwargs) + except Exception: + return str(x) - # a list shared with the container that is used to store temporary artists - # (artists will be removed after each draw-event!) - self._temporary_artists = temp_artists +class _CallbackMixin: def _popargs(self, kwargs): - # pop the default kwargs passed to each callback function - # (to avoid showing them as kwargs when called) - ID = kwargs.pop("ID", None) - pos = kwargs.pop("pos", None) - val = kwargs.pop("val", None) - ind = kwargs.pop("ind", None) - picker_name = kwargs.pop("picker_name", "default") - val_color = kwargs.pop("val_color", None) - - # decode values in case a encoding is provided - val = self.m._decode_values(val) - - return ID, pos, val, ind, picker_name, val_color - - @staticmethod - def _fmt(x, **kwargs): - # make sure to format arrays with "," separator to make them - # copy-pasteable - kwargs.setdefault("separator", ",") - try: - return np.array2string(np.asanyarray(x), **kwargs) - except Exception: - return str(x) + if "event" in kwargs: + event = kwargs.pop("event") + props = [ + getattr(event, prop, None) + for prop in ("ID", "pos", "val", "ind", "picker_name", "val_color") + ] + if event.name != "pick_event": + props[1] = (event.xdata, event.ydata) + + else: + props = [ + kwargs.pop(prop, None) + for prop in ("ID", "pos", "val", "ind", "picker_name", "val_color") + ] + if props[4] is None: + props[4] = "default" + + return props def _get_annotation_text( self, @@ -100,7 +97,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) @@ -161,14 +158,14 @@ def _get_annotation_text( ) x, y, x0, y0 = map( - lambda x: self._fmt(x, precision=pos_precision), coords + lambda x: _fmt(x, precision=pos_precision), coords ) if val is not None: - val = self._fmt( + val = _fmt( np.array(val, dtype=float), precision=val_precision ) if ID is not None: - ID = self._fmt(np.asanyarray(ID)) + ID = _fmt(np.asanyarray(ID)) equal_crs = self.m.data_specs.crs == self.m._crs_plot if len(parameter) > 15: @@ -216,12 +213,6 @@ def _get_annotation_text( return printstr - -class _MoveClickPickCallbacks(_CallbacksBase): - # callbacks usable with move (and also click and pick) - def __init__(self, m, temp_artists): - _CallbacksBase.__init__(self, m, temp_artists) - def print_to_console( self, pos_precision=4, @@ -428,13 +419,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 = [] @@ -456,6 +447,13 @@ def annotate( return annotation + def clear_annotations(self, **kwargs): + """Remove all temporary and permanent annotations from the plot.""" + if hasattr(self, "permanent_annotations"): + while len(self.permanent_annotations) > 0: + ann = self.permanent_annotations.pop(0) + self.m._bm.remove_artist(ann) + def mark( self, radius=None, @@ -506,8 +504,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 +599,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 +638,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 +661,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] @@ -667,329 +674,54 @@ def mark( return marker - def peek_layer( - self, layer="1", how=(0.4, 0.4), alpha=1, shape="rectangular", **kwargs - ): - """ - Overlay a part of the map with a different layer if you click on the map. - - This callback allows you to overlay one (or more) existing layers on top - of the currently visible layer if you click on the map. - - You can show a rectangular or circular area of the "peek-layer" centered at - the mouse-position or swipe between layers (e.g. from left/right/top or bottom). - - - Parameters - ---------- - layer : str or list - - - if str: The name of the layer you want to peek at. - - if list: A list of layer-names of the following form: - - - A layer-name (string) - - A tuple (< layer-name >, < transparency [0-1] >) - - see `m.show_layer()` for more details on how to provide combined layer-names - - how : str , float or tuple, optional - The method you want to visualize the second layer. - (e.g. swipe from a side or display a rectangle) - - - "left" (→), "right" (←), "top" (↓), "bottom" (↑): - swipe the layer at the mouse-position. - - "full": overlay the layer on the whole figure - - if float: peek a square at the mouse-position, specified as - percentage of the axis-width (0-1) - - if tuple: (width, height) peek a rectangle at the mouse-position, - specified as percentage of the axis-size (0-1) - - The default is "left". - alpha : float, optional - The transparency of the peeked layer. (between 0 and 1) - If you overlay a (possibly transparent) combination of multiple layers, - this transparency will be assigned as a global transparency for the - obtained "combined layer". - The default is 1. - shape : str, optional - The shape of the peek-window. - - - "rectangular": peek a rectangle - - "round": peek an ellipse - - The default is "rectangular" - - **kwargs : - additional kwargs passed to a rectangle-marker. - the default is `(fc="none", ec="k", lw=1)` - - - Examples - -------- - Overlay a single layer: - - >>> m = Maps() - >>> m.add_feature.preset.coastline() - >>> m2 = m.new_layer(layer="ocean") - >>> m2.add_feature.preset.ocean() - >>> m.cb.click.attach.peek_layer(layer="ocean") - - Overlay a (transparent) combination of multiple layers: - - >>> m = Maps() - >>> m.all.add_feature.preset.coastline() - >>> m.add_feature.preset.urban_areas() - >>> m.add_feature.preset.ocean(layer="ocean") - >>> m.add_feature.physical.land(layer="land", fc="g") - >>> m.cb.click.attach.peek_layer(layer=["ocean", ("land", 0.5)], - >>> shape="round", how=0.4) - - """ - shape = "ellipses" if shape == "round" else "rectangles" - - if not isinstance(layer, str): - 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) - - ID, pos, val, ind, picker_name, val_color = self._popargs(kwargs) - - ax = self.m.ax - - # default boundary args - kwargs.setdefault("fc", "none") - kwargs.setdefault("ec", "k") - kwargs.setdefault("lw", 1.1) - - if isinstance(how, str): - # base transformations on transData to ensure correct treatment - # for shared axes - if how == "left": - x, _ = ax.transData.transform((pos[0], pos[1])) - x0, y0 = ax.transAxes.transform((0, 0)) - blitw = x - x0 - blith = ax.bbox.height - elif how == "right": - x0, _ = ax.transData.transform((pos[0], pos[1])) - xa0, y0 = ax.transAxes.transform((0, 0)) - blitw = ax.bbox.width - x0 + xa0 - blith = ax.bbox.height - elif how == "top": - x0, ya0 = ax.transAxes.transform((0, 0)) - _, y0 = ax.transData.transform((pos[0], pos[1])) - - blitw = ax.bbox.width - blith = ax.bbox.height - y0 + ya0 - elif how == "bottom": - x0, y0 = ax.transAxes.transform((0, 0)) - _, y = ax.transData.transform((pos[0], pos[1])) - - blitw = ax.bbox.width - blith = y - y0 - elif how == "full": - x0, y0 = ax.transAxes.transform((0, 0)) - blitw = ax.bbox.width - blith = ax.bbox.height - - else: - raise TypeError(f"EOmaps: '{how}' is not a valid input for 'how'") - - if how != "full": - x0m, y0m = ax.transData.inverted().transform((x0, y0)) - x1m, y1m = ax.transData.inverted().transform((x0 + blitw, y0 + blith)) - w, h = abs(x1m - x0m), abs(y1m - y0m) - - clip_path = self.m.cb.click.attach._get_clip_path( - (x0m + x1m) / 2, - (y0m + y1m) / 2, - "out", - (w / 2, h / 2), - "out", - "rectangles", - 100, - ) - else: - clip_path = None - - elif isinstance(how, (float, list, tuple)): - if isinstance(how, float): - w0, h0 = self.m.ax.transAxes.transform((0, 0)) - w1, h1 = self.m.ax.transAxes.transform((how, how)) - blitw, blith = [min(w1 - w0, h1 - h0)] * 2 - - else: - w0, h0 = self.m.ax.transAxes.transform((0, 0)) - w1, h1 = self.m.ax.transAxes.transform(how) - blitw, blith = (w1 - w0, h1 - h0) - - x0, y0 = ax.transData.transform((pos[0], pos[1])) - x0, y0 = x0 - blitw / 2, y0 - blith / 2 - - # make sure that we don't blit outside the axis - bbox = self.m.ax.bbox - x1 = x0 + blitw - y1 = y0 + blith - if x0 < bbox.x0: - dx = bbox.x0 - x0 - x0 = bbox.x0 - blitw = blitw - dx * 2 - if x1 > bbox.x1: - dx = x1 - bbox.x1 - x0 = x0 + dx - blitw = blitw - dx * 2 - if y0 < bbox.y0: - dy = bbox.y0 - y0 - y0 = bbox.y0 - blith = blith - dy * 2 - if y1 > bbox.y1: - dy = y1 - bbox.y1 - y0 = y0 + dy - blith = blith - dy * 2 - - x0m, y0m = ax.transData.inverted().transform( - (x0 - blitw / 2.0, y0 - blith / 2) - ) - - # TODO check why a 1 pixel offset is required for a tight fit! - # (rounding issues?) - x1m, y1m = ax.transData.inverted().transform( - (x0 + blitw / 2.0 - 1, y0 + blith / 2) - ) - w, h = abs(x1m - x0m), abs(y1m - y0m) - - clip_path = self.m.cb.click.attach._get_clip_path( - x1m, y1m, "out", (w / 2, h / 2), "out", shape, 100 - ) - else: - raise TypeError(f"EOmaps: {how} is not a valid peek method!") - - if clip_path is not None: - patch = PathPatch(clip_path, **kwargs) - marker = self.m.ax.add_patch(patch) - 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") - - self.m.BM._after_update_actions.append(doit) - - # 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, - ) - ) - - -class _ClickCallbacks(_CallbacksBase): - """ - A collection of callback-functions. - - to attach a callback, use: - >>> cid = m.cb.click.attach.annotate(**kwargs) - or - >>> cid = m.cb.pick.attach.annotate(**kwargs) - - to remove an already attached callback, use: - >>> m.cb.click.remove(cid) - or - >>> m.cb.pick.remove(cid) - - - you can also define custom callback functions as follows: - - >>> def some_callback(self, **kwargs): - >>> print("hello world") - >>> print("the position of the clicked pixel", kwargs["pos"]) - >>> print("the data-index of the clicked pixel", kwargs["ID"]) - >>> print("data-value of the clicked pixel", kwargs["val"]) - and attach them via: - >>> cid = m.cb.click.attach(some_callback) - or - >>> cid = m.cb.click.attach(some_callback) - (... and remove them in the same way as pre-defined callbacks) - """ - - # the naming-convention of the functions is as follows: - # - # __cleanup : a function that is executed if the callback - # is removed from the plot - # - - # ID : any - # The index-value of the pixel in the data. - # pos : tuple - # A tuple of the position of the pixel in plot-coordinates. - # (ONLY relevant if ID is NOT provided!) - # val : int or float - # The parameter-value of the pixel. - # ind : int - # The index of the clicked pixel - # (ONLY relevant if ID is NOT provided!) - - # this list determines the order at which callbacks are executed! - # (custom callbacks are always added to the end) - - def __init__(self, m, temp_artists): - _CallbacksBase.__init__(self, m, temp_artists) - - def clear_annotations(self, **kwargs): - """Remove all temporary and permanent annotations from the plot.""" - 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() - 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): - """ - Successively collect return-values in a dict. - - The dict is accessible via `m.cb.[click/pick].get.picked_vals` - - The structure of the picked_vals dict is as follows: - (lists are appended as you click on more pixels) - - >>> dict( - >>> pos=[... center-position tuples in plot_crs ...], - >>> ID=[... the corresponding IDs in the dataframe...], - >>> val=[... the corresponding values ...] - >>> ) + def peek_layer(self, layer, **kwargs): + event = kwargs.pop("event") - removing the callback will also remove the associated value-dictionary! - """ - ID, pos, val, ind, picker_name, val_color = self._popargs(kwargs) + kwargs.setdefault("shape", "s") + kwargs.setdefault("size", 0.25) + kwargs.setdefault("shape_crs", "axes") - if not hasattr(self, "picked_vals"): - self.picked_vals = dict() + # make sure the layer has been fetched once to avoid making + # all pending artists temporary - for key, val in zip(["pos", "ID", "val"], [pos, ID, val]): - self.picked_vals.setdefault(key, []).append(val) + # TODO create a proper "ensure layer was initialized" method + # that fetches all potential sublayers of a layers + if not isinstance(layer, str): + layer = self.m._bm._get_combined_layer_name(*layer) + layers, _ = self.m._bm._parse_multi_layer_str(layer) + + for l in layers: + if ( + l + in self.m._bm._Hooks__hooks.get("layer_activation", {}) + .get(False, {}) + .keys() + ): + self.m._bm.fetch_bg(layer) + + self.m._bm.add_hook( + "extent_changed", + lambda *args, **kwargs: self.m._bm._clear_temp_artists(event._method), + ) - def _get_values_cleanup(self): - # cleanup method for get_values callback - if hasattr(self, "picked_vals"): - del self.picked_vals + with getattr(self.m.cb, event._method).make_artists_temporary( + use_artists=[self.m] + ): + self.m.add_peek_layer( + layer, + xy=(event.xdata, event.ydata), + xy_crs="plot", + dynamic=True, + **kwargs, + ) def _get_clip_path(self, x, y, xy_crs, radius, radius_crs, shape, n=100): shp = self.m.set_shape._get(shape) @@ -1026,98 +758,8 @@ def _get_clip_path(self, x, y, xy_crs, radius, radius_crs, shape, n=100): n=n, ) bnd_verts = np.stack(shp_pts[:2], axis=2).squeeze() - from matplotlib.path import Path - return Path(bnd_verts) - - def plot( - self, - x_index="pos", - precision=4, - **kwargs, - ): - """ - Generate a dynamically updated plot showing the values of the picked pixels. - - - x-axis represents pixel-coordinates (or IDs) - - y-axis represents pixel-values - - a new figure is started whenever the figure is closed! - - Parameters - ---------- - x_index : str - Indicator how the x-axis is labelled - - - pos : The position of the pixel in plot-coordinates - - ID : The index of the pixel in the data - precision : int - The floating-point precision of the coordinates printed to the - x-axis if `x_index="pos"` is used. - The default is 4. - **kwargs : - kwargs forwarded to the call to `plt.plot([...], [...], **kwargs)`. - - """ - ID, pos, val, ind, picker_name, val_color = self._popargs(kwargs) - - style = dict(marker=".") - style.update(**kwargs) - - if not hasattr(self, "_pick_f"): - self._pick_f, self._pick_ax = plt.subplots() - self._pick_ax.tick_params(axis="x", rotation=90) - self._pick_ax.set_ylabel(self.m.data_specs.parameter) - - # call the cleanup function if the figure is closed - def on_close(event): - self._plot_cleanup() - - self._pick_f.canvas.mpl_connect("close_event", on_close) - - if isinstance(self.m.data_specs.x, str): - _pick_xlabel = self.m.data_specs.x - else: - _pick_xlabel = "x" - - if isinstance(self.m.data_specs.y, str): - _pick_ylabel = self.m.data_specs.y - else: - _pick_ylabel = "y" - - if x_index == "pos": - x, y = [ - np.format_float_positional(i, trim="-", precision=precision) - for i in pos - ] - xindex = f"{_pick_xlabel}={x}\n{_pick_ylabel}={y}" - elif x_index == "ID": - xindex = str(ID) - - if not hasattr(self, "_pick_l"): - (self._pick_l,) = self._pick_ax.plot([xindex], [val], **style) - else: - self._pick_l.set_xdata(list(self._pick_l.get_xdata()) + [xindex]) - self._pick_l.set_ydata(list(self._pick_l.get_ydata()) + [val]) - - self._pick_ax.relim() - self._pick_ax.autoscale_view(True, True, True) - self._pick_f.tight_layout() - self._pick_f.canvas.draw() - - def _plot_cleanup(self): - # cleanup method for plot callback - if hasattr(self, "_pick_f"): - del self._pick_f - if hasattr(self, "_pick_ax"): - del self._pick_ax - if hasattr(self, "_pick_l"): - del self._pick_l - - -class _PickCallbacks: - def __init__(self, m, temp_artists): - _CallbacksBase.__init__(self, m, temp_artists) + return mpath.Path(bnd_verts) def highlight_geometry(self, permanent=False, **kwargs): """ @@ -1141,82 +783,7 @@ def highlight_geometry(self, permanent=False, **kwargs): else: self.m.add_gdf(geom, permanent=permanent, **kwargs) - def load( - self, database=None, load_method="load_fit", load_multiple=False, **kwargs - ): - """ - Load objects from a given database using the ID of the picked pixel. - - The returned object(s) are accessible via `m.cb.pick.get.picked_object`. - - Parameters - ---------- - database : any - The database object to use for loading the object - load_method : str or callable - If str: The name of the method to use for loading objects from the provided - database (the call-signature used is `database.load_method(ID)`) - If callable: A callable that will be executed on the database with the - following call-signature: `load_method(database, ID)` - load_multiple : bool - True: A single-object is returned, replacing `m.cb.picked_object` on each pick. - False: A list of objects is returned that is extended with each pick. - """ - ID, pos, val, ind, picker_name, val_color = self._popargs(kwargs) - assert database is not None, "you must provide a database object!" - try: - if isinstance(load_method, str): - assert hasattr( - database, load_method - ), "The provided database has no method '{load_method}'" - pick = getattr(database, load_method)(ID) - elif callable(load_method): - pick = load_method(database, ID) - else: - raise TypeError("load_method must be a string or a callable!") - except Exception: - _log.error( - f"EOmaps: Unable to load object with ID: '{ID}' from {database}" - ) - if load_multiple is True: - self.picked_object = getattr(self, "picked_object", list()) + [pick] - else: - self.picked_object = pick - - def _load_cleanup(self): - if hasattr(self, "picked_object"): - del self.picked_object - - -class PickCallbacks(_ClickCallbacks, _PickCallbacks, _MoveClickPickCallbacks): - """A collection of callbacks that are executed if you click on a datapoint.""" - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - -class ClickCallbacks(_ClickCallbacks, _MoveClickPickCallbacks): - """Collection of callbacks that are executed if you click anywhere on the map.""" - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - -class MoveCallbacks(_MoveClickPickCallbacks): - """Collection of callbacks that are executed on mouse-movement.""" - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - -class KeypressCallbacks: - """Collection of callbacks that are executed if you press a key on the keyboard.""" - - def __init__(self, m, temp_artists): - self._temporary_artists = temp_artists - self._m = m - - def switch_layer(self, layer, key="x"): + def switch_layer(self, layer, **kwargs): """ Set the currently visible layer of the map. @@ -1227,13 +794,6 @@ def switch_layer(self, layer, key="x"): For details on how to specify layer-names, see :py:meth:`Maps.show_layer` - Additional Parameters - --------------------- - key : str, optional - The key to use for triggering the callback. - Modifiers are indicated with a "+", e.g. "alt+x". - The default is "x". - Examples -------- Show layer A: @@ -1254,12 +814,13 @@ def switch_layer(self, layer, key="x"): """ + if isinstance(layer, (list, tuple)): - self._m.show_layer(*layer) + self.m.show_layer(*layer) elif isinstance(layer, str): - self._m.show_layer(layer) + self.m.show_layer(layer) - def overlay_layer(self, layer, key="x"): + def overlay_layer(self, layer, **kwargs): """ Toggle displaying a layer on top of the currently visible layers. @@ -1274,13 +835,6 @@ def overlay_layer(self, layer, key="x"): For details on how to specify layer-names, see :py:meth:`Maps.show_layer` - Additional Parameters - --------------------- - key : str, optional - The key to use for triggering the callback. - Modifiers are indicated with a "+", e.g. "alt+x". - The default is "x". - Note ---- If the visible layer changes **while the overlay-layer is active**, @@ -1315,24 +869,24 @@ 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) + self.m.show_layer(newlayer) - def fetch_layers(self, layers=None, verbose=True, key="x"): + def fetch_layers(self, layers=None, verbose=True, **kwargs): """ Fetch (and cache) layers of a map. @@ -1360,12 +914,5 @@ def fetch_layers(self, layers=None, verbose=True, key="x"): Indicator if status-messages should be printed or not. The default is True. - Additional Parameters - --------------------- - key : str, optional - The key to use for triggering the callback. - Modifiers are indicated with a "+", e.g. "alt+x". - The default is "x". - """ - self._m.fetch_layers(layers=layers, verbose=verbose) + self.m.fetch_layers(layers=layers, verbose=verbose) 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/drawer.py similarity index 92% rename from eomaps/draw.py rename to eomaps/drawer.py index 6465b92f7..79d391c84 100644 --- a/eomaps/draw.py +++ b/eomaps/drawer.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): """ @@ -180,7 +180,7 @@ def _finish_drawing(self, cb=None): cb : callable, optional A callable executed after finishing the draw. The default is None. """ - self._m.cb.execute_callbacks(True) + self._m.execute_callbacks = True if cb is None: self._m._emit_signal("drawAborted") @@ -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,10 +360,10 @@ 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) + self._m.execute_callbacks = False def handler(event): self._init_draw_line() @@ -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,9 +529,9 @@ 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) + self._m.execute_callbacks = False def handler(event): self._init_draw_line() @@ -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..af79ee323 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,8 @@ class Maps(MapsBase): See Also -------- + Maps.l : :py:class:`~eomaps._maps_base.LayerNamespace` to create/access 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 +135,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,290 +158,31 @@ 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 - - @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 - ) - - 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) + self._cid_keypress = self.f.canvas.mpl_connect( + "key_press_event", self._on_keypress + ) def new_map( self, @@ -567,7 +264,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,139 +283,13 @@ 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 - def new_layer( - self, - layer=None, - inherit_data=False, - inherit_classification=False, - inherit_shape=True, - **kwargs, - ): - """ - Create a new Maps-object that shares the same plot-axes. - - Parameters - ---------- - layer : int, 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 - - If None, the layer of the parent object is used. - - The default is None. - inherit_data, inherit_classification, inherit_shape : bool - Indicator if the corresponding properties should be inherited from - the parent Maps-object. - - By default only the shape is inherited. - - For more details, see :py:meth:`Maps.inherit_data` and - :py:meth:`Maps.inherit_classification` - - Returns - ------- - eomaps.Maps - A connected copy of the Maps-object that shares the same plot-axes. - - Examples - -------- - Create a new Maps-object **on an existing layer** - - >>> from eomaps import Maps - >>> m = Maps(layer="base") # m.layer == "base" - >>> m2 = m.new_layer() # m2.layer == "base" - - - Create a new Maps-object representing a **new layer** - - >>> from eomaps import Maps - >>> m = Maps(layer="base") # m.layer == "base" - >>> m2 = m.new_layer("a new layer") # m2.layer == "a new layer" - - - Create a new layer and immediately delete it after it has been exported. - (useful to free memory if a lot of layers are be exported) - - >>> from eomaps import Maps - >>> m = Maps(layer="base") - >>> with m.new_layer("a new layer") as m2: - >>> ... - >>> m2.show() # make the layer visible - >>> m2.savefig(...) # save it as an image - - - See Also - -------- - 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( - "copy_classify_specs", inherit_classification - ) - inherit_shape = kwargs.get("copy_shape", inherit_shape) - - if layer is None: - layer = copy.deepcopy(self.layer) - else: - layer = str(layer) - if len(layer) == 0: - raise SyntaxError( - "EOmaps: Unable to create a layer with an empty layer-name!" - ) - - m = self.copy( - data_specs=False, - classify_specs=False, - shape=False, - ax=self.ax, - layer=layer, - ) - - if inherit_data: - m.inherit_data(self) - if inherit_classification: - m.inherit_classification(self) - if inherit_shape and self._shape_assigned: - getattr(m.set_shape, self.shape.name)(**self.shape._initargs) - - # make sure the new layer does not attempt to reset the extent if - # it has already been set on the parent 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() - - # share the companion-widget with the parent - m._companion_widget = self._companion_widget - - return m - def new_inset_map( self, xy=(45, 45), @@ -734,6 +305,10 @@ def new_inset_map( shape="ellipses", indicate_extent=True, indicator_line=False, + inherit_data=False, + inherit_shape=False, + inherit_classification=False, + **kwargs, ): """ Create a new (empty) inset-map that shows a zoomed-in view on a given extent. @@ -832,6 +407,14 @@ def new_inset_map( indicate the inset-shape on arbitrary Maps-objects. The default is False. + inherit_data, inherit_classification, inherit_shape : bool + Indicator if the corresponding properties should be inherited from + the parent Maps-object. + + By default only the shape is inherited. + + For more details, see :py:meth:`Maps.inherit_data` and + :py:meth:`Maps.inherit_classification` Returns ------- @@ -898,7 +481,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, @@ -912,8 +496,16 @@ def new_inset_map( shape=shape, indicate_extent=indicate_extent, indicator_line=indicator_line, + **kwargs, ) + if inherit_data: + m2.inherit_data(self) + if inherit_classification: + m2.inherit_classification(self) + if inherit_shape and self._shape_assigned: + getattr(m2.set_shape, self.shape.name)(**self.shape._initargs) + return m2 @_add_to_docstring( @@ -936,2524 +528,174 @@ 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, - ): + def set_frame(self, rounded=0, gdf=None, countries=None, **kwargs): """ - Set the properties of the dataset you want to plot. + Set the properties of the map boundary and the background patch. - Use this function to update multiple data-specs in one go - Alternatively you can set the data-specifications via + - use `rounded` kwarg to get a rectangle border with rounded corners + - use `gdf` kwarg to use `geopandas.GeoDataFrame` geometries as map-border + - use `countries` kwarg to set the map-border to one (or more) countries. - >>> m.data_specs.< property > = ...` + All additional kwargs are used to style the border-line. 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` + rounded : float, optional + If provided, use a rectangle with rounded corners as map boundary + line. The corners will be rounded with respect to the provided + fraction (0=no rounding, 1=max. radius). The default is None. + gdf : geopandas.GeoDataFrame or path + A geopandas.GeoDataFrame that contains geometries that should be used as + map-frame. - >>> lon, lat, vals = pd.Series([...]), pd.Series([...]), pd.Series([...]) - >>> m.set_data(vals, x=lon, y=lat, crs=4326) + If a path (string or pathlib.Path) is provided, the corresponding file + will be read as a geopandas.GeoDataFrame and the boundaries of the + contained geometries will be used as map-boundary. - - using 1D lists + The default is None. + kwargs : + Additional kwargs to style the boundary line (e.g. the spine) + and the background patch - >>> lon, lat, vals = [...], [...], [...] - >>> m.set_data(vals, x=lon, y=lat, crs=4326) + Possible args for the boundary-line: - - using 1D or 2D numpy.arrays + - "edgecolor" or "ec": The line color + - "linewidth" or "lw": The line width + - "linestyle" or "ls": The line style + - "path_effects": A list of path-effects to apply to the line - >>> lon, lat, vals = np.array([[...]]), np.array([[...]]), np.array([[...]]) - >>> m.set_data(vals, x=lon, y=lat, crs=4326) + Possible args for the background-patch: - - integer-encoded datasets + - "facecolor" or "fc": The color of the background patch - >>> 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) + Other Parameters + ---------------- + set_extent : bool, optional + Only relevant if `gdf` is used. + If True, the map-extent is set to the extent of the provided geometry. + The default is True. + scale : int, optional + Only relevant if `countries` is used. + The scale factor of the used NaturalEarth dataset. + Must be one of [10, 50, 110]. The default is 50. - """ - if data is not None: - self.data_specs.data = data + Examples + -------- - if x is not None: - self.data_specs.x = x + >>> m = Maps() + >>> m.add_feature.preset.ocean() + >>> m.set_frame(fc="r", ec="b", lw=3, rounded=.2) - if y is not None: - self.data_specs.y = y + Customize the map-boundary style - if crs is not None: - self.data_specs.crs = crs + >>> import matplotlib.patheffects as pe + >>> m = Maps() + >>> m.add_feature.preset.ocean(fc="k") + >>> m.set_frame( + >>> facecolor=(.8, .8, 0, .5), edgecolor="w", linewidth=2, + >>> rounded=.5, + >>> path_effects=[pe.withStroke(linewidth=7, foreground="m")]) - if encoding is not None: - self.data_specs.encoding = encoding + Set the map-boundary to a custom polygon (in this case the boarder of Austria) - if cpos is not None: - self.data_specs.cpos = cpos + >>> m = Maps() + >>> m.add_feature.preset.land(fc="k") + >>> # Get a GeoDataFrame with all country-boarders from NaturalEarth + >>> gdf = m.add_feature.cultural.admin_0_countries.get_gdf() + >>> # set the map-boundary to the Austrian country-boarder + >>> m.set_frame(gdf = gdf[gdf.NAME=="Austria"]) - if cpos_radius is not None: - self.data_specs.cpos_radius = cpos_radius + Set the map-boundary to the country-border of Austria and Italy - if parameter is not None: - self.data_specs.parameter = parameter + >>> m = Maps(facecolor="0.4") + >>> m.set_frame(countries=["Austria", "Italy"], ec="r", lw=2, fc="k") - @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) + set_extent = kwargs.pop("set_extent", True) - Examples - -------- - >>> m.set_classify.Quantiles(k=5) + for key in ("fc", "facecolor"): + if key in kwargs: + self.ax.patch.set_facecolor(kwargs.pop(key)) - >>> m.set_classify.EqualInterval(k=5) + if countries is not None: + assert gdf is None, "You cannot specify both 'gdf' and 'countries'" - >>> m.set_classify.UserDefined(bins=[5, 10, 25, 50]) + gdf = self._get_country_frame(countries, scale=kwargs.pop("scale", 50)) - """ - (mapclassify,) = register_modules("mapclassify") + if gdf is not None: + assert ( + rounded == 0 + ), "EOmaps: using rounded > 0 is not supported for gdf frames!" - 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. - - - use `rounded` kwarg to get a rectangle border with rounded corners - - use `gdf` kwarg to use `geopandas.GeoDataFrame` geometries as map-border - - use `countries` kwarg to set the map-border to one (or more) countries. - - All additional kwargs are used to style the border-line. - - Parameters - ---------- - rounded : float, optional - If provided, use a rectangle with rounded corners as map boundary - line. The corners will be rounded with respect to the provided - fraction (0=no rounding, 1=max. radius). The default is None. - gdf : geopandas.GeoDataFrame or path - A geopandas.GeoDataFrame that contains geometries that should be used as - map-frame. - - If a path (string or pathlib.Path) is provided, the corresponding file - will be read as a geopandas.GeoDataFrame and the boundaries of the - contained geometries will be used as map-boundary. - - The default is None. - kwargs : - Additional kwargs to style the boundary line (e.g. the spine) - and the background patch - - Possible args for the boundary-line: - - - "edgecolor" or "ec": The line color - - "linewidth" or "lw": The line width - - "linestyle" or "ls": The line style - - "path_effects": A list of path-effects to apply to the line - - Possible args for the background-patch: - - - "facecolor" or "fc": The color of the background patch - - Other Parameters - ---------------- - set_extent : bool, optional - Only relevant if `gdf` is used. - If True, the map-extent is set to the extent of the provided geometry. - The default is True. - scale : int, optional - Only relevant if `countries` is used. - The scale factor of the used NaturalEarth dataset. - Must be one of [10, 50, 110]. The default is 50. - - Examples - -------- - - >>> m = Maps() - >>> m.add_feature.preset.ocean() - >>> m.set_frame(fc="r", ec="b", lw=3, rounded=.2) - - Customize the map-boundary style - - >>> import matplotlib.patheffects as pe - >>> m = Maps() - >>> m.add_feature.preset.ocean(fc="k") - >>> m.set_frame( - >>> facecolor=(.8, .8, 0, .5), edgecolor="w", linewidth=2, - >>> rounded=.5, - >>> path_effects=[pe.withStroke(linewidth=7, foreground="m")]) - - Set the map-boundary to a custom polygon (in this case the boarder of Austria) - - >>> m = Maps() - >>> m.add_feature.preset.land(fc="k") - >>> # Get a GeoDataFrame with all country-boarders from NaturalEarth - >>> gdf = m.add_feature.cultural.admin_0_countries.get_gdf() - >>> # set the map-boundary to the Austrian country-boarder - >>> m.set_frame(gdf = gdf[gdf.NAME=="Austria"]) - - Set the map-boundary to the country-border of Austria and Italy - - >>> m = Maps(facecolor="0.4") - >>> m.set_frame(countries=["Austria", "Italy"], ec="r", lw=2, fc="k") - - """ - set_extent = kwargs.pop("set_extent", True) - - for key in ("fc", "facecolor"): - if key in kwargs: - self.ax.patch.set_facecolor(kwargs.pop(key)) - - if countries is not None: - assert gdf is None, "You cannot specify both 'gdf' and 'countries'" - - gdf = self._get_country_frame(countries, scale=kwargs.pop("scale", 50)) - - if gdf is not None: - assert ( - rounded == 0 - ), "EOmaps: using rounded > 0 is not supported for gdf frames!" - - self._set_gdf_path_boundary(self._handle_gdf(gdf), set_extent=set_extent) - - elif rounded: - assert ( - rounded <= 1 - ), "EOmaps: rounded corner fraction must be between 0 and 1" - - self.ax._EOmaps_rounded_spine_frac = rounded - theta = np.linspace(0, np.pi / 2, 50) # use 50 intermediate points - s, c = np.sin(theta), np.cos(theta) - - # attach a function to dynamically update the corners of the - # map boundary prior to fetching a background - # Note: this function is only attached once and the relevant - # properties are fetched from the axes! - if not getattr(self.ax, "_EOmaps_rounded_spine_attached", False): - - def cb(*args, **kwargs): - if self.ax._EOmaps_rounded_spine_frac == 0: - return - - x0, x1, y0, y1 = self.get_extent(self.crs_plot) - r = min(x1 - x0, y1 - y0) * self.ax._EOmaps_rounded_spine_frac / 2 - - xs = [ - x0, - *(x0 + r - r * c), - x0 + r, - x1 - r, - *(x1 - r + r * s), - x1, - x1, - *(x1 - r + r * c), - x1 - r, - x0 + r, - *(x0 + r - r * s), - x0, - ] - - ys = [ - y1 - r, - *(y1 - r + r * s), - y1, - y1, - *(y1 - r + r * c), - y1 - r, - y0 + r, - *(y0 + r - r * s), - y0, - y0, - *(y0 + r - r * c), - y0 + r, - ] - - 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.ax._EOmaps_rounded_spine_attached = True - - self.ax.spines["geo"].update(kwargs) - - self.redraw() - - @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! - 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._set_gdf_path_boundary(self._handle_gdf(gdf), set_extent=set_extent) - """ - 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. + elif rounded: + assert ( + rounded <= 1 + ), "EOmaps: rounded corner fraction must be between 0 and 1" - After executing this function, `m.cb.pick` callbacks can be attached to the - `Maps` object. + self.ax._EOmaps_rounded_spine_frac = rounded + theta = np.linspace(0, np.pi / 2, 50) # use 50 intermediate points + s, c = np.sin(theta), np.cos(theta) - 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! + # attach a function to dynamically update the corners of the + # map boundary prior to fetching a background + # Note: this function is only attached once and the relevant + # properties are fetched from the axes! + if not getattr(self.ax, "_EOmaps_rounded_spine_attached", False): - - 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`) + def update_round_map_frame_corners(*args, **kwargs): + if self.ax._EOmaps_rounded_spine_frac == 0: + return - 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 + x0, x1, y0, y1 = self.get_extent(self.crs_plot) + r = min(x1 - x0, y1 - y0) * self.ax._EOmaps_rounded_spine_frac / 2 - # ---------------------- prepare the data - self._data_manager = DataManager(self._proxy(self)) - self._data_manager.set_props(layer=self.layer, only_pick=True) + xs = [ + x0, + *(x0 + r - r * c), + x0 + r, + x1 - r, + *(x1 - r + r * s), + x1, + x1, + *(x1 - r + r * c), + x1 - r, + x0 + r, + *(x0 + r - r * s), + x0, + ] - x0, x1 = self._data_manager.x0.min(), self._data_manager.x0.max() - y0, y1 = self._data_manager.y0.min(), self._data_manager.y0.max() + ys = [ + y1 - r, + *(y1 - r + r * s), + y1, + y1, + *(y1 - r + r * c), + y1 - r, + y0 + r, + *(y0 + r - r * s), + y0, + y0, + *(y0 + r - r * c), + y0 + r, + ] - # 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") + path = mpath.Path(np.column_stack((xs, ys))) + self.ax.set_boundary(path, transform=self.crs_plot) - self._coll = art + self._bm.add_hook( + "before_fetch_bg", update_round_map_frame_corners, True + ) - self.tree = SearchTree(m=self._proxy(self)) - self.cb.pick._set_artist(art) - self.cb.pick._init_cbs() - self.cb._methods.add("pick") + self.ax._EOmaps_rounded_spine_attached = True - self._coll_kwargs = dict() - self._coll_dynamic = True + self.ax.spines["geo"].update(kwargs) - # set _data_plotted to True to trigger updates in the data-manager - self._data_plotted = True + self.redraw() def copy( self, @@ -3498,23 +740,16 @@ 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) - + @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 +772,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 +818,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 +859,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..09a1a6446 100644 --- a/eomaps/helpers.py +++ b/eomaps/helpers.py @@ -11,8 +11,10 @@ import sys from importlib import import_module from textwrap import indent, dedent -from functools import wraps, lru_cache +from functools import wraps, lru_cache, reduce import warnings +import weakref +from string import Formatter import numpy as np import matplotlib.pyplot as plt @@ -28,6 +30,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). @@ -234,6 +248,99 @@ def show(j): file.flush() +# a recursive getattr method +# see https://stackoverflow.com/a/31174427/9703451 +def rgetattr(obj, attr, *args): + def _getattr(obj, attr): + return getattr(obj, attr, *args) + + return reduce(_getattr, [obj] + attr.split(".")) + + +def _submit_on_activation(maps_attr="self", label="", default_lazy=True): + """ + Decorator that will submit the method when the associated layer + becomes active. + + Parameters + ---------- + maps_attr : str, optional + The name of the attribute of the class that holds the reference to the + Maps-object to use. + If "self" is passed, the class is expected to be a Maps-subclass! + Otherwise `self.` is used. + The default is "self". + label : str, optional + A string that is used to indicate the pending method in the + companion widget. + Variable substitution is used via the '{NAME}` syntax. + NAME can hereby be any property of the class of the decorated method. + (also nested access, e.g. "{a.b.c} -> self.a.b.c" is supported!) + The default is "". + default_lazy : bool, optional + If True, the method is lazy by default. + If False, the method is executed immediately by default. + The default is True. + """ + + def decorator(f): + @wraps(f) + def inner(self, *args, **kwargs): + if maps_attr == "self": + m = self + else: + m = getattr(self, maps_attr) + + # if the Maps object is not lazy, immediately execute the method + if m._lazy is False or (m._lazy is None and default_lazy is False): + return f(self, *args, **kwargs) + + @wraps(f) + def lazy_method(m): + ret = f(self, *args, **kwargs) + return ret + + if label: + # to get a proper label in the CompanionWidget + substitutions = {} + for (_, key, _, _) in Formatter().parse(label): + if key: + substitutions[key] = rgetattr(self, key, "?") + + # use reduce to allow for substitutions containing "." + # (e.g. recursive attribute access) + lazy_method.__qualname__ = reduce( + lambda s, key: s.replace(f"{{{key}}}", substitutions[key]), + substitutions, + label, + ) + + # check if layer has been overwritten by a kwarg + if (layer := kwargs.get("layer", None)) is None: + layer = m.layer + + if layer is not None: + ret = m[layer].on_layer_activation(lazy_method) + + return ret + + return inner + + return decorator + + +def _from_parent(f): + """ + Maps-object method decorator to retrieve properties from the parent. + """ + + @wraps(f) + def inner(self, *args, **kwargs): + return f(self.parent, *args, **kwargs) + + return inner + + def _add_to_docstring(prefix=None, suffix=None, insert=None): """ Add text to an existing docstring @@ -564,3 +671,57 @@ 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 + + +class WeakOrderedCollection: + """ + A class that stores members as weak-references + while maintaining insert-order. + """ + + def __init__(self): + self._d = weakref.WeakValueDictionary() + + def __iter__(self): + return self._d.values() + + def __len__(self): + return len(self._d) + + def clear(self): + self._d.clear() + + def add(self, value): + self._d[hash(value)] = value + + def remove(self, value): + self._d.pop(hash(value)) + + def update(self, vals): + for v in vals: + h = hash(v) + if h not in self._d: + self._d[h] = v 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..c78935d31 100644 --- a/eomaps/layout_editor.py +++ b/eomaps/layout_editor.py @@ -115,19 +115,19 @@ def modifier_pressed(self): @modifier_pressed.setter def modifier_pressed(self, val): self._modifier_pressed = val - if hasattr(self.m, "cb"): - self.m.cb.execute_callbacks(not val) + # disable callbacks while the modifier is pressed + self.m.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..f40fa5ba8 --- /dev/null +++ b/eomaps/mixins/add_mixin.py @@ -0,0 +1,1343 @@ +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, PathPatch +from matplotlib.colors import to_rgb +from matplotlib.transforms import TransformedPath, Affine2D +import matplotlib.path as mpath + + +from ..ne_features import NaturalEarthFeatures +from ..grid import GridFactory +from ..helpers import ( + _TransformedBoundsLocator, + _get_rect_poly_verts, + _submit_on_activation, +) +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 = WebMapContainer(weakref.proxy(self)) + self._wms_legend = dict() + + self.add_feature = NaturalEarthFeatures(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) + + @_submit_on_activation(label="Maps.add_gridlines(...)") + @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) + + @_submit_on_activation(label="Maps.add_compass(...)") + @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 + + @_submit_on_activation(label="Maps.add_scalebar(...)") + @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 + + @_submit_on_activation(label="Maps.add_logo(...)") + 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) + ) + + @_submit_on_activation(label="Maps.add_line(...)") + def add_line( + self, + xy, + xy_crs=4326, + connect="geod", + n=None, + del_s=None, + mark_points=None, + layer=None, + dynamic=False, + **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, + return_back_azimuth=True, + ) + + 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})") + if dynamic is True: + self.l[layer].add_artist(art) + else: + 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})") + + if dynamic is True: + self.l[layer].add_artist(art2) + else: + self.l[layer].add_bg_artist(art2) + + return out_d_int, out_d_tot + + @_submit_on_activation(label="Maps.add_title(...)") + def add_title(self, title, **kwargs): + """ + Convenience function to add a title to the map. + + If used multiple-times, the title will be updated instead of creating + a new artist that will be added 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. + + """ + if (t := getattr(self, "_title", None)) is not None: + kwargs["text"] = title + t.set(**kwargs) + self._bm.update(layers=(self.layer)) + return t + + kwargs["s"] = title + kwargs.setdefault("x", 0.5) + kwargs.setdefault("y", 1.01) + kwargs.setdefault("fontsize", "large") + kwargs.setdefault("horizontalalignment", "center") + kwargs.setdefault("verticalalignment", "bottom") + + self._title = self.add_text(**kwargs) + + @_submit_on_activation(label="Maps.add_text(...)") + @wraps(plt.Figure.text) + def add_text(self, *args, layer=None, **kwargs): + """Add text to the map.""" + + kwargs.setdefault("animated", True) + 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 + + @_submit_on_activation(label="Maps.add_extent_indicator(...)") + 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) + + @_submit_on_activation(label="Maps.add_marker(...)") + 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 + + @_submit_on_activation(label="Maps.add_annotation(...)") + 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 + + @_submit_on_activation(label="Maps.add_background_patch(...)") + 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 + + from functools import lru_cache + + @lru_cache + def _get_clip_path(self, shape, loc, size, n=200): + from matplotlib.markers import MarkerStyle + + if shape == "s": + verts = _get_rect_poly_verts(0, 0, 1, 1, n) + clip_path = mpath.Path(verts) + elif shape in [".", "o"]: + ang = np.linspace(0, 2 * np.pi, n) + verts = 2 * np.column_stack((np.sin(ang), np.cos(ang))) + clip_path = mpath.Path(verts) + else: + clip_path = MarkerStyle(shape).get_path() + + clip_bbox = clip_path.get_extents() + + # make sure shape is positioned according to "loc" assignment + if loc == "center": + xshift, yshift = ( + -clip_bbox.width / 2 - clip_bbox.x0, + -clip_bbox.height / 2 - clip_bbox.y0, + ) + else: + if loc.startswith("lower"): + yshift = -clip_bbox.y0 + elif loc.startswith("upper"): + yshift = -clip_bbox.y1 + elif loc.startswith("center"): + yshift = -clip_bbox.height / 2 - clip_bbox.y0 + + if loc.endswith("left"): + xshift = -clip_bbox.x0 + elif loc.endswith("right"): + xshift = -clip_bbox.x1 + elif loc.endswith("center"): + xshift = -clip_bbox.width / 2 - clip_bbox.x0 + + clip_path = clip_path.transformed(Affine2D().translate(xshift, yshift)) + + # scale to desired size (rel. to max. width/height) + maxs = max(clip_bbox.width, clip_bbox.height) + + if isinstance(size, (int, float, np.number)): + size = (size, size) + + clip_path = clip_path.transformed( + Affine2D().scale(size[0] / maxs, size[1] / maxs) + ) + return clip_path + + def add_peek_layer( + self, + layer="base", + xy=(0.5, 0.5), + shape="s", + size=(0.4, 0.4), + xy_crs="axes", + shape_crs="axes", + loc="center", + boundary=True, + n_shape_points=100, + dynamic=False, + **kwargs, + ): + """ + Overlay a part of the map with the content of another layer. + + + Parameters + ---------- + layer : str or list + + - if str: The name of the layer you want to peek at. + - if list: A list of layer-names of the following form: + + - A layer-name (string) + - A tuple (< layer-name >, < transparency [0-1] >) + + see `m.show_layer()` for more details on how to provide combined layer-names + xy: tuple + The position of the peek-shape (provided in the xy_crs coordinate system). + (see "loc" argument on the anchor of the position) + The default is (0.5, 0.5) in "axes" crs. + + shape : str, optional + The shape of the peek-window. + + - "s": peek a rectangle + - ".": peek a circle/ellipse + - "geod_circles": peek an ellipse with "size" defined in meters + - "left", "right", "top", "bottom": + Split the map from left (→), right (←), top (↓) or bottom (↑). + (size and shape_crs kwargs are ignored) + - "*": peek a star + - "$x^2$" peek a methematical equation + - (5, 0, 20) peek a regular 5-sided polygon at 20° angle + + Since the peek-shape generation uses the same methods as matplotlib + markers under the hood, any method explained here is possible: + https://matplotlib.org/stable/api/markers_api.html + + The default is "s" + + size: float or (float, float) + The size of the shape (provided in the "shape_crs" coordinate + system). + + - If shape_crs="axes": + + - a single number represents a fraction of the shorter size of + the axes (to get "square" shapes). + - tuple (xsize, ysize) represents axes fractions of each side. + - If shape="geod_circles": + "shape_crs" is ignored and the size is expected to be in meters. + - If shape=("left", "right", "top" or "bottom"), "size" is ignored. + + The default is (0.5, 0.5) in "axes" crs. + xy_crs: a CRS specifier + The coordinate system in which the xy-coordinates are provided. + + - if "axes": relative fraction of axes size + - if "plot": the crs of the map + - all other provided values are identified as pyproj-crs identifier + + The default is "axes" + shape_crs: str or a CRS specifier + The coordinate system in which the "size" of the shape is defined. + + - if "axes": size in relative fraction of axes width/height + - if "plot": size in the plot-crs of the map + - all other provided values are identified as pyproj-crs identifier + + If shape == "geod_circles", "left", "right", "top" or "bottom", + "shape_crs" is ignored! + + The default is "axes" + loc : str + The anchor at which the xy-coordinates are defined. + + - "center": The center of the shape + - a combination of + ("upper", "lower", "center") and ("left", "right", "center") + (e.g. "upper left" or "center right") to use the corresponding + position of the bounding-box of the shape as xy-anchor. + + If shape == "geod_circles", "left", "right", "top" or "bottom", + "shape_crs" is ignored! + + The default is "center" + boundary: bool or dict + Style settings for the boundary + + - False: don't draw any boundary line + - True: draw the default boundary line (1px black) + - dict: use the provided kwargs to style the boundary-line + (e.g. {"ec":"red", "lw": 4}) + + The default is True + n_shape_points: int + The number of intermediate points to evaluate for the peek-shape. + (only relevant for shape=".", "s" or "geod_circles") + The default is 100 + dynamic : bool + If True, artists are added as "dynamic" artists, otherwise + artists are added as "background_artists". + + Additional Parameters + --------------------- + alpha : float, optional + The transparency of the peeked layer. (between 0 and 1) + If you overlay a (possibly transparent) combination of multiple layers, + this transparency will be assigned as a global transparency for the + obtained "combined layer". + The default is 1. + **kwargs : + additional kwargs passed to plt.imshow() + (e.g. "alpha=0.5" for 50% transparency) + + + Examples + -------- + Overlay a single layer: + + >>> m = Maps() + >>> m.add_feature.preset.coastline() + >>> m["ocean"].add_feature.preset.ocean() + >>> m.cb.click.attach.peek_layer("ocean", size=.3, shape=".") + + Overlay a (transparent) combination of multiple layers: + + >>> m = Maps(Maps.CRS.Stereographic()) + >>> m.all.add_feature.preset.coastline() + >>> m.add_feature.preset.urban_areas() + >>> m["ocean"]add_feature.preset.ocean() + >>> m["land"].add_feature.physical.land(fc="g") + >>> m.cb.click.attach.peek_layer( + >>> ["ocean", ("land", 0.5)], shape=".", shape_crs=4326, size=(15, 15) + >>> ) + + """ + if not isinstance(layer, str): + layer = self._bm._get_combined_layer_name(*layer) + + if boundary: + bnd_kwargs = { + "fc": "none", + "ec": "k", + "lw": 1.1, + "zorder": 100, + "animated": True, + } + if isinstance(boundary, dict): + bnd_kwargs.update(boundary) + elif boundary is False: + pass + else: + raise TypeError( + "EOmaps peek-boundary must be either True/False or a dict of style-kwargs" + ) + + t_ax_data = self.ax.transAxes + self.ax.transData.inverted() + + if shape_crs == "axes": + if np.size(size) == 1: + # to allow "square" or "circular" peek-shapes, we have to + # scale with respect to the axes width/height ratio as well + asp = self.ax.bbox.width / self.ax.bbox.height + if asp > 1: + tasp = Affine2D().scale(1 / asp, 1) + else: + tasp = Affine2D().scale(1, asp) + + t = tasp + t_ax_data + else: + t = t_ax_data + + t_peek_plot = lambda x, y: t.transform(np.column_stack((x, y))).T + t_plot_peek = lambda x, y: t.inverted().transform(np.column_stack((x, y))).T + xlim, ylim = (0, 1), (0, 1) + else: + if ( + shape_crs == "plot" + or (shape_ccrs := self._get_cartopy_crs(shape_crs)) == self.crs_plot + ): + t_peek_plot = t_plot_peek = lambda x, y: (x, y) + xlim, ylim = ( + None, + None, + ) # self.crs_plot.x_limits, self.crs_plot.y_limits + else: + # t_peek_plot = self._get_transformer(shape_crs, self.crs_plot).transform + # t_plot_peek = self._get_transformer(self.crs_plot, shape_crs).transform + + tpepl = ( + shape_ccrs._as_mpl_transform(self.ax) + self.ax.transData.inverted() + ) + t_plot_peek = ( + lambda x, y: tpepl.inverted().transform(np.column_stack((x, y))).T + ) + t_peek_plot = lambda x, y: tpepl.transform(np.column_stack((x, y))).T + + limcrs = self._get_cartopy_crs(shape_crs) + xlim, ylim = limcrs.x_limits, limcrs.y_limits + + if xy_crs == "axes": + t_xy_plot = lambda x, y: t_ax_data.transform(np.column_stack((x, y))).T + elif xy_crs == "plot": + t_xy_plot = lambda x, y: (x, y) + else: + t_xy_plot = self._get_transformer(xy_crs, self.crs_plot).transform + + # isinstance call is required to support numpy-arrays of vertices for shape + if isinstance(shape, str) and shape in ("left", "right", "top", "bottom"): + (x0, x1), (y0, y1) = self.ax.get_xlim(), self.ax.get_ylim() + x, y = t_xy_plot(*xy) + + # base transformations on transData to ensure correct treatment + # for shared axes + if shape == "left": + x1 = x + elif shape == "right": + x0 = x + elif shape == "top": + y0 = y + elif shape == "bottom": + y1 = y + + clip_path = mpath.Path( + _get_rect_poly_verts(x0, y0, x1, y1, n_shape_points), + ( + mpath.Path.MOVETO, + *[mpath.Path.LINETO] * (4 * n_shape_points - 2), + mpath.Path.CLOSEPOLY, + ), + ) + else: + if isinstance(shape, str) and shape == "geod_circle": + assert ( + np.size(size) == 1 + ), f"Size must be a number for shape={shape}, not {size}" + lon, lat = self.transform_plot_to_lonlat(*t_xy_plot(*xy)) + shp = self.set_shape._get("geod_circles") + + vx, vy = shp._calc_geod_circle_points( + lon=np.atleast_1d(lon), + lat=np.atleast_1d(lat), + radius=size, + n=n_shape_points, + ) + + # antimeridean "wrapping" + if (abs(np.diff(vx)) > 300).any(): + vx[vx < 0] = vx[vx < 0] % 360 + + verts = np.column_stack( + self.transform_lonlat_to_plot(vx.squeeze(), vy.squeeze()) + )[::-1] + + else: + t_xy_peek = lambda x, y: t_plot_peek(*t_xy_plot(x, y)) + + # translate to desired position (in peek-crs) + clip_path = self._get_clip_path(shape, loc, size, n_shape_points) + clip_path = clip_path.transformed(Affine2D().translate(*t_xy_peek(*xy))) + verts = clip_path.vertices.T + + verts = np.column_stack(t_peek_plot(*verts)) + x, y = clip_path.vertices.T + + # TODO find a way to replace infinities with appropriate points + # on the map boundary + mask = np.all(np.isfinite(verts), axis=1) + verts = verts[mask] + # linear rings require at least 4 coordinates + if verts.size <= 4: + return None + + if shape_crs == "axes" and shape != "geod_circle": + # no need for antimeridean wrapping if "axes" transform is used + # clip with respect to peek-crs limits + if xlim is not None: + verts[:, 0] = np.clip(verts[:, 0], *xlim) + if ylim is not None: + verts[:, 1] = np.clip(verts[:, 1], *ylim) + + # transform back to plot-crs + x, y = t_peek_plot(x, y) + + mask = np.logical_and(np.isfinite(x), np.isfinite(y)) + + # we need to clip with respect to plot-crs limits to avoid issues + xlim, ylim = self.crs_plot.x_limits, self.crs_plot.y_limits + if xlim is not None: + x = np.clip(x[mask], *xlim) + if ylim is not None: + y = np.clip(y[mask], *ylim) + + if len(x) <= 4: + return + + verts = np.column_stack((x, y)) + # transform to plot crs + clip_path = mpath.Path( + verts, clip_path.codes, clip_path._interpolation_steps + ) + else: + clip_path = mpath.Path( + verts, + ( + mpath.Path.MOVETO, + *[mpath.Path.LINETO] * (len(verts) - 2), + mpath.Path.CLOSEPOLY, + ), + ) + + if shape == "geod_circle": + self._gcp = clip_path + else: + + self._cp = clip_path + + argb = self._bm._get_restore_bg_img(layer) + + kwargs.setdefault("interpolation", "nearest") + kwargs.setdefault("interpolation_stage", "data") + kwargs.setdefault("animated", True) + kwargs.setdefault("zorder", 100) + kwargs.setdefault("resample", False) + + xt = argb.get_extents() + art = plt.Axes.imshow( + self.ax, + argb, + origin="upper", + extent=[xt[0], xt[2], xt[1], xt[3]], + transform=None, + **kwargs, + ) + + if clip_path is not None: + if boundary: + patch = PathPatch(clip_path, **bnd_kwargs) + marker = self.ax.add_patch(patch) + + if dynamic is True: + self.add_artist(marker) + else: + self.add_bg_artist(marker) + + # create a TransformedPath as needed for clipping + clip_path = TransformedPath( + clip_path, self.ax.projection._as_mpl_transform(self.ax) + ) + + art.set_clip_path(clip_path) + + # remember buffer object for comparison + art._peek_bufr = argb + + if dynamic is True: + self.add_artist(art) + else: + self.add_bg_artist(art) + + def update_peek_image(*args, **kwargs): + argb = self._bm._get_restore_bg_img(layer) + # only redraw if a new buffer has been obtained + # (use this to avoid costly equality checks and redraws) + if art._peek_bufr is argb: + return + + art._peek_bufr = argb + art.set_data(argb) + + self._bm.add_hook("after_fetch_bg", update_peek_image) + + def remove_method(*args, **kwargs): + try: + art._orig_remove_method(*args, **kwargs) + except ValueError: + # ValueError is returned in case the artist has already + # been removed when this function triggers + pass + finally: + self._bm.remove_hook("after_fetch_bg", update_peek_image) + + art._orig_remove_method = art._remove_method + art._remove_method = remove_method diff --git a/eomaps/mixins/callback_mixin.py b/eomaps/mixins/callback_mixin.py new file mode 100644 index 000000000..69ffd13a4 --- /dev/null +++ b/eomaps/mixins/callback_mixin.py @@ -0,0 +1,33 @@ +from ..callback_container import CallbackContainer +from ..helpers import _from_parent + + +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() + + @property + @_from_parent + def execute_callbacks(self): + """ + Indicator if callbacks are executed or not. + + If set to False, no callback functions are triggered! + (The set value is shared across all Maps-objects of a figure) + + """ + try: + return self.__execute_callbacks + except AttributeError: + self.__execute_callbacks = True + return self.__execute_callbacks + + @execute_callbacks.setter + @_from_parent + def execute_callbacks(self, value): + self.__execute_callbacks = value 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..bf26c4a11 --- /dev/null +++ b/eomaps/mixins/data_mixin.py @@ -0,0 +1,1453 @@ +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, + _submit_on_activation, +) +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 = Shapes(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 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] + + @_submit_on_activation(label="Maps.set_data(...)", default_lazy=False) + 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() + + @_submit_on_activation(label="Maps.inherit_data(...)", default_lazy=False) + 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 + + @_submit_on_activation(label="Maps.inherit_classification(...)", default_lazy=False) + 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 + + @_submit_on_activation(label="Maps.inherit_shape(...)", default_lazy=False) + def inherit_shape(self, m): + """ + Use the same shape to plot the data as assigned to "m". + + Parameters + ---------- + m : eomaps.Maps or None + The Maps-object that provides the shape definition. + """ + if m._shape_assigned: + getattr(self.set_shape, m.shape.name)(**m.shape._initargs) + + @_submit_on_activation(label="Maps.plot_map(...)", default_lazy=False) + 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._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 + # don't set extent if layer is not explicitly visible to avoid + # changing the plot-extent when peeking on a layer that is not yet fetched + if ( + set_extent + and self._set_extent_on_plot + and self._bm._layer_visible(layer) + ): + # 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() + + @_submit_on_activation(label="Maps.add_colorbar(...)", default_lazy=False) + @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 + + @_submit_on_activation(label="Maps.make_dataset_pickable(...)", default_lazy=False) + 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): + if all(zdata.mask): + if _log.getEffectiveLevel() <= logging.DEBUG: + _log.debug("EOmaps: No data to plot after masking!") + + return + + df = pd.DataFrame( + dict( + x=x0[~zdata.mask].ravel(), + y=y0[~zdata.mask].ravel(), + val=zdata[~zdata.mask].compressed(), + ), + copy=False, + ) + else: + 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() + + # don't set extent if layer is not explicitly visible to avoid + # changing the plot-extent when peeking on a layer that is not yet fetched + if ( + set_extent is True + and self._set_extent_on_plot is True + and self._bm._layer_visible(layer) + ): + # 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) + self._coll_dynamic = True + 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..d36ba373e --- /dev/null +++ b/eomaps/mixins/gpd_mixin.py @@ -0,0 +1,493 @@ +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 ..callback_container import GeoDataFramePicker +from ..helpers import ( + _get_rect_poly_verts, + register_modules, + progressbar, + _submit_on_activation, +) + + +class GeopandasMixin: + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + 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 + + @_submit_on_activation(label="Maps.add_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 _get_gdf_path_boundary(self, gdf): + 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) + return path + + def _set_gdf_path_boundary(self, gdf, set_extent=True): + path = self._get_gdf_path_boundary(gdf) + + self.ax.set_boundary(path, self.ax.transData) + if set_extent: + vertices = path.vertices + 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..286f117b9 --- /dev/null +++ b/eomaps/mixins/tools_mixin.py @@ -0,0 +1,40 @@ +import weakref +from functools import wraps + +from ..utilities import Utilities +from ..drawer 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.util = self.parent._ToolsMixin__util + + # do this on init to avoid confusing sphinx + self.draw = self.__draw + + super().__init__(*args, **kwargs) + + @property + def __draw(self): + # avoid initializing draw on init of Maps object + # to reduce init-time + if not hasattr(self, "_draw"): + self._draw = ShapeDrawer(weakref.proxy(self)) + return self._draw + + @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..e87df1a62 100644 --- a/eomaps/ne_features.py +++ b/eomaps/ne_features.py @@ -12,7 +12,7 @@ from cartopy import crs as ccrs -from .helpers import register_modules +from .helpers import register_modules, _submit_on_activation _log = logging.getLogger(__name__) @@ -94,6 +94,19 @@ class _Category: _category = "???" + def __init__(self, m): + self._m = m + + def __getattribute__(self, key): + if key.startswith("_"): + return object.__getattribute__(self, key) + elif key in _NE_features_all.get(self._category, []): + feature = _Feature(self._category, key) + feature._set_map(self._m) + return feature + else: + return object.__getattribute__(self, key) + def __repr__(self): return f"EOmaps interface for {self._category} " + "NaturalEarth features" @@ -110,15 +123,6 @@ def _setup(cls, category): _Category.__doc__, ) - def _set_map(self, m): - for feature_name in filter(lambda x: not x.startswith("_"), dir(self)): - try: - feature = _Feature(self._category, feature_name) - feature._set_map(m) - setattr(self, feature_name, feature) - except Exception: - _log.error(f"EOmaps: unable to set map for feature {feature}") - class _Feature: """ @@ -208,6 +212,7 @@ def __init__(self, category, name, scale=None): def _set_map(self, m): self._m = m + @_submit_on_activation("_m", "Maps.add_feature.{_category}.{_name}(...)") def __call__(self, layer=None, scale="auto", **kwargs): assert hasattr( self, "_m" @@ -219,6 +224,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 +267,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": @@ -457,6 +463,57 @@ def __call__(self, scale=50, layer=None, **kwargs): class _Physical(_Category): _category = "physical" + antarctic_ice_shelves_lines: _Feature + antarctic_ice_shelves_polys: _Feature + bathymetry_A_10000: _Feature + bathymetry_B_9000: _Feature + bathymetry_C_8000: _Feature + bathymetry_D_7000: _Feature + bathymetry_E_6000: _Feature + bathymetry_F_5000: _Feature + bathymetry_G_4000: _Feature + bathymetry_H_3000: _Feature + bathymetry_I_2000: _Feature + bathymetry_J_1000: _Feature + bathymetry_K_200: _Feature + bathymetry_L_0: _Feature + coastline: _Feature + geographic_lines: _Feature + geography_marine_polys: _Feature + geography_regions_elevation_points: _Feature + geography_regions_points: _Feature + geography_regions_polys: _Feature + glaciated_areas: _Feature + graticules_1: _Feature + graticules_10: _Feature + graticules_15: _Feature + graticules_20: _Feature + graticules_30: _Feature + graticules_5: _Feature + lakes: _Feature + lakes_australia: _Feature + lakes_europe: _Feature + lakes_historic: _Feature + lakes_north_america: _Feature + lakes_pluvial: _Feature + land: _Feature + land_ocean_label_points: _Feature + land_ocean_seams: _Feature + land_scale_rank: _Feature + minor_islands: _Feature + minor_islands_coastline: _Feature + minor_islands_label_points: _Feature + ocean: _Feature + ocean_scale_rank: _Feature + playas: _Feature + reefs: _Feature + rivers_australia: _Feature + rivers_europe: _Feature + rivers_lake_centerlines: _Feature + rivers_lake_centerlines_scale_rank: _Feature + rivers_north_america: _Feature + wgs84_bounding_box: _Feature + _Physical._setup("physical") @@ -464,6 +521,88 @@ class _Physical(_Category): class _Cultural(_Category): _category = "cultural" + admin_0_antarctic_claim_limit_lines: _Feature + admin_0_antarctic_claims: _Feature + admin_0_boundary_lines_disputed_areas: _Feature + admin_0_boundary_lines_land: _Feature + admin_0_boundary_lines_map_units: _Feature + admin_0_boundary_lines_maritime_indicator: _Feature + admin_0_boundary_lines_maritime_indicator_chn: _Feature + admin_0_boundary_map_units: _Feature + admin_0_breakaway_disputed_areas: _Feature + admin_0_countries: _Feature + admin_0_countries_arg: _Feature + admin_0_countries_bdg: _Feature + admin_0_countries_bra: _Feature + admin_0_countries_chn: _Feature + admin_0_countries_deu: _Feature + admin_0_countries_egy: _Feature + admin_0_countries_esp: _Feature + admin_0_countries_fra: _Feature + admin_0_countries_gbr: _Feature + admin_0_countries_grc: _Feature + admin_0_countries_idn: _Feature + admin_0_countries_ind: _Feature + admin_0_countries_iso: _Feature + admin_0_countries_isr: _Feature + admin_0_countries_ita: _Feature + admin_0_countries_jpn: _Feature + admin_0_countries_kor: _Feature + admin_0_countries_lakes: _Feature + admin_0_countries_mar: _Feature + admin_0_countries_nep: _Feature + admin_0_countries_nld: _Feature + admin_0_countries_pak: _Feature + admin_0_countries_pol: _Feature + admin_0_countries_prt: _Feature + admin_0_countries_pse: _Feature + admin_0_countries_rus: _Feature + admin_0_countries_sau: _Feature + admin_0_countries_swe: _Feature + admin_0_countries_tlc: _Feature + admin_0_countries_tur: _Feature + admin_0_countries_twn: _Feature + admin_0_countries_ukr: _Feature + admin_0_countries_usa: _Feature + admin_0_countries_vnm: _Feature + admin_0_disputed_areas: _Feature + admin_0_disputed_areas_scale_rank_minor_islands: _Feature + admin_0_label_points: _Feature + admin_0_map_subunits: _Feature + admin_0_map_units: _Feature + admin_0_pacific_groupings: _Feature + admin_0_scale_rank: _Feature + admin_0_scale_rank_minor_islands: _Feature + admin_0_seams: _Feature + admin_0_sovereignty: _Feature + admin_0_tiny_countries: _Feature + admin_0_tiny_countries_scale_rank: _Feature + admin_1_label_points: _Feature + admin_1_label_points_details: _Feature + admin_1_seams: _Feature + admin_1_states_provinces: _Feature + admin_1_states_provinces_lakes: _Feature + admin_1_states_provinces_lines: _Feature + admin_1_states_provinces_scale_rank: _Feature + admin_2_counties: _Feature + admin_2_counties_lakes: _Feature + admin_2_counties_scale_rank: _Feature + admin_2_counties_scale_rank_minor_islands: _Feature + admin_2_label_points: _Feature + admin_2_label_points_details: _Feature + airports: _Feature + parks_and_protected_lands: _Feature + populated_places: _Feature + populated_places_simple: _Feature + ports: _Feature + railroads: _Feature + railroads_north_america: _Feature + roads: _Feature + roads_north_america: _Feature + time_zones: _Feature + urban_areas: _Feature + urban_areas_landscan: _Feature + _Cultural._setup("cultural") @@ -523,7 +662,7 @@ def _feature_names(self): return [i for i in self.__dir__() if not i.startswith("_")] @property - def coastline(self): + def coastline(self) -> _Feature: """ Add a coastline to the map. @@ -544,7 +683,7 @@ def coastline(self): ) @property - def ocean(self): + def ocean(self) -> _Feature: """ Add ocean-coloring to the map. @@ -562,7 +701,7 @@ def ocean(self): ) @property - def land(self): + def land(self) -> _Feature: """ Add a land-coloring to the map. @@ -581,7 +720,7 @@ def land(self): ) @property - def countries(self): + def countries(self) -> _Feature: """ Add country-boundaries to the map. @@ -603,7 +742,7 @@ def countries(self): ) @property - def urban_areas(self): + def urban_areas(self) -> _Feature: """ Add urban-areas to the map. @@ -624,7 +763,7 @@ def urban_areas(self): ) @property - def lakes(self): + def lakes(self) -> _Feature: """ Add lakes to the map. @@ -645,7 +784,7 @@ def lakes(self): ) @property - def rivers_lake_centerlines(self): + def rivers_lake_centerlines(self) -> _Feature: """ Add rivers_lake_centerlines to the map. @@ -714,19 +853,16 @@ class NaturalEarthFeatures: """ - preset = NaturalEarthPresets - cultural = _Cultural - physical = _Physical + preset: NaturalEarthPresets = NaturalEarthPresets + cultural: _Cultural = _Cultural + physical: _Physical = _Physical def __init__(self, m): self._m = m - self.preset = self.preset(self._m) - self.cultural = _Cultural() - self.cultural._set_map(m) - - self.physical = _Physical() - self.physical._set_map(m) + self.preset = NaturalEarthPresets(self._m) + self.cultural = _Cultural(self._m) + self.physical = _Physical(self._m) def __call__(self, category, name, **kwargs): feature = self._get_feature(category, name) 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..5408eee9d 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) @@ -365,13 +365,13 @@ def populate_dropdown(self, *args, **kwargs): for i, m in enumerate(self.identify_pick_map()): if m.data_specs.parameter is not None: - name = f"{i}: {m.data_specs.parameter}" + name = f"{m.name}: {m.data_specs.parameter}" else: - name = f"{i}" + name = f"{m.name}" # 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) @@ -461,14 +461,14 @@ def remove_callback(self, key): # explicitly check if the callback is attached to avoid warnings if # the figure is closed while a callback is still attached # (this way cleanup might have already removed the callback) - if cid in m.cb.pick.get.attached_callbacks: + if cid in m.cb.pick.attached_callbacks: m.cb.pick.remove(cid) else: - if cid in m.cb.click.get.attached_callbacks: + if cid in m.cb.click.attached_callbacks: 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..8372545f5 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.l[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,13 @@ 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) + for m in list(self.m._bm._children._get_maps(layer)): + m.cleanup() + 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 +895,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 +905,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 +927,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 +967,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 +999,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 +1032,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 +1042,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 +1093,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 +1131,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 +1174,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 +1183,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 +1240,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 +1381,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 +1413,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 +1448,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 +1456,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 +1478,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 +1490,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 +1547,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 +1577,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 +1586,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 +1628,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 +1686,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 +1702,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..5711bd1a2 100644 --- a/eomaps/qtcompanion/widgets/peek.py +++ b/eomaps/qtcompanion/widgets/peek.py @@ -6,7 +6,7 @@ from qtpy import QtWidgets, QtGui from qtpy.QtCore import Qt, Signal, QSize, Slot -from .layer import AutoUpdatePeekLayerDropdown, AutoUpdateLayerMenuButton +from .layer import AutoUpdatePeekLayerDropdown from ..common import iconpath peek_methods = ( @@ -71,9 +71,9 @@ def __init__(self, *args, **kwargs): self._method = "?" self.rectangle_size = 1 - self.how = (self.rectangle_size, self.rectangle_size) + self.size = (self.rectangle_size, self.rectangle_size) self.alpha = 1 - self.shape = "rectangular" + self.shape = "s" self.buttons = dict() self.rect_button = ( @@ -266,41 +266,26 @@ def method_changed(self, method): val.setIcon(peek_icons[f"{key}"]) if method == "rectangle": - self.shape = "rectangular" + self.shape = "s" self.rectangle_slider.show() - if self.rectangle_size < 0.99: - self.how = (self.rectangle_size, self.rectangle_size) - else: - self.how = "full" + self.size = (self.rectangle_size, self.rectangle_size) elif method == "square": - self.shape = "rectangular" + self.shape = "s" self.rectangle_slider.show() - if self.rectangle_size < 0.99: - self.how = self.rectangle_size - else: - self.how = "full" - + self.size = self.rectangle_size elif method == "ellipse": self.rectangle_slider.show() - if self.rectangle_size < 0.99: - self.how = (self.rectangle_size, self.rectangle_size) - self.shape = "round" - else: - self.how = "full" - self.shape = "rectangular" - + self.size = (self.rectangle_size, self.rectangle_size) + self.shape = "." elif method == "circle": self.rectangle_slider.show() - if self.rectangle_size < 0.99: - self.how = self.rectangle_size - self.shape = "round" - else: - self.how = "full" - self.shape = "rectangular" - - else: + self.size = self.rectangle_size + self.shape = "." + elif method in ("left", "right", "top", "bottom"): self.rectangle_slider.hide() - self.how = method + self.shape = method + else: + raise TypeError(f"Handling of peek-method {method} not implemented") class ModifierInput(QtWidgets.QLineEdit): @@ -404,7 +389,7 @@ def set_layer_callback(self, l): self.cid = self.m.all.cb.click.attach.peek_layer( layer=l, - how=self.buttons.how, + size=self.buttons.size, alpha=self.buttons.alpha, modifier=modifier, shape=self.buttons.shape, @@ -428,7 +413,7 @@ def add_peek_cb(self): self.cid = self.m.all.cb.click.attach.peek_layer( layer=self.current_layer, - how=self.buttons.how, + size=self.buttons.size, alpha=self.buttons.alpha, modifier=modifier, shape=self.buttons.shape, @@ -442,12 +427,14 @@ 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: - if self.cid in self.m.all.cb.click.get.attached_callbacks: + if self.cid in self.m.all.cb.click.attached_callbacks: self.m.all.cb.click.remove(self.cid) + self.m.all.cb.click._clear_temporary_artists() + self.m._bm._clear_temp_artists("click") self.cid = None @@ -510,7 +497,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/qtcompanion/widgets/wms.py b/eomaps/qtcompanion/widgets/wms.py index f4cc56631..115cf88fc 100644 --- a/eomaps/qtcompanion/widgets/wms.py +++ b/eomaps/qtcompanion/widgets/wms.py @@ -86,9 +86,9 @@ def __init__(self, m=None): self.wmslayers = [] _log_problem(self.name) - def do_add_layer(self, wmslayer, layer): + def do_add_layer(self, wmslayer): wms = getattr(self.m.add_wms.GEBCO.add_layer, wmslayer) - wms(layer=layer, transparent=True) + wms(transparent=True) self.ask_for_legend(wms, wmslayer) @@ -108,9 +108,9 @@ def __init__(self, m=None): self.wmslayers = [] _log_problem(self.name) - def do_add_layer(self, wmslayer, layer): + def do_add_layer(self, wmslayer): wms = getattr(self.m.add_wms.GMRT.add_layer, wmslayer) - wms(layer=layer, transparent=True) + wms(transparent=True) self.ask_for_legend(wms, wmslayer) @@ -130,9 +130,9 @@ def __init__(self, m=None): self.wmslayers = [] _log_problem(self.name) - def do_add_layer(self, wmslayer, layer): + def do_add_layer(self, wmslayer): wms = getattr(self.m.add_wms.GLAD.add_layer, wmslayer) - wms(layer=layer, transparent=True) + wms(transparent=True) self.ask_for_legend(wms, wmslayer) @@ -152,9 +152,9 @@ def __init__(self, m=None): self.wmslayers = [] _log_problem(self.name) - def do_add_layer(self, wmslayer, layer): + def do_add_layer(self, wmslayer): wms = getattr(self.m.add_wms.GOOGLE.add_layer, wmslayer) - wms(layer=layer, transparent=True) + wms(transparent=True) self.ask_for_legend(wms, wmslayer) @@ -174,9 +174,9 @@ def __init__(self, m=None): self.wmslayers = [] _log_problem(self.name) - def do_add_layer(self, wmslayer, layer): + def do_add_layer(self, wmslayer): wms = getattr(self.m.add_wms.CAMS.add_layer, wmslayer) - wms(layer=layer, transparent=True) + wms(transparent=True) self.ask_for_legend(wms, wmslayer) @@ -208,9 +208,9 @@ def __init__(self, m=None): self.wmslayers = [] _log_problem(self.name) - def do_add_layer(self, wmslayer, layer): + def do_add_layer(self, wmslayer): wms = getattr(self.usewms.add_layer, wmslayer) - wms(layer=layer, transparent=True) + wms(transparent=True) self.ask_for_legend(wms, wmslayer) @@ -250,7 +250,7 @@ def __init__(self, m=None): self.wmslayers = [*self._AT_layers, *self._Wien_layers, *self._Wien_data_layers] - def do_add_layer(self, wmslayer, layer): + def do_add_layer(self, wmslayer): if wmslayer in self._AT_layers: wms = getattr( self.m.add_wms.Austria.AT_basemap.add_layer, @@ -267,7 +267,7 @@ def do_add_layer(self, wmslayer, layer): remove_prefix(wmslayer, "WienData__"), ) - wms(layer=layer, transparent=True) + wms(transparent=True) self.ask_for_legend(wms, wmslayer) @@ -293,7 +293,7 @@ def __init__(self, m=None): ] ) - def do_add_layer(self, wmslayer, layer): + def do_add_layer(self, wmslayer): wms = None @@ -312,10 +312,10 @@ def do_add_layer(self, wmslayer, layer): break if wms is None: - _log.error(f"EOaps: WebMap layer {wmslayer}, {layer} not found") + _log.error(f"EOaps: WebMap layer {wmslayer} not found") return - wms(layer=layer, transparent=True) + wms(transparent=True) self.ask_for_legend(wms, wmslayer) @@ -416,7 +416,7 @@ def __init__(self, m=None): self.wmslayers += self._OSM_openrailwaymap self.wmslayers += self._OSM_cartodb - def do_add_layer(self, wmslayer, layer): + def do_add_layer(self, wmslayer): wms = None @@ -440,7 +440,7 @@ def do_add_layer(self, wmslayer, layer): if wms is None: wms = getattr(self.m.add_wms.OpenStreetMap.add_layer, wmslayer) - wms(layer=layer, transparent=True) + wms(transparent=True) self.ask_for_legend(wms, wmslayer) @@ -456,9 +456,9 @@ def __init__(self, m=None): self.wmslayers = [] _log_problem(self.name) - def do_add_layer(self, wmslayer, layer): + def do_add_layer(self, wmslayer): wms = getattr(self.m.add_wms.S2_cloudless.add_layer, wmslayer) - wms(layer=layer) + wms() self.ask_for_legend(wms, wmslayer) @@ -474,9 +474,9 @@ def __init__(self, m=None): self.wmslayers = [] _log_problem(self.name) - def do_add_layer(self, wmslayer, layer): + def do_add_layer(self, wmslayer): wms = getattr(self.m.add_wms.ESA_WorldCover.add_layer, wmslayer) - wms(layer=layer) + wms() self.ask_for_legend(wms, wmslayer) @@ -488,9 +488,9 @@ def __init__(self, m=None): self.m = m self.wmslayers = ["vv", "vh"] - def do_add_layer(self, wmslayer, layer): + def do_add_layer(self, wmslayer): wms = getattr(self.m.add_wms.S1GBM.add_layer, wmslayer) - wms(layer=layer) + wms() self.ask_for_legend(wms, wmslayer) @@ -518,12 +518,12 @@ def __init__(self, m=None): except Exception: _log_problem(f"ISRIC_SoilGrids {subs}") - def do_add_layer(self, wmslayer, layer): + def do_add_layer(self, wmslayer): sub = wmslayer.split("_", 1)[0] wms = getattr(getattr(self.m.add_wms.ISRIC_SoilGrids, sub).add_layer, wmslayer) - wms(layer=layer) + wms() self.ask_for_legend(wms, wmslayer) @@ -544,11 +544,11 @@ def __init__(self, m=None): except Exception: _log_problem(f"DLR_{name}") - def do_add_layer(self, wmslayer, layer): + def do_add_layer(self, wmslayer): name, wmslayer = wmslayer.split("__", 1) wms = getattr(getattr(self.m.add_wms.DLR, name).add_layer, wmslayer) - wms(layer=layer, transparent=True) + wms(transparent=True) self.ask_for_legend(wms, wmslayer) @@ -583,7 +583,7 @@ def __init__(self, m=None): self.wmslayers += self._moon self.wmslayers += self._mars - def do_add_layer(self, wmslayer, layer): + def do_add_layer(self, wmslayer): wms = None @@ -602,7 +602,7 @@ def do_add_layer(self, wmslayer, layer): _log.error("EOmaps: the wms service {wmslayer} does not exist") return - wms(layer=layer, transparent=True) + wms(transparent=True) self.ask_for_legend(wms, wmslayer) @@ -877,10 +877,9 @@ def wms_cb(): self.window().statusBar().repaint() wmsclass = self.wms_dict[wmsname] - wms = wmsclass(m=self.m) if self._new_layer: - layer = wms.layer_prefix + wmslayer + layer = wmsclass.layer_prefix + wmslayer # indicate creation of new layer in statusbar self.window().statusBar().showMessage( f"New WebMap layer '{layer}' created!", 5000 @@ -900,7 +899,9 @@ def wms_cb(): ) self.window().statusBar().repaint() - wms.do_add_layer(wmslayer, layer=layer) + wms = wmsclass(m=self.m.l[layer]) + + wms.do_add_layer(wmslayer) # update the cached layer-names if necessary self._update_layer_cache(wmsname, wms.wmslayers) 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..667d8c89a 100644 --- a/eomaps/shapes.py +++ b/eomaps/shapes.py @@ -16,7 +16,7 @@ from pyproj import CRS import numpy as np -from .helpers import register_modules, version, mpl_version +from .helpers import register_modules, version, mpl_version, _submit_on_activation _log = logging.getLogger(__name__) @@ -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) @@ -607,6 +609,9 @@ class _GeodCircles(_CircularShapeBase): def __init__(self, m): super().__init__(m=m) + @_submit_on_activation( + maps_attr="_m", label="Maps.set_shape.{name}(...)", default_lazy=False + ) def __call__(self, radius=None, n=None): """ Draw geodesic circles with a radius defined in meters. @@ -657,9 +662,9 @@ def _calc_geod_circle_points(self, lon, lat, radius, n=20, start_angle=0): the latitudes of the geodetic circle points. """ + lon, lat = np.atleast_1d(lon), np.atleast_1d(lat) 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 +677,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 +801,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 +2213,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 +2296,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..dff832a20 100644 --- a/eomaps/utilities.py +++ b/eomaps/utilities.py @@ -149,11 +149,11 @@ 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: - self._m.cb.execute_callbacks(False) + self._m.execute_callbacks = False self.mouse_x = evt.mouseevent.x self.mouse_y = evt.mouseevent.y self.got_artist = True @@ -162,7 +162,7 @@ def on_pick(self, evt): def on_release(self, event): if self._check_still_parented() and self.got_artist: - self._m.cb.execute_callbacks(True) + self._m.execute_callbacks = True self.finalize_offset() self.got_artist = False self.canvas.mpl_disconnect(self._c1) @@ -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..0ce76ac6a 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)" + "Maps.add_wms.ESA_WorldCover.add_layer." "(...)" ) WMS.__doc__ = type(self).ESA_WorldCover.__doc__ @@ -258,7 +264,7 @@ def GEBCO(self): ) WMS._EOmaps_info = type(self).GEBCO.__doc__ - WMS._EOmaps_source_code = "m.add_wms.GEBCO.add_layer.(transparent=True)" + WMS._EOmaps_source_code = "Maps.add_wms.GEBCO.add_layer.(...)" WMS.__doc__ = type(self).GEBCO.__doc__ return WMS @@ -300,9 +306,7 @@ def GMRT(self): url="https://www.gmrt.org/services/mapserver/wms_merc?request=GetCapabilities&service=WMS&version=1.3.0", ) WMS._EOmaps_info = type(self).GMRT.__doc__ - WMS._EOmaps_source_code = ( - "m.add_wms.GMRT.add_layer." f"(transparent=True)" - ) + WMS._EOmaps_source_code = "Maps.add_wms.GMRT.add_layer." "(...)" WMS.__doc__ = type(self).GMRT.__doc__ return WMS @@ -328,9 +332,7 @@ def GLAD(self): url="https://glad.umd.edu/mapcache/?SERVICE=WMS", ) WMS._EOmaps_info = type(self).GLAD.__doc__ - WMS._EOmaps_source_code = ( - "m.add_wms.GLAD.add_layer." f"(transparent=True)" - ) + WMS._EOmaps_source_code = "Maps.add_wms.GLAD.add_layer." "(...)" WMS.__doc__ = type(self).GLAD.__doc__ return WMS @@ -372,9 +374,7 @@ def NASA_GIBS(self): url="https://gibs.earthdata.nasa.gov/wmts/epsg4326/all/1.0.0/WMTSCapabilities.xml", ) WMS._EOmaps_info = type(self).NASA_GIBS.__doc__ - WMS._EOmaps_source_code = ( - "m.add_wms.NASA_GIBS.add_layer." f"(transparent=True)" - ) + WMS._EOmaps_source_code = "Maps.add_wms.NASA_GIBS.add_layer." "(...)" WMS.__doc__ = type(self).NASA_GIBS.__doc__ return WMS @@ -394,7 +394,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)" + "Maps.add_wms.NASA_GIBS.EPSG_4326.add_layer." "(...)" ) WMS.__doc__ = WebMapContainer.NASA_GIBS.__doc__ return WMS @@ -409,7 +409,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)" + "Maps.add_wms.NASA_GIBS.EPSG_3857.add_layer." "(...)" ) WMS.__doc__ = WebMapContainer.NASA_GIBS.__doc__ return WMS @@ -424,7 +424,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)" + "Maps.add_wms.NASA_GIBS.EPSG_3413.add_layer." "(...)" ) WMS.__doc__ = WebMapContainer.NASA_GIBS.__doc__ return WMS @@ -439,7 +439,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)" + "Maps.add_wms.NASA_GIBS.EPSG_3031.add_layer." "(...)" ) WMS.__doc__ = WebMapContainer.NASA_GIBS.__doc__ return WMS @@ -863,7 +863,7 @@ def __init__(self, m): ) obj._EOmaps_source_code = ( - f"m.add_wms.OpenStreetMap.add_layer.{wmsname}()" + f"Maps.add_wms.OpenStreetMap.add_layer.{wmsname}()" ) class _OSM_waymarkedtrails: @@ -920,8 +920,8 @@ def __init__(self, m): check: https://{v}.waymarkedtrails.org/#help-legal """ srv._EOmaps_source_code = ( - "m.add_wms.OpenStreetMap.OSM_waymarkedtrails.add_layer." - f"{v}(transparent=True)" + "Maps.add_wms.OpenStreetMap.OSM_waymarkedtrails.add_layer." + f"{v}(...)" ) getattr(self, v).__doc__ = _combdoc( @@ -981,8 +981,8 @@ def __init__(self, m): check: https://wiki.openstreetmap.org/wiki/OpenRailwayMap/API """ srv._EOmaps_source_code = ( - "m.add_wms.OpenStreetMap.OSM_openrailwaymap.add_layer." - f"{v}(transparent=True)" + "Maps.add_wms.OpenStreetMap.OSM_openrailwaymap.add_layer." + f"{v}(...)" ) getattr(self, v).__doc__ = _combdoc( @@ -1055,8 +1055,8 @@ def __init__(self, m): """ srv._EOmaps_source_code = ( - "m.add_wms.OpenStreetMap.OSM_cartodb.add_layer." - f"{v}(transparent=True)" + "Maps.add_wms.OpenStreetMap.OSM_cartodb.add_layer." + f"{v}(...)" ) getattr(self, name).__doc__ = _combdoc( @@ -1085,8 +1085,7 @@ def OSM_terrestis(self): - https://www.terrestris.de/en/openstreetmap-wms/ """ WMS._EOmaps_source_code = ( - "m.add_wms.OpenStreetMap.OSM_terrestis.add_layer." - "(transparent=True)" + "Maps.add_wms.OpenStreetMap.OSM_terrestis.add_layer." "(...)" ) WMS.__doc__ = _combdoc( @@ -1116,8 +1115,7 @@ def OSM_mundialis(self): - https://www.mundialis.de/en/ows-mundialis/ """ WMS._EOmaps_source_code = ( - "m.add_wms.OpenStreetMap.OSM_mundialis.add_layer." - "(transparent=True)" + "Maps.add_wms.OpenStreetMap.OSM_mundialis.add_layer." "(...)" ) WMS.__doc__ = _combdoc( @@ -1150,8 +1148,7 @@ def OSM_wheregroup(self): """ WMS._EOmaps_source_code = ( - "m.add_wms.OpenStreetMap.OSM_wheregroup.add_layer." - "(transparent=True)" + "Maps.add_wms.OpenStreetMap.OSM_wheregroup.add_layer." "(...)" ) WMS.__doc__ = _combdoc( @@ -1210,7 +1207,7 @@ def OSM_wms(self): https://osm-wms.de """ WMS._EOmaps_source_code = ( - "m.add_wms.OpenStreetMap.OSM_wms.add_layer." "(transparent=True)" + "Maps.add_wms.OpenStreetMap.OSM_wms.add_layer." "(...)" ) WMS.__doc__ = _combdoc( @@ -1262,8 +1259,7 @@ def OSM_landuse(self): https://osmlanduse.org """ WMS._EOmaps_source_code = ( - "m.add_wms.OpenStreetMap.OSM_wheregroup.add_layer." - "(transparent=True)" + "Maps.add_wms.OpenStreetMap.OSM_wheregroup.add_layer." "(...)" ) WMS.__doc__ = _combdoc( @@ -1667,7 +1663,7 @@ def vv(self): ) WMS.__doc__ = _combdoc("Polarization: VV", WebMapContainer.S1GBM.__doc__) WMS._EOmaps_info = WMS.__doc__ - WMS._EOmaps_source_code = "m.add_wms.S1GBM.add_layer.vv(transparent=True)" + WMS._EOmaps_source_code = "Maps.add_wms.S1GBM.add_layer.vv(...)" return WMS @@ -1681,7 +1677,7 @@ def vh(self): ) WMS.__doc__ = _combdoc("Polarization: VH", WebMapContainer.S1GBM.__doc__) WMS._EOmaps_info = WMS.__doc__ - WMS._EOmaps_source_code = "m.add_wms.S1GBM.add_layer.vh(transparent=True)" + WMS._EOmaps_source_code = "Maps.add_wms.S1GBM.add_layer.vh(...)" return WMS @@ -1759,8 +1755,7 @@ def _addlayer(self, name, url, srv_name, docstring, maxzoom=19): srv._EOmaps_info = docstring srv._EOmaps_source_code = ( - "m.add_wms.OpenPlanetary.Moon.add_layer." - f"{name}(transparent=True)" + "Maps.add_wms.OpenPlanetary.Moon.add_layer." f"{name}(...)" ) getattr(self, name).__doc__ = _combdoc( @@ -1956,8 +1951,7 @@ def _addlayer(self, name, url, srv_name, docstring, maxzoom=19): setattr(self, name, srv) srv._EOmaps_info = docstring srv._EOmaps_source_code = ( - "m.add_wms.OpenPlanetary.Mars.add_layer." - f"{name}(transparent=True)" + "Maps.add_wms.OpenPlanetary.Mars.add_layer." f"{name}(...)" ) getattr(self, name).__doc__ = _combdoc( @@ -2022,9 +2016,7 @@ def _addlayer(self, name, url, srv_name, docstring, maxzoom=19): setattr(self, name, srv) srv._EOmaps_info = docstring - srv._EOmaps_source_code = ( - f"m.add_wms.GOOGLE.add_layer.{name}(transparent=True)" - ) + srv._EOmaps_source_code = f"Maps.add_wms.GOOGLE.add_layer.{name}(...)" getattr(self, name).__doc__ = _combdoc( docstring, @@ -2075,9 +2067,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)" - ) + WMS._EOmaps_source_code = "Maps.add_wms.S2_cloudless.add_layer.(...)" WMS.__doc__ = WebMapContainer.S2_cloudless.__doc__ return WMS @@ -2118,7 +2108,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 = "Maps.add_wms.CAMS.add_layer.(...)" WMS.__doc__ = WebMapContainer.CAMS.__doc__ return WMS @@ -2179,9 +2169,7 @@ def basemap(self): WebMapContainer._DLR.__doc__, ) - WMS._EOmaps_source_code = ( - "m.add_wms.DLR.basemap.add_layer.(transparent=True)" - ) + WMS._EOmaps_source_code = "Maps.add_wms.DLR.basemap.add_layer.(...)" WMS.__doc__ = WMS._EOmaps_info return WMS @@ -2205,9 +2193,7 @@ def land(self): """, WebMapContainer._DLR.__doc__, ) - WMS._EOmaps_source_code = ( - "m.add_wms.DLR.land.add_layer.(transparent=True)" - ) + WMS._EOmaps_source_code = "Maps.add_wms.DLR.land.add_layer.(...)" WMS.__doc__ = WMS._EOmaps_info return WMS @@ -2230,9 +2216,7 @@ def imagery(self): """, WebMapContainer._DLR.__doc__, ) - WMS._EOmaps_source_code = ( - "m.add_wms.DLR.imagery.add_layer.(transparent=True)" - ) + WMS._EOmaps_source_code = "Maps.add_wms.DLR.imagery.add_layer.(...)" WMS.__doc__ = WMS._EOmaps_info return WMS @@ -2256,7 +2240,7 @@ def elevation(self): WebMapContainer._DLR.__doc__, ) WMS._EOmaps_source_code = ( - "m.add_wms.DLR.elevation.add_layer.(transparent=True)" + "Maps.add_wms.DLR.elevation.add_layer.(...)" ) WMS.__doc__ = WMS._EOmaps_info @@ -2281,7 +2265,7 @@ def atmosphere(self): WebMapContainer._DLR.__doc__, ) WMS._EOmaps_source_code = ( - "m.add_wms.DLR.atmosphere.add_layer.(transparent=True)" + "Maps.add_wms.DLR.atmosphere.add_layer.(...)" ) WMS.__doc__ = WMS._EOmaps_info @@ -2361,7 +2345,7 @@ def AT_basemap(self): ) WMTS._EOmaps_info = type(self).AT_basemap.__doc__ WMTS._EOmaps_source_code = ( - "m.add_wms.Austria.AT_basemap.add_layer.(transparent=True)" + "Maps.add_wms.Austria.AT_basemap.add_layer.(...)" ) WMTS.__doc__ = WMTS._EOmaps_info return WMTS @@ -2388,7 +2372,7 @@ def Wien_basemap(self): ) WMTS._EOmaps_info = type(self).Wien_basemap.__doc__ WMTS._EOmaps_source_code = ( - "m.add_wms.Austria.Wien_basemap.add_layer.(transparent=True)" + "Maps.add_wms.Austria.Wien_basemap.add_layer.(...)" ) WMTS.__doc__ = WMTS._EOmaps_info return WMTS @@ -2415,7 +2399,7 @@ def Wien_data(self): ) WMS._EOmaps_info = type(self).Wien_data.__doc__ WMS._EOmaps_source_code = ( - "m.add_wms.Austria.Wien_data.add_layer.(transparent=True)" + "Maps.add_wms.Austria.Wien_data.add_layer.(...)" ) WMS.__doc__ = WMS._EOmaps_info 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..363144f40 100644 --- a/examples/01-Maps/multiple_maps.py +++ b/examples/01-Maps/multiple_maps.py @@ -1,103 +1,82 @@ # EOmaps example: Data-classification and multiple Maps in one figure -from eomaps import Maps +from eomaps import Maps, MapsGrid import pandas as pd import numpy as np # ----------- create some example-data lon, lat = np.meshgrid(np.arange(-20, 40, 0.5), np.arange(30, 60, 0.5)) -data = pd.DataFrame( - dict(lon=lon.flat, lat=lat.flat, data_variable=np.sqrt(lon**2 + lat**2).flat) -) -data = data.sample(4000) # take 4000 random datapoints from the dataset +data = np.sqrt(lon**2 + lat**2) +# take 4000 random datapoints from the dataset +df = pd.DataFrame({"lon": lon.flat, "lat": lat.flat, "data": data.flat}).sample(4000) # ------------------------------------ # initialize a grid of Maps objects -m = Maps(ax=131, crs=4326, figsize=(11, 5)) -m2 = m.new_map(ax=132, crs=Maps.CRS.Stereographic()) -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.set_classify.EqualInterval(k=10) - -# --------- set specs for the second map -m2.text(0.5, 1.1, "Stereographic", transform=m2.ax.transAxes) -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.set_classify_specs( - scheme="StdMean", - multiples=[-1, -0.75, -0.5, -0.25, 0.25, 0.5, 0.75, 1], -) - -# --------- plot all maps and add colorbars to all maps -# set the data on ALL maps-objects of the grid -for m_i in [m, m2, m3]: - m_i.set_data(data=data, x="lon", y="lat", crs=4326) - m_i.plot_map() - m_i.add_colorbar(extend="neither") - - m_i.add_feature.preset.ocean() - m_i.add_feature.preset.land() - # add the coastline to all layers of the maps - m_i.add_feature.preset.coastline(layer="all") - - -# --------- add a new layer for the second axis -# NOTE: this layer is not visible by default but it can be shown by clicking -# on the layer-switcher utility buttons (bottom center of the figure) -# or by using `m2.show()` or via `m.show_layer("layer 2")` -m21 = m2.new_layer(layer="layer 2") -m21.inherit_data(m2) -m21.set_shape.delaunay_triangulation(mask_radius=0.5) -m21.set_classify.Quantiles(k=4) -m21.plot_map(cmap="RdYlBu") -m21.add_colorbar(extend="neither") -# add an annotation that is only executed if "layer 2" is active -m21.cb.click.attach.annotate(text="callbacks are layer-sensitive!") - -# --------- add some callbacks to indicate the clicked data-point to all maps -for m_i in [m, m2, m3]: - m_i.cb.pick.attach.mark(fc="r", ec="none", buffer=1, permanent=True) - m_i.cb.pick.attach.mark(fc="none", ec="r", lw=1, buffer=5, permanent=True) - m_i.cb.move.attach.mark(fc="none", ec="k", lw=2, buffer=10, permanent=False) - -for m_i in [m, m2, m21, m3]: - # --------- rotate the ticks of the colorbars - m_i.colorbar.ax_cb.tick_params(rotation=90, labelsize=8) - # add logos - m_i.add_logo(size=0.05) - -# add an annotation-callback to the second map -m2.cb.pick.attach.annotate(text="the closest point is here!", zorder=99) - -# share click & pick-events between all Maps-objects of the MapsGrid -m.cb.move.share_events(m2, m3) -m.cb.pick.share_events(m2, m3) - -# --------- add a layer-selector widget -m.util.layer_selector(ncol=2, loc="lower center", draggable=False) - - -m.apply_layout( - { - "figsize": [11.0, 5.0], - "0_map": [0.015, 0.44, 0.3125, 0.34375], - "1_map": [0.35151, 0.363, 0.32698, 0.50973], - "2_map": [0.705, 0.44, 0.2875, 0.37872], - "3_cb": [0.05522, 0.0825, 0.2625, 0.2805], - "3_cb_histogram_size": 0.8, - "4_cb": [0.33625, 0.11, 0.3525, 0.2], - "4_cb_histogram_size": 0.8, - "5_cb": [0.72022, 0.0825, 0.2625, 0.2805], - "5_cb_histogram_size": 0.8, - "6_logo": [0.2725, 0.451, 0.05, 0.04538], - "7_logo": [0.625, 0.3795, 0.05, 0.04538], - "8_logo": [0.625, 0.3795, 0.05, 0.04538], - "9_logo": [0.93864, 0.451, 0.05, 0.04538], - } -) -m.show() +mg = MapsGrid(1, 3, crs=[4326, Maps.CRS.Stereographic(), 3035]) + +# add titles +mg[0].add_title("epsg=4326") +mg[1].add_title("Stereographic") +mg[2].add_title("epsg=3035") + +# add background features +mg.add_feature.preset("coastline", "ocean", "land") +mg[1]["delaunay"].add_feature.preset.coastline() + +# set classification specs +mg[0].set_classify.EqualInterval(k=10) +mg[1][:].set_classify.Quantiles(k=8) +mg[2].set_classify.StdMean(multiples=[-1, -0.75, -0.5, -0.25, 0.25, 0.5, 0.75, 1]) + +# set shapes to use +mg[[0, 1]].set_shape.ellipses() +mg[1].set_shape.rectangles() +mg[1]["delaunay"].set_shape.delaunay_triangulation(mask_radius=0.5) + +# assign the data to all layers of all maps +mg[:][:].set_data(data=df, x="lon", y="lat", crs=4326) + +# plot data +mg[:]["base"].plot_map(cmap="viridis") +mg[1]["delaunay"].plot_map(cmap="RdYlBu") + +# add colorbars +mg[:][:].add_colorbar(extend="neither") +mg[:][:].colorbar.ax_cb.tick_params(rotation=90, labelsize=8) + +# add logos to all maps +mg.add_logo(size=0.05) + +# attach callbacks +mg.cb.pick.attach.mark(fc=["r", "none"], ec="r", lw=1, buffer=[1, 5], permanent=True) +mg.cb.move.attach.mark(fc="none", ec="k", lw=2, buffer=10, permanent=False) + +mg[1].cb.pick.attach.annotate(text="the closest point is here!", zorder=99) +mg[1]["delaunay"].cb.move.attach.annotate(text="callbacks are layer-sensitive!") + +# share move & pick-events between maps +mg.cb.move.share_events(*mg) +mg.cb.pick.share_events(*mg) + +# add a layer-selector widget +mg.util.layer_selector(ncol=2, loc="lower center", draggable=False) + +# apply a pre-defined layout (obtained via the LayoutEditor) +layout = { + "figsize": [11.0, 5.0], + "0_map": [0.015, 0.495, 0.30994, 0.34375], + "1_map": [0.35151, 0.4125, 0.32413, 0.5095], + "2_map": [0.705, 0.495, 0.28707, 0.37602], + "3_cb": [0.05522, 0.1375, 0.2625, 0.2805], + "3_cb_histogram_size": 0.8, + "4_cb": [0.33625, 0.165, 0.3525, 0.2], + "4_cb_histogram_size": 0.8, + "5_cb": [0.72022, 0.1375, 0.2625, 0.2805], + "5_cb_histogram_size": 0.8, + "6_logo": [0.2725, 0.495, 0.05, 0.04538], + "7_logo": [0.625, 0.4125, 0.05, 0.04538], + "8_logo": [0.93864, 0.495, 0.05, 0.04538], +} + +mg.apply_layout(layout) +mg.show() diff --git a/examples/02-images/webmaps.py b/examples/02-images/webmaps.py new file mode 100644 index 000000000..b9556de93 --- /dev/null +++ b/examples/02-images/webmaps.py @@ -0,0 +1,75 @@ +# EOmaps example: WebMap services and layer-switching + +from eomaps import Maps +import numpy as np +import pandas as pd + +# ------ create some data -------- +lon, lat = np.meshgrid(np.linspace(-50, 50, 150), np.linspace(30, 60, 150)) +data = pd.DataFrame( + dict(lon=lon.flat, lat=lat.flat, data=np.sqrt(lon**2 + lat**2).flat) +) + +# -------- plot the map ---------- +# set the crs to epsg=3857 (e.g. WebMercator to avoid reprojecting the WebMaps +# (makes it a lot faster and it will also look much nicer!) +m = Maps(3857, figsize=(9, 4)) +m.add_logo() + +# add webmaps to dedicated layers +m["S1GBM_vv"].add_wms.S1GBM.add_layer.vv() +m["OSM"].add_wms.OpenStreetMap.add_layer.default() + +# create a new layer named "data" and plot some data +# (do this "non lazy" so that the extent is set to the data limits) +with Maps.lazy(False): + m["data"].set_data(data=data.sample(5000), x="lon", y="lat", crs=4326) + m["data"].set_shape.geod_circles(radius=20000) + m["data"].plot_map() + + # add a pick callback that is only executed if the "data" layer is visible + m["data"].cb.pick.attach.annotate() + +# -------- CALLBACKS ---------- +# (use m.all to execute independent of the visible layer) +# on a left-click, show layers ("data", "OSM") in a rectangle +m.all.cb.click.attach.peek_layer(("OSM", "data"), size=0.4) + +# on a right-click, "swipe" the layers ("S1GBM_vv" and "data") from the left +m.all.cb.click.attach.peek_layer(("S1GBM_vv", "data"), shape="left", button=3) + +# switch between the layers by pressing the keys 1, 2 and 3 +m.all.cb.keypress.attach.switch_layer("data", key="1") +m.all.cb.keypress.attach.switch_layer("OSM", key="2") +m.all.cb.keypress.attach.switch_layer("S1GBM_vv", key="3") + +# ------ UTILITY WIDGETS -------- +# add a clickable widget to switch between layers +m.util.layer_selector( + loc="upper left", + ncol=3, + bbox_to_anchor=(0.01, 0.99), + layers=["OSM", "S1GBM_vv", "data"], +) +# add a slider to switch between layers +s = m.util.layer_slider( + pos=(0.5, 0.93, 0.38, 0.025), + color="r", + handle_style=dict(facecolor="r"), + txt_patch_props=dict(fc="w", ec="none", alpha=0.75, boxstyle="round, pad=.25"), +) + +# explicitly set the layers you want to use in the slider +# (Note: you can also use combinations of multiple existing layers!) +s.set_layers(["data", "OSM", "S1GBM_vv", "OSM|data{0.5}"]) + +# ---------- layout ---------- + +m.apply_layout( + { + "figsize": [9.0, 4.0], + "0_map": [0.00625, 0.01038, 0.9875, 0.97924], + "1_logo": [0.865, 0.02812, 0.12, 0.11138], + "2_slider": [0.45, 0.93, 0.38, 0.025], + } +) diff --git a/examples/02-images/webmaps.rst b/examples/02-images/webmaps.rst index 369236b85..bcebb4fca 100644 --- a/examples/02-images/webmaps.rst +++ b/examples/02-images/webmaps.rst @@ -10,91 +10,4 @@ WebMap services and layer-switching :width: 75% :align: center -.. code-block:: python - - # EOmaps example: WebMap services and layer-switching - - from eomaps import Maps - import numpy as np - import pandas as pd - - # ------ create some data -------- - lon, lat = np.meshgrid(np.linspace(-50, 50, 150), np.linspace(30, 60, 150)) - data = pd.DataFrame( - dict(lon=lon.flat, lat=lat.flat, data=np.sqrt(lon**2 + lat**2).flat) - ) - # -------------------------------- - - m = Maps(Maps.CRS.GOOGLE_MERCATOR, layer="S1GBM_vv", figsize=(9, 4)) - # set the crs to GOOGLE_MERCATOR to avoid reprojecting the WebMap data - # (makes it a lot faster and it will also look much nicer!) - - # add S1GBM WebMap to the layer of this Maps-object - m.add_wms.S1GBM.add_layer.vv() - - # add OpenStreetMap on the currently invisible layer (OSM) - m.add_wms.OpenStreetMap.add_layer.default(layer="OSM") - - # create a new layer named "data" and plot some data - m2 = m.new_layer(layer="data") - m2.set_data(data=data.sample(5000), x="lon", y="lat", crs=4326) - m2.set_shape.geod_circles(radius=20000) - m2.plot_map() - - - # -------- CALLBACKS ---------- - # (use m.all to execute independent of the visible layer) - # on a left-click, show layers ("data", "OSM") in a rectangle - m.all.cb.click.attach.peek_layer(layer="OSM|data", how=0.2) - - # on a right-click, "swipe" the layers ("S1GBM_vv" and "data") from the left - m.all.cb.click.attach.peek_layer( - layer="S1GBM_vv|data", - how="left", - button=3, - ) - - # switch between the layers by pressing the keys 0, 1 and 2 - m.all.cb.keypress.attach.switch_layer(layer="S1GBM_vv", key="0") - m.all.cb.keypress.attach.switch_layer(layer="OSM", key="1") - m.all.cb.keypress.attach.switch_layer(layer="data", key="2") - - # add a pick callback that is only executed if the "data" layer is visible - m2.cb.pick.attach.annotate(zorder=100) # use a high zorder to put it on top - - # ------ UTILITY WIDGETS -------- - # add a clickable widget to switch between layers - m.util.layer_selector( - loc="upper left", - ncol=3, - bbox_to_anchor=(0.01, 0.99), - layers=["OSM", "S1GBM_vv", "data"], - ) - # add a slider to switch between layers - s = m.util.layer_slider( - pos=(0.5, 0.93, 0.38, 0.025), - color="r", - handle_style=dict(facecolor="r"), - txt_patch_props=dict(fc="w", ec="none", alpha=0.75, boxstyle="round, pad=.25"), - ) - - # explicitly set the layers you want to use in the slider - # (Note: you can also use combinations of multiple existing layers!) - s.set_layers(["OSM", "S1GBM_vv", "data", "OSM|data{0.5}"]) - - # ------------------------------ - - m.add_logo() - - m.apply_layout( - { - "figsize": [9.0, 4.0], - "0_map": [0.00625, 0.01038, 0.9875, 0.97924], - "1_slider": [0.45, 0.93, 0.38, 0.025], - "2_logo": [0.865, 0.02812, 0.12, 0.11138], - } - ) - - # fetch all layers before startup so that they are already cached - m.fetch_layers() - m.show() +.. literalinclude:: /../../examples/02-images/webmaps.py 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..fdcb86a95 --- /dev/null +++ b/examples/callbacks/callbacks.py @@ -0,0 +1,194 @@ +# 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.add_feature.preset.coastline() +m.add_feature.preset.ocean() +m.add_title("A clickable widget!") + + +m.set_data(data=data, x="lon", y="lat", crs=4326) +m.set_shape.rectangles() +m.set_classify.EqualInterval(k=5) +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), +) + +# add some dynamic text that will be updated +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", + zorder=99, +) + +txt2 = m.add_text( + 0.18, + 0.9, + " lon / lat " + "\n", + fontsize=12, + horizontalalignment="right", + verticalalignment="top", + fontweight="bold", + zorder=99, +) + + +# 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], + } +) + + +### callback to save all values to a global "picked_vals" dict +picked_vals = {} + + +def save_picked_vals(event): + picked_vals.setdefault("ID", []).append([event.ID]) + picked_vals.setdefault("val", []).append([event.val]) + + +def update_info_text(event, m): + # update the text that indicates how many pixels we've clicked + nvals = len(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(picked_vals['val']):.3f}" + ) + + # update the list of lon/lat coordinates on the top left of the figure + d = m.data_specs.data.loc[event.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") + + +def add_pick_marker(event, m): + # plot a marker at the pixel-position + (l,) = m.ax.plot(*event.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( + event.pos[0], + event.pos[1] - 150000, + f"{event.val:.2f}", + horizontalalignment="center", + verticalalignment="bottom", + color=l.get_color(), + zorder=1, + ) + + +# --------- 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, +) + + +### 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, +) + + +m.cb.pick.attach(save_picked_vals, button=1) +m.cb.pick.attach(update_info_text, button=1, m=m) + +### 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) + +m.cb.pick.attach(add_pick_marker, button=3, m=m) + + +### 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 +) + + +# 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") + +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/callbacks/location_indicator.py b/examples/callbacks/location_indicator.py index 68b97a7e0..edaf2e227 100644 --- a/examples/callbacks/location_indicator.py +++ b/examples/callbacks/location_indicator.py @@ -7,9 +7,9 @@ gl = m.add_gridlines(d=5, lw=0.25, ls=":") -def cb_location_indicator_grid(pos, **kwargs): +def cb_location_indicator_grid(event, **kwargs): """A (move) callback to add a dynamic location-indicator to the map.""" - lon, lat = map(round, m.transform_plot_to_lonlat(*pos)) + lon, lat = map(round, m.transform_plot_to_lonlat(event.xdata, event.ydata)) # get grid-values for +- 5° bounds = (lon - 5, lon + 5, lat - 5, lat + 5) lon_g, lat_g = np.linspace(*bounds[:2], 11), np.linspace(*bounds[2:], 11) 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..77f0d50c9 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() @@ -298,9 +294,9 @@ def test_add_callbacks(self): cbID == f"{cb}_0__{m.layer}__{'double' if double_click else 'single'}__{mouse_button}__{modifier}" ) - self.assertTrue(len(m.cb.pick.get.attached_callbacks) == 1) + self.assertTrue(len(m.cb.pick.attached_callbacks) == 1) m.cb.pick.remove(cbID) - self.assertTrue(len(m.cb.pick.get.attached_callbacks) == 0) + self.assertTrue(len(m.cb.pick.attached_callbacks) == 0) # attach all click callbacks for n, cb in enumerate(m.cb.click.attach._available_callbacks()): @@ -325,9 +321,9 @@ def test_add_callbacks(self): cbID == f"{cb}_0__{m.layer}__{'double' if double_click else 'single'}__{mouse_button}__{modifier}" ) - self.assertTrue(len(m.cb.click.get.attached_callbacks) == 1) + self.assertTrue(len(m.cb.click.attached_callbacks) == 1) m.cb.click.remove(cbID) - self.assertTrue(len(m.cb.click.get.attached_callbacks) == 0) + self.assertTrue(len(m.cb.click.attached_callbacks) == 0) # attach all keypress callbacks double_click, mouse_button = True, 1 @@ -341,29 +337,28 @@ def test_add_callbacks(self): cbID = m.cb.keypress.attach(cb, key=key) - self.assertTrue(cbID == f"{cb}_0__{m.layer}__{key}") - self.assertTrue(len(m.cb.keypress.get.attached_callbacks) == 1) + self.assertTrue(cbID == f"{cb}_0__{m.layer}__any__None__{key}") + self.assertTrue(len(m.cb.keypress.attached_callbacks) == 1) m.cb.keypress.remove(cbID) - self.assertTrue(len(m.cb.keypress.get.attached_callbacks) == 0) + self.assertTrue(len(m.cb.keypress.attached_callbacks) == 0) plt.close(m.f) 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") @@ -1240,7 +1191,7 @@ def test_maps_as_contextmanager(self): ) ) - self.assertTrue(len(m.cb.click.get.cbs) == 1) + self.assertTrue(len(m.cb.click.attached_callbacks) == 1) self.assertTrue(set(m._get_layers()) == {"first"}) with m.new_layer("second") as m2: @@ -1264,7 +1215,7 @@ def test_maps_as_contextmanager(self): ) self.assertFalse(m2.coll is None) - self.assertTrue(len(m2.cb.click.get.cbs) == 1) + self.assertTrue(len(m2.cb.click.attached_callbacks) == 1) self.assertTrue(set(m._get_layers()) == {"first"}) @@ -1283,14 +1234,14 @@ def test_maps_as_contextmanager(self): for i in ["xorig", "yorig", "x0", "y0", "z_data"] ) ) - self.assertTrue(len(m.cb.click.get.cbs) == 1) - self.assertTrue(len(m.cb.click.get.cbs) == 1) + self.assertTrue(len(m.cb.click.attached_callbacks) == 1) + self.assertTrue(len(m.cb.click.attached_callbacks) == 1) self.assertTrue(m2.coll is None) - self.assertTrue(len(m2.cb.click.get.cbs) == 0) + self.assertTrue(len(m2.cb.click.attached_callbacks) == 0) self.assertTrue(m.coll is None) - self.assertTrue(len(m.cb.click.get.cbs) == 0) + self.assertTrue(len(m.cb.click.attached_callbacks) == 0) self.assertTrue(len(m._data_manager._all_data) == 0) self.assertTrue(len(m._data_manager._current_data) == 0) @@ -1307,14 +1258,14 @@ 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")) - self.assertTrue(len(m.cb.click.get.cbs) == 1) - self.assertTrue(len(m.cb.click.get.cbs) == 1) - self.assertTrue(len(m.cb.click.get.cbs) == 1) + self.assertTrue(len(m.cb.click.attached_callbacks) == 1) + self.assertTrue(len(m.cb.click.attached_callbacks) == 1) + self.assertTrue(len(m.cb.click.attached_callbacks) == 1) # test cleaning a new layer m2 = m.new_layer("asdf") @@ -1329,58 +1280,67 @@ 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]) == 3 + ) 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")) - self.assertTrue(len(m2.cb.click.get.cbs) == 1) - self.assertTrue(len(m2.cb.click.get.cbs) == 1) - self.assertTrue(len(m2.cb.click.get.cbs) == 1) + self.assertTrue(len(m2.cb.click.attached_callbacks) == 1) + self.assertTrue(len(m2.cb.click.attached_callbacks) == 1) + self.assertTrue(len(m2.cb.click.attached_callbacks) == 1) 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) self.assertTrue(hasattr(m, "tree")) - self.assertTrue(len(m.cb.click.get.cbs) == 1) - self.assertTrue(len(m.cb.click.get.cbs) == 1) - self.assertTrue(len(m.cb.click.get.cbs) == 1) + self.assertTrue(len(m.cb.click.attached_callbacks) == 1) + self.assertTrue(len(m.cb.click.attached_callbacks) == 1) + self.assertTrue(len(m.cb.click.attached_callbacks) == 1) # m2 must already be cleared self.assertTrue(m2._data_manager.x0 is None) self.assertTrue(not hasattr(m2, "tree")) - self.assertTrue(len(m2.cb.click.get.cbs) == 0) - self.assertTrue(len(m2.cb.click.get.cbs) == 0) - self.assertTrue(len(m2.cb.click.get.cbs) == 0) + self.assertTrue(len(m2.cb.click.attached_callbacks) == 0) + self.assertTrue(len(m2.cb.click.attached_callbacks) == 0) + self.assertTrue(len(m2.cb.click.attached_callbacks) == 0) 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")) - self.assertTrue(len(m.cb.click.get.cbs) == 0) - self.assertTrue(len(m.cb.click.get.cbs) == 0) - self.assertTrue(len(m.cb.click.get.cbs) == 0) + self.assertTrue(len(m.cb.click.attached_callbacks) == 0) + self.assertTrue(len(m.cb.click.attached_callbacks) == 0) + self.assertTrue(len(m.cb.click.attached_callbacks) == 0) plt.close("all") def test_blit_artists(self): @@ -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..bc6ff301c 100644 --- a/tests/test_callbacks.py +++ b/tests/test_callbacks.py @@ -5,7 +5,6 @@ import numpy as np import pandas as pd import matplotlib.pyplot as plt -from scipy.spatial import KDTree from eomaps import Maps @@ -80,6 +79,7 @@ def click_ax_center(self, m, dx=0, dy=0, release=True): button_press_event(cv, x + dx, y + dy, 1, False) if release: button_release_event(cv, x + dx, y + dy, 1, False) + m.f.canvas.flush_events() # only required for python 3.10 def click_ID(self, m, ID, release=True): cv = m.f.canvas @@ -94,212 +94,7 @@ def click_ID(self, m, ID, release=True): button_press_event(cv, x, y, 1, False) if release: button_release_event(cv, x, y, 1, False) - - def test_get_values(self): - - # ---------- test as CLICK callback - m = self.create_basic_map() - cid = m.cb.click.attach.get_values() - - m.cb.pick.attach.annotate() - - self.click_ax_center(m) - self.assertEqual(len(m.cb.click.get.picked_vals["pos"]), 1) - self.assertTrue(m.cb.click.get.picked_vals["ID"][0] is None) - self.assertTrue(m.cb.click.get.picked_vals["val"][0] is None) - - self.click_ax_center(m) - self.assertEqual(len(m.cb.click.get.picked_vals["pos"]), 2) - self.assertTrue(m.cb.click.get.picked_vals["ID"][1] is None) - self.assertTrue(m.cb.click.get.picked_vals["val"][1] is None) - - m.cb.click.remove(cid) - plt.close("all") - - df = self.data - x1d = df["lon"].values - y1d = df["lat"].values - data1d = df["value"].values - - data2d = self.data.set_index(["lon", "lat"]).unstack("lon") - x1d2d, y1d2d = data2d.columns.get_level_values(1).values, data2d.index.values - data2d = data2d["value"].values - - x2d, y2d = np.meshgrid(x1d2d, y1d2d) - - data_selections = [ - dict(data=self.data, x="lon", y="lat", test="pandas"), - dict(data=data1d, x=x1d, y=y1d, test="1d"), - dict(data=data2d.T, x=x1d2d, y=y1d2d, test="1d2d"), - dict(data=data2d, x=x2d, y=y2d, test="2d"), - ] - - # ---------- test as PICK callback - # for ID, n, cpick, relpick, r, data, plotcrs in product( - # [1225, 350], - # [1, 5], - # [True, False], - # [True, False], - # ["10", 12.65], - # data_selections, - # [4326, Maps.CRS.Mollweide()], - # ): - for ID, n, cpick, relpick, r, data, plotcrs in product( - [1225], - [5], - [True], - [True], - ["10", None], - data_selections, - [4326, Maps.CRS.Mollweide()], - ): - - # note r is defined in units of the plot crs! - if r is None: - if plotcrs == 4326: - r = 12.65 - else: - r = 1e6 - - with self.subTest( - n=n, - consecutive_pick=cpick, - pick_relative_to_closest=relpick, - search_radius=r, - data=data["test"], - ): - print( - "--------------- TESTING:", ID, n, cpick, relpick, r, data["test"] - ) - - m = Maps(crs=plotcrs) - m.set_data(**{key: val for key, val in data.items() if key != "test"}) - m.plot_map() - - # identify x-y in plot_crs - ref_x, ref_y = m._transf_lonlat_to_plot.transform( - *self.data.loc[ID][["lon", "lat"]] - ) - - m.cb.pick.set_props( - n=n, - consecutive_pick=cpick, - pick_relative_to_closest=relpick, - search_radius=r, - ) - - cid = m.cb.pick.attach.get_values() - m.cb.pick.attach.print_to_console() - m.cb.click.attach.mark(radius=0.1) - m.f.canvas.draw() # make sure figure is drawn before testing - self.click_ID(m, ID) - - if n == 1: - self.assertEqual(len(m.cb.pick.get.picked_vals["pos"]), 1) - self.assertEqual(len(m.cb.pick.get.picked_vals["ID"]), 1) - self.assertEqual(len(m.cb.pick.get.picked_vals["val"]), 1) - - self.assertTrue(m.cb.pick.get.picked_vals["ID"][0] == ID) - self.assertTrue( - np.allclose( - m.cb.pick.get.picked_vals["val"][0], - self.data.loc[ID]["value"], - ) - ) - self.assertTrue( - np.allclose(m.cb.pick.get.picked_vals["pos"][0][0], ref_x) - ) - self.assertTrue( - np.allclose(m.cb.pick.get.picked_vals["pos"][0][1], ref_y) - ) - - elif n == 5: - # get n nearest neighbours from pandas dataframe - tree = KDTree(self.data[["lon", "lat"]].values) - d, pickids = tree.query( - self.data.loc[ID][["lon", "lat"]].values, k=n - ) - pickids.sort() # sort found IDs since KDtree sorting might be different - ref_x, ref_y = m._transf_lonlat_to_plot.transform( - *self.data.loc[pickids][["lon", "lat"]].values.T - ) - - if cpick is True: - self.assertEqual(len(m.cb.pick.get.picked_vals["pos"]), 5) - self.assertEqual(len(m.cb.pick.get.picked_vals["ID"]), 5) - self.assertEqual(len(m.cb.pick.get.picked_vals["val"]), 5) - else: - self.assertEqual(len(m.cb.pick.get.picked_vals["pos"]), 1) - self.assertEqual(len(m.cb.pick.get.picked_vals["ID"]), 1) - self.assertEqual(len(m.cb.pick.get.picked_vals["val"]), 1) - if relpick is True: - # sort found IDs to make sure sorting is same - # as reference IDs - sortp = np.argsort(m.cb.pick.get.picked_vals["ID"][0]) - - self.assertTrue( - np.allclose( - m.cb.pick.get.picked_vals["ID"][0][sortp], - pickids, - ) - ) - self.assertTrue( - np.allclose( - m.cb.pick.get.picked_vals["val"][0][sortp], - self.data.loc[pickids]["value"].values, - ) - ) - self.assertTrue( - np.allclose( - m.cb.pick.get.picked_vals["pos"][0][0][sortp], - ref_x, - ) - ) - self.assertTrue( - np.allclose( - m.cb.pick.get.picked_vals["pos"][0][1][sortp], - ref_y, - ) - ) - - else: - # TODO this might be failing irregularly - # (figure size, extent, dpi etc. might have an impact) - - # sort found IDs to make sure sorting is same - # as reference IDs - sortp = np.argsort(m.cb.pick.get.picked_vals["ID"][0]) - - # check only closest point for now - self.assertTrue( - np.allclose( - m.cb.pick.get.picked_vals["ID"][0][sortp][0], - pickids[0], - ) - ) - self.assertTrue( - np.allclose( - m.cb.pick.get.picked_vals["val"][0][sortp][0], - self.data.loc[pickids]["value"].values[0], - ) - ) - self.assertTrue( - np.allclose( - m.cb.pick.get.picked_vals["pos"][0][0][sortp][0], - ref_x[0], - ) - ) - self.assertTrue( - np.allclose( - m.cb.pick.get.picked_vals["pos"][0][1][sortp][0], - ref_y[0], - ) - ) - - m.cb.pick.remove(cid) - plt.close("all") - - plt.close("all") + m.f.canvas.flush_events() # only required for python 3.10 def test_print_to_console(self): # ---------- test as CLICK callback @@ -573,11 +368,11 @@ def test_clear_annotations(self): m = self.create_basic_map() m.cb.click.attach.annotate(permanent=True) self.click_ax_center(m) - self.assertTrue(len(m.cb.click.get.permanent_annotations) == 1) + self.assertTrue(len(m.cb.click.attach.permanent_annotations) == 1) cid = m.cb.click.attach.clear_annotations() self.click_ax_center(m) - self.assertTrue(len(m.cb.click.get.permanent_annotations) == 0) + self.assertTrue(len(m.cb.click.attach.permanent_annotations) == 0) m.cb.click.remove(cid) @@ -588,73 +383,16 @@ def test_clear_markers(self): m = self.create_basic_map() cid = m.cb.click.attach.mark(permanent=True) self.click_ax_center(m) - self.assertTrue(len(m.cb.click.get.permanent_markers) == 1) + self.assertTrue(len(m.cb.click.attach.permanent_markers) == 1) m.cb.click.remove(cid) cid = m.cb.click.attach.clear_markers() self.click_ax_center(m) - self.assertTrue(m.cb.click.get.permanent_markers is None) + self.assertFalse(hasattr(m.cb.click.attach, "permanent_markers")) m.cb.click.remove(cid) plt.close("all") - def test_plot(self): - m = self.create_basic_map() - cid = m.cb.pick.attach.plot(precision=2) - self.click_ax_center(m) - self.click_ax_center(m, 20, 20) - self.click_ax_center(m, 50, 50) - m.cb.pick.remove(cid) - - cid = m.cb.pick.attach.plot(x_index="ID", ls="--", lw=0.5, marker="*") - self.click_ax_center(m) - self.click_ax_center(m, 20, 20) - self.click_ax_center(m, 50, 50) - - m.cb.pick.remove(cid) - plt.close("all") - - def test_load(self): - for n, cpick, relpick, r in product( - [1, 5], [True, False], [True, False], ["10", 12.65] - ): - - with self.subTest( - n=n, - consecutive_pick=cpick, - pick_relative_to_closest=relpick, - search_radius=r, - ): - - db = self.data - - m = self.create_basic_map() - m.cb.pick.attach.get_values() - - cid = m.cb.pick.attach.load(database=db, load_method="xs") - - self.assertTrue(m.cb.pick.get.picked_object is None) - - self.click_ax_center(m) - ID = m.cb.pick.get.picked_vals["ID"] - - self.assertTrue( - all(m.cb.pick.get.picked_object == self.data.loc[ID[0]]) - ) - - m.cb.pick.remove(cid) - - def loadmethod(db, ID): - return db.loc[ID].lon - - cid = m.cb.pick.attach.load(database=db, load_method=loadmethod) - self.click_ax_center(m) - - self.assertTrue(m.cb.pick.get.picked_object == self.data.loc[ID[0]].lon) - - m.cb.pick.remove(cid) - plt.close("all") - def test_overlay_layer(self): # ---------- test as CLICK callback m = self.create_basic_map() @@ -669,28 +407,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 +447,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) @@ -748,14 +486,9 @@ def test_make_dataset_pickable(self): m2.cb.pick.attach.annotate() m2.cb.pick.attach.mark(fc="r", ec="g", lw=2, ls="--") m2.cb.pick.attach.print_to_console() - m2.cb.pick.attach.get_values() self.click_ID(m2, 1225) - self.assertEqual(len(m2.cb.pick.get.picked_vals["pos"]), 1) - self.assertEqual(len(m2.cb.pick.get.picked_vals["ID"]), 1) - self.assertEqual(len(m2.cb.pick.get.picked_vals["val"]), 1) - self.assertTrue(m2.cb.pick.get.picked_vals["ID"][0] == 1225) plt.close("all") def test_keypress_callbacks_for_any_key(self): @@ -763,18 +496,18 @@ def test_keypress_callbacks_for_any_key(self): m.new_layer("0") m.new_layer("1") - def cb(key): - m.show_layer(key) + def cb(event): + m.show_layer(event.key) m.all.cb.keypress.attach(cb, key=None) 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): @@ -785,8 +518,8 @@ def test_geodataframe_contains_picking(self): m.add_gdf(gdf, picker_name="nocol", pick_method="contains", fc="none") - def customcb(picked_vals, val, **kwargs): - picked_vals.append(val) + def customcb(event, picked_vals): + picked_vals.append(event.val) picked_vals_col = [] picked_vals_nocol = [] @@ -828,8 +561,8 @@ def test_geodataframe_centroid_picking(self): pick_method="centroids", ) - def customcb(picked_vals, val, **kwargs): - picked_vals.append(val) + def customcb(event, picked_vals): + picked_vals.append(event.val) picked_vals_col = [] picked_vals_nocol = [] 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..72e89f366 100644 --- a/tests/test_plot_shapes.py +++ b/tests/test_plot_shapes.py @@ -101,6 +101,9 @@ def test_contour(data): m3_1.set_shape.contour(filled=False) m3_1.plot_map(linestyles=["--", "-", ":", "-."]) + # show layers before indicating contours to trigger lazy plotting + m.show_layer("base", "contours") + cb3.indicate_contours( contour_map=m3_1, add_labels="top", @@ -125,9 +128,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) - - m.show_layer("base", "contours") + # m3_1._bm.add_bg_artist(a, layer=m3_1.layer) return m diff --git a/tests/test_widgets.py b/tests/test_widgets.py index 85f111a56..008f47de8 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] @@ -115,12 +115,12 @@ def test_callback_widgets(widget): elif widget.__name__.startswith("Click"): cbs = m.all.cb.click - assert cbs.get.attached_callbacks == [w._cid], "callback not attached" + assert cbs.attached_callbacks == [w._cid], "callback not attached" state["value"] = False w.set_state(state) - assert cbs.get.attached_callbacks == [], "callback not removed" + assert cbs.attached_callbacks == [], "callback not removed" @pytest.mark.parametrize( @@ -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"