diff --git a/.github/workflows/testMaps.yml b/.github/workflows/testMaps.yml old mode 100644 new mode 100755 index a3ae9949b..19ac018f9 --- a/.github/workflows/testMaps.yml +++ b/.github/workflows/testMaps.yml @@ -13,7 +13,7 @@ jobs: # set operating systems to test os: [ubuntu-latest] # set python versions to test - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] name: test_Maps ${{ matrix.os }} ${{ matrix.python-version }} steps: diff --git a/docs/source/_static/example_images/example_inset_maps.png b/docs/source/_static/example_images/example_inset_maps.png index 41a77b6c5..d3b8293f4 100644 Binary files a/docs/source/_static/example_images/example_inset_maps.png and b/docs/source/_static/example_images/example_inset_maps.png differ diff --git a/eomaps/_blit_manager.py b/eomaps/_blit_manager.py old mode 100644 new mode 100755 index 89182f100..95b7cb41e --- a/eomaps/_blit_manager.py +++ b/eomaps/_blit_manager.py @@ -903,14 +903,14 @@ def _on_draw_cb(self, event): if loglevel <= 5: _log.log(5, "There was an error during draw!", exc_info=True) - def add_artist(self, art, layer=None): + def add_artist(self, *artists, layer=None): """ Add a dynamic-artist to be managed. (Dynamic artists are re-drawn on every update!) Parameters ---------- - art : Artist + artists : Artist The artist to be added. Will be set to 'animated' (just to be safe). *art* must be in the figure associated with @@ -922,38 +922,38 @@ def add_artist(self, art, layer=None): The default is None in which case the layer of the base-Maps object is used. """ - - if art.figure != self.figure: - raise RuntimeError( - "EOmaps: The artist does not belong to the figure" - "of this Maps-object!" - ) - if layer is None: layer = self._m.layer # make sure all layers are converted to string layer = str(layer) - self._artists.setdefault(layer, list()) + for art in artists: + if art.figure != self.figure: + raise RuntimeError( + "EOmaps: The artist does not belong to the figure" + "of this Maps-object!" + ) - if art in self._artists[layer]: - return - else: - art.set_animated(True) - self._artists[layer].append(art) + self._artists.setdefault(layer, list()) - if isinstance(art, plt.Axes): - self._managed_axes.add(art) + if art in self._artists[layer]: + continue + else: + art.set_animated(True) + self._artists[layer].append(art) - def add_bg_artist(self, art, layer=None, draw=True): + if isinstance(art, plt.Axes): + self._managed_axes.add(art) + + def add_bg_artist(self, *artists, layer=None, draw=True): """ Add a background-artist to be managed. (Background artists are only updated on zoom-events... they are NOT animated!) Parameters ---------- - art : Artist + artists : Artist The artist to be added. Will be set to 'animated' (just to be safe). *art* must be in the figure associated with the canvas this class is managing. @@ -974,31 +974,32 @@ def add_bg_artist(self, art, layer=None, draw=True): # make sure all layer names are converted to string layer = str(layer) - if art.figure != self.figure: - raise RuntimeError + for art in artists: + if art.figure != self.figure: + raise RuntimeError - # put all artist of inset-maps on dedicated layers - if ( - getattr(art, "axes", None) is not None - and art.axes.get_label() == "inset_map" - and not layer.startswith("__inset_") - ): - layer = "__inset_" + str(layer) + # put all artist of inset-maps on dedicated layers + if ( + getattr(art, "axes", None) is not None + and art.axes.get_label() == "inset_map" + and not layer.startswith("__inset_") + ): + layer = "__inset_" + str(layer) - if layer in self._bg_artists and art in self._bg_artists[layer]: - _log.info( - f"EOmaps: Background-artist '{art}' already added on layer '{layer}'" - ) - return + if layer in self._bg_artists and art in self._bg_artists[layer]: + _log.info( + f"EOmaps: Background-artist '{art}' already added on layer '{layer}'" + ) + continue - art.set_animated(True) - self._bg_artists.setdefault(layer, []).append(art) + art.set_animated(True) + self._bg_artists.setdefault(layer, []).append(art) - if isinstance(art, plt.Axes): - self._managed_axes.add(art) + if isinstance(art, plt.Axes): + self._managed_axes.add(art) - # tag all relevant layers for refetch - self._refetch_layer(layer) + # tag all relevant layers for refetch + self._refetch_layer(layer) for f in self._on_add_bg_artist: f() diff --git a/eomaps/_data_manager.py b/eomaps/_data_manager.py old mode 100644 new mode 100755 index 550afd51f..6cac8c514 --- a/eomaps/_data_manager.py +++ b/eomaps/_data_manager.py @@ -916,9 +916,9 @@ def on_fetch_bg(self, layer=None, bbox=None, check_redraw=True): self.m.ax.add_collection(coll, autolim=False) if self.m._coll_dynamic: - self.m.BM.add_artist(coll, self.layer) + self.m.BM.add_artist(coll, layer=self.layer) else: - self.m.BM.add_bg_artist(coll, self.layer) + self.m.BM.add_bg_artist(coll, layer=self.layer) self.m._coll = coll diff --git a/eomaps/_maps_base.py b/eomaps/_maps_base.py index 512a6816c..09ebfe224 100644 --- a/eomaps/_maps_base.py +++ b/eomaps/_maps_base.py @@ -26,6 +26,7 @@ from .layout_editor import LayoutEditor from ._blit_manager import BlitManager from .projections import Equi7Grid_projection # import also supercharges cartopy.ccrs +from ._zoom import LazyZoomMixin def _handle_backends(): @@ -233,7 +234,7 @@ def handle_event(self, event): FigureManagerWebAgg.refresh_all = refresh_all -class MapsBase(metaclass=_MapsMeta): +class MapsBase(LazyZoomMixin, metaclass=_MapsMeta): def __init__( self, crs=None, @@ -453,6 +454,7 @@ def _init_figure(self, **kwargs): # variable of the parent Maps-object while keeping the figure open # causes all weakrefs to be garbage-collected! self.parent.f._EOmaps_parent = self.parent._real_self + self._connect_zoom_events() else: if not hasattr(self.parent.f, "_EOmaps_parent"): self.parent.f._EOmaps_parent = self.parent._real_self diff --git a/eomaps/_webmap.py b/eomaps/_webmap.py old mode 100644 new mode 100755 index 4a69e55a2..fd4db5d36 --- a/eomaps/_webmap.py +++ b/eomaps/_webmap.py @@ -171,7 +171,7 @@ def add_legend(self, style=None, img=None): if not self._m.BM._layer_visible(self._layer): legax.set_visible(False) - self._m.BM.add_artist(legax, self._layer) + self._m.BM.add_artist(legax, layer=self._layer) def cb_move(event): if not self._legend_picked: diff --git a/eomaps/_zoom.py b/eomaps/_zoom.py new file mode 100644 index 000000000..820825825 --- /dev/null +++ b/eomaps/_zoom.py @@ -0,0 +1,172 @@ +from matplotlib.transforms import Bbox +from types import SimpleNamespace +import numpy as np +from cartopy.mpl.geoaxes import GeoAxes + + +class LazyZoomMixin: + _zoom_scroll_scale_fine = 10 + _zoom_scroll_scale_coarse = 50 + _zoom_lazy_activator_key = " " + + def _connect_zoom_events(self): + """ + Connect events for lazy-zoom and zoom via scroll-wheel + + Returns + ------- + cid_scroll, cid_keypress, cid_release: matplotlib callback IDs + + """ + cid_scroll = self.f.canvas.mpl_connect( + "scroll_event", self._zoom_mousewheel_move + ) + cid_keypress = self.f.canvas.mpl_connect( + "key_press_event", self._activate_lazy_zoom + ) + cid_release = self.f.canvas.mpl_connect( + "key_release_event", self._deactivate_lazy_zoom + ) + + return (cid_scroll, cid_keypress, cid_release) + + @staticmethod + def _add_lazy_zoom_axes_image(ax): + """ + Create a static image of the axes that is used for lazy-zooming. + + Parameters + ---------- + ax : matplotlib.Axes + The axes to use. + + Returns + ------- + axi : matplotlib.Axes + The axes object containing the image used for zooming. + + """ + x0, y0, x1, y1 = ax.bbox.bounds + (x0, y0), (x1, y1) = np.floor([x0, y0]), np.ceil([x1, y1]) + bbox = Bbox.from_bounds(x0, y0, x1, y1) + ax._eomaps_img_buffer = ax.figure.canvas.copy_from_bbox(bbox) + + axi = ax.figure.add_axes(ax.get_position()) + axi.imshow(ax._eomaps_img_buffer, zorder=-100, alpha=0.75) + # axi.get_xaxis().set_visible(False) + # axi.get_yaxis().set_visible(False) + axi.tick_params( + which="both", + axis="both", + left=False, + right=False, + bottom=False, + top=False, + labelleft=False, + labelright=False, + labelbottom=False, + labeltop=False, + ) + for _, s in axi.spines.items(): + s.set_linewidth(2) + s.set_edgecolor("r") + + axi.name = "eomaps_ax_image" + axi.eomaps_parent_ax = ax + + axi.set_forward_navigation_events(True) + axi.set_zorder(-9999) + + return axi + + @staticmethod + def _check_auto_repeat_key(event): + """ + Check if a keypress event is triggered by auto-repeat. + (e.g. non-modifier keys tend to re-trigger automatically) + + Parameters + ---------- + event : matplotlib.KeyPressEvent + The matpltolib event to check . + + Returns + ------- + bool: True if event is triggered by auto-repeat, else False + + """ + # check if keypress event is triggered by auto-repeat + try: + return event.guiEvent.isAutoRepeat() + except Exception: + return False + + def _zoom_mousewheel_move(self, event): + """A callback to support zooming with the mouse-wheel.""" + ax = event.inaxes + + if not ax: + return + + if event.key and "shift" in event.key: + scale = self._zoom_scroll_scale_coarse + else: + scale = self._zoom_scroll_scale_fine + + axes = [ax] + if hasattr(ax, "_temp_zoom_ax"): + axes.append(ax._temp_zoom_ax) + + for axi in axes: + axi._pan_start = SimpleNamespace( + lim=axi.viewLim.frozen(), + trans=axi.transData.frozen(), + trans_inverse=axi.transData.inverted().frozen(), + bbox=axi.bbox.frozen(), + x=event.x, + y=event.y, + ) + + if event.button == "up": + axi.drag_pan(3, event.key, event.x + scale, event.y + scale) + else: + axi.drag_pan(3, event.key, event.x - scale, event.y - scale) + + self.redraw() + + def _activate_lazy_zoom(self, event): + """A callback to activate lazy-zooming.""" + + # ignore auto-repeat events to support ordinary keys as modifiers + if self._check_auto_repeat_key(event): + return + + if event.key == self._zoom_lazy_activator_key: + for ax in self.f.axes: + if not isinstance(ax, GeoAxes): + continue + + ax._temp_zoom_ax = self._add_lazy_zoom_axes_image(ax) + + self.BM._disable_draw = True + self.BM._disable_update = True + self.f.canvas.draw_idle() + + def _deactivate_lazy_zoom(self, event): + """A callback to de-activate lazy-zooming.""" + + # ignore auto-repeat events to support ordinary keys as modifiers + if self._check_auto_repeat_key(event): + return + + for ax in self.f.axes: + if hasattr(ax, "_temp_zoom_ax"): + try: + ax._temp_zoom_ax.remove() + del ax._temp_zoom_ax + except Exception: + continue + + self.BM._disable_draw = False + self.BM._disable_update = False + self.redraw() diff --git a/eomaps/annotation_editor.py b/eomaps/annotation_editor.py old mode 100644 new mode 100755 index 845d71bf8..9c99c4823 --- a/eomaps/annotation_editor.py +++ b/eomaps/annotation_editor.py @@ -315,7 +315,7 @@ def show_info_text(self): fontfamily="monospace", ) - self.m.BM.add_artist(self._info_artist, "all") + self.m.BM.add_artist(self._info_artist, layer="all") self._info_cids.add( self.m.f.canvas.mpl_connect("button_press_event", self._on_press) diff --git a/eomaps/callbacks.py b/eomaps/callbacks.py old mode 100644 new mode 100755 index 03d2891fd..ef7322861 --- a/eomaps/callbacks.py +++ b/eomaps/callbacks.py @@ -654,11 +654,11 @@ def mark( if permanent is False: # make the annotation temporary self._temporary_artists.append(marker) - self.m.BM.add_artist(marker, layer) + self.m.BM.add_artist(marker, layer=layer) elif permanent is None: - self.m.BM.add_bg_artist(marker, layer) + self.m.BM.add_bg_artist(marker, layer=layer) elif permanent is True: - self.m.BM.add_artist(marker, layer) + self.m.BM.add_artist(marker, layer=layer) if not hasattr(self, "permanent_markers"): self.permanent_markers = [marker] diff --git a/eomaps/cb_container.py b/eomaps/cb_container.py old mode 100644 new mode 100755 index fc610c71a..590de4b08 --- a/eomaps/cb_container.py +++ b/eomaps/cb_container.py @@ -241,31 +241,42 @@ def share_events(self, *args): if self._method == "click": self._m.cb._click_move.share_events(*args) - def add_temporary_artist(self, artist, layer=None): + def add_temporary_artist(self, *artists, layer=None): """ Make an artist temporary (remove it from the map at the next event). Parameters ---------- - artist : matplotlib.artist - The artist to use + artists : matplotlib.artist + The artist(s) to use as temporary artists. layer : str or None, optional The layer to put the artist on. If None, the layer of the used Maps-object is used. (e.g. `m.layer`) + Examples + -------- + Add artists that will be removed with the next click on the map. + + >>> m = Maps() + >>> text = m.ax.text(45, 45, "click map to remove") + >>> line, = m.ax.plot([10,20,50]) + >>> + >>> m.cb.click.add_temporary_artist(text, line) + """ if layer is None: layer = self._m.layer - # in case the artist has already been added as normal or background - # artist, remove it first! - if artist in chain(*self._m.BM._bg_artists.values()): - self._m.BM.remove_bg_artist(artist) + for artist in artists: + # in case the artist has already been added as normal or background + # artist, remove it first! + if artist in chain(*self._m.BM._bg_artists.values()): + self._m.BM.remove_bg_artist(artist) - if artist in chain(*self._m.BM._artists.values()): - self._m.BM.remove_artist(artist) + if artist in chain(*self._m.BM._artists.values()): + self._m.BM.remove_artist(artist) - self._m.BM.add_artist(artist, layer=layer) - self._temporary_artists.append(artist) + self._m.BM.add_artist(artist, layer=layer) + self._temporary_artists.append(artist) def _execute_cb(self, layer): """ diff --git a/eomaps/colorbar.py b/eomaps/colorbar.py old mode 100644 new mode 100755 index 30826f037..f51afa1fe --- a/eomaps/colorbar.py +++ b/eomaps/colorbar.py @@ -762,9 +762,9 @@ def _add_axes_to_layer(self, dynamic): for a in (self.ax_cb, self.ax_cb_plot): if a is not None: if dynamic is True: - BM.add_artist(a, self._layer) + BM.add_artist(a, layer=self._layer) else: - BM.add_bg_artist(a, self._layer) + BM.add_bg_artist(a, layer=self._layer) # we need to re-draw all layers since the axis size has changed! self._m.redraw() diff --git a/eomaps/eomaps.py b/eomaps/eomaps.py old mode 100644 new mode 100755 index 7823d7f99..845cd67f0 --- a/eomaps/eomaps.py +++ b/eomaps/eomaps.py @@ -1280,7 +1280,7 @@ def set_extent_to_location(self, location, annotate=False, user_agent=None): _log.info(f"Centering Map to:\n {r['display_name']}") def _set_gdf_path_boundary(self, gdf, set_extent=True): - geom = gdf.to_crs(self.crs_plot).unary_union + geom = gdf.to_crs(self.crs_plot).union_all() if "Polygon" in geom.geom_type: geom = geom.boundary @@ -1861,9 +1861,9 @@ def add_gdf( for art, prefix in zip(artists, prefixes): art.set_label(f"EOmaps GeoDataframe ({prefix.lstrip('_')}, {len(gdf)})") if permanent is True: - self.BM.add_bg_artist(art, layer) + self.BM.add_bg_artist(art, layer=layer) else: - self.BM.add_artist(art, layer) + self.BM.add_artist(art, layer=layer) return artists def _handle_gdf( @@ -2582,7 +2582,7 @@ def add_line( raise TypeError(f"EOmaps: '{connect}' is not a valid connection-method!") art.set_label(f"Line ({connect})") - self.BM.add_bg_artist(art, layer) + self.BM.add_bg_artist(art, layer=layer) if mark_points: zorder = kwargs.get("zorder", 10) @@ -2599,7 +2599,7 @@ def add_line( (art2,) = self.ax.plot(xplot, yplot, mark_points, zorder=zorder, lw=0) art2.set_label(f"Line Marker ({connect})") - self.BM.add_bg_artist(art2, layer) + self.BM.add_bg_artist(art2, layer=layer) return out_d_int, out_d_tot @@ -2680,7 +2680,7 @@ def getpos(pos): figax.set_axis_off() _ = figax.imshow(im, aspect="equal", zorder=999, interpolation_stage="rgba") - self.BM.add_bg_artist(figax, layer) + self.BM.add_bg_artist(figax, layer=layer) if fix_position: fixed_pos = ( @@ -3246,9 +3246,9 @@ def _shade_map( self._coll = coll if dynamic is True: - self.BM.add_artist(coll, layer) + self.BM.add_artist(coll, layer=layer) else: - self.BM.add_bg_artist(coll, layer) + self.BM.add_bg_artist(coll, layer=layer) if dynamic is True: self.BM.update(clear=False) @@ -3811,7 +3811,7 @@ def _indicate_companion_map(self, visible): ) self.ax.add_artist(self._companion_map_indicator) - self.BM.add_artist(self._companion_map_indicator, "all") + self.BM.add_artist(self._companion_map_indicator, layer="all") self.BM.update() @@ -3829,7 +3829,6 @@ def _identify_maps_object(self, xy): return clicked_map - def _open_companion_widget(self, xy=None): """ Open the companion-widget. @@ -3915,7 +3914,7 @@ def _init_companion_widget(self, show_hide_key="w"): else: self._companion_widget = MenuWindow(m=self) self._companion_widget.toggle_always_on_top() - self._companion_widget.hide() # hide on init + self._companion_widget.hide() # hide on init # connect any pending signals for key, funcs in getattr(self, "_connect_signals_on_init", dict()).items(): diff --git a/eomaps/inset_maps.py b/eomaps/inset_maps.py old mode 100644 new mode 100755 index 90d9abc04..b1afc0089 --- a/eomaps/inset_maps.py +++ b/eomaps/inset_maps.py @@ -304,7 +304,7 @@ def add_indicator_line(self, m=None, **kwargs): l = self._parent.ax.add_artist(l) l.set_clip_on(False) - self.BM.add_bg_artist(l, self.layer, draw=False) + self.BM.add_bg_artist(l, layer=self.layer, draw=False) self._indicator_lines.append((l, m)) if isinstance(m, InsetMaps): @@ -326,7 +326,7 @@ def add_indicator_line(self, m=None, **kwargs): l2.set_clip_on(True) l2 = m.ax.add_artist(l2) - self.BM.add_bg_artist(l2, self.layer) + self.BM.add_bg_artist(l2, layer=self.layer) self._indicator_lines.append((l2, m)) self._update_indicator_lines() diff --git a/pyproject.toml b/pyproject.toml index 9f738e625..3098b856a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,12 +11,12 @@ eomaps = ["logo.png", "NE_features.json", "qtcompanion/icons/*"] [project] name = "eomaps" -version = "8.3.3" +version = "8.4" description = "A library to create interactive maps of geographical datasets." readme = "README.md" license = {file = "LICENSE"} -requires-python = ">=3.8" +requires-python = ">=3.9" authors = [ { name="Raphael Quast", email="raphael.quast@geo.tuwien.ac.at" },