-
Notifications
You must be signed in to change notification settings - Fork 5
Move logx, logy, logc, color-autoscale buttons from toolbar to axes #572
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
6f00e25
68cd95b
c5a9178
dc6a2d3
be6f603
3b027ce
337cc39
b4e2eb0
d862a06
723832a
96a0ef1
c2afe97
7e039b7
67cf5cb
e99e6f7
5e15781
bae1d88
80efc71
1173d4e
12ad9ce
da2d378
1f3daa1
6bcc9ec
2953cf7
d246196
5e03088
6094bfd
e81850e
3ec83c7
2909232
676a0ff
f684184
0b3366c
7da358c
12f8e9b
4ad20b3
57f8a56
12d0eaf
a5a09ac
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,12 +2,15 @@ | |
| # Copyright (c) 2023 Scipp contributors (https://github.com/scipp) | ||
|
|
||
| import warnings | ||
| from collections.abc import Callable | ||
| from typing import Literal | ||
|
|
||
| import matplotlib.pyplot as plt | ||
| import numpy as np | ||
| import scipp as sc | ||
| from matplotlib import dates as mdates | ||
| from matplotlib.backend_bases import MouseEvent | ||
| from matplotlib.transforms import Bbox | ||
| from mpl_toolkits.axes_grid1 import make_axes_locatable | ||
|
|
||
| from ...core.utils import maybe_variable_to_number, scalar_to_string | ||
|
|
@@ -52,6 +55,70 @@ def _maybe_trim_polar_limits( | |
| return tuple(np.clip(limits, 0, 2 * np.pi)) | ||
|
|
||
|
|
||
| class CanvasToggleButton: | ||
| _value: bool | ||
|
|
||
| def __init__( | ||
| self, | ||
| ax: plt.Axes, | ||
| label: str, | ||
| position: tuple[float, float], | ||
| value: bool = False, | ||
| **kwargs, | ||
| ): | ||
| default_style = {"visible": False, "fontsize": 9, "zorder": np.inf} | ||
| self._text = ax.text(*position, label, **{**default_style, **kwargs}) | ||
| self.value = value | ||
|
|
||
| def contains(self, event: MouseEvent) -> bool: | ||
| return self._text.contains(event)[0] | ||
|
|
||
| @property | ||
| def visible(self) -> bool: | ||
| return self._text.get_visible() | ||
|
|
||
| @visible.setter | ||
| def visible(self, visible: bool): | ||
| self._text.set_visible(visible) | ||
|
|
||
| def toggle(self): | ||
| self.value = not self.value | ||
|
|
||
| @property | ||
| def value(self) -> bool: | ||
| return self._value | ||
|
|
||
| @value.setter | ||
| def value(self, val: bool): | ||
| self._value = val | ||
| self._text.set_bbox( | ||
| { | ||
| "boxstyle": "round,pad=0.3", | ||
| "facecolor": "0.65" if val else "0.9", | ||
| "edgecolor": "0.5", | ||
| } | ||
| ) | ||
|
|
||
| @property | ||
| def label(self) -> str: | ||
| return self._text.get_text() | ||
|
|
||
| @label.setter | ||
| def label(self, text: str): | ||
| self._text.set_text(text) | ||
|
|
||
| @property | ||
| def position(self) -> tuple[float, float]: | ||
| """ | ||
| The position of the button in display coordinates. | ||
| """ | ||
| return self._text.get_position() | ||
|
|
||
| @position.setter | ||
| def position(self, pos: tuple[float, float]): | ||
| self._text.set_position(pos) | ||
|
|
||
|
|
||
| class Canvas: | ||
| """ | ||
| Matplotlib-based canvas used to render 2D graphics. | ||
|
|
@@ -124,6 +191,7 @@ def __init__( | |
| xlabel: str | None = None, | ||
| ylabel: str | None = None, | ||
| norm: Literal['linear', 'log'] | None = None, | ||
| autoscale_axes: Callable | None = None, | ||
| **ignored, | ||
| ): | ||
| # Note on the `**ignored`` keyword arguments: the figure which owns the canvas | ||
|
|
@@ -152,13 +220,13 @@ def __init__( | |
| self.units = {} | ||
| self.dims = {} | ||
| self._legend = legend | ||
| self._autoscale_axes = autoscale_axes | ||
| self._toggle_color_norm = None | ||
| self._autoscale_colors = None | ||
|
|
||
| if self.ax is None: | ||
| self.fig = make_figure(figsize=(6.0, 4.0) if figsize is None else figsize) | ||
| self.ax = self.fig.add_subplot() | ||
| if self.is_widget(): | ||
| self.fig.canvas.toolbar_visible = False | ||
| self.fig.canvas.header_visible = False | ||
| else: | ||
| self.fig = self.ax.get_figure() | ||
| if aspect is not None: | ||
|
|
@@ -179,6 +247,36 @@ def __init__( | |
| self.ax.set_title(title) | ||
| self._coord_formatters = [] | ||
|
|
||
| self._logx_button = None | ||
| self._logy_button = None | ||
| self._logc_button = None | ||
| self._fitc_button = None | ||
| if self.is_widget(): | ||
| # Hide the native toolbar: it will be replaced by our own. Also hide the | ||
| # header in ipympl figures which takes up a lot of vertical space. | ||
| self.fig.canvas.toolbar_visible = False | ||
| self.fig.canvas.header_visible = False | ||
|
|
||
| args = {"transform": self.ax.transAxes, "ha": "right", "va": "top"} | ||
| self._logx_button = CanvasToggleButton( | ||
| ax=self.ax, label="logX", position=(0.985, -0.02), **args | ||
| ) | ||
| self._logy_button = CanvasToggleButton( | ||
| ax=self.ax, label="logY", position=(-0.015, 0.98), **args | ||
| ) | ||
|
|
||
| if self.cax is not None: | ||
| args = {"transform": self.cax.transAxes, "ha": "center"} | ||
| self._logc_button = CanvasToggleButton( | ||
| ax=self.cax, label="log", position=(0.5, 0.98), va="top", **args | ||
| ) | ||
| self._fitc_button = CanvasToggleButton( | ||
| ax=self.cax, label="fit", position=(0.5, 0.02), va="bottom", **args | ||
| ) | ||
|
|
||
| self.fig.canvas.mpl_connect("motion_notify_event", self._on_mouse_move) | ||
| self.fig.canvas.mpl_connect("button_press_event", self._on_log_button_click) | ||
|
|
||
| if logx: | ||
| self.xscale = 'log' | ||
| if logy: | ||
|
|
@@ -188,6 +286,47 @@ def __init__( | |
| if ylabel is not None: | ||
| self.ylabel = ylabel | ||
|
|
||
| def _on_mouse_move(self, event: MouseEvent): | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We have to use the event when the mouse moves instead of the axes enter/leave event because we want to place the buttons outside of the plotting area so as to not appear over and hide some important data in the plot. |
||
| """ | ||
| Show log buttons when hovering over the axes or colorbar, and hide them | ||
| otherwise. | ||
| """ | ||
| need_redraw = False | ||
|
|
||
| logxy_visible = self._logx_button.visible | ||
| inside = self._axes_bbox.contains(event.x, event.y) | ||
| if inside and (not logxy_visible): | ||
| self._logx_button.visible = True | ||
| self._logy_button.visible = True | ||
| need_redraw = True | ||
| elif (not inside) and logxy_visible: | ||
| self._logx_button.visible = False | ||
| self._logy_button.visible = False | ||
| need_redraw = True | ||
|
|
||
| if self._logc_button is not None: | ||
| logc_visible = self._logc_button.visible | ||
| inside_cax = self._cbar_bbox.contains(event.x, event.y) | ||
| if inside_cax and (not logc_visible): | ||
| # We may need to update the button toggle state as the canvas is created | ||
| # before the colormapper, but the latter sets the cbar state. We sync | ||
| # the button state when we hover over the axes just before making them | ||
| # visible | ||
| log_state = self.cax.get_yscale() == "log" | ||
| if self._logc_button.value != log_state: | ||
| self._logc_button.value = log_state | ||
|
|
||
| self._logc_button.visible = True | ||
| self._fitc_button.visible = True | ||
| need_redraw = True | ||
| elif (not inside_cax) and logc_visible: | ||
| self._logc_button.visible = False | ||
| self._fitc_button.visible = False | ||
| need_redraw = True | ||
|
|
||
| if need_redraw: | ||
| self.draw() | ||
|
|
||
| def is_widget(self): | ||
| return hasattr(self.fig.canvas, "on_widget_constructed") | ||
|
|
||
|
|
@@ -215,12 +354,71 @@ def to_widget(self): | |
| return VBox([self.fig.canvas]) | ||
| return self.to_image() | ||
|
|
||
| def _on_log_button_click(self, event: MouseEvent): | ||
| """ | ||
| Handle clicks on the log buttons. | ||
| """ | ||
| if (self.cax is not None) and (event.inaxes is self.cax): | ||
| if self._logc_button.contains(event): | ||
| self._logc_button.toggle() | ||
| self._toggle_color_norm() | ||
| elif self._fitc_button.contains(event): | ||
| self._autoscale_colors() | ||
| self.draw() | ||
| return | ||
|
|
||
| need_redraw = False | ||
| if self._logx_button.contains(event): | ||
| self.toggle_logx() | ||
| need_redraw = True | ||
| elif self._logy_button.contains(event): | ||
| self.toggle_logy() | ||
| need_redraw = True | ||
| if need_redraw: | ||
| self._autoscale_axes() | ||
| self.draw() | ||
|
|
||
| def draw(self): | ||
| """ | ||
| Make a draw call to the underlying figure. | ||
|
|
||
| We also update the bounding box of the canvas, which is used to determine when | ||
|
SimonHeybrock marked this conversation as resolved.
|
||
| to show the log buttons. | ||
| """ | ||
| self.fig.canvas.draw_idle() | ||
|
|
||
| if not self.is_widget(): | ||
| return | ||
|
|
||
| dpi = self.fig.dpi | ||
| axes_bbox = self.ax.get_position().transformed(self.fig.transFigure) | ||
| # The region where the log buttons are shown is slightly larger than the axes: | ||
| # the logx button is in the lower right corner just below the x axis, and the | ||
| # logy button is in the upper left corner just to the left of the y axis. | ||
| # We add corresponding padding to the bbox in pixels that depends on the dpi. | ||
| # We need to add at least the width of the logy button to the left of the y | ||
| # axis, and the height of the logx button below the x axis. Trial and error | ||
| # shows that 0.45 inch of padding is enough for the logy button, and 0.25 inch | ||
| # is enough for the logx button. | ||
| self._axes_bbox = Bbox( | ||
| [ | ||
| [axes_bbox.x0 - 0.45 * dpi, axes_bbox.y0 - 0.25 * dpi], | ||
| [axes_bbox.x1, axes_bbox.y1], | ||
| ] | ||
| ) | ||
| if self.cax is not None: | ||
| # For the colorbar, we want to show the log button above the colorbar when | ||
| # we hover over it. We add some padding around the colorbar to make it | ||
| # easier to trigger the button display, which is for example when hovering | ||
| # over the colorbar label and not just the coloured bar itself. | ||
| cbar_bbox = self.cax.get_position().transformed(self.fig.transFigure) | ||
| self._cbar_bbox = Bbox( | ||
| [ | ||
| [cbar_bbox.x0 - 0.2 * dpi, cbar_bbox.y0 - 0.1 * dpi], | ||
| [cbar_bbox.x1 + 0.2 * dpi, cbar_bbox.y1 + 0.1 * dpi], | ||
| ] | ||
| ) | ||
|
|
||
| def update_legend(self): | ||
| """ | ||
| Update the legend on the canvas. | ||
|
|
@@ -379,6 +577,8 @@ def xscale(self) -> Literal['linear', 'log']: | |
| @xscale.setter | ||
| def xscale(self, scale: Literal['linear', 'log']): | ||
| self.ax.set_xscale(scale) | ||
| if self._logx_button is not None: | ||
| self._logx_button.value = scale == 'log' | ||
|
|
||
| @property | ||
| def yscale(self) -> Literal['linear', 'log']: | ||
|
|
@@ -390,6 +590,8 @@ def yscale(self) -> Literal['linear', 'log']: | |
| @yscale.setter | ||
| def yscale(self, scale: Literal['linear', 'log']): | ||
| self.ax.set_yscale(scale) | ||
| if self._logy_button is not None: | ||
| self._logy_button.value = scale == 'log' | ||
|
|
||
| @property | ||
| def xmin(self) -> float: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,8 +9,7 @@ | |
| from matplotlib import gridspec | ||
|
|
||
| from ...core.typing import FigureLike | ||
| from .figure import get_repr_maker | ||
| from .utils import is_interactive_backend, make_figure | ||
| from .utils import make_figure | ||
|
|
||
|
|
||
| class Tiled: | ||
|
|
@@ -68,8 +67,8 @@ def __init__( | |
| nrows: int, | ||
| ncols: int, | ||
| figsize: tuple[float, float] | None = None, | ||
| hspace: float = 0.05, | ||
| wspace: float = 0.1, | ||
| hspace: float | None = None, | ||
| wspace: float | None = None, | ||
| **kwargs: Any, | ||
| ) -> None: | ||
| self.nrows = nrows | ||
|
|
@@ -83,6 +82,12 @@ def __init__( | |
| layout='constrained', | ||
| ) | ||
|
|
||
| is_widget_backend = hasattr(self.fig.canvas, "on_widget_constructed") | ||
| if hspace is None: | ||
| hspace = 0.2 if is_widget_backend else 0.02 | ||
| if wspace is None: | ||
| wspace = 0.2 if is_widget_backend else 0.05 | ||
|
|
||
| self.gs = gridspec.GridSpec( | ||
| nrows, ncols, figure=self.fig, wspace=wspace, hspace=hspace, **kwargs | ||
| ) | ||
|
|
@@ -103,19 +108,11 @@ def __getitem__( | |
| ) -> FigureLike: | ||
| return self.figures[inds] | ||
|
|
||
| def _repr_mimebundle_(self, include=None, exclude=None) -> dict: | ||
| def _repr_mimebundle_(self, *args, **kwargs) -> dict: | ||
| """ | ||
| Mimebundle display representation for jupyter notebooks. | ||
| """ | ||
| if is_interactive_backend(): | ||
| return self.fig.canvas._repr_mimebundle_(include=include, exclude=exclude) | ||
| else: | ||
| out = {'text/plain': f'TiledFigure(nrows={self.nrows}, ncols={self.ncols})'} | ||
| npoints = sum( | ||
| len(line.get_xdata()) for ax in self.fig.get_axes() for line in ax.lines | ||
| ) | ||
| out.update(get_repr_maker(npoints=npoints)(self.fig)) | ||
| return out | ||
| return self.figures.ravel()[0]._repr_mimebundle_(*args, **kwargs) | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Showing the repr from any figure will show the entire figure because the matplotlib axes are part of the same mpl canvas. We just pick the first one. |
||
|
|
||
| def save(self, filename: str, **kwargs: Any) -> None: | ||
| """ | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
Viewis the one who knows how to properly autoscale the axes because it holds the references to the plotted artists. But the canvas need to call autoscale when the log buttons are clicked. So we pass the callback to the canvas here.Not the most elegant design but it was the least invasive I could come up with.