diff --git a/eomaps/_version.py b/eomaps/_version.py index 638b07121..6b5a69c78 100644 --- a/eomaps/_version.py +++ b/eomaps/_version.py @@ -1 +1 @@ -__version__ = "7.3.1" +__version__ = "7.3.2" diff --git a/eomaps/eomaps.py b/eomaps/eomaps.py index a9359b3d9..5ef870471 100644 --- a/eomaps/eomaps.py +++ b/eomaps/eomaps.py @@ -3478,7 +3478,11 @@ def snapshot(self, *layer, transparent=False, clear=False): try: from IPython.display import display - display(Image.fromarray(sn, "RGBA"), display_id=True, clear=clear) + display( + Image.fromarray(sn, "RGBA").convert("RGB"), + display_id=True, + clear=clear, + ) except Exception: _log.exception( "Unable to display the snapshot... is the script " @@ -4090,6 +4094,8 @@ def _init_figure(self, ax=None, plot_crs=None, **kwargs): _handle_backends() self._f = plt.figure(**kwargs) + # to hide canvas header in jupyter notebooks (default figure label) + self._f.canvas.header_visible = False # override Figure.savefig with Maps.savefig but keep original # method accessible via Figure._mpl_orig_savefig @@ -4567,7 +4573,7 @@ def _classify_data( bins = [vmin, *bins] if vmax > max(bins): - bins[np.argmax(bins)] = vmax + bins = [*bins, vmax] cbcmap = cmap norm = mpl.colors.BoundaryNorm(bins, cmap.N) diff --git a/eomaps/helpers.py b/eomaps/helpers.py index c4fc715ec..927d8cb95 100644 --- a/eomaps/helpers.py +++ b/eomaps/helpers.py @@ -582,7 +582,8 @@ def add_info_text(self): "0 - 9: Snap-grid spacing\n" "SHIFT: Multi-select\n" "P: Print to console\n" - "ESCAPE (or ALT + L): Exit\n" + "ESCAPE: Exit\n" + "R: Refetch images\n" "\n" "ARROW-KEYS: Move\n" "SCROLL (+/-): Resize\n" @@ -825,7 +826,6 @@ def cb_pick(self, event): self._info_text_hidden = not vis eventax = event.inaxes - if eventax not in self.axes: # if no axes is clicked "unpick" previously picked axes if len(self._ax_picked) + len(self._cb_picked) == 0: @@ -846,6 +846,9 @@ def cb_pick(self, event): self.blit_artists() return + if eventax.name == "eomaps_ax_image": + eventax = eventax.eomaps_parent_ax + if self._shift_pressed: if eventax in self.maxes: m = self.ms[self.maxes.index(eventax)] @@ -910,6 +913,9 @@ def fetch_current_background(self): with ExitStack() as stack: for ax in self._ax_picked: stack.enter_context(ax._cm_set(visible=False)) + ax_image = getattr(ax, "eomaps_ax_image", None) + if ax_image: + stack.enter_context(ax_image._cm_set(visible=False)) for cb in self._cb_picked: stack.enter_context(cb.ax_cb._cm_set(visible=False)) @@ -936,6 +942,8 @@ def cb_move_with_key(self, event): for ax in self._ax_picked: bbox = self._get_move_with_key_bbox(ax, event.key) ax.set_position(bbox) + if hasattr(ax, "eomaps_ax_image"): + ax.eomaps_ax_image.set_position(bbox) for cb in self._cb_picked: bbox = self._get_move_with_key_bbox(cb._ax, event.key) @@ -961,6 +969,8 @@ def cb_move(self, event): bbox = self._get_move_bbox(ax, event.x, event.y) ax.set_position(bbox) + if hasattr(ax, "eomaps_ax_image"): + ax.eomaps_ax_image.set_position(bbox) for cb in self._cb_picked: if cb is None: @@ -982,6 +992,8 @@ def blit_artists(self): return artists = [*self._ax_picked] + artists += [ax for ax in self.f.axes if ax.name == "eomaps_ax_image"] + for cb in self._cb_picked: artists.append(cb.ax_cb) artists.append(cb.ax_cb_plot) @@ -1006,6 +1018,8 @@ def cb_scroll(self, event): resize_bbox = self._get_resize_bbox(ax, event.step) if resize_bbox is not None: ax.set_position(resize_bbox) + if hasattr(ax, "eomaps_ax_image"): + ax.eomaps_ax_image.set_position(resize_bbox) for cb in self._cb_picked: if cb is None: @@ -1025,6 +1039,12 @@ def cb_scroll(self, event): # self._color_axes() self.blit_artists() + def print_layout(self): + s = "\nlayout = {\n " + s += "\n ".join(f'"{key}": {val},' for key, val in self.get_layout().items()) + s += "\n}\n" + print(s) + def cb_key_press(self, event): # release shift key on every keypress self._shift_pressed = False @@ -1038,12 +1058,7 @@ def cb_key_press(self, event): self._undo_draggable() return elif (event.key.lower() == "p") and (self.modifier_pressed): - s = "\nlayout = {\n " - s += "\n ".join( - f'"{key}": {val},' for key, val in self.get_layout().items() - ) - s += "\n}\n" - print(s) + self.print_layout() elif (event.key.lower() == "q") and (self.modifier_pressed): print( "\n##########################\n\n" @@ -1061,6 +1076,7 @@ def cb_key_press(self, event): "Use the keys 1-9 to adjust the spacing of the 'snap grid' (Note that " "the grid-spacing also determines the step-size for size- and " "position-changes!) Press 0 to disable grid-snapping.\n\n" + "Press 'r' to refetch axes-images." f"To exit, press 'escape' or '{self.modifier}'\n" "\n##########################\n\n" ) @@ -1083,7 +1099,8 @@ def cb_key_press(self, event): self._scale_direction = "vertical" elif event.key in ("control", "ctrl", "ctrl++", "ctrl+-"): self._scale_direction = "set_hist_size" - + elif event.key == "r": + self.refetch_axes_images() elif event.key == "shift": self._shift_pressed = True @@ -1125,29 +1142,19 @@ def _snap(self): return snap - def _make_draggable(self, filepath=None): - # Uncheck avtive pan/zoom actions of the matplotlib toolbar. - # use a try-except block to avoid issues with ipympl in jupyter notebooks - # (see https://github.com/matplotlib/ipympl/issues/530#issue-1780919042) - try: - toolbar = getattr(self.m.BM.canvas, "toolbar", None) - if toolbar is not None: - for key in ["pan", "zoom"]: - val = toolbar._actions.get(key, None) - if val is not None and val.isCheckable() and val.isChecked(): - val.trigger() - except AttributeError: - pass + def _apply_modifications(self): - self._filepath = filepath - self.modifier_pressed = True - _log.info( - "EOmaps: Layout Editor activated! (press 'esc' to exit " "and 'q' for info)" - ) + for ax in self.axes: + from matplotlib.transforms import Bbox - self._history.clear() - self._history_undone.clear() - self._add_to_history() + bbox = Bbox.from_bounds(*(round(i) for i in ax.bbox.bounds)) + ax._eomaps_img_buffer = ax.figure.canvas.copy_from_bbox(bbox) + + for ax in self.axes: + from matplotlib.transforms import Bbox + + bbox = Bbox.from_bounds(*(round(i) for i in ax.bbox.bounds)) + ax._eomaps_img_buffer = ax.figure.canvas.copy_from_bbox(bbox) self._revert_props = [] for ax in self.f.axes: @@ -1227,15 +1234,55 @@ def _make_draggable(self, filepath=None): if cb._m.layer != self.m.BM.bg_layer: cb.set_visible(False) + self._color_axes() + + def add_image_axes(ax): + axi = self.m.f.add_axes(ax.get_position()) + axi.set_axis_off() + axi.imshow(ax._eomaps_img_buffer, zorder=-100, alpha=0.75) + axi.patch.set_alpha(0.5) + + axi.name = "eomaps_ax_image" + axi.eomaps_parent_ax = ax + + ax.eomaps_ax_image = axi + + for ax in self.axes: + add_image_axes(ax) + + def _make_draggable(self, filepath=None): + self.modifier_pressed = True + + # Uncheck avtive pan/zoom actions of the matplotlib toolbar. + # use a try-except block to avoid issues with ipympl in jupyter notebooks + # (see https://github.com/matplotlib/ipympl/issues/530#issue-1780919042) + try: + toolbar = getattr(self.m.BM.canvas, "toolbar", None) + if toolbar is not None: + for key in ["pan", "zoom"]: + val = toolbar._actions.get(key, None) + if val is not None and val.isCheckable() and val.isChecked(): + val.trigger() + except AttributeError: + pass + + self._filepath = filepath + _log.info( + "EOmaps: Layout Editor activated! (press 'esc' to exit " "and 'q' for info)" + ) + + self._history.clear() + self._history_undone.clear() + self._add_to_history() + # only re-draw if info-text is None if getattr(self, "_info_text", None) is None: self._info_text = self.add_info_text() - self._color_axes() + self._apply_modifications() self._attach_callbacks() self.m._emit_signal("layoutEditorActivated") - self.m.redraw() def _add_revert_props(self, child, *args): @@ -1248,7 +1295,15 @@ def _add_revert_props(self, child, *args): ) ) + def refetch_axes_images(self): + self.m.redraw() + self._undo_draggable() + self.m.f.canvas.draw() + self._make_draggable() + def _undo_draggable(self): + self.modifier_pressed = False + if getattr(self, "_info_text", None) not in (None, False): self._info_text.remove() # set to None to avoid crating the info-text again @@ -1288,6 +1343,17 @@ def _undo_draggable(self): ) self._reset_callbacks() + self._undo_modifications() + + self.m._emit_signal("layoutEditorDeactivated") + + self.m.redraw() + + def _undo_modifications(self): + for ax in self.axes: + if ax.name == "eomaps_ax_image": + ax.remove() + # revert all changes to artists for p in self._revert_props: if isinstance(p, tuple): @@ -1295,8 +1361,6 @@ def _undo_draggable(self): else: p() - self.modifier_pressed = False - # show all colorbars that are on the visible layer active_layers = self.m.BM._get_layers_alphas()[0] for cb in self.cbs: @@ -1312,10 +1376,6 @@ def _undo_draggable(self): # remove snap-grid (if it's still visible) self._remove_snap_grid() - self.m._emit_signal("layoutEditorDeactivated") - - self.m.redraw() - def _reset_callbacks(self): # disconnect all callbacks of the layout-editor while len(self.cids) > 0: @@ -1324,7 +1384,6 @@ def _reset_callbacks(self): def _attach_callbacks(self): # make sure all previously set callbacks are reset - self._reset_callbacks() events = ( ("scroll_event", self.cb_scroll), @@ -1376,7 +1435,9 @@ def _remove_snap_grid(self): self._snap_grid_artist.remove() del self._snap_grid_artist - def get_layout(self, filepath=None, override=False, precision=5): + def get_layout( + self, filepath=None, override=False, precision=5, include_extents=True + ): """ Get the positions of all axes within the current plot. @@ -1411,6 +1472,12 @@ def get_layout(self, filepath=None, override=False, precision=5): The precision of the returned floating-point numbers. If None, all available digits are returned The default is 5 + include_extents : bool + If True, the returned layout will also include the current + extent of all maps in the figure. + If False, only the location of the axes and the figure-size is + returned. + The default is True. Returns ------- layout : dict or None @@ -1438,6 +1505,15 @@ def get_layout(self, filepath=None, override=False, precision=5): label = ax.get_label() name = f"{i}_{label}" + # check if it's a maps-object axis, if yes, try to get the extent + if include_extents: + try: + # try to get extent if axis is associated with a Maps object + m = next((m for m in self.ms if m.ax is ax)) + layout[f"{name}_extent"] = m.get_extent() + except StopIteration: + pass + if precision is not None: layout[name] = np.round(ax.get_position().bounds, precision).tolist() else: @@ -1484,7 +1560,6 @@ def apply_layout(self, layout): If a string or a pathlib.Path object is provided, it will be used to read a previously dumped layout (e.g. with `m.get_layout(filepath)`) - """ if isinstance(layout, (str, Path)): with open(layout, "r") as file: @@ -1492,12 +1567,17 @@ def apply_layout(self, layout): # check if all relevant axes are specified in the layout valid_keys = set(self.get_layout()) - if valid_keys != set(layout): + active_keys = set(layout) + + missing_keys = [ + i for i in (valid_keys - active_keys) if not i.endswith("extent") + ] + if len(missing_keys) > 0: warnings.warn( "EOmaps: The the layout does not match the expected structure! " "Layout might not be properly restored. " "Invalid or missing keys:\n" - f"{sorted(valid_keys.symmetric_difference(set(layout)))}\n" + f"{sorted(missing_keys)}\n" ) # set the figsize @@ -1508,7 +1588,6 @@ def apply_layout(self, layout): axes = [ a for a in self.axes if a.get_label() not in ["EOmaps_cb", "EOmaps_cb_hist"] ] - # identify relevant colorbars colorbars = [getattr(m, "colorbar", None) for m in self.ms] cbaxes = [getattr(cb, "_ax", None) for cb in colorbars] @@ -1517,11 +1596,23 @@ def apply_layout(self, layout): for key in valid_keys.intersection(set(layout)): if key == "figsize": continue + val = layout[key] i = int(key[: key.find("_")]) if key.endswith("_histogram_size"): cbs[i].set_hist_size(val) + elif key.endswith("_extent"): + try: + # try to find an associated Maps object for the axis + m = next((m for m in self.ms if m.ax is axes[i])) + m.set_extent(val) + except Exception: + _log.warning( + "EOmaps: Unable to set the plot-extent" + f" for {axes[i].get_label()}", + exc_info=_log.getEffectiveLevel() <= logging.DEBUG, + ) else: axes[i].set_position(val) diff --git a/eomaps/ne_features.py b/eomaps/ne_features.py index 4cec87112..f6986d485 100644 --- a/eomaps/ne_features.py +++ b/eomaps/ne_features.py @@ -34,7 +34,7 @@ class NaturalEarth_presets: def __init__(self, m): self._m = m - def __call__(self, *args, scale=50, layer=None): + def __call__(self, *args, scale=50, layer=None, **kwargs): """ Add multiple preset-features in one go. @@ -42,7 +42,7 @@ def __call__(self, *args, scale=50, layer=None): Parameters ---------- - *args : str + \*args : str The names of the features to add. scale : int or str Set the scale of the feature preset (10, 50, 110 or "auto") @@ -54,7 +54,9 @@ def __call__(self, *args, scale=50, layer=None): - If None, the layer of the parent object is used. The default is None. - + \*\*kwargs: + Additional style kwargs passed to all features + (e.g. alpha, facecolor, edgecolor, linewidth, ...) """ wrong_names = set(args).difference(self._feature_names) assert len(wrong_names) == 0, ( @@ -63,7 +65,7 @@ def __call__(self, *args, scale=50, layer=None): ) for a in args: - getattr(self, a)(scale=scale, layer=layer) + getattr(self, a)(scale=scale, layer=layer, **kwargs) @property def _feature_names(self):