Skip to content

Move logx, logy, logc, color-autoscale buttons from toolbar to axes#572

Merged
nvaytet merged 39 commits into
mainfrom
log-buttons-per-axis
Jun 19, 2026
Merged

Move logx, logy, logc, color-autoscale buttons from toolbar to axes#572
nvaytet merged 39 commits into
mainfrom
log-buttons-per-axis

Conversation

@nvaytet

@nvaytet nvaytet commented Jun 11, 2026

Copy link
Copy Markdown
Member

The logx, logy, log and color-autoscale buttons in the toolbar are designed to act on one axes.
The Home, Pan, Zoom, and Save buttons act on all axes in a subplot.

It was ambiguous what to do with the toolbar buttons when subplots/user-supplied axes/tiled were used.

We now move them to being on the axes themselves, appearing upon hover.

1d plot:
Screenshot_20260611_132415

2d plot:
Screenshot_20260611_132433
Screenshot_20260611_132429

3d plot:
Screenshot_20260611_132511

Note: for 3d plots we replaced the ipywidgets.Image widget with a custom Anywidget because the former does not handle hover or click events.

Needs #571

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.

@nvaytet nvaytet marked this pull request as ready for review June 15, 2026 12:47
Comment thread src/plopp/backends/matplotlib/canvas.py Outdated
Comment on lines +189 to +190

# fontsize = 9

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
# fontsize = 9

Comment thread src/plopp/backends/matplotlib/canvas.py
Comment thread src/plopp/graphics/colormapper.py Outdated
self.widget = ipw.HTML()
self.widget = HoverButtonWidget()
self._update_colorbar_widget()
# TODO: this doesn't seem to work on widget creation

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Meaning?

Comment thread src/plopp/graphics/colormapper.py Outdated
Comment on lines 230 to 233
# TODO: When the widget is created, if logc=True as passed in the arguments,
# we need to set the button state according to the value of logc.
# For some reason, setting `log_toggle_value` here does not seem to work.
self.widget.log_toggle_value = self._logc

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

TODO now or later? I don't understand if this comment implies that this new feature does not work at all when passing logc=True?

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.

Sorry I should have been more clear. I'm still figuring out the last details in this PR.

I marked as ready for review to get early feedback and so people could try it out from the branch, to see if this was the direction we wanted to go in.

Don't worry about the small details here, I am trying to fix it and I added a todo for myself. If I don't manage to fix, I will make the comment clearer and leave it for later. It is only a minor issue of appearance but doesn't break any functionality.

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.

I now managed to fix the issue by manually calling a style update in the button javascript initialization because trait syncing only happens automatically after the button is displayed.

@nvaytet nvaytet marked this pull request as draft June 18, 2026 07:35
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.

)
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.

CASES1DINTERACTIVE.values(),
ids=CASES1DINTERACTIVE.keys(),
)
class TestCanvasInteractive1d:

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.

These tests were moved to mpl_canvas_test.py.

CASESINTERACTIVE.values(),
ids=CASESINTERACTIVE.keys(),
)
class TestColormapperInteractiveCases:

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 tests for the 2d case were moved to mpl_canvas_test.py.

@nvaytet nvaytet marked this pull request as ready for review June 18, 2026 09:58
@nvaytet

nvaytet commented Jun 18, 2026

Copy link
Copy Markdown
Member Author

@SimonHeybrock this is now ready for another look as I'm done ironing out all the details. Thanks!

@SimonHeybrock

Copy link
Copy Markdown
Member

I had no comments myself anymore, but here is what Claude suggests:

Solid, well-motivated change: putting per-axes controls on the axes resolves the genuine ambiguity with subplots/tiled/user-supplied axes. Below, ordered by significance.

Architecture / design

1. Cross-object attribute injection (canvas ⇄ colormapper). colormapper.py does:

self._canvas._toggle_color_norm = self.toggle_norm
self._canvas._autoscale_colors = self.autoscale

and Canvas._on_log_button_click calls self._toggle_color_norm() / self._autoscale_colors() — attributes that are never defined in Canvas.__init__. The whole thing only works because of an implicit ordering contract: canvas created → cbar present → colormapper created → it patches the attributes back onto the canvas. This is fragile and hard to follow. The constructor already accepts autoscale_axes as a proper callback — the colorbar callbacks deserve the same treatment, or at minimum be declared (as None) in __init__ so the contract is visible and a missing wiring fails cleanly rather than with AttributeError.

2. _setup_linked_home_buttons monkeypatches the matplotlib Figure. It stashes fig._plopp_home_data = {'callbacks': [...], 'toolbars': [...]} on a third-party object and appends to it on every figure construction, with no cleanup. Repeatedly creating Plopp figures over the same mpl figure grows these lists unbounded, and a copy() re-registers. It works for the subplots case, but it's a leaky global keyed on a private attr. Worth at least de-duplicating, and ideally not storing mutable state on the mpl figure. Also: this is the headline feature ("Home rescales all axes") and there's no test for it.

3. Two parallel button implementations. 2D uses matplotlib Text artists (CanvasToggleButton); 3D uses an anywidget with hand-written JS. Understandable given the 3D image-widget can't take events, but the two diverge in styling and labels (logX/logY/log/fit vs log/fit, "0.65"/"0.9" vs gray/lightgray). Flagging the duplication; not blocking.

Correctness / robustness

4. _axes_bbox / _cbar_bbox are only created in draw(), but _on_mouse_move reads them. A motion_notify_event arriving before the first draw raises AttributeError. In practice ipympl draws on display so it's probably never hit, but it's a latent crash — initialize both in __init__.

