diff --git a/.github/workflows/testMaps.yml b/.github/workflows/testMaps.yml index 3e4480e92..6387eca7d 100644 --- a/.github/workflows/testMaps.yml +++ b/.github/workflows/testMaps.yml @@ -25,9 +25,12 @@ jobs: environment-file: tests/test_env.yml # use mamba to speed up installation - mamba-version: "*" - channels: conda-forge - channel-priority: true + #mamba-version: "*" + #channels: conda-forge + #channel-priority: true + + miniforge-variant: Mambaforge + miniforge-version: latest activate-environment: testMaps diff --git a/README.md b/README.md index 319fdc6ad..e4092da50 100644 --- a/README.md +++ b/README.md @@ -126,61 +126,76 @@ Interested in actively contributing to the library? ```python from eomaps import Maps +import numpy as np +### Initialize Maps object +m = Maps(crs=Maps.CRS.Orthographic(), figsize=(12, 8)) -# initialize Maps object -m = Maps(crs=Maps.CRS.Orthographic()) - -# add map-features from NaturalEarth +### Add map-features from NaturalEarth m.add_feature.preset.coastline() -m.add_feature.cultural_50m.admin_0_countries(fc="none", ec="g") - -# assign a dataset -m.set_data(data=[1, 2, 3, 4], x=[45, 46, 47, 42], y=[23, 24, 25, 26], crs=4326) -# set the shape you want to use to represent the data-points -m.set_shape.geod_circles(radius=10000) # (e.g. geodetic circles with 10km radius) -# (optionally) classify the data -m.set_classify_specs(scheme=Maps.CLASSIFIERS.Quantiles, k=5) -# plot the data -m.plot_map(cmap="viridis", vmin=2, vmax=4) -# add a colorbar with a colored histogram on top -m.add_colorbar(histbins=200) - -# add a scalebar -m.add_scalebar() -# add a compass (or north-arrow) -m.add_compass() - -# add imagery from a open-access WebMap services -m.add_wms.OpenStreetMap.add_layer.default() +m.add_feature.cultural.admin_0_countries(scale=50, fc="none", ec="g", lw=0.3) -# use callback functions to interact with the map -m.cb.pick.attach.annotate() - -# use multiple layers to compare and analyze different datasets -m3 = m.new_layer(layer="layer 2") -m3.add_feature.preset.ocean() +### Add imagery from open-access WebMap services +m.add_wms.OpenStreetMap.add_layer.default() -# attach a callback to peek on layer 1 if you click on the map +### Plot datasets +# --- Create some random data +x, y = np.mgrid[-50:40:5, -20:50:3] +data = x + y +# --- +m.set_data(data=data, x=x, y=y, crs=4326) # assign a dataset +m.set_shape.ellipses() # set how you want to represent the data-points on the map +m.set_classify_specs(scheme=Maps.CLASSIFIERS.FisherJenks, k=6) # classify the data +m.plot_map(cmap="viridis", vmin=-100, vmax=100, set_extent=False) # plot the data +m.add_colorbar(hist_bins="bins", label="What a nice colorbar") # add a colorbar + +### Use callback functions to interact with the map +# (NOTE: you can also define custom callbacks!) +# - Click callbacks are executed if you click anywhere on the map +# (Use keypress-modifiers to trigger only if a button is pressed) +m.cb.click.attach.mark(shape="geod_circles", radius=1e5, button=3) m.cb.click.attach.peek_layer(layer="layer 2", how=0.4) -# attach a callback to show an annotation while you move the mouse -# (and simultaneously press "a" on the keyboard) -m.cb.move.attach.annotate(modifier="a") -# attach callbacks to switch between the layers with the keyboard -m.cb.keypress.attach.switch_layer(layer=0, key="0") -m.cb.keypress.attach.switch_layer(layer="layer 2", key="1") - -# get a clickable widget to switch between the available plot-layers -m.util.layer_selector() - -# add zoomed-in "inset-maps" to highlight areas on th map -m_inset = m.new_inset_map((10, 45)) -m_inset.add_feature.preset.coastline(fc="g") - -# ---- plot data directly from GeoTIFF / NetCDF or CSV files -m4 = m.new_layer_from_file.GeoTIFF(...) -m4 = m.new_layer_from_file.NetCDF(...) -m4 = m.new_layer_from_file.CSV(...) - +m.cb.click.attach.annotate(modifier="a") +# - Pick callbacks identify the closest datapoint +m.cb.pick.attach.annotate() +# - Keypress callbacks are executed if you press a key on the keyboard +# (using "m.all" ensures that the cb triggers irrespective of the visible layer) +m.all.cb.keypress.attach.switch_layer(layer="base", key="0") +m.all.cb.keypress.attach.switch_layer(layer="layer 2", key="1") + +### Use multiple layers to compare and analyze different datasets +m2 = m.new_layer(layer="layer 2") # create a new plot-layer +m2.add_feature.preset.ocean() # populate the layer +# Get a clickable widget to switch between the available plot-layers +m.util.layer_selector(loc="upper center") + +### Add zoomed-in "inset-maps" to highlight areas on th map +m_inset = m.new_inset_map((10, 45), radius=10, layer="base") +m_inset.add_feature.preset.coastline() +m_inset.add_feature.preset.ocean() + +### Reposition axes based on a given layout (check m.get_layout()) +m.apply_layout( + {'0_map': [0.44306, 0.25, 0.48889, 0.73333], + '1_cb': [0.0125, 0.0, 0.98, 0.23377], + '1_cb_histogram_size': 0.8, + '2_map': [0.03333, 0.46667, 0.33329, 0.5]} + ) + +### Add a scalebar +s = m_inset.add_scalebar(lon=15.15, lat=44.45, + autoscale_fraction=.4, + scale_props=dict(n=6), + label_props=dict(scale=3, every=2), + patch_props=dict(lw=0.5) + ) + +### Add a compass (or north-arrow) +c = m_inset.add_compass(pos=(.825,.88), layer="base") + +### Plot data directly from GeoTIFF / NetCDF or CSV files +#m4 = m.new_layer_from_file.GeoTIFF(...) +#m4 = m.new_layer_from_file.NetCDF(...) +#m4 = m.new_layer_from_file.CSV(...) ``` ---- diff --git a/docs/_static/minigifs/pick_multi.gif b/docs/_static/minigifs/pick_multi.gif new file mode 100644 index 000000000..5a580c63c Binary files /dev/null and b/docs/_static/minigifs/pick_multi.gif differ diff --git a/docs/api.rst b/docs/api.rst index 7ab38f693..0f27eb909 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -924,7 +924,6 @@ a python-script, such as: (csv, NetCDF, GeoTIFF, shapefile) - .. _callbacks: 🛸 Callbacks - make the map interactive! @@ -949,7 +948,7 @@ They can be attached to a map via the ``.attach`` directive: +--------------------------------------------------------------+----------------------------------------------------------------------------------+ | :class:`click ` | Callbacks that are executed if you click anywhere on the Map. | +--------------------------------------------------------------+----------------------------------------------------------------------------------+ - | :class:`pick ` | Callbacks that select the nearest datapoint if you click on the map. | + | :class:`pick ` | Callbacks that select the nearest datapoint(s) if you click on the map. | +--------------------------------------------------------------+----------------------------------------------------------------------------------+ | :class:`move ` | Callbacks that are executed if you press a key on the keyboard. | +--------------------------------------------------------------+----------------------------------------------------------------------------------+ @@ -1020,6 +1019,7 @@ In addition, each callback-container supports the following useful methods: | :class:`set_sticky_modifiers ` | Define keys on the keyboard that should be treated as "sticky modifiers". | +---------------------------------------------------------------------------------------------+---------------------------------------------------------------------------+ + .. currentmodule:: eomaps._cb_container._cb_container .. autosummary:: @@ -1031,8 +1031,11 @@ In addition, each callback-container supports the following useful methods: add_temporary_artist +🍬 Pre-defined callbacks +~~~~~~~~~~~~~~~~~~~~~~~~~ + Pre-defined click, pick and move callbacks -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +****************************************** Callbacks that can be used with ``m.cb.click``, ``m.cb.pick`` and ``m.cb.move``: @@ -1049,7 +1052,7 @@ Callbacks that can be used with ``m.cb.click``, ``m.cb.pick`` and ``m.cb.move``: print_to_console -Callbacks that can be used with ``m.cb.click`` or ``m.cb.pick``: +Callbacks that can be used with ``m.cb.click`` and ``m.cb.pick``: .. currentmodule:: eomaps.callbacks.click_callbacks @@ -1077,7 +1080,7 @@ Callbacks that can be used only with ``m.cb.pick``: Pre-defined keypress callbacks -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +****************************** Callbacks that can be used with ``m.cb.keypress`` @@ -1094,27 +1097,43 @@ Callbacks that can be used with ``m.cb.keypress`` 👽 Custom callbacks ~~~~~~~~~~~~~~~~~~~ -Custom callback functions can be attached to the map via: +Custom callback functions can be attached to the map via ``m.cb.< METHOD >.attach(< CALLBACK FUNCTION >, **kwargs)``: .. code-block:: python - def some_callback(asdf, **kwargs): - print("hello world") - print("the value of 'asdf' is", asdf) + def some_callback(custom_kwarg, **kwargs): + print("the value of 'custom_kwarg' is", custom_kwarg) print("the position of the clicked pixel in plot-coordinates", kwargs["pos"]) print("the dataset-index of the nearest datapoint", kwargs["ID"]) print("data-value of the nearest datapoint", kwargs["val"]) + print("the color of the nearest datapoint", kwargs["val_color"]) + print("the numerical index of the nearest datapoint", kwargs["ind"]) ... # attaching custom callbacks works completely similar for "click", "pick" and "keypress"! m = Maps() ... - m.cb.pick.attach(some_callback, double_click=False, button=1, asdf=1) - m.cb.click.attach(some_callback, double_click=False, button=2, asdf=1) - m.cb.keypress.attach(some_callback, key="x", asdf=1) + m.cb.pick.attach(some_callback, double_click=False, button=1, custom_kwarg=1) + m.cb.click.attach(some_callback, double_click=False, button=2, custom_kwarg=2) + m.cb.keypress.attach(some_callback, key="x", custom_kwarg=3) + + +.. note:: + + Custom callbacks **must** always accept the following keyword arguments: + ``pos``, ``ID``, ``val``, ``val_color``, ``ind`` + + - ❗ for click callbacks the kwargs ``ID``, ``val`` and ``val_color`` are set to ``None``! + - ❗ for keypress callbacks the kwargs ``ID``, ``val``, ``val_color``, ``ind`` and ``pos`` are set to ``None``! + + For better readability it is recommended that you "unpack" used arguments like this: + + .. code-block:: python + + def cb(ID, val, **kwargs): + print(f"the ID is {ID} and the value is {val}") + -- ❗ for click callbacks the kwargs ``ID`` and ``val`` are set to ``None``! -- ❗ for keypress callbacks the kwargs ``ID`` and ``val`` and ``pos`` are set to ``None``! 👾 Using modifiers for pick- click- and move callbacks @@ -1167,6 +1186,78 @@ NOTE: sticky modifiers are defined for each callback method individually! m.cb.move.attach.mark(modifier="1", radius=5, fc="r") +🍭 Picking N nearest neighbours +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +[requires EOmaps >= 5.4] + +By default pick-callbacks pick the nearest datapoint with respect to the click position. + +To customize the picking-behavior, use ``m.cb.pick.set_props()``. The following properties can be adjusted: + +- ``n``: The (maximum) number of datapoints to pick within the search-circle. +- ``search_radius``: The radius of a circle (in units of the plot-crs) that is used to identify the nearest neighbours. +- ``pick_relative_to_closest``: Set the center of the search-circle. + + - If True, the nearest neighbours are searched relative to the closest identified datapoint. + - If False, the nearest neighbours are searched relative to the click position. + +- ``consecutive_pick``: Pick datapoints individually or alltogether. + + - If True, callbacks are executed for each picked point individually + - If False, callbacks are executed only once and get lists of all picked values as input-arguments. + +.. currentmodule:: eomaps._cb_container.cb_pick_container + +.. autosummary:: + :nosignatures: + :template: only_names_in_toc.rst + + set_props + + +.. table:: + :widths: 50 50 + :align: center + + +--------------------------------------------------------------------------------+--------------------------------------------+ + | .. code-block:: python | .. image:: _static/minigifs/pick_multi.gif | + | | :align: center | + | from eomaps import Maps | | + | import numpy as np | | + | | | + | # create some random data | | + | x, y = np.mgrid[-30:67, -12:50] | | + | data = np.random.randint(0, 100, x.shape) | | + | | | + | # a callback to indicate the search-radius | | + | def indicate_search_radius(m, pos, *args, **kwargs): | | + | art = m.add_marker( | | + | xy=(np.atleast_1d(pos[0])[0], | | + | np.atleast_1d(pos[1])[0]), | | + | shape="ellipses", radius=m.tree.d, radius_crs="out", | | + | n=100, fc="none", ec="k", lw=2) | | + | m.cb.pick.add_temporary_artist(art) | | + | | | + | # a callback to set the number of picked neighbours | | + | def pick_n_neighbours(m, n, **kwargs): | | + | m.cb.pick.set_props(n=n) | | + | | | + | | | + | m = Maps() | | + | m.add_feature.preset.coastline() | | + | m.set_data(data, x, y) | | + | m.plot_map() | | + | m.cb.pick.set_props(n=50, search_radius=10, pick_relative_to_closest=True) | | + | | | + | m.cb.pick.attach.annotate() | | + | m.cb.pick.attach.mark(fc="none", ec="r") | | + | m.cb.pick.attach(indicate_search_radius, m=m) | | + | | | + | for key, n in (("1", 1), ("2", 9), ("3", 50), ("4", 500)): | | + | m.cb.keypress.attach(pick_n_neighbours, key=key, m=m, n=n) | | + +--------------------------------------------------------------------------------+--------------------------------------------+ + 📍 Picking a dataset without plotting it first ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ It is possible to attach ``pick`` callbacks to a ``Maps`` object without plotting the data first @@ -1498,6 +1589,10 @@ The most commonly used features are accessible with pre-defined colors via the ` A ``geopandas.GeoDataFrame`` can be added to the map via ``m.add_gdf()``. +- This is basically just a wrapper for the plotting capabilities of ``geopandas`` (e.g. ``gdf.plot(...)`` ) + supercharged with EOmaps features. + + .. currentmodule:: eomaps .. autosummary:: @@ -1512,14 +1607,25 @@ A ``geopandas.GeoDataFrame`` can be added to the map via ``m.add_gdf()``. from eomaps import Maps import geopandas as gpd - gdf = gpd.GeoDataFrame(geometries=[...], crs=...) + gdf = gpd.GeoDataFrame(geometries=[...], crs=...)<> m = Maps() m.add_gdf(gdf, fc="r", ec="g", lw=2) + + It is possible to make the shapes of a ``GeoDataFrame`` pickable (e.g. usable with ``m.cb.pick`` callbacks) by providing a ``picker_name`` -(and optionally specifying a ``pick_method``). +(and specifying a ``pick_method``). + +- use ``pick_method="contains"`` if your ``GeoDataFrame`` consists of **polygon-geometries** (the default) + + - pick a geometry if `geometry.contains(mouse-click-position) == True` + +- use ``pick_method="centroids"`` if your ``GeoDataFrame`` consists of **point-geometries** + + - pick the geometry with the closest centroid + Once the ``picker_name`` is specified, pick-callbacks can be attached via: @@ -1840,7 +1946,7 @@ To indicate rectangular areas in any given crs, simply use ``m.indicate_extent`` | pass | | +-----------------------------------------------------------------------+-------------------------------------------------+ -👽 Logos +🥦 Logos ~~~~~~~~ To add a logo (or basically any image file ``.png``, ``.jpeg`` etc.) to the map, you can use ``m.add_logo``. @@ -2234,7 +2340,7 @@ Make sure to checkout the :ref:`layout_editor` which can be used to quickly re-p | .. code-block:: python | .. image:: _static/minigifs/inset_maps.png | | | :align: center | | m = Maps(Maps.CRS.PlateCarree(central_longitude=-60)) | | - | m.add_feature.preset.ocean(reproject="cartopy") | | + | m.add_feature.preset.ocean() | | | m2 = m.new_inset_map(xy=(5, 45), radius=10, | | | plot_position=(.3, .5), plot_size=.7, | | | boundary=dict(ec="r", lw=4), | | @@ -2246,12 +2352,12 @@ Make sure to checkout the :ref:`layout_editor` which can be used to quickly re-p | m2.add_feature.preset.ocean() | | | | | | m2.add_feature.cultural_10m.urban_areas(fc="r") | | - | m2.add_feature.physical_10m.rivers_europe(ec="b", lw=0.25) | | + | m2.add_feature.physical_10m.rivers_europe(ec="b", lw=0.25, | | + | fc="none") | | | m2.add_feature.physical_10m.lakes_europe(fc="b") | | | | | +----------------------------------------------------------------+--------------------------------------------+ - .. currentmodule:: eomaps.Maps .. autosummary:: diff --git a/docs/conf.py b/docs/conf.py index 1bf00e520..c4a4d0a11 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -69,6 +69,7 @@ def setup(app): "sphinx.ext.autosummary", "sphinx.ext.napoleon", "sphinx_copybutton", + "sphinx_rtd_theme", ] diff --git a/docs/general.rst b/docs/general.rst index 945074a64..0b13dc0ad 100644 --- a/docs/general.rst +++ b/docs/general.rst @@ -140,7 +140,3 @@ A list of the dependencies can be found below: - sphinx-copybutton - sphinx_rtd_theme - mock - # -------------- for Equi7Grid projections - - pip - - pip: - - equi7grid diff --git a/docs/requirements.txt b/docs/requirements.txt deleted file mode 100644 index 2b3f75c2c..000000000 --- a/docs/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -sphinx_copybutton diff --git a/eomaps/_cb_container.py b/eomaps/_cb_container.py index d6cb97684..9c4c38179 100644 --- a/eomaps/_cb_container.py +++ b/eomaps/_cb_container.py @@ -13,6 +13,107 @@ import numpy as np +gpd = None + + +def _register_geopandas(): + global gpd + try: + import geopandas as gpd + except ImportError: + return False + + return True + + +class _gpd_picker: + # a collection of pick-methods for geopandas.GeoDataFrames + def __init__(self, gdf, val_key, pick_method): + self.gdf = gdf + self.val_key = val_key + self.pick_method = pick_method + + def get_picker(self): + assert _register_geopandas(), ( + "EOmaps: Missing dependency `geopandas`!\n" + + "please install '(conda install -c conda-forge geopandas)'" + + "to make geopandas GeoDataFrames pickable." + ) + + if self.pick_method == "contains": + return self._contains_picker + elif self.pick_method == "centroids": + from scipy.spatial import cKDTree + + self.tree = cKDTree( + list(map(lambda x: (x.x, x.y), self.gdf.geometry.centroid)) + ) + return self._centroids_picker + else: + raise TypeError( + f"EOmaps: {self.pick_method} is not a valid " "pick_method!" + ) + + def _contains_picker(self, artist, mouseevent): + try: + query = getattr(self.gdf, "contains")( + gpd.points_from_xy( + np.atleast_1d(mouseevent.xdata), + np.atleast_1d(mouseevent.ydata), + )[0] + ) + + if query.any(): + + ID = self.gdf.index[query][0] + ind = query.values.nonzero()[0][0] + + if self.val_key: + val = self.gdf[query][self.val_key].iloc[0] + else: + val = None + + if artist.get_array() is not None: + val_numeric = artist.norm(artist.get_array()[ind]) + val_color = artist.cmap(val_numeric) + else: + val_numeric = None + val_color = None + + return True, dict( + ID=ID, + ind=ind, + val=val, + val_color=val_color, + pos=(mouseevent.xdata, mouseevent.ydata), + ) + else: + return False, dict() + except Exception: + return False, dict() + + def _centroids_picker(self, artist, mouseevent): + try: + dist, ind = self.tree.query((mouseevent.xdata, mouseevent.ydata), 1) + ID = self.gdf.index[ind] + + if self.val_key is not None: + val = self.gdf.iloc[ind][self.val_key] + else: + val = None + + pos = self.tree.data[ind].tolist() + try: + val_numeric = artist.norm(artist.get_array()[ID]) + val_color = artist.cmap(val_numeric) + except Exception: + val_color = None + + return True, dict(ID=ID, pos=pos, val=val, ind=ind, val_color=val_color) + + except Exception: + return False, dict() + class _cb_container(object): """base-class for callback containers""" @@ -504,6 +605,28 @@ def set_sticky_modifiers(self, *args): if self._method == "click": self._m.cb._click_move._sticky_modifiers = args + def _init_picker(self): + try: + # Lazily make a plotted dataset pickable a + if getattr(self._m, "tree", None) is None: + assert getattr(self._m, "coll", None) is not None, ( + "EOmaps: you MUST call `m.plot_map()` or " + "`m.make_dataset_pickable()` before assigning pick callbacks!" + ) + + 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 as ex: + print( + "EOmaps: There was an error while trying to initialize " + "pick-callbacks!", + ex, + ) + def _add_callback( self, *args, @@ -593,9 +716,10 @@ def _add_callback( if self._method == "pick": assert self._m.coll is not None, ( - "you can only attach pick-callbacks after plotting a dataset!" - + "... use `m.plot_map()` first." + "Pick-callbacks can only be attached AFTER calling `m.plot_map()` " + "or `m.make_dataset_pickable()`!" ) + self._init_picker() # attach "on_move" callbacks movecb_name = None @@ -1007,9 +1131,13 @@ class cb_pick_container(_click_container): Note ---- - The threshold for the default picker can be set via the `pick_distance` argument. - Use `m.plot_map(pick_distance=20)` to specify the maximal distance (in pixels) - that is used to identify the closest datapoint. + + To speed up identification of points for very large datasets, the search + is limited to points located inside a "search rectangle". + The side-length of this rectangle is determined in the plot-crs and can be + set via `m.cb.pick.set_props(search_radius=...)`. + + The default is to use a side-length of 50 times the dataset-radius. Methods -------- @@ -1027,6 +1155,8 @@ class cb_pick_container(_click_container): set_sticky_modifiers : define keypress-modifiers that remain active after release + set_props : set the picking behaviour (e.g. number of points, search radius, etc.) + """ def __init__(self, picker_name="default", picker=None, *args, **kwargs): @@ -1034,7 +1164,12 @@ def __init__(self, picker_name="default", picker=None, *args, **kwargs): self._cid_pick_event = dict() self._picker_name = picker_name self._artist = None - self._pick_distance = np.inf + + self._n_ids = 1 + self._consecutive_multipick = False + self._pick_relative_to_closest = True + + self._search_radius = "50" if picker is None: self._picker = self._default_picker @@ -1055,6 +1190,64 @@ def __getitem__(self, name): f"the picker {name} does not exist...", "use `m.cb.add_picker` first!" ) + def set_props( + self, + n=None, + consecutive_pick=None, + pick_relative_to_closest=None, + search_radius=None, + ): + """ + Set the picker-properties (number of picked points, max. search radius, etc.) + (Only provided arguments will be updated!) + + Parameters + ---------- + n : int, optional + The number of nearest neighbours to pick at each pick-event. + The default is 1. + consecutive_pick : bool, optional + + - If True, pick-callbacks will be executed consecutively for each + picked datapoint. + - if False, pick-callbacks will get lists of all picked values + as input-arguments + + The default is False. + pick_relative_to_closest : bool, optional + ONLY relevant if `n > 1`. + + - If True: pick (n) nearest neighbours based on the center of the + closest identified datapoint + - If False: pick (n) nearest neighbours based on the click-position + + The default is True. + search_radius : int, float, str or None optional + Set the radius of the area that is used to limit the number of + pixels when searching for nearest-neighbours. + + if `int` or `float`: + The radius of the circle in units of the plot_crs + if `str: + A multiplication-factor for the estimated pixel-radius. + (e.g. a circle with (r=search_radius * m.shape.radius) is + used if possible and else np.inf is used. + + The default is "50" (e.g. 50 times the pixel-radius). + """ + + if n is not None: + self._n_ids = n + + if consecutive_pick is not None: + self._consecutive_multipick = consecutive_pick + + if pick_relative_to_closest is not None: + self._pick_relative_to_closest = pick_relative_to_closest + + if search_radius is not None: + self._search_radius = search_radius + def _set_artist(self, artist): self._artist = artist self._artist.set_picker(self._picker) @@ -1064,13 +1257,14 @@ def _init_cbs(self): 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._m.cb.pick.get.cbs) == 0: + if len(self.get.cbs) == 0: return False, None except ReferenceError: # in case we encounter a reference-error, remove the picker from the artist @@ -1086,32 +1280,41 @@ def _default_picker(self, artist, event): if not np.isfinite((event.xdata, event.ydata)).all(): return False, dict(ind=None, dblclick=event.dblclick, button=event.button) - # find the closest point to the clicked pixel - dist, index = self._m.tree.query((event.xdata, event.ydata)) + # 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) - pos = self._m._get_xy_from_index(index, reprojected=True) - ID = self._get_id(index) - val = self._m._props["z_data"].flat[index] - try: - color = artist.cmap(artist.norm(val)) - except Exception: - color = None + # 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, + ) if index is not None: + pos = self._m._get_xy_from_index(index, reprojected=True) + ID = self._get_id(index) + val = self._m._props["z_data"].flat[index] + try: + val_color = artist.cmap(artist.norm(val)) + except Exception: + val_color = None + return True, dict( dblclick=event.dblclick, button=event.button, - dist=dist, ind=index, ID=ID, pos=pos, val=val, - val_color=color, + val_color=val_color, ) else: - return True, dict( - ind=None, dblclick=event.dblclick, button=event.button, dist=dist - ) + # 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 False, None @@ -1122,7 +1325,7 @@ def _get_id(self, ind): Parameters ---------- - ind : int + ind : int or list of int The index of the flattened array. Returns @@ -1133,27 +1336,67 @@ def _get_id(self, ind): ids = self._m._props["ids"] if isinstance(ids, (list, range)): - ID = ids[ind] + ind = np.atleast_1d(ind).tolist() # to treat numbers and lists + ID = [ids[i] for i in ind] + if len(ID) == 1: + ID = ID[0] elif isinstance(ids, np.ndarray): ID = ids.flat[ind] else: ID = "?" - return ID def _get_pickdict(self, event): - ind = event.ind - mouseevent = event.mouseevent + event_ind = event.ind + 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: - clickdict = dict( - ID=getattr(event, "ID", None), - pos=getattr(event, "pos", (mouseevent.xdata, mouseevent.ydata)), - val=getattr(event, "val", None), - ind=getattr(event, "ind", None), - picker_name=self._picker_name, - val_color=getattr(event, "val_color", None), - ) - return clickdict + 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, + ) + + 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, + ) + ] + + return clickdicts def _onpick(self, event): if event.artist is not self._artist: @@ -1201,7 +1444,11 @@ def _onpick(self, event): cb = bcbs[key] if clickdict is not None: - cb(**clickdict) + if self._consecutive_multipick is False: + cb(**clickdict) + else: + for c in clickdict: + cb(**c) def _reset_cids(self): for method, cid in self._cid_pick_event.items(): @@ -1234,9 +1481,8 @@ def pickcb(event): self._m.BM._clear_temp_artists(self._method) self._event = event - # check if the artists has a custom picker assigned self._clear_temporary_artists() - self._m.BM._clear_temp_artists(self._method) + # self._m.BM._clear_temp_artists(self._method) # execute "_onpick" on the maps-object that belongs to the clicked axes # and forward the event to all forwarded maps-objects @@ -1298,17 +1544,14 @@ def _fwd_cb(self, event, picker_name): ) pick = obj._picker(obj._artist, dummymouseevent) - if pick[1] is not None: dummyevent.ID = pick[1].get("ID", None) dummyevent.ind = pick[1].get("ind", None) dummyevent.val = pick[1].get("val", None) + dummyevent.pos = pick[1].get("pos", None) - if "dist" in pick[1]: - dummyevent.dist = pick[1].get("dist", None) else: dummyevent.ind = None - dummyevent.dist = None obj._onpick(dummyevent) @@ -1628,7 +1871,7 @@ class cb_container: def __init__(self, m): self._m = m - self._methods = ["click", "move", "keypress", "_click_move"] + self._methods = {"click", "move", "keypress", "_click_move"} self._click = cb_click_container( m=self._m, @@ -1772,7 +2015,7 @@ def add_picker(self, name, artist, picker): # add the picker method to the accessible cbs setattr(self._m.cb, new_pick._method, new_pick) - self._methods.append(new_pick._method) + self._methods.add(new_pick._method) return new_pick diff --git a/eomaps/_shapes.py b/eomaps/_shapes.py index 05506ed4d..861330b9e 100644 --- a/eomaps/_shapes.py +++ b/eomaps/_shapes.py @@ -109,13 +109,15 @@ def _get_radius(m, radius, radius_crs): else: radius = m._estimated_radius else: - if isinstance(radius, (list, np.ndarray)): - radiusx = radiusy = tuple(radius) # get manually specified radius (e.g. if radius != "estimate") + if isinstance(radius, (list, np.ndarray)): + radiusx = radiusy = np.asanyarray(radius).ravel() elif isinstance(radius, tuple): radiusx, radiusy = radius elif isinstance(radius, (int, float, np.number)): radiusx = radiusy = radius + else: + radiusx = radiusy = radius radius = (radiusx, radiusy) return radius @@ -257,6 +259,7 @@ class _geod_circles(object): def __init__(self, m): self._m = m + self._n = None def __call__(self, radius=1000, n=None): """ @@ -264,8 +267,11 @@ def __call__(self, radius=1000, n=None): Parameters ---------- - radius : float + radius : float or array-like The radius of the circles in meters. + + If you provide an array of sizes, each datapoint will be drawn with + the respective size! n : int or None The number of intermediate points to calculate on the geodesic circle. If None, 100 is used for < 10k pixels and 20 otherwise. @@ -298,6 +304,8 @@ def n(self): return 100 else: return 20 + else: + return 20 return self._n @n.setter @@ -310,17 +318,7 @@ def radius(self): @radius.setter def radius(self, val): - if not isinstance(val, (int, float)): - print("EOmaps: geod_circles only support a number as radius!") - if isinstance(val[0], (int, float)): - print("EOmaps: ... using the mean") - val = np.mean(val) - else: - raise TypeError( - f"EOmaps: '{val}' is not a valid radius for 'geod_circles'!" - ) - - self._radius = val + self._radius = np.asanyarray(np.atleast_1d(val)) @property def radius_crs(self): @@ -355,7 +353,7 @@ def _calc_geod_circle_points(self, lon, lat, radius, n=20, start_angle=0): ------- lons : array-like the longitudes of the geodetic circle points. - lats : TYPE + lats : array-like the latitudes of the geodetic circle points. """ @@ -364,7 +362,10 @@ def _calc_geod_circle_points(self, lon, lat, radius, n=20, start_angle=0): if isinstance(radius, (int, float)): radius = np.full((size, n), radius) else: - radius = (np.broadcast_to(radius[:, None], (size, n)),) + if radius.size != lon.size: + radius = np.broadcast_to(radius[:, None], (size, n)) + else: + radius = np.broadcast_to(radius.ravel()[:, None], (size, n)) geod = self._m.crs_plot.get_geod() lons, lats, back_azim = geod.fwd( @@ -402,8 +403,8 @@ def _get_geod_circle_points(self, x, y, crs, radius, n=20): ~xs.mask.any(axis=0) & ~ys.mask.any(axis=0) & ((dx / dy) < 10) - & (dx < radius * 50) - & (dy < radius * 50) + & (dx < np.max(radius) * 50) + & (dy < np.max(radius) * 50) ) mask = np.broadcast_to(mask[:, None].T, lons.shape) @@ -440,6 +441,65 @@ def get_coll(self, x, y, crs, **kwargs): return coll + class _scatter_points(object): + name = "scatter_points" + + def __init__(self, m): + self._m = m + self._n = None + + def __call__(self, size=None, marker=None): + """ + Draw each datapoint as a shape with a size defined in points**2. + + All arguments are forwarded to `m.ax.scatter()`. + + Parameters + ---------- + size : int, float, array-like or str, optional + The marker size in points**2. + + If you provide an array of sizes, each datapoint will be drawn with + the respective size! + marker : str + The marker style. Can be either an instance of the class or the text + shorthand for a particular marker. Some examples are: + + - `".", "o", "s", "<", ">", "^", "$A^2$"` + + See matplotlib.markers for more information about marker styles. + """ + from . import MapsGrid # do this here to avoid circular imports! + + for m in self._m if isinstance(self._m, MapsGrid) else [self._m]: + shape = self.__class__(m) + shape._size = size + shape._marker = marker + m._shape = shape + + @property + def _initargs(self): + return dict(size=self._size, marker=self._marker) + + @property + def radius(self): + radius = shapes._get_radius(self._m, "estimate", "in") + return radius + + @property + def radius_crs(self): + return "in" + + def get_coll(self, x, y, crs, **kwargs): + color_and_array = shapes._get_colors_and_array( + kwargs, np.full((x.size,), True) + ) + color_and_array["c"] = color_and_array["array"] + coll = self._m.ax.scatter( + x, y, s=self._size, marker=self._marker, **color_and_array, **kwargs + ) + return coll + class _ellipses(object): name = "ellipses" @@ -453,10 +513,13 @@ def __call__(self, radius="estimate", radius_crs="in", n=None): Parameters ---------- - radius : int, float, tuple or str, optional + radius : int, float, array-like or str, optional The radius in x- and y- direction. The default is "estimate" in which case the radius is attempted to be estimated from the input-coordinates. + + If you provide an array of sizes, each datapoint will be drawn with + the respective size! radius_crs : crs-specification, optional The crs in which the dimensions are defined. The default is "in". @@ -488,6 +551,8 @@ def n(self): return 100 else: return 20 + else: + return 20 return self._n @n.setter @@ -501,7 +566,10 @@ def radius(self): @radius.setter def radius(self, val): - self._radius = val + if isinstance(val, (list, np.ndarray)): + self._radius = np.asanyarray(val).ravel() + else: + self._radius = val def __repr__(self): try: @@ -559,7 +627,6 @@ def _calc_ellipse_points(self, x0, y0, a, b, theta, n, start_angle=0): def _get_ellipse_points(self, x, y, crs, radius, radius_crs="in", n=20): crs = self._m.get_crs(crs) radius_crs = self._m.get_crs(radius_crs) - # transform from crs to the plot_crs t_in_plot = shapes.get_transformer(crs, self._m.crs_plot) # transform from crs to the radius_crs @@ -579,8 +646,8 @@ def _get_ellipse_points(self, x, y, crs, radius, radius_crs="in", n=20): xs, ys = self._calc_ellipse_points( p[0], p[1], - np.full_like(x, rx, dtype=float), - np.full_like(x, ry, dtype=float), + np.broadcast_to(rx, x.shape).astype(float), + np.broadcast_to(ry, y.shape).astype(float), np.full_like(x, 0), n=n, ) @@ -592,8 +659,8 @@ def _get_ellipse_points(self, x, y, crs, radius, radius_crs="in", n=20): xs, ys = self._calc_ellipse_points( p[0], p[1], - np.full_like(x, rx, dtype=float), - np.full_like(x, ry, dtype=float), + np.broadcast_to(rx, x.shape).astype(float), + np.broadcast_to(ry, y.shape).astype(float), np.full_like(x, 0), n=n, ) @@ -693,6 +760,9 @@ def __call__(self, radius="estimate", radius_crs="in", mesh=False, n=None): The radius in x- and y- direction. The default is "estimate" in which case the radius is attempted to be estimated from the input-coordinates. + + If you provide an array of sizes, each datapoint will be drawn with + the respective size! radius_crs : crs-specification, optional The crs in which the dimensions are defined. The default is "in". @@ -719,17 +789,8 @@ def __call__(self, radius="estimate", radius_crs="in", mesh=False, n=None): shape._radius = radius shape.radius_crs = radius_crs shape.mesh = mesh + shape.n = n - if mesh is True: - if n is None: - n = 1 - elif n > 1: - warnings.warn( - "EOmaps: rectangles with 'mesh=True' only supports n=1" - ) - shape.n = 1 - else: - shape.n = n m._shape = shape @property @@ -759,7 +820,14 @@ def n(self): @n.setter def n(self, val): - self._n = val + if self.mesh is True: + if val is not None and val != 1: + warnings.warn( + "EOmaps: rectangles with 'mesh=True' only supports n=1" + ) + self._n = 1 + else: + self._n = val @property def radius(self): @@ -768,7 +836,10 @@ def radius(self): @radius.setter def radius(self, val): - self._radius = val + if isinstance(val, (list, np.ndarray)): + self._radius = np.asanyarray(val).ravel() + else: + self._radius = val def __repr__(self): try: @@ -1629,6 +1700,11 @@ def get_coll(self, x, y, crs, **kwargs): return self._get_polygon_coll(x, y, crs, **kwargs) + @wraps(_scatter_points.__call__) + def scatter_points(self, *args, **kwargs): + shp = self._scatter_points(m=self._m) + return shp.__call__(*args, **kwargs) + @wraps(_geod_circles.__call__) def geod_circles(self, *args, **kwargs): shp = self._geod_circles(m=self._m) diff --git a/eomaps/_version.py b/eomaps/_version.py index f0b4b81b3..09cdb5570 100644 --- a/eomaps/_version.py +++ b/eomaps/_version.py @@ -1 +1 @@ -__version__ = "5.3" +__version__ = "5.4" diff --git a/eomaps/callbacks.py b/eomaps/callbacks.py index 1bb05c475..ec79acb7a 100644 --- a/eomaps/callbacks.py +++ b/eomaps/callbacks.py @@ -193,6 +193,18 @@ def annotate( ID, pos, val, ind, picker_name, val_color = self._popargs(kwargs) + try: + n_ids = len(ID) + except TypeError: + n_ids = 1 + + if ID is not None and n_ids > 1: + multipick = True + picked_pos = (pos[0][0], pos[1][0]) + else: + multipick = False + picked_pos = pos + if isinstance(self.m.data_specs.x, str): xlabel = self.m.data_specs.x else: @@ -211,25 +223,61 @@ def annotate( if text is None: if ID is not None and self.m.data is not None: - x, y = [ - np.format_float_positional(i, trim="-", precision=pos_precision) - for i in self.m._get_xy_from_index(ind) - ] - x0, y0 = [ - np.format_float_positional(i, trim="-", precision=pos_precision) - for i in pos - ] - if isinstance(val, (int, float)): - val = np.format_float_positional( - val, trim="-", precision=val_precision - ) - + if not multipick: + x, y = [ + np.format_float_positional(i, trim="-", precision=pos_precision) + for i in self.m._get_xy_from_index(ind) + ] + x0, y0 = [ + np.format_float_positional(i, trim="-", precision=pos_precision) + for i in pos + ] + + if isinstance(val, (int, float)): + val = np.format_float_positional( + val, trim="-", precision=val_precision + ) + else: + coords = [ + *self.m._get_xy_from_index(ind), + *self.m._get_xy_from_index(ind, reprojected=True), + ] + + for n, c in enumerate(coords): + mi = np.format_float_positional( + np.nanmin(c), trim="-", precision=pos_precision + ) + ma = np.format_float_positional( + np.nanmax(c), trim="-", precision=pos_precision + ) + coords[n] = f"{mi} ... {ma}" + + x, y, x0, y0 = coords + + if ID is not None: + ID = f"{np.nanmin(ID)} ... {np.nanmax(ID)}" + + if val is not None: + val = np.array(val, dtype=float) # to handle None + mi = np.format_float_positional( + np.nanmin(val), trim="-", precision=pos_precision + ) + ma = np.format_float_positional( + np.nanmax(val), trim="-", precision=pos_precision + ) + val = f"{mi}...{ma}" + + equal_crs = self.m.data_specs.crs != self.m._crs_plot printstr = ( - f"{xlabel} = {x} ({x0})\n" - + f"{ylabel} = {y} ({y0})\n" + (f"Picked {n_ids} points\n" if multipick else "") + + f"{xlabel} = {x}" + + (f" ({x0})\n" if equal_crs else "\n") + + f"{ylabel} = {y}" + + (f" ({y0})\n" if equal_crs else "\n") + (f"ID = {ID}" if ID is not None else "") + (f"\n{parameter} = {val}" if val is not None else "") ) + else: lon, lat = self.m._transf_plot_to_lonlat.transform(*pos) x, y = [ @@ -256,8 +304,12 @@ def annotate( if printstr is not None: # create a new annotation - bbox = dict(boxstyle="round", fc="w", ec=val_color) - bbox.update(kwargs.pop("bbox", dict())) + if not multipick: + bbox = dict(boxstyle="round", fc="w", ec=val_color) + bbox.update(kwargs.pop("bbox", dict())) + else: + bbox = dict(boxstyle="round", fc="w", ec="k") + bbox.update(kwargs.pop("bbox", dict())) styledict = dict( xytext=(20, 20), @@ -267,7 +319,7 @@ def annotate( ) styledict.update(**kwargs) - annotation = ax.annotate("", xy=pos, **styledict) + annotation = ax.annotate("", xy=picked_pos, **styledict) annotation.set_zorder(zorder) if permanent is False: @@ -284,7 +336,7 @@ def annotate( self.permanent_annotations.append(annotation) annotation.set_visible(True) - annotation.xy = pos + annotation.xy = picked_pos annotation.set_text(printstr) def clear_annotations(self, **kwargs): @@ -426,10 +478,7 @@ def mark( ), f"'{shape}' is not a valid marker-shape... use one of {possible_shapes}" if radius_crs is None: - try: - radius_crs = self.m.shape.radius_crs - except Exception: - radius_crs = "in" + radius_crs = getattr(self.m.shape, "radius_crs", "in") if radius is None: if self.m.coll is not None: @@ -440,12 +489,11 @@ def mark( radius = (t.width / 10.0, t.height / 10.0) ID, pos, val, ind, picker_name, val_color = self._popargs(kwargs) - if ID is not None and picker_name == "default": if ind is None: - # ind = self.m.data.index.get_loc(ID) - ind = np.flatnonzero(np.isin(self.m._props["ids"], ID)) - pos = self.m._get_xy_from_index(ind) + pos = self.m._get_xy_from_ID(ID) + else: + pos = self.m._get_xy_from_index(ind) pos_crs = "in" else: pos_crs = "out" @@ -749,9 +797,7 @@ def load( 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( @@ -764,7 +810,6 @@ def load( raise TypeError("load_method must be a string or a callable!") except Exception: print(f"could not load object with ID: '{ID}' from {database}") - if load_multiple is True: self.picked_object = getattr(self, "picked_object", list()) + [pick] else: diff --git a/eomaps/eomaps.py b/eomaps/eomaps.py index 976e00124..7d442a7c4 100644 --- a/eomaps/eomaps.py +++ b/eomaps/eomaps.py @@ -115,7 +115,7 @@ def _register_mapclassify(): from ._webmap_containers import wms_container from .ne_features import NaturalEarth_features -from ._cb_container import cb_container +from ._cb_container import cb_container, _gpd_picker from .scalebar import ScaleBar, Compass from .projections import Equi7Grid_projection # import to supercharge cartopy.ccrs from .reader import read_file, from_file, new_layer_from_file @@ -1251,14 +1251,16 @@ def get_crs(self, crs="plot"): the pyproj CRS instance """ - if crs == "in": - crs = self.data_specs.crs - elif crs == "out" or crs == "plot": - crs = self.crs_plot - if not hasattr(self, "_crs_cache"): self._crs_cache = dict() + # 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": + crs = self.crs_plot + h = hash(crs) if h in self._crs_cache: crs = self._crs_cache[h] @@ -1513,11 +1515,6 @@ def add_gdf( The matplotlib-artists added to the plot """ - assert pick_method in ["centroids", "contains"], ( - f"EOmaps: '{pick_method}' is not a valid GeoDataFrame pick-method! " - + "... use one of ['contains', 'centroids']" - ) - assert _register_geopandas(), ( "EOmaps: Missing dependency `geopandas`!\n" + "please install '(conda install -c conda-forge geopandas)'" @@ -1592,89 +1589,31 @@ def add_gdf( prefixes.append(f"_{i.__class__.__name__.replace('Collection', '')}") if picker_name is not None: - if pick_method is not None: - if isinstance(pick_method, str): - if pick_method == "contains": - - def picker(artist, mouseevent): - try: - query = getattr(gdf, pick_method)( - gpd.points_from_xy( - [mouseevent.xdata], [mouseevent.ydata] - )[0] - ) - if query.any(): - - ID = gdf.index[query][0] - ind = query.values.nonzero()[0][0] - if val_key: - val = gdf[query][val_key].iloc[0] - else: - val = None - - val_numeric = artist.norm(artist.get_array()[ind]) - color = artist.cmap(val_numeric) - - return True, dict( - ID=ID, - ind=ind, - val=val, - val_color=color, - ) - else: - return False, dict() - except: - return False, dict() - - elif pick_method == "centroids": - from scipy.spatial import cKDTree - - tree = cKDTree( - list(map(lambda x: (x.x, x.y), gdf.geometry.centroid)) - ) - - def picker(artist, mouseevent): - try: - dist, ind = tree.query( - (mouseevent.xdata, mouseevent.ydata), 1 - ) - - ID = gdf.index[ind] - val = gdf.iloc[ind][val_key] if val_key else None - pos = tree.data[ind].tolist() - - val_numeric = artist.norm(artist.get_array()[ID]) - color = artist.cmap(val_numeric) - - except: - return False, dict() - - return True, dict( - ID=ID, pos=pos, val=val, ind=ind, val_color=color - ) - - elif callable(pick_method): - picker = pick_method - else: - print( - "EOmaps: I don't know what to do with the provided pick_method" - ) - - if len(artists) > 1: - warnings.warn( - "EOmaps: Multiple geometry types encountered in `m.add_gdf`. " - + "The pick containers are re-named to" - + f"{[picker_name + prefix for prefix in prefixes]}" - ) - else: - prefixes = [""] + if isinstance(pick_method, str): + self._picker_cls = _gpd_picker( + gdf=gdf, pick_method=pick_method, val_key=val_key + ) + picker = self._picker_cls.get_picker() + elif callable(pick_method): + picker = pick_method + else: + print("EOmaps: I don't know what to do with the provided pick_method") - for artist, prefix in zip(artists, prefixes): - # make the newly added collection pickable - self.cb.add_picker(picker_name + prefix, artist, picker=picker) + if len(artists) > 1: + warnings.warn( + "EOmaps: Multiple geometry types encountered in `m.add_gdf`. " + + "The pick containers are re-named to" + + f"{[picker_name + prefix for prefix in prefixes]}" + ) + else: + prefixes = [""] - # attach the re-projected GeoDataFrame to the pick-container - self.cb.pick[picker_name + prefix].data = gdf + 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 if layer is None: layer = self.layer @@ -2382,7 +2321,6 @@ def apply_layout(self, layout): def plot_map( self, - pick_distance=100, layer=None, dynamic=False, set_extent=True, @@ -2404,22 +2342,6 @@ def plot_map( Parameters ---------- - pick_distance : int, float, str or None - - - If None, NO pick-callbacks will be assigned ('m.cb.pick' will not work!!) - (useful for very large datasets to speed up plotting and save memory) - - If a number is provided, it will be used to determine the search-area - used to identify clicked pixels (e.g. a rectangle with a edge-size of - `pick_distance * estimated radius`). - - If a string is provided, it will be directly assigned as pick-radius - (without multiplying by the estimated radius). This is useful for datasets - whose radius cannot be determined (e.g. singular points etc.) - - The provided number is identified as radius in the plot-crs! - - The string must be convertible to a number, e.g. `float("40.5")` - - The default is 100. layer : str or None The layer at which the dataset will be plotted. ONLY relevant if `dynamic = False`! @@ -2467,7 +2389,6 @@ def plot_map( The default is True. - Other Parameters ---------------- vmin, vmax : float, optional @@ -2482,6 +2403,12 @@ def plot_map( For "shade_points" or "shade_raster" shapes, kwargs are passed to `datashader.mpl_ext.dsshow` """ + if getattr(self, "coll", None) is not None: + print( + "EOmaps-warning: Calling `m.plot_map()` or " + "`m.make_dataset_pickable()` more than once on the " + "same Maps-object will override the assigned PICK-dataset!" + ) # convert vmin/vmax values to respect the encoding of the data vmin = kwargs.get("vmin", None) @@ -2500,7 +2427,7 @@ def plot_map( useshape = self.shape # invoke the setter to set the default shape - # make sure the colormap is properly set and transparencys are assigned + # make sure the colormap is properly set and transparencies are assigned cmap = kwargs.setdefault("cmap", "viridis") if "alpha" in kwargs and kwargs["alpha"] < 1: # get a unique name for the colormap @@ -2523,7 +2450,7 @@ def plot_map( name=cmapname, ) - plt.register_cmap(name=cmapname, cmap=kwargs["cmap"]) + plt.colormaps.register(name=cmapname, cmap=kwargs["cmap"]) if self._companion_widget is not None: self._companion_widget.cmapsChanged.emit() # remember registered colormaps (to de-register on close) @@ -2535,7 +2462,6 @@ def plot_map( if useshape.name.startswith("shade"): self._shade_map( - pick_distance=pick_distance, layer=layer, dynamic=dynamic, set_extent=set_extent, @@ -2544,7 +2470,6 @@ def plot_map( ) else: self._plot_map( - pick_distance=pick_distance, layer=layer, dynamic=dynamic, set_extent=set_extent, @@ -2572,7 +2497,6 @@ def plot_map( def make_dataset_pickable( self, - pick_distance=100, ): """ Make the associated dataset pickable **without plotting** it first. @@ -2591,14 +2515,6 @@ def 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`) - Parameters - ---------- - pick_distance : int - The search-area surrounding the clicked pixel used to identify the datapoint - (e.g. a rectangle with a edge-size of `pick_distance * estimated radius`). - - The default is 100. - Examples -------- @@ -2606,7 +2522,6 @@ def make_dataset_pickable( >>> m.add_feature.preset.coastline() >>> ... >>> # a dataset that should be pickable but NOT visible... - >>> # (e.g. in this case 100 points along the diagonal) >>> m2 = m.new_layer() >>> m2.set_data(*np.linspace([0, -180,-90,], [100, 180, 90], 100).T) >>> m2.make_dataset_pickable() @@ -2620,8 +2535,9 @@ def make_dataset_pickable( if self.coll is not None: print( - "EOmaps: There is already a collection assigned to this Maps-object" - + "... make sure to use a new layer for the pickable dataset!" + "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 @@ -2638,12 +2554,10 @@ def make_dataset_pickable( self._coll = art - if pick_distance is not None: - self.tree = searchtree(m=self._proxy(self), pick_distance=pick_distance) - self.cb.pick._set_artist(art) - self.cb.pick._init_cbs() - self.cb.pick._pick_distance = pick_distance - self.cb._methods.append("pick") + self.tree = searchtree(m=self._proxy(self)) + self.cb.pick._set_artist(art) + self.cb.pick._init_cbs() + self.cb._methods.add("pick") def show_layer(self, name): """ @@ -3227,7 +3141,7 @@ def _on_close(self, event): # de-register colormaps for cmap in self._registered_cmaps: - plt.cm.unregister_cmap(cmap) + plt.colormaps.unregister(cmap) # run garbage-collection to immediately free memory gc.collect @@ -3600,6 +3514,49 @@ def _get_xy_from_index(self, ind, reprojected=False): else: return (self._props["xorig"].flat[xind], self._props["yorig"].flat[yind]) + def _get_xy_from_ID(self, ID, reprojected=False): + ind = self._get_ind(ID) + if self._1D2D: + xind, yind = np.unravel_index(ind, self._zshape) + else: + xind = yind = ind + + if reprojected: + return (self._props["x0"].flat[xind], self._props["y0"].flat[yind]) + else: + return (self._props["xorig"].flat[xind], self._props["yorig"].flat[yind]) + + def _get_ind(self, ID): + """ + Identify the numerical data-index from a given ID + + Parameters + ---------- + ID : single ID or list of IDs + The IDs to search for. + + Returns + ------- + ind : any + The corresponding (flat) data-index. + """ + ids = self._props["ids"] + + ID = np.atleast_1d(ID) + if isinstance(ids, range): + # if "ids" is range-like, so is "ind", therefore we can simply + # select the values. + inds = [ids[i] for i in ID] + if isinstance(ids, list): + # for lists, using .index to identify the index + inds = [ids.index(i) for i in ID] + elif isinstance(ids, np.ndarray): + inds = np.flatnonzero(np.isin(ids, ID)) + else: + ID = "?" + + return inds + def _classify_data( self, z_data=None, @@ -3872,7 +3829,6 @@ def _set_cpos(self, x, y, radiusx, radiusy, cpos): def _plot_map( self, - pick_distance=100, layer=None, dynamic=False, set_extent=True, @@ -3998,13 +3954,11 @@ def _plot_map( self._coll = coll - if pick_distance is not None: - self.tree = searchtree(m=self._proxy(self), pick_distance=pick_distance) - - self.cb.pick._set_artist(coll) - self.cb.pick._init_cbs() - self.cb.pick._pick_distance = pick_distance - self.cb._methods.append("pick") + # This is now done lazily (only if a pick-callback is attached) + # self.tree = searchtree(m=self._proxy(self)) + # self.cb.pick._set_artist(coll) + # self.cb.pick._init_cbs() + # self.cb._methods.add("pick") if dynamic is True: self.BM.add_artist(coll, layer) @@ -4030,7 +3984,6 @@ def _plot_map( def _shade_map( self, - pick_distance=100, verbose=0, layer=None, dynamic=False, @@ -4301,13 +4254,11 @@ def _shade_map( if verbose: print("EOmaps: Indexing for pick-callbacks...") - if pick_distance is not None: - self.tree = searchtree(m=self._proxy(self), pick_distance=pick_distance) - - self.cb.pick._set_artist(coll) - self.cb.pick._init_cbs() - self.cb.pick._pick_distance = pick_distance - self.cb._methods.append("pick") + # This is now done lazily (only if a pick-callback is attached) + # self.tree = searchtree(m=self._proxy(self)) + # self.cb.pick._set_artist(coll) + # self.cb.pick._init_cbs() + # self.cb._methods.add("pick") if dynamic is True: self.BM.add_artist(coll, layer) @@ -4409,6 +4360,8 @@ def _decode_values(self, val): 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)): @@ -4561,7 +4514,7 @@ def _init_companion_widget(self, show_hide_key="w"): """ try: - if plt.get_backend() not in ["Qt5Agg"]: + if plt.get_backend() not in ["QtAgg", "Qt5Agg"]: print( "EOmaps: Using m.open_widget() is only possible if you use matplotlibs" + f" 'Qt5Agg' backend! (active backend: '{plt.get_backend()}')" diff --git a/eomaps/helpers.py b/eomaps/helpers.py index 02f67179b..04c431509 100644 --- a/eomaps/helpers.py +++ b/eomaps/helpers.py @@ -119,109 +119,214 @@ def show(j): class searchtree: - def __init__(self, m=None, pick_distance=50): + def __init__(self, m): """ - search for coordinates + Nearest-neighbour search. Parameters ---------- - m : eomaps.Maps, optional - the maps-object. The default is None. - pick_distance : int, float or str optional - used to limit the number of pixels in the search to - - if a number is provided: - use a rectangle of (pick_distance * estimated radius in plot_crs) - - if a string is provided: - use a rectangle with r=float(pick_distance) in plot_crs - - The default is 50. + m : eomaps.Maps + The maps-object that provides the data. """ self._m = m - self._pick_distance = pick_distance + # set starting pick-distance to 50 times the radius + self.set_search_radius("50") + + self._misses = 0 + + @property + def d(self): + """Side-length of the search-rectangle (in units of the plot-crs)""" + return self._d + + def set_search_radius(self, r): + """ + Set the rectangle side-length that is used to limit the query. + + (e.g. only points that are within a rectangle of the specified size + centered at the clicked point are considered!) - if isinstance(pick_distance, (int, float, np.number)): + Parameters + ---------- + r : int, float or str, optional + Set the radius of the (circular) area that is used to limit the + number of pixels when searching for nearest-neighbours. + + - if `int` or `float`: + The radius of the circle in units of the plot_crs + - if `str`: + A multiplication-factor for the estimated pixel-radius. + (e.g. a circle with (`r=search_radius * m.shape.radius`) is + used if possible and else np.inf is used. + + The default is "50" (e.g. 50 times the pixel-radius). + """ + + self._search_radius = r + + if isinstance(r, str): # evaluate an appropriate pick-distance - if self._m.shape.radius_crs != "out": + if getattr(self._m.shape, "radius_crs", "?") != "out": try: radius = self._m.set_shape._estimate_radius(self._m, "out", np.max) except AssertionError: print( - "EOmaps... unable to estimate 'pick_distance' radius... " - + "Defaulting to `np.inf`. See docstring of m.plot_map() for " - + "more details on how to set the pick_distance!" + "EOmaps: Unable to estimate search-radius based on data." + "Defaulting to `np.inf`. " + "See `m.tree.set_search_radius` for more details!" ) radius = [np.inf] else: radius = self._m.shape.radius - self.d = max(radius) * self._pick_distance - elif isinstance(pick_distance, str): - self.d = float(pick_distance) - - self._misses = 0 + self._d = np.max(radius) * float(self._search_radius) + elif isinstance(r, (int, float, np.number)): + self._d = float(r) + else: + raise TypeError( + f"EOmaps: {r} is not a valid search-radius. " + "The search-radius must be provided as " + "int, float or as string that can be identified " + "as float!" + ) - def query(self, x, k=1, d=None): - if d is None: - d = self.d + def _identify_search_subset(self, x, d): + # select a rectangle around the pick-coordinates + # (provides tremendous speedups for very large datasets) - i = None + # get a rectangular boolean mask + mx = np.logical_and( + self._m._props["x0"] > (x[0] - d), self._m._props["x0"] < (x[0] + d) + ) + my = np.logical_and( + self._m._props["y0"] > (x[1] - d), self._m._props["y0"] < (x[1] + d) + ) - # take care of 1D coordinates and 2D data if self._m._1D2D: - # just perform a brute-force search for 1D coords - ix = np.argmin(np.abs(self._m._props["x0"] - x[0])) - iy = np.argmin(np.abs(self._m._props["y0"] - x[1])) + mx_id, my_id = np.where(mx)[0], np.where(my)[0] + m_rect_x, m_rect_y = np.meshgrid(mx_id, my_id) - i = np.ravel_multi_index((ix, iy), self._m._zshape) + x_rect = self._m._props["x0"][m_rect_x].ravel() + y_rect = self._m._props["y0"][m_rect_y].ravel() + # get the unravelled indexes of the boolean mask + idx = np.ravel_multi_index((m_rect_x, m_rect_y), self._m._zshape).ravel() else: - # select a rectangle around the pick-coordinates - # (provides tremendous speedups for very large datasets) - mx = np.logical_and( - self._m._props["x0"] > (x[0] - d), self._m._props["x0"] < (x[0] + d) - ) - my = np.logical_and( - self._m._props["y0"] > (x[1] - d), self._m._props["y0"] < (x[1] + d) - ) m = np.logical_and(mx, my) # get the indexes of the search-rectangle idx = np.where(m.ravel())[0] - # evaluate the clicked pixel as the one with the smallest - # euclidean distance + if len(idx) > 0: - i = idx[ - ( - (self._m._props["x0"][m].ravel() - x[0]) ** 2 - + (self._m._props["y0"][m].ravel() - x[1]) ** 2 - ).argmin() - ] + x_rect = self._m._props["x0"][m].ravel() + y_rect = self._m._props["y0"][m].ravel() + else: + x_rect, y_rect = [], [] + if len(x_rect) > 0 and len(y_rect) > 0: + mcircle = (x_rect - x[0]) ** 2 + (y_rect - x[1]) ** 2 < d**2 + return x_rect[mcircle], y_rect[mcircle], idx[mcircle] + else: + return [], [], [] + + def query(self, x, k=1, d=None, pick_relative_to_closest=True): + """ + Find the (k) closest points. + + Parameters + ---------- + x : list, tuple or np.array of length 2 + The x- and y- coordinates to search. + k : int, optional + The number of points to identify. + The default is 1. + d : float, optional + The max. distance (in plot-crs) to consider when identifying points. + If None, the currently assigned distance (e.g. `m.tree.d`) is used. + (see `m.tree.set_search_radius` on how to set the default distance!) + The default is None. + pick_relative_to_closest : bool, optional + ONLY relevant if `k > 1`. + + - If True: pick (k) nearest neighbours based on the center of the + closest point + - If False: pick (k) nearest neighbours based on the click-position + + The default is True. + + Returns + ------- + i : list + The indexes of the selected datapoints with respect to the + flattened array. + """ + if d is None: + d = self.d + + i = None + # take care of 1D coordinates and 2D data + if self._m._1D2D: + if k == 1: + # just perform a brute-force search for 1D coords + ix = np.argmin(np.abs(self._m._props["x0"] - x[0])) + iy = np.argmin(np.abs(self._m._props["y0"] - x[1])) + + i = np.ravel_multi_index((ix, iy), self._m._zshape) + else: + if pick_relative_to_closest is True: + ix = np.argmin(np.abs(self._m._props["x0"] - x[0])) + iy = np.argmin(np.abs(self._m._props["y0"] - x[1])) + + # query again (starting from the closest point) + return self.query( + (self._m._props["x0"][ix], self._m._props["y0"][iy]), + k=k, + d=d, + pick_relative_to_closest=False, + ) + + x_rect, y_rect, idx = self._identify_search_subset(x, d) + if len(idx) > 0: + + if k == 1: + i = idx[((x_rect - x[0]) ** 2 + (y_rect - x[1]) ** 2).argmin()] else: - # show some warning if no points are found within the pick_distance - - if self._misses < 3: - self._misses += 1 - - text = "Found no data here...\n Increase pick_distance?" - - self._m.cb.click._cb.annotate( - pos=x, - permanent=False, - text=text, - xytext=(0.98, 0.98), - textcoords=self._m.f.transFigure, - horizontalalignment="right", - verticalalignment="top", - arrowprops=None, - fontsize=7, - bbox=dict( - ec="r", fc=(1, 0.9, 0.9, 0.5), lw=0.25, boxstyle="round" - ), + if pick_relative_to_closest is True: + i0 = ((x_rect - x[0]) ** 2 + (y_rect - x[1]) ** 2).argmin() + + return self.query( + (x_rect[i0], y_rect[i0]), + k=k, + d=d, + pick_relative_to_closest=False, ) + i = idx[ + ((x_rect - x[0]) ** 2 + (y_rect - x[1]) ** 2).argpartition( + range(min(k, x_rect.size)) + )[:k] + ] + else: + # show a warning if no points are found in the search area + if self._misses < 3: + self._misses += 1 + else: + text = "Found no data here...\n Increase search_radius?" + # TODO fix cleanup of temporary artists!! + self._m.add_annotation( + xy=x, + permanent=False, + text=text, + xytext=(0.98, 0.98), + textcoords=self._m.ax.transAxes, + horizontalalignment="right", + verticalalignment="top", + arrowprops=None, + fontsize=7, + bbox=dict(ec="r", fc=(1, 0.9, 0.9, 0.5), lw=0.25, boxstyle="round"), + ) - i = None + i = None - return None, i + return i class LayoutEditor: @@ -1433,6 +1538,7 @@ def _draw_animated(self, layers=None, artists=None): # redraw artists from the selected layers and explicitly provided artists # (sorted by zorder) allartists = chain(*(self._artists.get(layer, []) for layer in layers), artists) + for a in sorted(allartists, key=self._get_artist_zorder): fig.draw_artist(a) @@ -1582,9 +1688,7 @@ def blit_artists(self, artists, bg="active", blit=True): # paranoia in case we missed the first draw event if getattr(self.figure, "_cachedRenderer", "nope") is None: self.on_draw(None) - self._after_update_actions.append( - lambda: self.blit_artists(artists.copy(), bg) - ) + self._after_update_actions.append(lambda: self.blit_artists(artists, bg)) return # restore the background diff --git a/eomaps/qtcompanion/widgets/files.py b/eomaps/qtcompanion/widgets/files.py index c59f8f874..7e05057ae 100644 --- a/eomaps/qtcompanion/widgets/files.py +++ b/eomaps/qtcompanion/widgets/files.py @@ -31,6 +31,8 @@ def _identify_radius(r): # try to identify tuples if r.startswith("(") and r.endswith(")"): rx, ry = map(float, r.lstrip("(").rstrip(")").split(",")) + elif r == "None": + r = None else: r = float(r) rx = ry = r @@ -47,6 +49,7 @@ class ShapeSelector(QtWidgets.QFrame): aggregator=_none_or_val, mask_radius=_none_or_val, radius=_identify_radius, + n=_none_or_val, ) _argtypes = dict( @@ -106,7 +109,7 @@ def argparser(self, key, val): return convval - print(r"WARNING value-conversion for {key} = {val} did not succeed!") + print(f"WARNING value-conversion for {key} = {val} did not succeed!") return val @property diff --git a/eomaps/scalebar.py b/eomaps/scalebar.py index 066c447c5..622086459 100644 --- a/eomaps/scalebar.py +++ b/eomaps/scalebar.py @@ -125,6 +125,19 @@ def __init__( The default is: >>> dict(scale=1, offset=1, rotation=0, every=2) """ + self._m = m + + self._scale_props = dict(scale=None) + self._label_props = dict() + self._patch_props = dict() + self._patch_offsets = (1, 1, 1, 1) + + self._font_kwargs = dict() + self._fontkeys = ("family", "style", "variant", "stretch", "weight") + + # apply preset styling (so that any additional properties are applied on top + # of the preset) + self._apply_preset(preset) if scale is None: self._autoscale = autoscale_fraction @@ -133,7 +146,11 @@ def __init__( self._auto_position = auto_position - self._m = m + self.set_scale_props(scale=scale, **(scale_props if scale_props else {})) + # set the label properties + self.set_label_props(**(label_props if label_props else {})) + # set the patch properties + self.set_patch_props(**(patch_props if patch_props else {})) # number of intermediate points for evaluating the curvature self._interm_pts = 20 @@ -141,20 +158,6 @@ def __init__( self._cb_offset_interval = 0.05 self._cb_rotate_inverval = 1 - self._fontkeys = ("family", "style", "variant", "stretch", "weight") - self._font_kwargs = dict() - - self._scale_props = dict(scale=scale, n=10, width=5, colors=("k", "w")) - self._patch_props = dict(fc=".75", ec="k", lw=1, ls="-") - self._patch_offsets = (1, 1, 1, 1) - self._label_props = dict(scale=2, rotation=0, every=2, offset=1, color="k") - - self.set_scale_props(scale=scale, **(scale_props if scale_props else {})) - # set the label properties - self.set_label_props(**(label_props if label_props else {})) - # set the patch properties - self.set_patch_props(**(patch_props if patch_props else {})) - # geod from plot_crs self._geod = self._m.crs_plot.get_geod() # Transformer from lon/lat to the plot_crs @@ -171,21 +174,39 @@ def __init__( ) self._artists = OrderedDict(patch=None, scale=None) - - self.preset = preset - self._picker_name = None - def apply_preset(self, preset): + def _get_preset_props(self, preset): + scale_props = dict(n=10, width=5, colors=("k", "w")) + patch_props = dict(fc=".75", ec="k", lw=1, ls="-") + label_props = dict(scale=2, offset=1, every=2, rotation=0, color="k") + if preset == "bw": - self.set_scale_props(n=10, width=4, colors=("k", "w")) - self.set_patch_props(fc="none", ec="none", offsets=(1, 1.6, 1, 1)) - self.set_label_props( - scale=1.5, offset=0.5, every=2, weight="bold", family="Courier New" + scale_props.update(dict(n=10, width=4, colors=("k", "w"))) + patch_props.update(dict(fc="none", ec="none")) + label_props.update( + dict( + scale=1.5, + offset=0.5, + every=2, + weight="bold", + family="Courier New", + ) ) + return scale_props, patch_props, label_props - self._estimate_scale() - self.set_position() + def _apply_preset(self, preset): + self.preset = preset + + scale_props, patch_props, label_props = self._get_preset_props(preset) + self.set_scale_props(**scale_props) + self.set_patch_props(**patch_props) + self.set_label_props(**label_props) + + def apply_preset(self, preset): + self._apply_preset(preset) + self._estimate_scale() + self.set_position() @staticmethod def round_to_n(x, n=0): @@ -308,7 +329,7 @@ def set_scale_props(self, scale=None, n=None, width=None, colors=None): if hasattr(self, "_lon") and hasattr(self, "_lat"): self.set_position() - self._m.BM.update(blit=False) + self._m.BM.blit_artists(self._artists.values()) def set_patch_props(self, offsets=None, **kwargs): """ @@ -338,15 +359,16 @@ def set_patch_props(self, offsets=None, **kwargs): ["lw", "linewidth"], ["ls", "linestyle"], ]: - self._patch_props[key] = kwargs.pop( - key, kwargs.pop(synonym, self._patch_props[key]) - ) + if key in self._patch_props: + self._patch_props[key] = kwargs.pop( + key, kwargs.pop(synonym, self._patch_props[key]) + ) self._patch_props.update(kwargs) if hasattr(self, "_lon") and hasattr(self, "_lat"): self.set_position() - self._m.BM.update(blit=False) + self._m.BM.blit_artists(self._artists.values()) def set_label_props( self, scale=None, rotation=None, every=None, offset=None, color=None, **kwargs @@ -401,7 +423,7 @@ def set_label_props( if hasattr(self, "_lon") and hasattr(self, "_lat"): self.set_position() - self._m.BM.update(blit=False) + self._m.BM.blit_artists(self._artists.values()) def _get_base_pts(self, lon, lat, azim, npts=None): if npts is None: @@ -463,6 +485,7 @@ def _txt(self): def _get_d(self): # the base length used to define the size of the scalebar + # get the position in figure coordinates x, y = self._m.ax.transData.transform( self._t_plot.transform(self._lon, self._lat) @@ -476,16 +499,15 @@ def _get_d(self): return np.abs(xb[1] - yb[1]) def _get_patch_verts(self, pts, lon, lat, ang, d): - ot = 0.75 * d * self._patch_offsets[0] # top offset - # ob = 2.5 * d * self._patch_offsets[1] # bottom offset - ob = (2.5 * d + self._maxw) * self._patch_offsets[1] # bottom offset - - ob = ((self._label_props["offset"] + 2) * d + self._maxw) * self._patch_offsets[ - 1 - ] # bottom offset + # top bottom left right referrs to a horizontally oriented colorbar! + ot = d * self._patch_offsets[0] + ob = self._maxw + d * (self._label_props["offset"] + self._patch_offsets[1]) + o_l = d * self._patch_offsets[2] + o_r = d * self._patch_offsets[3] - o_l = 0.5 * d * self._patch_offsets[2] # left offset - o_r = 1.5 * d * self._patch_offsets[3] # right offset + # in case the top scale has a label, add a margin to encompass the text! + if len(pts) % self._label_props["every"] == 0: + o_r += self._top_h * 1.5 dxy = np.gradient(pts.reshape((-1, 2)), axis=0) alpha = np.arctan2(dxy[:, 1], -dxy[:, 0]) @@ -500,6 +522,8 @@ def _get_patch_verts(self, pts, lon, lat, ang, d): ptop[-1] -= (o_r * np.cos(alpha[-1]), -o_r * np.sin(alpha[-1])) pbottom[-1] -= (o_r * np.cos(alpha[-1]), -o_r * np.sin(alpha[-1])) + # TODO check how to deal with invalid vertices (e.g. self-intersections) + return np.vstack([ptop, pbottom[::-1]]) def _get_ang(self, p0, p1): @@ -516,8 +540,8 @@ def _get_ang(self, p0, p1): def _get_txt_coords(self, lon, lat, d, ang): # get the base point for the text xt, yt = self._t_plot.transform(lon, lat) - xt = xt - d * self._label_props["offset"] * np.sin(ang) / 2 - yt = yt + d * self._label_props["offset"] * np.cos(ang) / 2 + xt = xt - d * self._label_props["offset"] * np.sin(ang) + yt = yt + d * self._label_props["offset"] * np.cos(ang) return xt, yt from functools import lru_cache @@ -540,16 +564,27 @@ def _get_maxw(self, sscale, sn, lscale, lrotation, levery): # if the object is within the canvas! try: # get the widths of the text patches in data-coordinates - bbox = val.get_tightbbox(self._m.f.canvas.get_renderer()) + bbox = val.get_window_extent(self._m.f.canvas.get_renderer()) bbox = bbox.transformed(self._m.ax.transData.inverted()) - # use the max to account for rotated text objects w = max(bbox.width, bbox.height) if w > _maxw: _maxw = w except Exception: pass + + _top_h = 0 + try: + _top_label = next(i for i in sorted(self._artists) if i.startswith("text_")) + val = self._artists[_top_label] + bbox = val.get_window_extent(self._m.f.canvas.get_renderer()) + bbox = bbox.transformed(self._m.ax.transData.inverted()) + _top_h = min(bbox.width, bbox.height) + except Exception: + pass + self._maxw = _maxw + self._top_h = _top_h def _set_minitxt(self, d, pts): angs = np.arctan2(*np.array([p[0] - p[-1] for p in pts]).T[::-1]) @@ -566,9 +601,7 @@ def _set_minitxt(self, d, pts): else: txt = self._get_txt(i) - xy = self._get_txt_coords( - lon, lat, self._label_props["scale"] * d * 1.5, ang - ) + xy = self._get_txt_coords(lon, lat, d, ang) tp = TextPath( xy, txt, size=self._label_props["scale"] * d / 2, prop=self._font_props ) @@ -617,9 +650,7 @@ def _update_minitxt(self, d, pts): else: txt = self._get_txt(i) - xy = self._get_txt_coords( - lon, lat, self._label_props["scale"] * d * 1.5, ang - ) + xy = self._get_txt_coords(lon, lat, d, ang) tp = PathPatch( TextPath( @@ -691,9 +722,6 @@ def _add_scalebar(self, lon, lat, azim): coll.set_linewidth(self._scale_props["width"]) self._artists["scale"] = self._m.ax.add_collection(coll, autolim=False) - # apply preset - self.apply_preset(self.preset) - # -------------- make all artists animated self._artists["scale"].set_zorder(1) self._artists["patch"].set_zorder(0) @@ -702,14 +730,13 @@ def _add_scalebar(self, lon, lat, azim): # self._m.BM.add_artist(self._artists["text"]) self._m.BM.add_artist(self._artists["patch"]) - self._m.BM.update(artists=self._artists.values()) - + self._m.BM.blit_artists(self._artists.values()) # make sure to update the artists on zoom self._decorate_zooms() def set_position(self, lon=None, lat=None, azim=None, update=False): """ - Sset the position of the colorbar + Set the position of the colorbar Parameters ---------- @@ -752,6 +779,12 @@ def set_position(self, lon=None, lat=None, azim=None, update=False): self._artists["scale"].set_colors(colors) verts = self._get_patch_verts(pts, lon, lat, ang, d) + + # TODO check how to deal with invalid vertices!! + # print(np.all(np.isfinite(self._m._transf_plot_to_lonlat.transform(*verts.T)))) + + # verts = np.ma.masked_invalid(verts) + self._artists["patch"].set_verts([verts]) self._artists["patch"].update(self._patch_props) @@ -1444,6 +1477,29 @@ def set_position(self, pos, coords="data"): c.set_transform(trans) self._pos = pos + def get_position(self, coords="axis"): + """ + Return the current position of the compass + + Parameters + ---------- + coords : str, optional + Define what coordinates are returned + + - "data" : coordinates in the plot-crs + - "axis": relative [0-1] coordinates with respect to the + axis (e.g. (0, 0) = lower left corner, (1, 1) = upper right corner) + + The default is "axis". + + Returns + ------- + pos + a tuple (x, y) representing the current location of the compass. + + """ + return self._ax2data.inverted().transform(self._pos) + def set_ignore_invalid_angles(self, val): """ Set how to deal with invalid rotation-angles. diff --git a/tests/example2.py b/tests/example2.py index b8167a8be..9a46a9aa0 100644 --- a/tests/example2.py +++ b/tests/example2.py @@ -32,7 +32,6 @@ mg.m_0_1.ax.set_title("Stereographic") mg.m_0_1.set_shape.rectangles() mg.m_0_1.set_classify_specs(scheme="Quantiles", k=8) -mg.m_0_1.plot_map() # --------- set specs for the third axis mg.m_0_2.ax.set_extent(mg.m_0_2.crs_plot.area_of_use.bounds) diff --git a/tests/example7.py b/tests/example7.py index 5d1c795ee..2f421600f 100644 --- a/tests/example7.py +++ b/tests/example7.py @@ -35,7 +35,7 @@ ) mg.set_shape.rectangles(radius=3, radius_crs=4326) -mg.plot_map(alpha=0.75, ec=(1, 1, 1, 0.5), pick_distance=25) +mg.plot_map(alpha=0.75, ec=(1, 1, 1, 0.5)) for m in mg: # attach a callback to highlite the rectangles diff --git a/tests/example8.py b/tests/example8.py index d39a6e12d..655e7943b 100644 --- a/tests/example8.py +++ b/tests/example8.py @@ -23,8 +23,8 @@ -20, 45, scale_props=dict(n=6, width=3, colors=("k", "r")), - patch_props=dict(fc="none", ec="r", lw=0.5, offsets=(1, 1, 1, 2)), - label_props=dict(rotation=45, weight="bold", family="Impact"), + patch_props=dict(fc="none", ec="none", offsets=(1, 1, 1, 2)), + label_props=dict(scale=1, rotation=45, weight="bold", family="Impact", offset=0.5), ) s3 = m.add_scalebar( diff --git a/tests/test_WMS_capabilities.py b/tests/test_WMS_capabilities.py index c1467d035..d042333a7 100644 --- a/tests/test_WMS_capabilities.py +++ b/tests/test_WMS_capabilities.py @@ -7,6 +7,36 @@ from eomaps import Maps +from matplotlib.backend_bases import MouseEvent + + +def button_press_event(canvas, x, y, button, dblclick=False, guiEvent=None): + canvas._button = button + s = "button_press_event" + mouseevent = MouseEvent( + s, canvas, x, y, button, canvas._key, dblclick=dblclick, guiEvent=guiEvent + ) + canvas.callbacks.process(s, mouseevent) + + +def button_release_event(canvas, x, y, button, guiEvent=None): + s = "button_release_event" + event = MouseEvent(s, canvas, x, y, button, canvas._key, guiEvent=guiEvent) + canvas.callbacks.process(s, event) + canvas._button = None + + +def scroll_event(canvas, x, y, step, guiEvent=None): + s = "scroll_event" + event = MouseEvent(s, canvas, x, y, step=step, guiEvent=guiEvent) + canvas.callbacks.process(s, event) + + +def motion_notify_event(canvas, x, y, guiEvent=None): + s = "motion_notify_event" + event = MouseEvent(s, canvas, x, y, guiEvent=guiEvent) + canvas.callbacks.process(s, event) + class TestWMS(unittest.TestCase): def setUp(self): @@ -62,18 +92,19 @@ def test_WMS_legend_capabilities_NASA_GIBS(self): ) # pick up the the legend (e.g. click on it) - m.f.canvas.button_press_event(*leg_cpos, 1, False) + button_press_event(m.f.canvas, *leg_cpos, 1, False) # resize the legend - m.f.canvas.scroll_event(*leg_cpos, 20, False) + scroll_event(m.f.canvas, *leg_cpos, 20, False) # move the legend - m.f.canvas.motion_notify_event( + motion_notify_event( + m.f.canvas, (m.ax.bbox.x0 + m.ax.bbox.x1) / 2, (m.ax.bbox.y0 + m.ax.bbox.y1) / 2, None, ) # release the legend - m.f.canvas.button_press_event(0, 0, 1, False) + button_press_event(m.f.canvas, 0, 0, 1, False) plt.close(m.f) diff --git a/tests/test_basic_functions.py b/tests/test_basic_functions.py index bee72fb86..5cc9e474d 100644 --- a/tests/test_basic_functions.py +++ b/tests/test_basic_functions.py @@ -12,6 +12,49 @@ from eomaps import Maps, MapsGrid +from matplotlib.backend_bases import MouseEvent, KeyEvent + + +def button_press_event(canvas, x, y, button, dblclick=False, guiEvent=None): + canvas._button = button + s = "button_press_event" + mouseevent = MouseEvent( + s, canvas, x, y, button, canvas._key, dblclick=dblclick, guiEvent=guiEvent + ) + canvas.callbacks.process(s, mouseevent) + + +def button_release_event(canvas, x, y, button, guiEvent=None): + s = "button_release_event" + event = MouseEvent(s, canvas, x, y, button, canvas._key, guiEvent=guiEvent) + canvas.callbacks.process(s, event) + canvas._button = None + + +def motion_notify_event(canvas, x, y, guiEvent=None): + s = "motion_notify_event" + event = MouseEvent(s, canvas, x, y, guiEvent=guiEvent) + canvas.callbacks.process(s, event) + + +def scroll_event(canvas, x, y, step, guiEvent=None): + s = "scroll_event" + event = MouseEvent(s, canvas, x, y, step=step, guiEvent=guiEvent) + canvas.callbacks.process(s, event) + + +def key_press_event(canvas, key, guiEvent=None): + s = "key_press_event" + event = KeyEvent(s, canvas, key, guiEvent=guiEvent) + canvas.callbacks.process(s, event) + + +def key_release_event(canvas, key, guiEvent=None): + s = "key_release_event" + event = KeyEvent(s, canvas, key, guiEvent=guiEvent) + canvas.callbacks.process(s, event) + canvas._key = None + class TestBasicPlotting(unittest.TestCase): def setUp(self): @@ -44,8 +87,8 @@ def test_simple_map(self): def test_simple_plot_shapes(self): usedata = self.data.sample(500) - m = Maps(4326) # rectangles + m = Maps(4326) m.set_data(usedata, x="x", y="y", in_crs=3857) m.set_shape.geod_circles(radius=100000) m.set_classify.Quantiles(k=5) @@ -53,7 +96,6 @@ def test_simple_plot_shapes(self): m.indicate_masked_points() m.add_feature.preset.ocean(ec="k", scale="110m") - plt.close("all") # rectangles @@ -65,10 +107,17 @@ def test_simple_plot_shapes(self): m.add_feature.preset.ocean(ec="k", scale="110m") m.set_shape.rectangles(radius=1, radius_crs=4326) - m.plot_map() + m.plot_map(ec="k") m.set_shape.rectangles(radius=(1, 2), radius_crs="out") - m.plot_map() + m.plot_map(ec="k") + + r = usedata.x.rank() + r = r / r.max() * 10 + m.set_shape.rectangles(radius=r, radius_crs="out") + m.plot_map(ec="k", fc="none") + + plt.close("all") # rectangles m = Maps(4326) @@ -94,6 +143,28 @@ def test_simple_plot_shapes(self): m.set_shape.ellipses(radius=1, radius_crs=4326) m.plot_map() + r = usedata.x.rank() + r = r / r.max() * 10 + m.set_shape.ellipses(radius=r, radius_crs="out") + m.plot_map(ec="k", fc="none") + + plt.close("all") + + # scatter_points + m = Maps(4326) + m.set_data(usedata, x="x", y="y", in_crs=3857) + + m.set_shape.scatter_points(marker="*", size=20) + m.plot_map() + + m.set_shape.scatter_points(size=1) + m.plot_map() + + r = usedata.x.rank() + r = r / r.max() * 50 + m.set_shape.scatter_points(size=r, marker="s") + m.plot_map(ec="k", fc="none") + plt.close("all") # delaunay @@ -391,7 +462,7 @@ def test_copy_connect(self): m.plot_map() # plot on the same axes - m2 = m.copy(parent=m, data_specs=True, ax=m.ax) + m2 = m.copy(data_specs=True, ax=m.ax) m2.set_shape.ellipses() m2.plot_map(facecolor="none", edgecolor="r") @@ -598,14 +669,16 @@ def test_compass(self): m = Maps(Maps.CRS.Stereographic()) m.add_feature.preset.coastline(ec="k", scale="110m") c1 = m.add_compass((0.1, 0.1)) + self.assertTrue(np.allclose(c1.get_position(), np.array([0.1, 0.1]))) c2 = m.add_compass((0.9, 0.9)) + self.assertTrue(np.allclose(c2.get_position(), np.array([0.9, 0.9]))) cv = m.f.canvas # click on compass to move it around - cv.button_press_event(*m.ax.transAxes.transform((0.1, 0.1)), 1, False) - cv.motion_notify_event(*m.ax.transAxes.transform((0.5, 0.5)), False) - cv.button_release_event(*m.ax.transAxes.transform((0.5, 0.5)), 1, False) + button_press_event(cv, *m.ax.transAxes.transform((0.1, 0.1)), 1, False) + motion_notify_event(cv, *m.ax.transAxes.transform((0.5, 0.5)), False) + button_release_event(cv, *m.ax.transAxes.transform((0.5, 0.5)), 1, False) c1.set_position((-30000000, -2000000)) c1.set_patch("r", "g", 5) @@ -666,6 +739,9 @@ def test_ScaleBar(self): label_props=dict(scale=1.5, weight="bold", family="Courier New"), ) + # test_presets + s_bw = m.add_scalebar(preset="bw") + # ----------------- TEST interactivity cv = m.f.canvas x, y = m.ax.transData.transform(s3.get_position()[:2]) @@ -675,34 +751,36 @@ def test_ScaleBar(self): ) # click on scalebar - cv.button_press_event(x, y, 1, False) + button_press_event(cv, x, y, 1, False) # move the scalebar - cv.motion_notify_event(x1, y1, False) + motion_notify_event(cv, x1, y1, False) # increase bbox size - cv.key_press_event("left") - cv.key_press_event("right") - cv.key_press_event("up") - cv.key_press_event("down") + key_press_event(cv, "left") + key_press_event(cv, "right") + key_press_event(cv, "up") + key_press_event(cv, "down") # deincrease bbox size - cv.key_press_event("alt+left") - cv.key_press_event("alt+right") - cv.key_press_event("alt+up") - cv.key_press_event("alt+down") + key_press_event(cv, "alt+left") + key_press_event(cv, "alt+right") + key_press_event(cv, "alt+up") + key_press_event(cv, "alt+down") # rotate the scalebar - cv.key_press_event("+") - cv.key_press_event("-") + key_press_event(cv, "+") + key_press_event(cv, "-") # adjust the padding between the ruler and the text - cv.key_press_event("alt+-") - cv.key_press_event("alt++") + key_press_event(cv, "alt+-") + key_press_event(cv, "alt++") - for si in [s, s1, s2, s3]: + for si in [s, s1, s2, s3, s_bw]: si.remove() + plt.close("all") + def test_set_extent_to_location(self): m = Maps() resp = m._get_nominatim_response("austria") @@ -838,7 +916,7 @@ def test_a_complex_figure(self): m.ax.set_title(title) getattr(m.set_shape, i[0])(**i[1]) - m.plot_map(edgecolor="none", pick_distance=5) + m.plot_map(edgecolor="none") m.cb.click.attach.annotate(fontsize=6) m.add_feature.preset.coastline(lw=0.5) m.add_colorbar() @@ -883,10 +961,10 @@ def test_add_feature(self): # test providing custom args m = Maps() - countries = m.add_feature.cultural_110m.admin_0_countries - countries(ec="k", fc="g") + countries = m.add_feature.cultural.admin_0_countries + countries(ec="k", fc="g", scale=110) - m.add_feature.physical_110m.ocean(fc="b") + m.add_feature.physical.ocean(fc="b", scale=110) plt.close("all") diff --git a/tests/test_callbacks.py b/tests/test_callbacks.py index 5c195b9a1..035f23ff0 100644 --- a/tests/test_callbacks.py +++ b/tests/test_callbacks.py @@ -1,28 +1,17 @@ -import matplotlib as mpl - # mpl.rcParams["toolbar"] = "None" import unittest +from itertools import product import numpy as np import pandas as pd import matplotlib.pyplot as plt -from eomaps import Maps, MapsGrid +from eomaps import Maps -from matplotlib.backend_bases import MouseEvent +from matplotlib.backend_bases import MouseEvent, KeyEvent # copy of depreciated matplotlib function def button_press_event(canvas, x, y, button, dblclick=False, guiEvent=None): - """ - Callback processing for mouse button press events. - - Backend derived classes should call this function on any mouse - button press. (*x*, *y*) are the canvas coords ((0, 0) is lower left). - button and key are as defined in `MouseEvent`. - - This method will call all functions connected to the - 'button_press_event' with a `MouseEvent` instance. - """ canvas._button = button s = "button_press_event" mouseevent = MouseEvent( @@ -33,30 +22,25 @@ def button_press_event(canvas, x, y, button, dblclick=False, guiEvent=None): # copy of depreciated matplotlib function def button_release_event(canvas, x, y, button, guiEvent=None): - """ - Callback processing for mouse button release events. - - Backend derived classes should call this function on any mouse - button release. - - This method will call all functions connected to the - 'button_release_event' with a `MouseEvent` instance. - - Parameters - ---------- - x : float - The canvas coordinates where 0=left. - y : float - The canvas coordinates where 0=bottom. - guiEvent - The native UI event that generated the Matplotlib event. - """ s = "button_release_event" event = MouseEvent(s, canvas, x, y, button, canvas._key, guiEvent=guiEvent) canvas.callbacks.process(s, event) canvas._button = None +def key_press_event(canvas, key, guiEvent=None): + s = "key_press_event" + event = KeyEvent(s, canvas, key, guiEvent=guiEvent) + canvas.callbacks.process(s, event) + + +def key_release_event(canvas, key, guiEvent=None): + s = "key_release_event" + event = KeyEvent(s, canvas, key, guiEvent=guiEvent) + canvas.callbacks.process(s, event) + canvas._key = None + + class TestCallbacks(unittest.TestCase): def setUp(self): self.lon, self.lat = np.meshgrid( @@ -125,27 +109,98 @@ def test_get_values(self): plt.close("all") # ---------- test as PICK callback - m = self.create_basic_map() - cid = m.cb.pick.attach.get_values() - m.cb.pick.attach.annotate() - m.cb.click.attach.mark(radius=0.1) - - self.click_ID(m, 1225) - 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] == 1225) - - self.click_ID(m, 317) - self.assertEqual(len(m.cb.pick.get.picked_vals["pos"]), 2) - self.assertEqual(len(m.cb.pick.get.picked_vals["ID"]), 2) - self.assertEqual(len(m.cb.pick.get.picked_vals["val"]), 2) - - self.assertTrue(m.cb.pick.get.picked_vals["ID"][1] == 317) - - m.cb.click.remove(cid) - plt.close("all") + 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, + ): + m = self.create_basic_map() + 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.annotate() + m.cb.click.attach.mark(radius=0.1) + + self.click_ID(m, 1225) + + 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] == 1225) + + elif n == 5: + 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: + self.assertTrue( + np.allclose( + m.cb.pick.get.picked_vals["ID"][0], + np.array([1225, 1275, 1175, 1224, 1226]), + ) + ) + else: + self.assertTrue( + np.allclose( + m.cb.pick.get.picked_vals["ID"][0], + np.array([1225, 1275, 1175, 1224, 1325]), + ) + ) + + # click on another pixel + self.click_ID(m, 317) + + if n == 1: + self.assertEqual(len(m.cb.pick.get.picked_vals["pos"]), 2) + self.assertEqual(len(m.cb.pick.get.picked_vals["ID"]), 2) + self.assertEqual(len(m.cb.pick.get.picked_vals["val"]), 2) + + self.assertTrue(m.cb.pick.get.picked_vals["ID"][1] == 317) + + elif n == 5: + if cpick is True: + self.assertEqual(len(m.cb.pick.get.picked_vals["pos"]), 10) + self.assertEqual(len(m.cb.pick.get.picked_vals["ID"]), 10) + self.assertEqual(len(m.cb.pick.get.picked_vals["val"]), 10) + else: + self.assertEqual(len(m.cb.pick.get.picked_vals["pos"]), 2) + self.assertEqual(len(m.cb.pick.get.picked_vals["ID"]), 2) + self.assertEqual(len(m.cb.pick.get.picked_vals["val"]), 2) + + if relpick is True: + self.assertTrue( + np.allclose( + m.cb.pick.get.picked_vals["ID"][1], + np.array([317, 367, 267, 316, 417]), + ) + ) + else: + self.assertTrue( + np.allclose( + m.cb.pick.get.picked_vals["ID"][1], + np.array([317, 367, 267, 316, 417]), + ) + ) + + m.cb.pick.remove(cid) + plt.close("all") def test_print_to_console(self): # ---------- test as CLICK callback @@ -157,15 +212,33 @@ def test_print_to_console(self): plt.close("all") # ---------- test as PICK callback - m = self.create_basic_map() - cid = m.cb.pick.attach.print_to_console() - - self.click_ax_center(m) - m.cb.click.remove(cid) - plt.close("all") + 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, + ): + + # ---------- test as CLICK callback + m = self.create_basic_map() + m.cb.pick.set_props( + n=n, + consecutive_pick=cpick, + pick_relative_to_closest=relpick, + search_radius=r, + ) + cid = m.cb.pick.attach.print_to_console() + + self.click_ax_center(m) + m.cb.pick.remove(cid) + plt.close("all") def test_annotate(self): - # ---------- test as CLICK callback + m = self.create_basic_map() cid = m.cb.click.attach.annotate() self.click_ax_center(m) @@ -193,31 +266,50 @@ def text(m, ID, val, pos, ind): self.click_ax_center(m) # ---------- test as PICK callback - m = self.create_basic_map() - cid = m.cb.pick.attach.annotate() - self.click_ax_center(m) - m.cb.pick.remove(cid) - - m.cb.pick.attach.annotate( - pos_precision=8, val_precision=8, permanent=False, xytext=(-30, -50) - ) - self.click_ax_center(m) - - m.cb.pick.attach.annotate(text="hellooo", xytext=(200, 200)) - self.click_ax_center(m) - - def text(m, ID, val, pos, ind): - return f"{ID}\n {val}\n {pos}\n {ind}\n {m.data_specs.crs}" - - props = dict( - xytext=(-50, 100), - textcoords="offset points", - bbox=dict(boxstyle="round", fc="g", ec="r"), - arrowprops=dict(arrowstyle="fancy"), - ) - - m.cb.pick.attach.annotate(text=text, **props) - self.click_ax_center(m) + 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, + ): + + # ---------- test as CLICK callback + m = self.create_basic_map() + m.cb.pick.set_props( + n=n, + consecutive_pick=cpick, + pick_relative_to_closest=relpick, + search_radius=r, + ) + + cid = m.cb.pick.attach.annotate() + self.click_ax_center(m) + m.cb.pick.remove(cid) + + m.cb.pick.attach.annotate( + pos_precision=8, val_precision=8, permanent=False, xytext=(-30, -50) + ) + self.click_ax_center(m) + + m.cb.pick.attach.annotate(text="hellooo", xytext=(200, 200)) + self.click_ax_center(m) + + def text(m, ID, val, pos, ind): + return f"{ID}\n {val}\n {pos}\n {ind}\n {m.data_specs.crs}" + + props = dict( + xytext=(-50, 100), + textcoords="offset points", + bbox=dict(boxstyle="round", fc="g", ec="r"), + arrowprops=dict(arrowstyle="fancy"), + ) + + m.cb.pick.attach.annotate(text=text, **props) + self.click_ax_center(m) plt.close("all") @@ -264,45 +356,70 @@ def test_mark(self): plt.close("all") # ---------- test as PICK callback - m = self.create_basic_map() - cid = m.cb.pick.attach.mark() - self.click_ax_center(m) - m.cb.pick.remove(cid) - - cid = m.cb.pick.attach.mark( - radius=400000, - radius_crs=3857, - shape="rectangles", - fc="r", - ec="g", - permanent=False, - ) - self.click_ax_center(m) - - cid = m.cb.pick.attach.mark( - radius=500000, shape="geod_circles", fc="none", ec="k", n=6, permanent=True - ) - - self.click_ax_center(m) - - cid = m.cb.pick.attach.mark( - radius=500000, - shape="geod_circles", - fc="none", - ec="m", - n=100, - permanent=False, - ) - - self.click_ax_center(m) - - cid = m.cb.pick.attach.mark( - fc=(1, 0, 0, 0.5), ec="k", n=100, permanent=False, buffer=15 - ) - - self.click_ax_center(m) - plt.close("all") + # test different pick-properties + 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, + ): + + m = self.create_basic_map() + m.cb.pick.set_props( + n=n, + consecutive_pick=cpick, + pick_relative_to_closest=relpick, + search_radius=r, + ) + + cid = m.cb.pick.attach.mark() + + self.click_ax_center(m) + m.cb.pick.remove(cid) + + cid = m.cb.pick.attach.mark( + radius=400000, + radius_crs=3857, + shape="rectangles", + fc="r", + ec="g", + permanent=False, + ) + self.click_ax_center(m) + + cid = m.cb.pick.attach.mark( + radius=500000, + shape="geod_circles", + fc="none", + ec="k", + n=6, + permanent=True, + ) + + self.click_ax_center(m) + + cid = m.cb.pick.attach.mark( + radius=500000, + shape="geod_circles", + fc="none", + ec="m", + n=100, + permanent=False, + ) + + self.click_ax_center(m) + + cid = m.cb.pick.attach.mark( + fc=(1, 0, 0, 0.5), ec="k", n=100, permanent=False, buffer=15 + ) + + self.click_ax_center(m) + plt.close("all") def test_peek_layer(self): # ---------- test as CLICK callback @@ -395,33 +512,45 @@ def test_plot(self): plt.close("all") def test_load(self): + for n, cpick, relpick, r in product( + [1, 5], [True, False], [True, False], ["10", 12.65] + ): - db = self.data + with self.subTest( + n=n, + consecutive_pick=cpick, + pick_relative_to_closest=relpick, + search_radius=r, + ): - m = self.create_basic_map() - m.cb.pick.attach.get_values() + db = self.data - cid = m.cb.pick.attach.load(database=db, load_method="xs") + m = self.create_basic_map() + m.cb.pick.attach.get_values() - self.assertTrue(m.cb.pick.get.picked_object is None) + cid = m.cb.pick.attach.load(database=db, load_method="xs") - self.click_ax_center(m) - ID = m.cb.pick.get.picked_vals["ID"] + self.assertTrue(m.cb.pick.get.picked_object is None) - self.assertTrue(all(m.cb.pick.get.picked_object == self.data.loc[ID[0]])) + self.click_ax_center(m) + ID = m.cb.pick.get.picked_vals["ID"] - m.cb.pick.remove(cid) + self.assertTrue( + all(m.cb.pick.get.picked_object == self.data.loc[ID[0]]) + ) - def loadmethod(db, ID): - return db.loc[ID].lon + m.cb.pick.remove(cid) - cid = m.cb.pick.attach.load(database=db, load_method=loadmethod) - self.click_ax_center(m) + def loadmethod(db, ID): + return db.loc[ID].lon - self.assertTrue(m.cb.pick.get.picked_object == self.data.loc[ID[0]].lon) + cid = m.cb.pick.attach.load(database=db, load_method=loadmethod) + self.click_ax_center(m) - m.cb.pick.remove(cid) - plt.close("all") + self.assertTrue(m.cb.pick.get.picked_object == self.data.loc[ID[0]].lon) + + m.cb.pick.remove(cid) + plt.close("all") def test_switch_layer(self): # ---------- test as CLICK callback @@ -438,23 +567,23 @@ def test_switch_layer(self): cid3 = m.cb.keypress.attach.switch_layer(layer="3", key="3") # switch to layer 2 - m.f.canvas.key_press_event("2") - m.f.canvas.key_release_event("2") + key_press_event(m.f.canvas, "2") + key_release_event(m.f.canvas, "2") self.assertTrue(m.BM._bg_layer == "2") # the 3rd callback should not trigger - m.f.canvas.key_press_event("3") - m.f.canvas.key_release_event("3") + key_press_event(m.f.canvas, "3") + key_release_event(m.f.canvas, "3") self.assertTrue(m.BM._bg_layer == "2") # switch to the "base" layer - m.f.canvas.key_press_event("0") - m.f.canvas.key_release_event("0") + key_press_event(m.f.canvas, "0") + key_release_event(m.f.canvas, "0") self.assertTrue(m.BM._bg_layer == "base") # now the 3rd callback should trigger - m.f.canvas.key_press_event("3") - m.f.canvas.key_release_event("3") + key_press_event(m.f.canvas, "3") + key_release_event(m.f.canvas, "3") self.assertTrue(m.BM._bg_layer == "3") m.all.cb.keypress.remove(cid0) @@ -484,6 +613,7 @@ def test_make_dataset_pickable(self): 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): m = self.create_basic_map() @@ -495,10 +625,90 @@ def cb(key): m.all.cb.keypress.attach(cb, key=None) - m.f.canvas.key_press_event("0") - m.f.canvas.key_release_event("0") + key_press_event(m.f.canvas, "0") + key_release_event(m.f.canvas, "0") self.assertTrue(m.BM._bg_layer == "0") - m.f.canvas.key_press_event("1") - m.f.canvas.key_release_event("1") + key_press_event(m.f.canvas, "1") + key_release_event(m.f.canvas, "1") self.assertTrue(m.BM._bg_layer == "1") + plt.close("all") + + def test_geodataframe_contains_picking(self): + m = Maps() + m.show() # do this to make sure transforms are correctly set + gdf = m.add_feature.cultural.admin_0_countries.get_gdf(scale=110) + + m.add_gdf(gdf, column="NAME", picker_name="col", pick_method="contains") + + m.add_gdf(gdf, picker_name="nocol", pick_method="contains") + + def customcb(picked_vals, val, **kwargs): + picked_vals.append(val) + + picked_vals_col = [] + picked_vals_nocol = [] + + m.cb.pick["col"].attach.annotate() + m.cb.pick["col"].attach(customcb, picked_vals=picked_vals_col) + m.cb.pick__col.attach.highlight_geometry(fc="r", ec="g") + + m.cb.pick["nocol"].attach.annotate() + m.cb.pick["nocol"].attach(customcb, picked_vals=picked_vals_nocol) + m.cb.pick__nocol.attach.highlight_geometry(fc="r", ec="g") + + # evaluate pick position AFTER plotting geodataframes since the plot + # extent might have changed! + pickid = 50 + clickpt = gdf.centroid[pickid] + clickxy = m.ax.transData.transform((clickpt.x, clickpt.y)) + + button_press_event(m.f.canvas, *clickxy, 1) + button_release_event(m.f.canvas, *clickxy, 1) + + self.assertTrue(picked_vals_col[0] == gdf.NAME.loc[pickid]) + self.assertTrue(picked_vals_nocol[0] is None) + plt.close("all") + + def test_geodataframe_centroid_picking(self): + m = Maps() + m.redraw() # do this to make sure transforms are correctly set + gdf = m.add_feature.cultural.populated_places.get_gdf(scale=110) + + m.add_gdf(gdf, column="NAME", picker_name="col", pick_method="centroids") + + m.add_gdf( + gdf, + fc="none", + ec="k", + markersize=10, + picker_name="nocol", + pick_method="centroids", + ) + + def customcb(picked_vals, val, **kwargs): + picked_vals.append(val) + + picked_vals_col = [] + picked_vals_nocol = [] + + m.cb.pick["col"].attach.annotate() + m.cb.pick["col"].attach(customcb, picked_vals=picked_vals_col) + m.cb.pick__col.attach.highlight_geometry(fc="r", ec="g") + + m.cb.pick["nocol"].attach.annotate(xytext=(20, -20)) + m.cb.pick["nocol"].attach(customcb, picked_vals=picked_vals_nocol) + m.cb.pick__nocol.attach.highlight_geometry(fc="r", ec="g") + + # evaluate pick position AFTER plotting geodataframes since the plot + # extent might have changed! + pickid = 50 + clickpt = gdf.centroid[pickid] + clickxy = m.ax.transData.transform((clickpt.x, clickpt.y)) + + button_press_event(m.f.canvas, *clickxy, 1) + button_release_event(m.f.canvas, *clickxy, 1) + + self.assertTrue(picked_vals_col[0] == gdf.NAME.loc[pickid]) + self.assertTrue(picked_vals_nocol[0] is None) + plt.close("all") diff --git a/tests/test_drawer.py b/tests/test_drawer.py new file mode 100644 index 000000000..9be2c3e63 --- /dev/null +++ b/tests/test_drawer.py @@ -0,0 +1,118 @@ +import unittest +import numpy as np +from matplotlib.backend_bases import MouseEvent +from eomaps import Maps + +# copy of depreciated matplotlib function +def button_press_event(canvas, x, y, button, dblclick=False, guiEvent=None): + """ + Callback processing for mouse button press events. + + Backend derived classes should call this function on any mouse + button press. (*x*, *y*) are the canvas coords ((0, 0) is lower left). + button and key are as defined in `MouseEvent`. + + This method will call all functions connected to the + 'button_press_event' with a `MouseEvent` instance. + """ + canvas._button = button + s = "button_press_event" + mouseevent = MouseEvent( + s, canvas, x, y, button, canvas._key, dblclick=dblclick, guiEvent=guiEvent + ) + canvas.callbacks.process(s, mouseevent) + + +# copy of depreciated matplotlib function +def button_release_event(canvas, x, y, button, guiEvent=None): + """ + Callback processing for mouse button release events. + + Backend derived classes should call this function on any mouse + button release. + + This method will call all functions connected to the + 'button_release_event' with a `MouseEvent` instance. + + Parameters + ---------- + x : float + The canvas coordinates where 0=left. + y : float + The canvas coordinates where 0=bottom. + guiEvent + The native UI event that generated the Matplotlib event. + """ + s = "button_release_event" + event = MouseEvent(s, canvas, x, y, button, canvas._key, guiEvent=guiEvent) + canvas.callbacks.process(s, event) + canvas._button = None + + +class TestDraw(unittest.TestCase): + def setUp(self): + pass + + def click_ax_center(self, m, dx=0, dy=0, release=True, button=1): + ax = m.ax + cv = m.f.canvas + x, y = (ax.bbox.x0 + ax.bbox.x1) / 2, (ax.bbox.y0 + ax.bbox.y1) / 2 + button_press_event(cv, x + dx, y + dy, button, False) + if release: + button_release_event(cv, x + dx, y + dy, button, False) + + def test_basic_drawing_capabilities(self): + m = Maps() + m.add_feature.preset.coastline() + m.draw.rectangle(fc="none", ec="r") + self.click_ax_center(m) + self.click_ax_center(m, dx=20, dy=20, button=2) + self.assertTrue(len(m.draw._artists) == 1) + + m.draw.circle(fc="b", ec="g", alpha=0.5) + self.click_ax_center(m, dx=50) + self.click_ax_center(m, dx=20, dy=20, button=2) + self.assertTrue(len(m.draw._artists) == 2) + + m.draw.polygon(fc="g", ec="b", lw=2) + for i, j in np.random.randint(0, 100, (20, 2)): + self.click_ax_center(m, dx=i, dy=j) + + self.click_ax_center(m, dx=20, dy=20, button=2) + self.assertTrue(len(m.draw._artists) == 3) + + # ----------------------------- + d = m.draw.new_drawer(layer="shapes") + + d.rectangle(fc="none", ec="r") + self.click_ax_center(m) + self.click_ax_center(m, dx=20, dy=20, button=2) + self.assertTrue(len(d._artists) == 1) + + d.circle(fc="b", ec="g", alpha=0.5) + self.click_ax_center(m, dx=50) + self.click_ax_center(m, dx=20, dy=20, button=2) + self.assertTrue(len(d._artists) == 2) + + d.polygon(fc="g", ec="b", lw=2) + for i, j in np.random.randint(0, 100, (20, 2)): + self.click_ax_center(m, dx=i, dy=j) + + self.click_ax_center(m, dx=20, dy=20, button=2) + self.assertTrue(len(d._artists) == 3) + + m.show_layer("shapes") + + m.draw.remove_last_shape() + self.assertTrue(len(m.draw._artists) == 2) + m.draw.remove_last_shape() + self.assertTrue(len(m.draw._artists) == 1) + m.draw.remove_last_shape() + self.assertTrue(len(m.draw._artists) == 0) + + d.remove_last_shape() + self.assertTrue(len(d._artists) == 2) + d.remove_last_shape() + self.assertTrue(len(d._artists) == 1) + d.remove_last_shape() + self.assertTrue(len(d._artists) == 0) diff --git a/tests/test_env.yml b/tests/test_env.yml index bc18d86c7..b1fffdea1 100644 --- a/tests/test_env.yml +++ b/tests/test_env.yml @@ -31,3 +31,8 @@ dependencies: - pytest-cov # --------------for building the docs - sphinx-copybutton + - sphinx==5.3.0 + - docutils==0.16 + - pip + - pip: + - sphinx_rtd_theme==1.1.1 diff --git a/tests/test_layout_editor.py b/tests/test_layout_editor.py index 15b82635a..f503ee578 100644 --- a/tests/test_layout_editor.py +++ b/tests/test_layout_editor.py @@ -5,9 +5,51 @@ import unittest import numpy as np import pandas as pd -import matplotlib.pyplot as plt -from eomaps import Maps, MapsGrid +from eomaps import MapsGrid + +from matplotlib.backend_bases import MouseEvent, KeyEvent + + +def button_press_event(canvas, x, y, button, dblclick=False, guiEvent=None): + canvas._button = button + s = "button_press_event" + mouseevent = MouseEvent( + s, canvas, x, y, button, canvas._key, dblclick=dblclick, guiEvent=guiEvent + ) + canvas.callbacks.process(s, mouseevent) + + +def button_release_event(canvas, x, y, button, guiEvent=None): + s = "button_release_event" + event = MouseEvent(s, canvas, x, y, button, canvas._key, guiEvent=guiEvent) + canvas.callbacks.process(s, event) + canvas._button = None + + +def motion_notify_event(canvas, x, y, guiEvent=None): + s = "motion_notify_event" + event = MouseEvent(s, canvas, x, y, guiEvent=guiEvent) + canvas.callbacks.process(s, event) + + +def scroll_event(canvas, x, y, step, guiEvent=None): + s = "scroll_event" + event = MouseEvent(s, canvas, x, y, step=step, guiEvent=guiEvent) + canvas.callbacks.process(s, event) + + +def key_press_event(canvas, key, guiEvent=None): + s = "key_press_event" + event = KeyEvent(s, canvas, key, guiEvent=guiEvent) + canvas.callbacks.process(s, event) + + +def key_release_event(canvas, key, guiEvent=None): + s = "key_release_event" + event = KeyEvent(s, canvas, key, guiEvent=guiEvent) + canvas.callbacks.process(s, event) + canvas._key = None class TestLayoutEditor(unittest.TestCase): @@ -29,77 +71,77 @@ def test_layout_editor(self): cv = mg.f.canvas # activate draggable axes - cv.key_press_event("alt+l") - cv.key_release_event("alt+l") + key_press_event(cv, "alt+l") + key_release_event(cv, "alt+l") # ################ check handling axes # click on top left axes x0 = (mg.m_0_0.ax.bbox.x1 + mg.m_0_0.ax.bbox.x0) / 2 y0 = (mg.m_0_0.ax.bbox.y1 + mg.m_0_0.ax.bbox.y0) / 2 - cv.button_press_event(x0, y0, 1, False) + button_press_event(cv, x0, y0, 1, False) # move the axes to the center x1 = (mg.m_0_0.f.bbox.x1 + mg.m_0_0.f.bbox.x0) / 2 y1 = (mg.m_0_0.f.bbox.y1 + mg.m_0_0.f.bbox.y0) / 2 - cv.motion_notify_event(x1, y1, False) + motion_notify_event(cv, x1, y1, False) # release the mouse - cv.button_release_event(0, 0, 1, False) + button_release_event(cv, 0, 0, 1, False) # resize the axis - cv.scroll_event(x1, y1, 10) + scroll_event(cv, x1, y1, 10) # click on bottom right x2 = (mg.m_1_1.ax.bbox.x1 + mg.m_1_1.ax.bbox.x0) / 2 y2 = (mg.m_1_1.ax.bbox.y1 + mg.m_1_1.ax.bbox.y0) / 2 - cv.button_press_event(x2, y2, 1, False) + button_press_event(cv, x2, y2, 1, False) # move the axes to the top left - cv.motion_notify_event(x0, y0, False) + motion_notify_event(cv, x0, y0, False) # release the mouse - cv.button_release_event(0, 0, 1, False) + button_release_event(cv, 0, 0, 1, False) # resize the axis - cv.scroll_event(x1, y1, -10) + scroll_event(cv, x1, y1, -10) # ------------- check keystrokes # click on bottom left axis x3 = (mg.m_1_0.ax.bbox.x1 + mg.m_1_0.ax.bbox.x0) / 2 y3 = (mg.m_1_0.ax.bbox.y1 + mg.m_1_0.ax.bbox.y0) / 2 - cv.button_press_event(x3, y3, 1, False) + button_press_event(cv, x3, y3, 1, False) - cv.key_press_event("left") - cv.key_press_event("right") - cv.key_press_event("up") - cv.key_press_event("down") + key_press_event(cv, "left") + key_press_event(cv, "right") + key_press_event(cv, "up") + key_press_event(cv, "down") # release the mouse - cv.button_release_event(0, 0, 1, False) + button_release_event(cv, 0, 0, 1, False) # ################ check handling colorbars # click on top left colorbar x4 = (mg.m_1_0.colorbar.ax_cb.bbox.x1 + mg.m_1_0.colorbar.ax_cb.bbox.x0) / 2 y4 = (mg.m_1_0.colorbar.ax_cb.bbox.y1 + mg.m_1_0.colorbar.ax_cb.bbox.y0) / 2 - cv.button_press_event(x4, y4, 1, False) + button_press_event(cv, x4, y4, 1, False) # move it around with keys - cv.key_press_event("left") - cv.key_press_event("right") - cv.key_press_event("up") - cv.key_press_event("down") + key_press_event(cv, "left") + key_press_event(cv, "right") + key_press_event(cv, "up") + key_press_event(cv, "down") # move it around with the mouse - cv.motion_notify_event(x0, y0, False) + motion_notify_event(cv, x0, y0, False) # resize it - cv.scroll_event(x1, y1, 10) + scroll_event(cv, x1, y1, 10) # release the mouse - cv.button_release_event(0, 0, 1, False) + button_release_event(cv, 0, 0, 1, False) # ------ test re-showing axes on click # click on bottom right histogram @@ -109,16 +151,16 @@ def test_layout_editor(self): y5 = ( mg.m_1_1.colorbar.ax_cb_plot.bbox.y1 + mg.m_1_1.colorbar.ax_cb_plot.bbox.y0 ) / 2 - cv.button_press_event(x5, y5, 1, False) + button_press_event(cv, x5, y5, 1, False) # click on bottom right colorbar 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 - cv.button_press_event(x6, y6, 1, False) + button_press_event(cv, x6, y6, 1, False) # deactivate draggable axes - cv.key_press_event("alt+l") - cv.key_release_event("alt+l") + key_press_event(cv, "alt+l") + key_release_event(cv, "alt+l") # save the new layout new_layout = mg.get_layout()