Repository navigation
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 20 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 | ||||||
|
|
@@ -124,6 +127,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 +156,11 @@ def __init__( | |||||
| self.units = {} | ||||||
| self.dims = {} | ||||||
| self._legend = legend | ||||||
| self._autoscale_axes = autoscale_axes | ||||||
|
|
||||||
| 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 +181,54 @@ def __init__( | |||||
| self.ax.set_title(title) | ||||||
| self._coord_formatters = [] | ||||||
|
|
||||||
| if self.is_widget(): | ||||||
| # Hide the native toolbar: it will be replaced by our own. Also hide the | ||||||
| # (ugly) header in ipympl figures which takes up a lot of vertical space. | ||||||
| self.fig.canvas.toolbar_visible = False | ||||||
| self.fig.canvas.header_visible = False | ||||||
|
|
||||||
| # fontsize = 9 | ||||||
|
Member
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.
Suggested change
|
||||||
|
|
||||||
| args = { | ||||||
| "transform": self.ax.transAxes, | ||||||
| "ha": "right", | ||||||
| "va": "top", | ||||||
| "visible": False, | ||||||
| "fontsize": 9, | ||||||
| "zorder": np.inf, | ||||||
| } | ||||||
|
|
||||||
| self._logx_button = self.ax.text(0.985, -0.02, "logX", **args) | ||||||
| self._logy_button = self.ax.text(-0.015, 0.98, "logY", **args) | ||||||
|
|
||||||
| if self.cax is not None: | ||||||
| args = { | ||||||
| "transform": self.cax.transAxes, | ||||||
| "ha": "center", | ||||||
| "visible": False, | ||||||
| "fontsize": 9, | ||||||
| "zorder": np.inf, | ||||||
| } | ||||||
| self._logc_button = self.cax.text(0.5, 0.98, "log", va="top", **args) | ||||||
| self._fitc_button = self.cax.text( | ||||||
| 0.5, | ||||||
| 0.02, | ||||||
| "fit", | ||||||
| va="bottom", | ||||||
| bbox={ | ||||||
| "boxstyle": "round,pad=0.3", | ||||||
| "facecolor": "0.9", | ||||||
| "edgecolor": "0.5", | ||||||
| }, | ||||||
| **args, | ||||||
| ) | ||||||
| else: | ||||||
| self._logc_button = None | ||||||
| self._fitc_button = None | ||||||
|
|
||||||
| 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 +238,45 @@ def __init__( | |||||
| if ylabel is not None: | ||||||
| self.ylabel = ylabel | ||||||
|
|
||||||
| def before_render(self): | ||||||
| """ | ||||||
| Function to be called before rendering the figure. | ||||||
| """ | ||||||
| self.update_log_buttons() | ||||||
|
|
||||||
| 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.get_visible() | ||||||
| inside = self._axes_bbox.contains(event.x, event.y) | ||||||
| if inside and (not logxy_visible): | ||||||
| self._logx_button.set_visible(True) | ||||||
| self._logy_button.set_visible(True) | ||||||
| need_redraw = True | ||||||
| elif (not inside) and logxy_visible: | ||||||
| self._logx_button.set_visible(False) | ||||||
| self._logy_button.set_visible(False) | ||||||
| need_redraw = True | ||||||
|
|
||||||
| if self._logc_button is not None: | ||||||
| logc_visible = self._logc_button.get_visible() | ||||||
| inside_cax = self._cbar_bbox.contains(event.x, event.y) | ||||||
| if inside_cax and (not logc_visible): | ||||||
| self._logc_button.set_visible(True) | ||||||
| self._fitc_button.set_visible(True) | ||||||
| need_redraw = True | ||||||
| elif (not inside_cax) and logc_visible: | ||||||
| self._logc_button.set_visible(False) | ||||||
| self._fitc_button.set_visible(False) | ||||||
| need_redraw = True | ||||||
|
|
||||||
| if need_redraw: | ||||||
| self.draw() | ||||||
|
|
||||||
| def is_widget(self): | ||||||
| return hasattr(self.fig.canvas, "on_widget_constructed") | ||||||
|
|
||||||
|
|
@@ -215,11 +304,74 @@ def to_widget(self): | |||||
| return VBox([self.fig.canvas]) | ||||||
| return self.to_image() | ||||||
|
|
||||||
| def update_log_buttons(self): | ||||||
| """ | ||||||
| Update the background color of the log buttons to indicate whether the | ||||||
| corresponding axis is in log scale or not. | ||||||
| """ | ||||||
| if not self.is_widget(): | ||||||
| return | ||||||
|
|
||||||
| buttons = [(self._logx_button, self.xscale), (self._logy_button, self.yscale)] | ||||||
| if self._logc_button is not None: | ||||||
| buttons.append((self._logc_button, self.cax.get_yscale())) | ||||||
| for button, scale in buttons: | ||||||
| button.set_bbox( | ||||||
| { | ||||||
| "boxstyle": "round,pad=0.3", | ||||||
| "facecolor": "0.65" if scale == "log" else "0.9", | ||||||
| "edgecolor": "0.5", | ||||||
| } | ||||||
| ) | ||||||
|
|
||||||
| def _on_log_button_click(self, event: MouseEvent): | ||||||
| """ | ||||||
| Handle clicks on the log buttons. | ||||||
| """ | ||||||
| if event.inaxes in ({self.cax} - {None}): | ||||||
| if self._logc_button.contains(event)[0]: | ||||||
| self._toggle_color_norm() | ||||||
| self.update_log_buttons() | ||||||
| elif self._fitc_button.contains(event)[0]: | ||||||
| self._autoscale_colors() | ||||||
| self.draw() | ||||||
| return | ||||||
|
|
||||||
| need_redraw = False | ||||||
| if self._logx_button.contains(event)[0]: | ||||||
| self.toggle_logx() | ||||||
| need_redraw = True | ||||||
| elif self._logy_button.contains(event)[0]: | ||||||
| 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() | ||||||
| dpi = self.fig.dpi | ||||||
| axes_bbox = self.ax.get_position().transformed(self.fig.transFigure) | ||||||
| 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: | ||||||
| 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): | ||||||
| """ | ||||||
|
|
@@ -379,6 +531,7 @@ def xscale(self) -> Literal['linear', 'log']: | |||||
| @xscale.setter | ||||||
| def xscale(self, scale: Literal['linear', 'log']): | ||||||
| self.ax.set_xscale(scale) | ||||||
| self.update_log_buttons() | ||||||
|
|
||||||
| @property | ||||||
| def yscale(self) -> Literal['linear', 'log']: | ||||||
|
|
@@ -390,6 +543,7 @@ def yscale(self) -> Literal['linear', 'log']: | |||||
| @yscale.setter | ||||||
| def yscale(self, scale: Literal['linear', 'log']): | ||||||
| self.ax.set_yscale(scale) | ||||||
| self.update_log_buttons() | ||||||
|
|
||||||
| @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: | ||
| """ | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -205,6 +205,13 @@ def __init__( | |
| if self._clabel is not None: | ||
| self.cax.set_ylabel(self._clabel) | ||
|
|
||
| # Disable zoom and pan on colorbar as this feature is not handled in the | ||
| # current implementation. | ||
| self.cax.set_navigate(False) | ||
|
|
||
| self._canvas._toggle_color_norm = self.toggle_norm | ||
| self._canvas._autoscale_colors = self.autoscale | ||
|
|
||
| def add_artist(self, key: str, artist: Any): | ||
| self.artists[key] = artist | ||
|
|
||
|
|
@@ -215,18 +222,32 @@ def to_widget(self): | |
| """ | ||
| Convert the colorbar into a widget for use with other ``ipywidgets``. | ||
| """ | ||
| import ipywidgets as ipw | ||
| from ..widgets.hoverbutton import HoverButtonWidget | ||
|
|
||
| self.widget = ipw.HTML() | ||
| self.widget = HoverButtonWidget() | ||
| self._update_colorbar_widget() | ||
| # TODO: this doesn't seem to work on widget creation | ||
|
Member
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. Meaning? |
||
| self.widget.log_toggle_value = self._logc | ||
|
|
||
| def toggle_log_button(): | ||
| self.toggle_norm() | ||
| self.widget.log_toggle_value = self._logc | ||
|
|
||
| self.widget.on_log_button_click(toggle_log_button) | ||
|
|
||
| def fit(): | ||
| self.autoscale() | ||
| self._canvas.draw() | ||
|
|
||
| self.widget.on_fit_button_click(fit) | ||
| return self.widget | ||
|
|
||
| def _update_colorbar_widget(self): | ||
| """ | ||
| Upon an updated colorscale range, we need to update the image inside the widget. | ||
| """ | ||
| if self.widget is not None: | ||
| self.widget.value = fig_to_bytes(self.cax.get_figure(), form='svg').decode() | ||
| self.widget.set_svg(fig_to_bytes(self.cax.get_figure(), form='svg')) | ||
|
|
||
| def rgba(self, data: sc.DataArray) -> np.ndarray: | ||
| """ | ||
|
|
||
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.