5. _autoscale_axes defaults to None yet is called unconditionally in the logx/logy click path. Safe today only because graphicalview always passes self.autoscale. If a widget Canvas is ever built directly, clicking logx → TypeError: 'NoneType' not callable. Either make it required or guard.

6. Magic-number hover hit-areas (0.45 * dpi, 0.25 * dpi, 0.2 * dpi...) in draw(). These silently encode where the buttons sit relative to the axes; if button positions change they break with no signal. At least pull them into named constants with a one-line rationale.

Minor / nits

  • Dead code: LogxTool, LogyTool, LogNormTool, AutoscaleTool are now unused in src/ but still exported and listed in docs/api-reference/index.md. Decide: remove, or keep as public API intentionally.
  • if event.inaxes in ({self.cax} - {None}): is obscure — if self.cax is not None and event.inaxes is self.cax: reads better.
  • Redundant else: self._logc_button = None / self._fitc_button = None (already set to None above the is_widget() block).
  • draw() computes the bboxes even for static (non-widget) canvases where they're never used.
  • Behavior change: native toolbar_visible/header_visible are now hidden for user-supplied axes too (previously only for canvas-created figures). Likely intended, but worth confirming it doesn't surprise users embedding Plopp in their own mpl figures.
  • Tests exercise private methods/attrs (_on_log_button_click, _logx_button) — acceptable given there's no public handle, but combined with the missing home-link and tiled-repr coverage, the new interactive surface is thinly tested.

Question on Tiled: _repr_mimebundle_ now delegates to self.figures.ravel()[0]._repr_mimebundle_(...). This relies on all sub-figures sharing one mpl figure so figure[0] renders the whole grid. Does the static (mpl-static) backend still render the full grid through this path? The old code special-cased it; worth a manual check.

@nvaytet

nvaytet commented Jun 18, 2026

Copy link
Copy Markdown
Member Author

1.

The constructor already accepts autoscale_axes as a proper callback — the colorbar callbacks deserve the same treatment,

I do not see how I can pass a method from a colorbar as an argument when the colorbar has not yet been created (at the time of canvas creation)...
I agree that I don't like the double linking, but I could not think of another way.

or at minimum be declared (as None) in init so the contract is visible and a missing wiring fails cleanly rather than with AttributeError.

I declared them as None in the init.

2.

It stashes fig._plopp_home_data = {'callbacks': [...], 'toolbars': [...]} on a third-party object

The matplotlib figure is the only object that can know how many axes it has. So this was the only place that seemed possible to add.

Repeatedly creating Plopp figures over the same mpl figure grows these lists unbounded,

Fair point, but without an alternative suggestion, the only thing I could think of is to change the list for dicts which would replace the callback in case the same axes were used twice.

3.

No other way I could see. You don't want to artificially activate the %matplotlib widget backend when you make a 3d plot just so that you can make a colorbar that can be clicked. If the user never asked for interactive figures, subsequent 2d figures should remain static.

Once the interactive backend has been activated in a notebook, it is buggy to go back to the static one.

4.

In practice ipympl draws on display so it's probably never hit,

Never happens so I will leave it as is.

5.

If a widget Canvas is ever built directly, clicking logx → TypeError: 'NoneType' not callable. Either make it required or guard.

Toggling a scale can only work properly if the view's autoscale method is called (otherwise you get garbage from matplotlib). I don't see any use case where a Canvas would be used directly apart from unit tests, where we can control the environment anyway, and make sure errors don't happen.

6.

I added explanations for the magic numbers.

Minor / nits

Addressed most of those.

Question on Tiled:

Behaviour verified manually.

@nvaytet

nvaytet commented Jun 18, 2026

Copy link
Copy Markdown
Member Author

Finally: I tested that the new buttons also work in VScode; they do 🙂

@SimonHeybrock

Copy link
Copy Markdown
Member

👍

Last thing, should we add a test like this:

  ⎿  diff --git a/tests/backends/matplotlib/mpl_canvas_test.py b/tests/backends/matplotlib/mpl_canvas_test.py
     index 8204c1bc..dc9783d3 100644
     --- a/tests/backends/matplotlib/mpl_canvas_test.py
     +++ b/tests/backends/matplotlib/mpl_canvas_test.py                                                                                    
     @@ -1,6 +1,7 @@
      # SPDX-License-Identifier: BSD-3-Clause
      # Copyright (c) 2026 Scipp contributors (https://github.com/scipp)

     +import matplotlib.pyplot as plt
      import pytest

      from plopp.data.testing import data_array
     @@ -151,3 +152,20 @@ def test_clicking_logc_button_toggles_colormapper_norm():
          fig.canvas._on_log_button_click(MouseEvent(x, y - 5, fig.canvas.cax))
          assert fig.view.colormapper.norm == 'linear'
          assert not but.value
     +
     +
     +def test_home_button_rescales_all_axes_sharing_a_figure():
     +    da = data_array(ndim=1)
     +    _, (ax0, ax1) = plt.subplots(2, 1)
     +    p0 = da.plot(ax=ax0)
     +    p1 = (da * 10.0).plot(ax=ax1)
     +
     +    expected = p1.canvas.yrange
     +    # Perturb only the second figure.
     +    p1.canvas.yrange = (-100.0, 100.0)
     +
     +    # Clicking Home on the first figure must also rescale the second one, since they
     +    # share the same Matplotlib figure (e.g. subplots).
     +    p0.toolbar['home'].callback()
     +
     +    assert p1.canvas.yrange == pytest.approx(expected)

@nvaytet nvaytet merged commit e926adf into main Jun 19, 2026
5 checks passed
@nvaytet nvaytet deleted the log-buttons-per-axis branch June 19, 2026 08:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants