Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
6f00e25
logXY buttons in canvas instead of toolbar
nvaytet Jun 10, 2026
68cd95b
update buttons when scale property is set instead
nvaytet Jun 10, 2026
c5a9178
add buttons in colorbar
nvaytet Jun 10, 2026
dc6a2d3
update cbar button colors on click
nvaytet Jun 10, 2026
be6f603
also make clickable buttons on 3d plots colorbar
nvaytet Jun 10, 2026
3b027ce
toggle log button color in anywidget
nvaytet Jun 10, 2026
337cc39
move hover button widget into its own file
nvaytet Jun 11, 2026
b4e2eb0
disable zoom and pan on colorbar axes
nvaytet Jun 11, 2026
d862a06
place buttons outside of plotting area
nvaytet Jun 11, 2026
723832a
make hover area larger for colorbar
nvaytet Jun 11, 2026
96a0ef1
Apply automatic formatting
pre-commit-ci-lite[bot] Jun 11, 2026
c2afe97
make home button in toolbar rescale all axes
nvaytet Jun 11, 2026
7e039b7
Merge branch 'log-buttons-per-axis' of github.com:scipp/plopp into lo…
nvaytet Jun 11, 2026
67cf5cb
improve tiled
nvaytet Jun 11, 2026
e99e6f7
fix tiled spacing
nvaytet Jun 11, 2026
5e15781
lint
nvaytet Jun 11, 2026
bae1d88
some type hints and docstrings
nvaytet Jun 11, 2026
80efc71
refactor
nvaytet Jun 11, 2026
1173d4e
lint
nvaytet Jun 15, 2026
12ad9ce
Merge branch 'main' into log-buttons-per-axis
nvaytet Jun 17, 2026
da2d378
make canvastogglebutton class
nvaytet Jun 17, 2026
1f3daa1
draw before getting position
nvaytet Jun 17, 2026
6bcc9ec
fix some tests
nvaytet Jun 17, 2026
2953cf7
fix last tests
nvaytet Jun 17, 2026
d246196
remove old toolbar tests
nvaytet Jun 17, 2026
5e03088
fix some more tests
nvaytet Jun 18, 2026
6094bfd
fix log button state on initialization for 3d plots cbar
nvaytet Jun 18, 2026
e81850e
remove unused import
nvaytet Jun 18, 2026
3ec83c7
fix more colormapper tests
nvaytet Jun 18, 2026
2909232
copyright year
nvaytet Jun 18, 2026
676a0ff
remove unused toolbar tools from code and api reference
nvaytet Jun 18, 2026
f684184
remove redundant setting buttons to none
nvaytet Jun 18, 2026
0b3366c
only compute bbox on draw if interactive
nvaytet Jun 18, 2026
7da358c
declare private canvas members as None
nvaytet Jun 18, 2026
12f8e9b
use dicts to store home callbacks to avoid growing list indefinitely
nvaytet Jun 18, 2026
4ad20b3
add explanations for magic numbers
nvaytet Jun 18, 2026
57f8a56
more readable syntax
nvaytet Jun 18, 2026
12d0eaf
move buttons closer to center on 3d plot cbar
nvaytet Jun 18, 2026
a5a09ac
add test rescale all axes
nvaytet Jun 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions docs/api-reference/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,6 @@
widgets.tools.CameraTool
widgets.tools.ColorTool
widgets.tools.HomeTool
widgets.tools.LogNormTool
widgets.tools.LogxTool
widgets.tools.LogyTool
widgets.tools.MultiToggleTool
widgets.tools.OutlineTool
widgets.tools.PanZoomTool
Expand Down
208 changes: 205 additions & 3 deletions src/plopp/backends/matplotlib/canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The View is 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.

**ignored,
):
# Note on the `**ignored`` keyword arguments: the figure which owns the canvas
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -188,6 +286,47 @@ def __init__(
if ylabel is not None:
self.ylabel = ylabel

def _on_mouse_move(self, event: MouseEvent):

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The 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")

Expand Down Expand Up @@ -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
Comment thread
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.
Expand Down Expand Up @@ -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']:
Expand All @@ -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:
Expand Down
29 changes: 29 additions & 0 deletions src/plopp/backends/matplotlib/figure.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,9 @@ def __init__(self, View, *args, **kwargs):
self.__init_figure__(View, *args, **kwargs)
self.interactive = True
self.toolbar = make_toolbar_canvas2d(view=self.view)

self._setup_linked_home_buttons()

self.left_bar = VBar([self.toolbar])
self.right_bar = VBar()
self.bottom_bar = HBar()
Expand All @@ -155,6 +158,32 @@ def _make_children(self):
self.bottom_bar,
]

def _setup_linked_home_buttons(self):
"""
Link home buttons across all Plopp figures sharing this matplotlib figure.
This is needed when multiple Plopp figures are created from matplotlib axes
that belong to the same figure, e.g. subplots.
"""
if not hasattr(self.fig, '_plopp_home_data'):
self.fig._plopp_home_data = {'callbacks': {}, 'toolbars': {}}
data = self.fig._plopp_home_data
axes_id = id(self.ax)
# Store the original callback.
# We use a dict to make sure that for repeated use of the same axes via
# successive figure creations, we do not grow the list of callbacks
# indefinitely.
data['callbacks'][axes_id] = self.toolbar['home'].callback
data['toolbars'][axes_id] = self.toolbar

# Create a linked callback that calls ALL original callbacks
def linked_home():
for callback in data['callbacks'].values():
callback()

# Update all toolbars (including this one) to use the linked callback
for toolbar in data['toolbars'].values():
toolbar['home'].callback = linked_home


class StaticFigure(MplBaseFig):
"""
Expand Down
25 changes: 11 additions & 14 deletions src/plopp/backends/matplotlib/tiled.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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
)
Expand All @@ -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)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The 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:
"""
Expand Down
Loading
Loading