From 59e0d34579437bddbe28a0c18f1da7b6b314cf55 Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Wed, 18 Feb 2026 18:35:53 +0100 Subject: [PATCH 01/32] add new combined slider for slicer --- src/plopp/plotting/slicer.py | 4 +- src/plopp/widgets/__init__.pyi | 3 +- src/plopp/widgets/slice.py | 103 ++++++++++++++++++++++++++++++--- 3 files changed, 100 insertions(+), 10 deletions(-) diff --git a/src/plopp/plotting/slicer.py b/src/plopp/plotting/slicer.py index 6d1e8187..0889d792 100644 --- a/src/plopp/plotting/slicer.py +++ b/src/plopp/plotting/slicer.py @@ -93,9 +93,9 @@ def __init__( f"were not found in the input's dimensions {dims}." ) - from ..widgets import SliceWidget, slice_dims + from ..widgets import CombinedSliceWidget, slice_dims - self.slider = SliceWidget( + self.slider = CombinedSliceWidget( nodes[0](), dims=[dim for dim in dims if dim not in keep], enable_player=enable_player, diff --git a/src/plopp/widgets/__init__.pyi b/src/plopp/widgets/__init__.pyi index ae1a2ef6..8354ff1e 100644 --- a/src/plopp/widgets/__init__.pyi +++ b/src/plopp/widgets/__init__.pyi @@ -6,7 +6,7 @@ from .checkboxes import Checkboxes from .clip3d import Clip3dTool, ClippingManager from .drawing import DrawingTool, PointsTool, PolygonTool, RectangleTool from .linesave import LineSaveTool -from .slice import RangeSliceWidget, SliceWidget, slice_dims +from .slice import CombinedSliceWidget, RangeSliceWidget, SliceWidget, slice_dims from .toolbar import Toolbar, make_toolbar_canvas2d, make_toolbar_canvas3d from .tools import ButtonTool, ColorTool, ToggleTool @@ -17,6 +17,7 @@ __all__ = [ "Clip3dTool", "ClippingManager", "ColorTool", + "CombinedSliceWidget", "DrawingTool", "HBar", "LineSaveTool", diff --git a/src/plopp/widgets/slice.py b/src/plopp/widgets/slice.py index 39ef8ce8..ee28d942 100644 --- a/src/plopp/widgets/slice.py +++ b/src/plopp/widgets/slice.py @@ -12,7 +12,7 @@ from .box import VBar -class DimSlicer(ipw.VBox): +class DimSlicer(ipw.HBox): def __init__( self, dim: str, @@ -66,7 +66,7 @@ def __init__( self._update_label({"new": self.slider.value}) self.slider.observe(self._update_label, names='value') - super().__init__([ipw.HBox(children)]) + super().__init__(children) def _update_label(self, change: dict[str, Any]): """ @@ -93,12 +93,77 @@ def value(self, value: int | tuple[int, int]): self.slider.value = value +class CombinedSlicer(ipw.HBox): + def __init__( + self, + dim: str, + size: int, + coord: sc.Variable, + width: str = "25em", + **ignored, + ): + + self.int_slicer = DimSlicer( + dim=dim, size=size, coord=coord, slider_constr=ipw.IntSlider + ) + self.int_slicer.slider.value = self.int_slicer.slider.min + self.int_slicer.slider.layout = {"width": width} + + self.range_slicer = DimSlicer( + dim=dim, size=size, coord=coord, slider_constr=ipw.IntRangeSlider + ) + self.range_slicer.slider.value = 0, size + self.range_slicer.slider.layout = {"width": width} + + self.int_slicer.slider.observe(self.move_range, names='value') + + self.slider_toggler = ipw.ToggleButtons( + options=["o-o", "-o-"], + tooltips=['Range slider', 'Single handle slider'], + style={"button_width": "3.2em"}, + ) + + self.slider_toggler.observe(self.toggle_slider_mode, names='value') + + # children = [self.slider_toggler, self.range_slicer] + + super().__init__([self.slider_toggler, self.range_slicer]) + + def move_range(self, change): + self.range_slicer.slider.value = (change["new"], change["new"]) + + def toggle_slider_mode(self, change): + if change["new"] == "o-o": + self.children = [self.slider_toggler, self.range_slicer] + else: + self.int_slicer.slider.value = int( + 0.5 * sum(self.range_slicer.slider.value) + ) + self.children = [self.slider_toggler, self.int_slicer] + + @property + def slider(self) -> ipw.Widget: + return self.range_slicer.slider + + @property + def value(self) -> int | tuple[int, int]: + """ + The value of the slider. + """ + return self.slider.value + + @value.setter + def value(self, value: int | tuple[int, int]): + self.slider.value = value + + class _BaseSliceWidget(VBar, ipw.ValueWidget): def __init__( self, da: sc.DataArray, dims: list[str], slider_constr: ipw.Widget, + slicer_constr: type[DimSlicer] | type[CombinedSlicer], enable_player: bool = False, ): if isinstance(dims, str): @@ -113,7 +178,7 @@ def __init__( if dim in da.coords else sc.arange(dim, da.sizes[dim], unit=None) ) - self.controls[dim] = DimSlicer( + self.controls[dim] = slicer_constr( dim=dim, size=da.sizes[dim], coord=coord, @@ -131,10 +196,12 @@ def _on_subwidget_change(self, _=None): Update the value of the widget. The value is a dict containing one entry per slider, giving the slider's value. """ - self.value = {dim: slicer.slider.value for dim, slicer in self.controls.items()} + self.value = {dim: slicer.value for dim, slicer in self.controls.items()} -SliceWidget = partial(_BaseSliceWidget, slider_constr=ipw.IntSlider) +SliceWidget = partial( + _BaseSliceWidget, slider_constr=ipw.IntSlider, slicer_constr=DimSlicer +) """ Widgets containing a slider for each of the requested dimensions. The widget uses the input data array to determine the range each slider should have. @@ -153,9 +220,11 @@ def _on_subwidget_change(self, _=None): .. versionadded:: 25.07.0 """ -RangeSliceWidget = partial(_BaseSliceWidget, slider_constr=ipw.IntRangeSlider) +RangeSliceWidget = partial( + _BaseSliceWidget, slider_constr=ipw.IntRangeSlider, slicer_constr=DimSlicer +) """ -Widgets containing a slider for each of the requested dimensions. +Widgets containing a range slider for each of the requested dimensions. The widget uses the input data array to determine the range each slider should have. Each slider also comes with a checkbox to toggle on and off the slider's continuous update. @@ -170,6 +239,26 @@ def _on_subwidget_change(self, _=None): The dimensions to make sliders for. """ +CombinedSliceWidget = partial( + _BaseSliceWidget, slider_constr=None, slicer_constr=CombinedSlicer +) +""" +Widgets containing a combined slider (able to toggle between normal slider and range +slider) for each of the requested dimensions. +The widget uses the input data array to determine the range each slider should have. +Each slider also comes with a checkbox to toggle on and off the slider's continuous +update. + +.. versionadded:: 26.03.0 + +Parameters +---------- +da: + The input data array. +dims: + The dimensions to make sliders for. +""" + @node def slice_dims(data_array: sc.DataArray, slices: dict[str, slice]) -> sc.DataArray: From 70118f5de5ef35a53a005e4b86ee085ae865573d Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Wed, 18 Feb 2026 20:49:12 +0100 Subject: [PATCH 02/32] add reduce nodes --- src/plopp/plotting/slicer.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/plopp/plotting/slicer.py b/src/plopp/plotting/slicer.py index 0889d792..64f5cd80 100644 --- a/src/plopp/plotting/slicer.py +++ b/src/plopp/plotting/slicer.py @@ -7,7 +7,7 @@ import scipp as sc -from ..core import widget_node +from ..core import Node, widget_node from ..core.typing import FigureLike, PlottableMulti from ..graphics import imagefigure, linefigure from .common import ( @@ -19,6 +19,12 @@ ) +def _maybe_reduce_dim(da, dim, op): + if dim in da.dims: + return op(da, dim=dim) + return da + + class Slicer: """ Class that slices out dimensions from the data and displays the resulting data as @@ -95,13 +101,19 @@ def __init__( from ..widgets import CombinedSliceWidget, slice_dims + other_dims = [dim for dim in dims if dim not in keep] + self.slider = CombinedSliceWidget( nodes[0](), - dims=[dim for dim in dims if dim not in keep], + dims=other_dims, enable_player=enable_player, ) self.slider_node = widget_node(self.slider) self.slice_nodes = [slice_dims(node, self.slider_node) for node in nodes] + self.reduce_nodes = [ + Node(partial(_maybe_reduce_dim, dim=other_dims, op=sc.sum), node) + for node in nodes + ] args = categorize_args(**kwargs) @@ -118,7 +130,7 @@ def __init__( f'but {ndims} were requested.' ) - self.figure = make_figure(*self.slice_nodes) + self.figure = make_figure(*self.reduce_nodes) require_interactive_figure(self.figure, 'slicer') self.figure.bottom_bar.add(self.slider) From 11ffc99f27c5907ea10e6d894b2b0ab3cdfee89a Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Wed, 18 Feb 2026 22:42:24 +0100 Subject: [PATCH 03/32] fix reduce dims --- src/plopp/plotting/slicer.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/plopp/plotting/slicer.py b/src/plopp/plotting/slicer.py index 64f5cd80..c00b0f33 100644 --- a/src/plopp/plotting/slicer.py +++ b/src/plopp/plotting/slicer.py @@ -19,9 +19,10 @@ ) -def _maybe_reduce_dim(da, dim, op): - if dim in da.dims: - return op(da, dim=dim) +def _maybe_reduce_dim(da, dims, op): + to_be_reduced = set(dims) & set(da.dims) + if to_be_reduced: + return op(da, dim=to_be_reduced) return da @@ -111,8 +112,8 @@ def __init__( self.slider_node = widget_node(self.slider) self.slice_nodes = [slice_dims(node, self.slider_node) for node in nodes] self.reduce_nodes = [ - Node(partial(_maybe_reduce_dim, dim=other_dims, op=sc.sum), node) - for node in nodes + Node(partial(_maybe_reduce_dim, dims=other_dims, op=sc.sum), node) + for node in self.slice_nodes ] args = categorize_args(**kwargs) From 15a9a694b79c7621039a7b5fd33246ecdd0d5ab0 Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Thu, 19 Feb 2026 00:01:18 +0100 Subject: [PATCH 04/32] remove maybe reduce function --- src/plopp/plotting/slicer.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/plopp/plotting/slicer.py b/src/plopp/plotting/slicer.py index c00b0f33..901fc5f9 100644 --- a/src/plopp/plotting/slicer.py +++ b/src/plopp/plotting/slicer.py @@ -18,12 +18,12 @@ require_interactive_figure, ) - -def _maybe_reduce_dim(da, dims, op): - to_be_reduced = set(dims) & set(da.dims) - if to_be_reduced: - return op(da, dim=to_be_reduced) - return da +# def _maybe_reduce_dim(da, dims, op): +# to_be_reduced = set(dims) & set(da.dims) +# if to_be_reduced: +# print(f"Reducing dimensions {to_be_reduced} using {op.__name__}") +# return op(da, dim=to_be_reduced) +# return da class Slicer: @@ -112,8 +112,7 @@ def __init__( self.slider_node = widget_node(self.slider) self.slice_nodes = [slice_dims(node, self.slider_node) for node in nodes] self.reduce_nodes = [ - Node(partial(_maybe_reduce_dim, dims=other_dims, op=sc.sum), node) - for node in self.slice_nodes + Node(sc.sum, node, dim=other_dims) for node in self.slice_nodes ] args = categorize_args(**kwargs) From 9ef00cf116539f36d33eae47fdecdf7209ca63ca Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Wed, 25 Feb 2026 23:27:31 +0100 Subject: [PATCH 05/32] add editable text box for bounds --- src/plopp/widgets/slice.py | 38 ++++++++++++++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/src/plopp/widgets/slice.py b/src/plopp/widgets/slice.py index ee28d942..7f557613 100644 --- a/src/plopp/widgets/slice.py +++ b/src/plopp/widgets/slice.py @@ -5,10 +5,11 @@ from typing import Any import ipywidgets as ipw +import numpy as np import scipp as sc from ..core import node -from ..core.utils import coord_element_to_string +from ..core.utils import value_to_string from .box import VBar @@ -43,12 +44,19 @@ def __init__( indent=False, layout={"width": "1.52em"}, ) - self.label = ipw.Label() + self.label = ipw.Text(continuous_update=False, layout={"width": "10em"}) + self.unit = ipw.Label("" if coord.unit is None else f" [{coord.unit}]") ipw.jslink( (self.continuous_update, 'value'), (self.slider, 'continuous_update') ) - children = [self.dim_label, self.slider, self.continuous_update, self.label] + children = [ + self.dim_label, + self.slider, + self.continuous_update, + self.label, + self.unit, + ] if enable_player: self.player = ipw.Play( value=self.slider.value, @@ -65,6 +73,7 @@ def __init__( self.coord = coord self._update_label({"new": self.slider.value}) self.slider.observe(self._update_label, names='value') + self.label.observe(self._move_slider_to_label, names='value') super().__init__(children) @@ -79,7 +88,28 @@ def _update_label(self, change: dict[str, Any]): inds = (inds[0], inds[1] + 1) else: inds = (inds, inds + 1) - self.label.value = coord_element_to_string(self.coord[self.dim, inds]) + # self.label.value = coord_element_to_string(self.coord[self.dim, inds]) + self.label._lock = True + self.label.value = ' : '.join( + [value_to_string(v) for v in self.coord[self.dim, inds].values] + ) + self.label._lock = False + + def _move_slider_to_label(self, change: dict[str, Any]): + """ + Move the slider to the position corresponding to the coordinate value in the + label, if possible. + """ + if getattr(self.label, '_lock', False): + return + # Find the index of the coordinate value closest to the one in the label. + vmin, vmax = change["new"].split(':') + vmin, vmax = float(vmin), float(vmax) + imin = np.argmin(np.abs(self.coord.values - vmin)) + imax = np.argmin(np.abs(self.coord.values - vmax)) + # self.label._lock = True + self.slider.value = (imin, imax) + # self.label._lock = False @property def value(self) -> int | tuple[int, int]: From 93a047a2dd1a1ca9b49170b4f6aa146e734c2363 Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Thu, 26 Feb 2026 16:23:56 +0100 Subject: [PATCH 06/32] add operations along sliced dimension --- src/plopp/plotting/slicer.py | 72 +++++++++++++++++++++++++-------- src/plopp/plotting/superplot.py | 1 + src/plopp/widgets/slice.py | 21 ++++++---- 3 files changed, 71 insertions(+), 23 deletions(-) diff --git a/src/plopp/plotting/slicer.py b/src/plopp/plotting/slicer.py index 901fc5f9..25ebd20c 100644 --- a/src/plopp/plotting/slicer.py +++ b/src/plopp/plotting/slicer.py @@ -18,12 +18,30 @@ require_interactive_figure, ) -# def _maybe_reduce_dim(da, dims, op): -# to_be_reduced = set(dims) & set(da.dims) -# if to_be_reduced: -# print(f"Reducing dimensions {to_be_reduced} using {op.__name__}") -# return op(da, dim=to_be_reduced) -# return da + +def _maybe_reduce_dim(da, dims, op): + to_be_reduced = set(dims) & set(da.dims) + if not to_be_reduced: + return da + # If the operation is a mean, there is currently a bug in the implementation + # in scipp where doing a mean over a subset of the array's dimensions gives the + # wrong result: https://github.com/scipp/scipp/issues/3841 + # Instead, we manually compute the mean + if 'mean' not in op: + return getattr(da, op)(dims) + + kept_dims = set(da.dims) - to_be_reduced + sliced = da + for dim in kept_dims: + sliced = sliced[dim, 0] + + denominator = sliced.size + if 'nan' in op: + numerator = da.nansum(dims) + denominator = denominator - sc.isnan(sliced.data).sum() + else: + numerator = da.sum(dims) + return numerator / denominator class Slicer: @@ -31,13 +49,8 @@ class Slicer: Class that slices out dimensions from the data and displays the resulting data as either a 1D line or a 2D image. - Note: - - This class primarily exists to facilitate unit testing. When running unit tests, we - are not in a Jupyter notebook, and the generated figures are not widgets that can - be placed in the `Box` widget container at the end of the `slicer` function. - We therefore place most of the code for creating a Slicer in this class, which is - under unit test coverage. The thin `slicer` wrapper is not covered by unit tests. + This class exists so that it can be re-used by other plotting functions that want + to offer slicing functionality, such as the :func:`superplot` function. Parameters ---------- @@ -53,6 +66,9 @@ class Slicer: be a list of dims. If no dims are provided, the last dim will be kept in the case of a 2-dimensional input, while the last two dims will be kept in the case of higher dimensional inputs. + operation: + The reduction operation to be applied to the sliced dimensions. This is ``sum`` + by default. **kwargs: The additional arguments are forwarded to the underlying 1D or 2D figures. """ @@ -64,6 +80,10 @@ def __init__( coords: list[str] | None = None, enable_player: bool = False, keep: list[str] | None = None, + slider_mode: Literal['single', 'range', 'combined'] = 'combined', + operation: Literal[ + 'sum', 'mean', 'max', 'min', 'nansum', 'nanmean', 'nanmax', 'nanmin' + ] = 'sum', **kwargs, ): nodes = input_to_nodes( @@ -100,11 +120,29 @@ def __init__( f"were not found in the input's dimensions {dims}." ) - from ..widgets import CombinedSliceWidget, slice_dims + from ..widgets import ( + CombinedSliceWidget, + RangeSliceWidget, + SliceWidget, + slice_dims, + ) other_dims = [dim for dim in dims if dim not in keep] - self.slider = CombinedSliceWidget( + match slider_mode: + case 'single': + slicer_constr = SliceWidget + case 'range': + slicer_constr = RangeSliceWidget + case 'combined': + slicer_constr = CombinedSliceWidget + case _: + raise ValueError( + f"Invalid slider_mode: {slider_mode}. Expected one of 'single', " + f"'range', or 'combined'." + ) + + self.slider = slicer_constr( nodes[0](), dims=other_dims, enable_player=enable_player, @@ -112,7 +150,8 @@ def __init__( self.slider_node = widget_node(self.slider) self.slice_nodes = [slice_dims(node, self.slider_node) for node in nodes] self.reduce_nodes = [ - Node(sc.sum, node, dim=other_dims) for node in self.slice_nodes + Node(_maybe_reduce_dim, da=node, dims=other_dims, op=operation) + for node in self.slice_nodes ] args = categorize_args(**kwargs) @@ -273,6 +312,7 @@ def slicer( logx=logx, logy=logy, mask_color=mask_color, + mask_cmap=mask_cmap, nan_color=nan_color, norm=norm, scale=scale, diff --git a/src/plopp/plotting/superplot.py b/src/plopp/plotting/superplot.py index c268fc30..3f26a764 100644 --- a/src/plopp/plotting/superplot.py +++ b/src/plopp/plotting/superplot.py @@ -113,6 +113,7 @@ def superplot( slicer = Slicer( obj, keep=keep, + slider_mode='single', aspect=aspect, autoscale=autoscale, coords=coords, diff --git a/src/plopp/widgets/slice.py b/src/plopp/widgets/slice.py index 7f557613..6fd9653b 100644 --- a/src/plopp/widgets/slice.py +++ b/src/plopp/widgets/slice.py @@ -30,10 +30,12 @@ def __init__( 'step': 1, 'min': 0, 'max': size - 1, - 'value': (size - 1) // 2 if slider_constr is ipw.IntSlider else None, + 'value': (size - 1) // 2 + if slider_constr is ipw.IntSlider + else (0, size - 1), 'continuous_update': True, 'readout': False, - 'layout': {"width": "15.2em", "margin": "0px 0px 0px 10px"}, + 'layout': {"width": "25em", "margin": "0px 0px 0px 10px"}, } self._is_bin_edges = coord.sizes[dim] > size self.dim_label = ipw.Label(value=dim) @@ -90,9 +92,12 @@ def _update_label(self, change: dict[str, Any]): inds = (inds, inds + 1) # self.label.value = coord_element_to_string(self.coord[self.dim, inds]) self.label._lock = True - self.label.value = ' : '.join( - [value_to_string(v) for v in self.coord[self.dim, inds].values] - ) + if isinstance(inds, tuple): + self.label.value = ' : '.join( + [value_to_string(v) for v in self.coord[self.dim, inds].values] + ) + else: + self.label.value = value_to_string(self.coord[self.dim, inds].value) self.label._lock = False def _move_slider_to_label(self, change: dict[str, Any]): @@ -132,7 +137,6 @@ def __init__( width: str = "25em", **ignored, ): - self.int_slicer = DimSlicer( dim=dim, size=size, coord=coord, slider_constr=ipw.IntSlider ) @@ -142,7 +146,10 @@ def __init__( self.range_slicer = DimSlicer( dim=dim, size=size, coord=coord, slider_constr=ipw.IntRangeSlider ) - self.range_slicer.slider.value = 0, size + self.range_slicer.slider.value = ( + 0, + size - 1, + ) # + int(self.range_slicer._is_bin_edges) self.range_slicer.slider.layout = {"width": width} self.int_slicer.slider.observe(self.move_range, names='value') From d29c47db0a149e20f5964e00b9055aac4e8aa7d0 Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Thu, 26 Feb 2026 17:28:26 +0100 Subject: [PATCH 07/32] start fixing tests --- docs/plotting/slicer-plot.ipynb | 15 +++++++----- src/plopp/plotting/slicer.py | 20 ++++++++++++++++ src/plopp/widgets/slice.py | 32 ++++++++++++++++++------- tests/plotting/slicer_test.py | 42 ++++++++++++++++----------------- 4 files changed, 74 insertions(+), 35 deletions(-) diff --git a/docs/plotting/slicer-plot.ipynb b/docs/plotting/slicer-plot.ipynb index ce608737..b7aec11a 100644 --- a/docs/plotting/slicer-plot.ipynb +++ b/docs/plotting/slicer-plot.ipynb @@ -21,9 +21,11 @@ "outputs": [], "source": [ "%matplotlib widget\n", + "import scipp as sc\n", "import plopp as pp\n", + "from plopp.data.examples import clusters3d\n", "\n", - "da = pp.data.data3d()" + "da = clusters3d(nclusters=50, seed=12).hist(z=100, y=100, x=100)" ] }, { @@ -41,7 +43,7 @@ "metadata": {}, "outputs": [], "source": [ - "pp.slicer(da, keep=['x', 'y'])" + "pp.slicer(da, keep=['x', 'y'], logc=True)" ] }, { @@ -82,7 +84,7 @@ "metadata": {}, "outputs": [], "source": [ - "pp.slicer(da * da.coords['z'], keep=['x'], autoscale=False)" + "pp.slicer(da * sc.arange('z', 100), keep=['x'], autoscale=False)" ] }, { @@ -102,8 +104,9 @@ "\n", "\n", "\n", - "It is possible to display some animation controls (play button) next to the slider,\n", - "by using the `enable_player=True` option:" + "It is possible to display some animation controls (play button) next to the slider, by using the `enable_player=True` option.\n", + "\n", + "Note that we need to use a slider with a single handle via `slider_mode='single'` for this to work:" ] }, { @@ -113,7 +116,7 @@ "metadata": {}, "outputs": [], "source": [ - "pp.slicer(da, keep=['x', 'y'], enable_player=True)" + "pp.slicer(da, keep=['x', 'y'], logc=True, enable_player=True, slider_mode='single')" ] }, { diff --git a/src/plopp/plotting/slicer.py b/src/plopp/plotting/slicer.py index 25ebd20c..25fbc5a2 100644 --- a/src/plopp/plotting/slicer.py +++ b/src/plopp/plotting/slicer.py @@ -86,6 +86,11 @@ def __init__( ] = 'sum', **kwargs, ): + if enable_player and slider_mode != 'single': + raise ValueError( + 'The play button cannot be used with range sliders. Please set ' + 'slider_mode to "single" to use the play button.' + ) nodes = input_to_nodes( obj, processor=partial(preprocess, ignore_size=True, coords=coords), @@ -198,7 +203,11 @@ def slicer( mask_color: str | None = None, nan_color: str | None = None, norm: Literal['linear', 'log'] | None = None, + operation: Literal[ + 'sum', 'mean', 'max', 'min', 'nansum', 'nanmean', 'nanmax', 'nanmin' + ] = 'sum', scale: dict[str, str] | None = None, + slider_mode: Literal['single', 'range', 'combined'] = 'combined', title: str | None = None, vmax: sc.Variable | float | None = None, vmin: sc.Variable | float | None = None, @@ -265,10 +274,19 @@ def slicer( norm: Set to ``'log'`` for a logarithmic y-axis (1d plots) or logarithmic colorscale (2d plots). Legacy, prefer ``logy`` and ``logc`` instead. + operation: + The reduction operation to be applied to the sliced dimensions. This is ``sum`` + by default. scale: Change axis scaling between ``log`` and ``linear``. For example, specify ``scale={'time': 'log'}`` if you want log-scale for the ``time`` dimension. Legacy, prefer ``logx`` and ``logy`` instead. + slider_mode: + The type of slider to use for slicing. Can be either ``'single'`` for sliders + that select a single index along the sliced dimension, ``'range'`` for sliders + that select a range of indices along the sliced dimension, or ``'combined'`` for + sliders that allow both single index selection and range selection. + Defaults to ``'combined'``. title: The figure title. vmax: @@ -315,7 +333,9 @@ def slicer( mask_cmap=mask_cmap, nan_color=nan_color, norm=norm, + operation=operation, scale=scale, + slider_mode=slider_mode, title=title, vmax=vmax, vmin=vmin, diff --git a/src/plopp/widgets/slice.py b/src/plopp/widgets/slice.py index 6fd9653b..b77912bc 100644 --- a/src/plopp/widgets/slice.py +++ b/src/plopp/widgets/slice.py @@ -98,6 +98,9 @@ def _update_label(self, change: dict[str, Any]): ) else: self.label.value = value_to_string(self.coord[self.dim, inds].value) + # We assume here that if the _update_label function was called, it means + # that the bounds are valid and we this reset the color of the text input. + self.label.style.background = "none" self.label._lock = False def _move_slider_to_label(self, change: dict[str, Any]): @@ -105,16 +108,29 @@ def _move_slider_to_label(self, change: dict[str, Any]): Move the slider to the position corresponding to the coordinate value in the label, if possible. """ - if getattr(self.label, '_lock', False): + if self.label._lock: return + bad_color = "#F88379" # Find the index of the coordinate value closest to the one in the label. - vmin, vmax = change["new"].split(':') - vmin, vmax = float(vmin), float(vmax) - imin = np.argmin(np.abs(self.coord.values - vmin)) - imax = np.argmin(np.abs(self.coord.values - vmax)) - # self.label._lock = True - self.slider.value = (imin, imax) - # self.label._lock = False + if ':' not in change["new"]: + value = float(change["new"]) + if value < self.coord.values[0] or value > self.coord.values[-1]: + self.label.style.background = bad_color + return + self.slider.value = np.argmin(np.abs(self.coord.values - value)) + else: + vmin, vmax = tuple(float(x) for x in change["new"].split(':')) + if ( + (vmin > vmax) + or (vmin < self.coord.values[0]) + or (vmax > self.coord.values[-1]) + ): + self.label.style.background = bad_color + return + self.slider.value = tuple( + np.argmin(np.abs(self.coord.values - float(x))) + for x in change["new"].split(':') + ) @property def value(self) -> int | tuple[int, int]: diff --git a/tests/plotting/slicer_test.py b/tests/plotting/slicer_test.py index 95e303a4..4c0b24aa 100644 --- a/tests/plotting/slicer_test.py +++ b/tests/plotting/slicer_test.py @@ -13,7 +13,7 @@ class TestSlicer1d: def test_creation_keep_one_dim(self): da = data_array(ndim=3) - sl = Slicer(da, keep=['xx']) + sl = Slicer(da, keep=['xx'], slider_mode='single') assert sl.slider.value == {'zz': 14, 'yy': 19} assert sl.slider.controls['yy'].slider.max == da.sizes['yy'] - 1 assert sl.slider.controls['zz'].slider.max == da.sizes['zz'] - 1 @@ -21,7 +21,7 @@ def test_creation_keep_one_dim(self): def test_update_keep_one_dim(self): da = data_array(ndim=3) - sl = Slicer(da, keep=['xx']) + sl = Slicer(da, keep=['xx'], slider_mode='single') assert sl.slider.value == {'zz': 14, 'yy': 19} assert sc.identical(sl.slice_nodes[0].request_data(), da['yy', 19]['zz', 14]) sl.slider.controls['yy'].value = 5 @@ -33,7 +33,7 @@ def test_update_keep_one_dim(self): def test_with_dataset(self): ds = dataset(ndim=2) - sl = Slicer(ds, keep=['xx']) + sl = Slicer(ds, keep=['xx'], slider_mode='single') nodes = list(sl.figure.graph_nodes.values()) sl.slider.controls['yy'].value = 5 assert sc.identical(nodes[0].request_data(), ds['a']['yy', 5]) @@ -42,7 +42,7 @@ def test_with_dataset(self): def test_with_data_group(self): da = data_array(ndim=2) dg = sc.DataGroup(a=da, b=da * 2.5) - sl = Slicer(dg, keep=['xx']) + sl = Slicer(dg, keep=['xx'], slider_mode='single') nodes = list(sl.figure.graph_nodes.values()) sl.slider.controls['yy'].value = 5 assert sc.identical(nodes[0].request_data(), dg['a']['yy', 5]) @@ -51,7 +51,7 @@ def test_with_data_group(self): def test_with_dict_of_data_arrays(self): a = data_array(ndim=2) b = data_array(ndim=2) * 2.5 - sl = Slicer({'a': a, 'b': b}, keep=['xx']) + sl = Slicer({'a': a, 'b': b}, keep=['xx'], slider_mode='single') nodes = list(sl.figure.graph_nodes.values()) sl.slider.controls['yy'].value = 5 assert sc.identical(nodes[0].request_data(), a['yy', 5]) @@ -61,12 +61,12 @@ def test_with_data_arrays_same_shape_different_coord(self): a = data_array(ndim=2) b = data_array(ndim=2) * 2.5 b.coords['xx'] *= 1.5 - Slicer({'a': a, 'b': b}, keep=['xx']) + Slicer({'a': a, 'b': b}, keep=['xx'], slider_mode='single') def test_with_data_arrays_different_shape_along_keep_dim(self): a = data_array(ndim=2) b = data_array(ndim=2) * 2.5 - Slicer({'a': a, 'b': b['xx', :10]}, keep=['xx']) + Slicer({'a': a, 'b': b['xx', :10]}, keep=['xx'], slider_mode='single') def test_with_data_arrays_different_shape_along_non_keep_dim_raises(self): a = data_array(ndim=2) @@ -74,23 +74,23 @@ def test_with_data_arrays_different_shape_along_non_keep_dim_raises(self): with pytest.raises( ValueError, match='Slicer plot: all inputs must have the same sizes' ): - Slicer({'a': a, 'b': b['yy', :10]}, keep=['xx']) + Slicer({'a': a, 'b': b['yy', :10]}, keep=['xx'], slider_mode='single') def test_raises_ValueError_when_given_binned_data(self): da = sc.data.table_xyz(100).bin(x=10, y=20) with pytest.raises(ValueError, match='Cannot plot binned data'): - Slicer(da, keep=['xx']) + Slicer(da, keep=['xx'], slider_mode='single') def test_from_node_1d(self): da = data_array(ndim=2) - Slicer(Node(da)) + Slicer(Node(da), slider_mode='single') def test_mixing_raw_data_and_nodes(self): a = data_array(ndim=2) b = 6.7 * a - Slicer({'a': Node(a), 'b': Node(b)}) - Slicer({'a': a, 'b': Node(b)}) - Slicer({'a': Node(a), 'b': b}) + Slicer({'a': Node(a), 'b': Node(b)}, slider_mode='single') + Slicer({'a': a, 'b': Node(b)}, slider_mode='single') + Slicer({'a': Node(a), 'b': b}, slider_mode='single') def test_raises_when_requested_keep_dims_do_not_exist(self): da = data_array(ndim=3) @@ -98,7 +98,7 @@ def test_raises_when_requested_keep_dims_do_not_exist(self): ValueError, match='Slicer plot: one or more of the requested dims to be kept', ): - Slicer(da, keep=['time']) + Slicer(da, keep=['time'], slider_mode='single') def test_raises_when_number_of_keep_dims_requested_is_bad(self): da = data_array(ndim=4) @@ -106,25 +106,25 @@ def test_raises_when_number_of_keep_dims_requested_is_bad(self): ValueError, match='Slicer plot: the number of dims to be kept must be 1 or 2', ): - Slicer(da, keep=['xx', 'yy', 'zz']) + Slicer(da, keep=['xx', 'yy', 'zz'], slider_mode='single') with pytest.raises( ValueError, match='Slicer plot: the list of dims to be kept cannot be empty' ): - Slicer(da, keep=[]) + Slicer(da, keep=[], slider_mode='single') @pytest.mark.usefixtures("_parametrize_interactive_2d_backends") class TestSlicer2d: def test_creation_keep_two_dims(self): da = data_array(ndim=3) - sl = Slicer(da, keep=['xx', 'yy']) + sl = Slicer(da, keep=['xx', 'yy'], slider_mode='single') assert sl.slider.value == {'zz': 14} assert sl.slider.controls['zz'].slider.max == da.sizes['zz'] - 1 assert sc.identical(sl.slice_nodes[0].request_data(), da['zz', 14]) def test_update_keep_two_dims(self): da = data_array(ndim=3) - sl = Slicer(da, keep=['xx', 'yy']) + sl = Slicer(da, keep=['xx', 'yy'], slider_mode='single') assert sl.slider.value == {'zz': 14} assert sc.identical(sl.slice_nodes[0].request_data(), da['zz', 14]) sl.slider.controls['zz'].value = 5 @@ -133,7 +133,7 @@ def test_update_keep_two_dims(self): def test_from_node_2d(self): da = data_array(ndim=3) - Slicer(Node(da)) + Slicer(Node(da), slider_mode='single') def test_update_triggers_autoscale(self): da = sc.DataArray( @@ -144,7 +144,7 @@ def test_update_triggers_autoscale(self): # `autoscale=True` should be the default, but there is no guarantee that it will # not change in the future, so we explicitly set it here to make the test # robust. - sl = Slicer(da, keep=['y', 'x'], autoscale=True) + sl = Slicer(da, keep=['y', 'x'], autoscale=True, slider_mode='single') cm = sl.figure.view.colormapper # Colormapper fits to the values in the initial slice (slider value in the # middle) @@ -161,7 +161,7 @@ def test_no_autoscale(self): dim='x', sizes={'z': 20, 'y': 10, 'x': 5} ) ) - sl = Slicer(da, keep=['y', 'x'], autoscale=False) + sl = Slicer(da, keep=['y', 'x'], autoscale=False, slider_mode='single') cm = sl.figure.view.colormapper # Colormapper fits to the values in the initial slice (slider value in the # middle) From 4f6202aa0da083b4f43a2e6d919721138b12a6ba Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Fri, 27 Feb 2026 12:17:49 +0100 Subject: [PATCH 08/32] use bounded text boxes for bounds --- src/plopp/plotting/slicer.py | 5 +- src/plopp/widgets/slice.py | 122 +++++++++++++++++++++-------------- 2 files changed, 78 insertions(+), 49 deletions(-) diff --git a/src/plopp/plotting/slicer.py b/src/plopp/plotting/slicer.py index 25fbc5a2..dd7bba0b 100644 --- a/src/plopp/plotting/slicer.py +++ b/src/plopp/plotting/slicer.py @@ -49,8 +49,9 @@ class Slicer: Class that slices out dimensions from the data and displays the resulting data as either a 1D line or a 2D image. - This class exists so that it can be re-used by other plotting functions that want - to offer slicing functionality, such as the :func:`superplot` function. + This class exists both for simplifying unit tests and for re-use by other plotting + functions that want to offer slicing functionality, + such as the :func:`superplot` function. Parameters ---------- diff --git a/src/plopp/widgets/slice.py b/src/plopp/widgets/slice.py index b77912bc..bbcf6ebb 100644 --- a/src/plopp/widgets/slice.py +++ b/src/plopp/widgets/slice.py @@ -22,22 +22,27 @@ def __init__( slider_constr: type[ipw.Widget], enable_player: bool = False, ): - if enable_player and not issubclass(slider_constr, ipw.IntSlider): + self._kind = "single" if issubclass(slider_constr, ipw.IntSlider) else "range" + if enable_player and (self._kind != "single"): raise TypeError( "Player can only be enabled for IntSlider, not for IntRangeSlider." ) + + self.dim = dim + self.coord = coord + self._is_bin_edges = self.coord.sizes[dim] > size + self.coord_min = self.coord.values[0] + self.coord_max = self.coord.values[-1] + widget_args = { 'step': 1, 'min': 0, 'max': size - 1, - 'value': (size - 1) // 2 - if slider_constr is ipw.IntSlider - else (0, size - 1), + 'value': (size - 1) // 2 if self._kind == "single" else (0, size - 1), 'continuous_update': True, 'readout': False, 'layout': {"width": "25em", "margin": "0px 0px 0px 10px"}, } - self._is_bin_edges = coord.sizes[dim] > size self.dim_label = ipw.Label(value=dim) self.slider = slider_constr(**widget_args) self.continuous_update = ipw.Checkbox( @@ -46,8 +51,33 @@ def __init__( indent=False, layout={"width": "1.52em"}, ) - self.label = ipw.Text(continuous_update=False, layout={"width": "10em"}) - self.unit = ipw.Label("" if coord.unit is None else f" [{coord.unit}]") + + step = (self.coord_max - self.coord_min) / 999 + self.bound_min = ipw.BoundedFloatText( + continuous_update=False, + min=self.coord_min, + max=self.coord_max, + step=step, + value=self.coord_min, + layout={"width": "6em"}, + ) + if self._is_bin_edges or (self._kind == "range"): + self.bound_max = ipw.BoundedFloatText( + continuous_update=False, + min=self.coord_min, + max=self.coord_max, + step=step, + value=self.coord_max, + layout={"width": "6em"}, + ) + ipw.jslink((self.bound_min, 'max'), (self.bound_max, 'value')) + ipw.jslink((self.bound_max, 'min'), (self.bound_min, 'value')) + else: + self.bound_max = None + + self.unit = ipw.Label( + "" if self.coord.unit is None else f" [{self.coord.unit}]" + ) ipw.jslink( (self.continuous_update, 'value'), (self.slider, 'continuous_update') ) @@ -56,9 +86,12 @@ def __init__( self.dim_label, self.slider, self.continuous_update, - self.label, - self.unit, + self.bound_min, ] + if self.bound_max is not None: + children.append(ipw.Label(value=":")) + children.append(self.bound_max) + children.append(self.unit) if enable_player: self.player = ipw.Play( value=self.slider.value, @@ -71,11 +104,14 @@ def __init__( ipw.jslink((self.player, 'value'), (self.slider, 'value')) children.insert(0, self.player) - self.dim = dim - self.coord = coord + # self.dim = dim + # self.coord = coord + self._bounds_lock = False self._update_label({"new": self.slider.value}) self.slider.observe(self._update_label, names='value') - self.label.observe(self._move_slider_to_label, names='value') + self.bound_min.observe(self._move_slider_to_label, names='value') + if self.bound_max is not None: + self.bound_max.observe(self._move_slider_to_label, names='value') super().__init__(children) @@ -91,46 +127,44 @@ def _update_label(self, change: dict[str, Any]): else: inds = (inds, inds + 1) # self.label.value = coord_element_to_string(self.coord[self.dim, inds]) - self.label._lock = True + self._bounds_lock = True + # if self.bound_max is not None: if isinstance(inds, tuple): - self.label.value = ' : '.join( - [value_to_string(v) for v in self.coord[self.dim, inds].values] - ) + # self.bound_max._lock = True + # self.label.value = ' : '.join( + # [value_to_string(v) for v in self.coord[self.dim, inds].values] + # ) + self.bound_min.value, self.bound_max.value = [ + round(v, 3) for v in self.coord[self.dim, inds].values + ] + # self.bound_max._lock = False else: - self.label.value = value_to_string(self.coord[self.dim, inds].value) - # We assume here that if the _update_label function was called, it means - # that the bounds are valid and we this reset the color of the text input. - self.label.style.background = "none" - self.label._lock = False + self.bound_min.value = round(self.coord[self.dim, inds].value, 3) + self._bounds_lock = False def _move_slider_to_label(self, change: dict[str, Any]): """ Move the slider to the position corresponding to the coordinate value in the label, if possible. """ - if self.label._lock: + if self._bounds_lock: return - bad_color = "#F88379" # Find the index of the coordinate value closest to the one in the label. - if ':' not in change["new"]: - value = float(change["new"]) - if value < self.coord.values[0] or value > self.coord.values[-1]: - self.label.style.background = bad_color - return - self.slider.value = np.argmin(np.abs(self.coord.values - value)) + if self.bound_max is None: + # value = float(change["new"]) + self.slider.value = np.argmin(np.abs(self.coord.values - change["new"])) else: - vmin, vmax = tuple(float(x) for x in change["new"].split(':')) - if ( - (vmin > vmax) - or (vmin < self.coord.values[0]) - or (vmax > self.coord.values[-1]) - ): - self.label.style.background = bad_color - return - self.slider.value = tuple( - np.argmin(np.abs(self.coord.values - float(x))) - for x in change["new"].split(':') + vmin, vmax = self.bound_min.value, self.bound_max.value + bounds = tuple( + np.argmin(np.abs(self.coord.values - x)) for x in (vmin, vmax) ) + if self._kind == "range": + self.slider.value = bounds + else: + # Here it means that the user has entered a range in the label, + # but the slider is a single slider. We move the slider to the middle + # of the range. + self.slider.value = int(0.5 * sum(bounds)) @property def value(self) -> int | tuple[int, int]: @@ -162,10 +196,7 @@ def __init__( self.range_slicer = DimSlicer( dim=dim, size=size, coord=coord, slider_constr=ipw.IntRangeSlider ) - self.range_slicer.slider.value = ( - 0, - size - 1, - ) # + int(self.range_slicer._is_bin_edges) + # self.range_slicer.slider.value = (0, size - 1) self.range_slicer.slider.layout = {"width": width} self.int_slicer.slider.observe(self.move_range, names='value') @@ -175,11 +206,8 @@ def __init__( tooltips=['Range slider', 'Single handle slider'], style={"button_width": "3.2em"}, ) - self.slider_toggler.observe(self.toggle_slider_mode, names='value') - # children = [self.slider_toggler, self.range_slicer] - super().__init__([self.slider_toggler, self.range_slicer]) def move_range(self, change): From 7c74a9e32868a04047a0c5c580c323543a5c298e Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Fri, 27 Feb 2026 12:45:05 +0100 Subject: [PATCH 09/32] issues with linking bounds --- src/plopp/widgets/slice.py | 49 ++++++++++++++++++++++++-------------- 1 file changed, 31 insertions(+), 18 deletions(-) diff --git a/src/plopp/widgets/slice.py b/src/plopp/widgets/slice.py index bbcf6ebb..146147e4 100644 --- a/src/plopp/widgets/slice.py +++ b/src/plopp/widgets/slice.py @@ -20,6 +20,7 @@ def __init__( size: int, coord: sc.Variable, slider_constr: type[ipw.Widget], + value: int | tuple[int, int] | None = None, enable_player: bool = False, ): self._kind = "single" if issubclass(slider_constr, ipw.IntSlider) else "range" @@ -34,17 +35,19 @@ def __init__( self.coord_min = self.coord.values[0] self.coord_max = self.coord.values[-1] - widget_args = { - 'step': 1, - 'min': 0, - 'max': size - 1, - 'value': (size - 1) // 2 if self._kind == "single" else (0, size - 1), - 'continuous_update': True, - 'readout': False, - 'layout': {"width": "25em", "margin": "0px 0px 0px 10px"}, - } + if value is None: + value = (size - 1) // 2 if self._kind == "single" else (0, size - 1) + self.dim_label = ipw.Label(value=dim) - self.slider = slider_constr(**widget_args) + self.slider = slider_constr( + step=1, + min=0, + max=size - 1, + value=value, + continuous_update=True, + readout=False, + layout={"width": "25em", "margin": "0px 0px 0px 10px"}, + ) self.continuous_update = ipw.Checkbox( value=True, tooltip="Continuous update", @@ -70,8 +73,8 @@ def __init__( value=self.coord_max, layout={"width": "6em"}, ) - ipw.jslink((self.bound_min, 'max'), (self.bound_max, 'value')) - ipw.jslink((self.bound_max, 'min'), (self.bound_min, 'value')) + # ipw.jsdlink((self.bound_min, 'max'), (self.bound_max, 'value')) + # ipw.jsdlink((self.bound_max, 'min'), (self.bound_min, 'value')) else: self.bound_max = None @@ -120,6 +123,7 @@ def _update_label(self, change: dict[str, Any]): Update the readout label with the coordinate value, instead of the integer readout index. """ + # print("update label", self._kind, change["new"]) inds = change["new"] if self._is_bin_edges: if isinstance(inds, tuple): @@ -134,9 +138,20 @@ def _update_label(self, change: dict[str, Any]): # self.label.value = ' : '.join( # [value_to_string(v) for v in self.coord[self.dim, inds].values] # ) - self.bound_min.value, self.bound_max.value = [ - round(v, 3) for v in self.coord[self.dim, inds].values - ] + print("update bounds", self.coord[self.dim, inds].values, inds) + print([round(v, 3) for v in self.coord[self.dim, inds].values]) + new_bounds = tuple(round(v, 3) for v in self.coord[self.dim, inds].values) + + # ipw.jslink((self.bound_min, 'max'), (self.bound_max, 'value')) + # ipw.jslink((self.bound_max, 'min'), (self.bound_min, 'value')) + print("current bounds", self.bound_min.value, self.bound_max.value) + + if new_bounds[0] > self.bound_max.value: + self.bound_max.value = new_bounds[1] + self.bound_min.value = new_bounds[0] + else: + self.bound_min.value = new_bounds[0] + self.bound_max.value = new_bounds[1] # self.bound_max._lock = False else: self.bound_min.value = round(self.coord[self.dim, inds].value, 3) @@ -188,15 +203,13 @@ def __init__( **ignored, ): self.int_slicer = DimSlicer( - dim=dim, size=size, coord=coord, slider_constr=ipw.IntSlider + dim=dim, size=size, coord=coord, slider_constr=ipw.IntSlider, value=0 ) - self.int_slicer.slider.value = self.int_slicer.slider.min self.int_slicer.slider.layout = {"width": width} self.range_slicer = DimSlicer( dim=dim, size=size, coord=coord, slider_constr=ipw.IntRangeSlider ) - # self.range_slicer.slider.value = (0, size - 1) self.range_slicer.slider.layout = {"width": width} self.int_slicer.slider.observe(self.move_range, names='value') From c77a4320b2c2873c5f82a870d4936f197da21e81 Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Fri, 27 Feb 2026 14:30:17 +0100 Subject: [PATCH 10/32] use link instead of jslink to ensure everything is properly in sync on the python side --- src/plopp/widgets/slice.py | 23 ++--------------------- 1 file changed, 2 insertions(+), 21 deletions(-) diff --git a/src/plopp/widgets/slice.py b/src/plopp/widgets/slice.py index 146147e4..b89391af 100644 --- a/src/plopp/widgets/slice.py +++ b/src/plopp/widgets/slice.py @@ -9,7 +9,6 @@ import scipp as sc from ..core import node -from ..core.utils import value_to_string from .box import VBar @@ -73,8 +72,8 @@ def __init__( value=self.coord_max, layout={"width": "6em"}, ) - # ipw.jsdlink((self.bound_min, 'max'), (self.bound_max, 'value')) - # ipw.jsdlink((self.bound_max, 'min'), (self.bound_min, 'value')) + ipw.link((self.bound_min, 'max'), (self.bound_max, 'value')) + ipw.link((self.bound_max, 'min'), (self.bound_min, 'value')) else: self.bound_max = None @@ -107,8 +106,6 @@ def __init__( ipw.jslink((self.player, 'value'), (self.slider, 'value')) children.insert(0, self.player) - # self.dim = dim - # self.coord = coord self._bounds_lock = False self._update_label({"new": self.slider.value}) self.slider.observe(self._update_label, names='value') @@ -123,36 +120,21 @@ def _update_label(self, change: dict[str, Any]): Update the readout label with the coordinate value, instead of the integer readout index. """ - # print("update label", self._kind, change["new"]) inds = change["new"] if self._is_bin_edges: if isinstance(inds, tuple): inds = (inds[0], inds[1] + 1) else: inds = (inds, inds + 1) - # self.label.value = coord_element_to_string(self.coord[self.dim, inds]) self._bounds_lock = True - # if self.bound_max is not None: if isinstance(inds, tuple): - # self.bound_max._lock = True - # self.label.value = ' : '.join( - # [value_to_string(v) for v in self.coord[self.dim, inds].values] - # ) - print("update bounds", self.coord[self.dim, inds].values, inds) - print([round(v, 3) for v in self.coord[self.dim, inds].values]) new_bounds = tuple(round(v, 3) for v in self.coord[self.dim, inds].values) - - # ipw.jslink((self.bound_min, 'max'), (self.bound_max, 'value')) - # ipw.jslink((self.bound_max, 'min'), (self.bound_min, 'value')) - print("current bounds", self.bound_min.value, self.bound_max.value) - if new_bounds[0] > self.bound_max.value: self.bound_max.value = new_bounds[1] self.bound_min.value = new_bounds[0] else: self.bound_min.value = new_bounds[0] self.bound_max.value = new_bounds[1] - # self.bound_max._lock = False else: self.bound_min.value = round(self.coord[self.dim, inds].value, 3) self._bounds_lock = False @@ -166,7 +148,6 @@ def _move_slider_to_label(self, change: dict[str, Any]): return # Find the index of the coordinate value closest to the one in the label. if self.bound_max is None: - # value = float(change["new"]) self.slider.value = np.argmin(np.abs(self.coord.values - change["new"])) else: vmin, vmax = self.bound_min.value, self.bound_max.value From 6ac34fc4e2c1d75409d70f12e0e56b5be2865cbb Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Fri, 27 Feb 2026 14:35:18 +0100 Subject: [PATCH 11/32] update docs thumbnail --- docs/_static/plotting/slicer-plot.png | Bin 55181 -> 52606 bytes docs/plotting/slicer-plot.ipynb | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/_static/plotting/slicer-plot.png b/docs/_static/plotting/slicer-plot.png index 272be162aaf914bc17c33aec91477744608f620d..a11d2ff8c24df5522180a12911263baa7e13abb2 100644 GIT binary patch literal 52606 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 Date: Fri, 27 Feb 2026 15:18:12 +0100 Subject: [PATCH 12/32] add tests --- src/plopp/plotting/slicer.py | 28 +++-- src/plopp/widgets/slice.py | 32 +++-- tests/plotting/slicer_test.py | 231 ++++++++++++++++++++++++++++------ 3 files changed, 233 insertions(+), 58 deletions(-) diff --git a/src/plopp/plotting/slicer.py b/src/plopp/plotting/slicer.py index dd7bba0b..399124d7 100644 --- a/src/plopp/plotting/slicer.py +++ b/src/plopp/plotting/slicer.py @@ -67,6 +67,8 @@ class Slicer: be a list of dims. If no dims are provided, the last dim will be kept in the case of a 2-dimensional input, while the last two dims will be kept in the case of higher dimensional inputs. + mode: + The mode of the slicer. This can be 'single', 'range', or 'combined'. operation: The reduction operation to be applied to the sliced dimensions. This is ``sum`` by default. @@ -81,16 +83,16 @@ def __init__( coords: list[str] | None = None, enable_player: bool = False, keep: list[str] | None = None, - slider_mode: Literal['single', 'range', 'combined'] = 'combined', + mode: Literal['single', 'range', 'combined'] = 'combined', operation: Literal[ 'sum', 'mean', 'max', 'min', 'nansum', 'nanmean', 'nanmax', 'nanmin' ] = 'sum', **kwargs, ): - if enable_player and slider_mode != 'single': + if enable_player and mode != 'single': raise ValueError( 'The play button cannot be used with range sliders. Please set ' - 'slider_mode to "single" to use the play button.' + 'mode to "single" to use the play button.' ) nodes = input_to_nodes( obj, @@ -135,7 +137,7 @@ def __init__( other_dims = [dim for dim in dims if dim not in keep] - match slider_mode: + match mode: case 'single': slicer_constr = SliceWidget case 'range': @@ -144,7 +146,7 @@ def __init__( slicer_constr = CombinedSliceWidget case _: raise ValueError( - f"Invalid slider_mode: {slider_mode}. Expected one of 'single', " + f"Invalid mode: {mode}. Expected one of 'single', " f"'range', or 'combined'." ) @@ -208,7 +210,7 @@ def slicer( 'sum', 'mean', 'max', 'min', 'nansum', 'nanmean', 'nanmax', 'nanmin' ] = 'sum', scale: dict[str, str] | None = None, - slider_mode: Literal['single', 'range', 'combined'] = 'combined', + mode: Literal['single', 'range', 'combined'] = 'combined', title: str | None = None, vmax: sc.Variable | float | None = None, vmin: sc.Variable | float | None = None, @@ -270,6 +272,12 @@ def slicer( Colormap to use for masks in 2d plots. mask_color: Color of masks. + mode: + The type of slider to use for slicing. Can be either ``'single'`` for sliders + that select a single index along the sliced dimension, ``'range'`` for sliders + that select a range of indices along the sliced dimension, or ``'combined'`` for + sliders that allow both single index selection and range selection. + Defaults to ``'combined'``. nan_color: Color to use for NaN values in 2d plots. norm: @@ -282,12 +290,6 @@ def slicer( Change axis scaling between ``log`` and ``linear``. For example, specify ``scale={'time': 'log'}`` if you want log-scale for the ``time`` dimension. Legacy, prefer ``logx`` and ``logy`` instead. - slider_mode: - The type of slider to use for slicing. Can be either ``'single'`` for sliders - that select a single index along the sliced dimension, ``'range'`` for sliders - that select a range of indices along the sliced dimension, or ``'combined'`` for - sliders that allow both single index selection and range selection. - Defaults to ``'combined'``. title: The figure title. vmax: @@ -332,11 +334,11 @@ def slicer( logy=logy, mask_color=mask_color, mask_cmap=mask_cmap, + mode=mode, nan_color=nan_color, norm=norm, operation=operation, scale=scale, - slider_mode=slider_mode, title=title, vmax=vmax, vmin=vmin, diff --git a/src/plopp/widgets/slice.py b/src/plopp/widgets/slice.py index b89391af..b968f9df 100644 --- a/src/plopp/widgets/slice.py +++ b/src/plopp/widgets/slice.py @@ -21,6 +21,7 @@ def __init__( slider_constr: type[ipw.Widget], value: int | tuple[int, int] | None = None, enable_player: bool = False, + width: str = "25em", ): self._kind = "single" if issubclass(slider_constr, ipw.IntSlider) else "range" if enable_player and (self._kind != "single"): @@ -37,7 +38,7 @@ def __init__( if value is None: value = (size - 1) // 2 if self._kind == "single" else (0, size - 1) - self.dim_label = ipw.Label(value=dim) + self.dim_label = ipw.Label(value=dim, layout={"margin": "0px 0px 0px 10px"}) self.slider = slider_constr( step=1, min=0, @@ -45,7 +46,7 @@ def __init__( value=value, continuous_update=True, readout=False, - layout={"width": "25em", "margin": "0px 0px 0px 10px"}, + layout={"width": width, "margin": "0px 10px 0px 10px"}, ) self.continuous_update = ipw.Checkbox( value=True, @@ -184,14 +185,17 @@ def __init__( **ignored, ): self.int_slicer = DimSlicer( - dim=dim, size=size, coord=coord, slider_constr=ipw.IntSlider, value=0 + dim=dim, + size=size, + coord=coord, + slider_constr=ipw.IntSlider, + value=0, + width=width, ) - self.int_slicer.slider.layout = {"width": width} self.range_slicer = DimSlicer( dim=dim, size=size, coord=coord, slider_constr=ipw.IntRangeSlider ) - self.range_slicer.slider.layout = {"width": width} self.int_slicer.slider.observe(self.move_range, names='value') @@ -240,6 +244,7 @@ def __init__( slider_constr: ipw.Widget, slicer_constr: type[DimSlicer] | type[CombinedSlicer], enable_player: bool = False, + width: str = "25em", ): if isinstance(dims, str): dims = [dims] @@ -259,6 +264,7 @@ def __init__( coord=coord, slider_constr=slider_constr, enable_player=enable_player, + width=width, ) self.controls[dim].slider.observe(self._on_subwidget_change, names='value') children.append(self.controls[dim]) @@ -291,12 +297,17 @@ def _on_subwidget_change(self, _=None): The dimensions to make sliders for. enable_player: Add a play button to animate the slider if True. Defaults to False. +width: + The width of the sliders. Defaults to "25em". .. versionadded:: 25.07.0 """ RangeSliceWidget = partial( - _BaseSliceWidget, slider_constr=ipw.IntRangeSlider, slicer_constr=DimSlicer + _BaseSliceWidget, + slider_constr=ipw.IntRangeSlider, + slicer_constr=DimSlicer, + enable_player=False, ) """ Widgets containing a range slider for each of the requested dimensions. @@ -312,10 +323,15 @@ def _on_subwidget_change(self, _=None): The input data array. dims: The dimensions to make sliders for. +width: + The width of the sliders. Defaults to "25em". """ CombinedSliceWidget = partial( - _BaseSliceWidget, slider_constr=None, slicer_constr=CombinedSlicer + _BaseSliceWidget, + slider_constr=None, + slicer_constr=CombinedSlicer, + enable_player=False, ) """ Widgets containing a combined slider (able to toggle between normal slider and range @@ -332,6 +348,8 @@ def _on_subwidget_change(self, _=None): The input data array. dims: The dimensions to make sliders for. +width: + The width of the sliders. Defaults to "25em". """ diff --git a/tests/plotting/slicer_test.py b/tests/plotting/slicer_test.py index 4c0b24aa..53abfe56 100644 --- a/tests/plotting/slicer_test.py +++ b/tests/plotting/slicer_test.py @@ -3,6 +3,7 @@ import pytest import scipp as sc +from scipp.testing import assert_allclose, assert_identical from plopp import Node from plopp.data.testing import data_array, dataset @@ -11,62 +12,132 @@ @pytest.mark.usefixtures("_parametrize_interactive_1d_backends") class TestSlicer1d: - def test_creation_keep_one_dim(self): + def test_creation_keep_one_dim_single_mode(self): da = data_array(ndim=3) - sl = Slicer(da, keep=['xx'], slider_mode='single') + sl = Slicer(da, keep=['xx'], mode='single') assert sl.slider.value == {'zz': 14, 'yy': 19} assert sl.slider.controls['yy'].slider.max == da.sizes['yy'] - 1 assert sl.slider.controls['zz'].slider.max == da.sizes['zz'] - 1 - assert sc.identical(sl.slice_nodes[0].request_data(), da['yy', 19]['zz', 14]) + assert_identical(sl.slice_nodes[0].request_data(), da['yy', 19]['zz', 14]) - def test_update_keep_one_dim(self): + def test_update_keep_one_dim_single_mode(self): da = data_array(ndim=3) - sl = Slicer(da, keep=['xx'], slider_mode='single') + sl = Slicer(da, keep=['xx'], mode='single') assert sl.slider.value == {'zz': 14, 'yy': 19} - assert sc.identical(sl.slice_nodes[0].request_data(), da['yy', 19]['zz', 14]) + assert_identical(sl.slice_nodes[0].request_data(), da['yy', 19]['zz', 14]) sl.slider.controls['yy'].value = 5 assert sl.slider.value == {'zz': 14, 'yy': 5} - assert sc.identical(sl.slice_nodes[0].request_data(), da['yy', 5]['zz', 14]) + assert_identical(sl.slice_nodes[0].request_data(), da['yy', 5]['zz', 14]) sl.slider.controls['zz'].value = 8 assert sl.slider.value == {'zz': 8, 'yy': 5} - assert sc.identical(sl.slice_nodes[0].request_data(), da['yy', 5]['zz', 8]) + assert_identical(sl.slice_nodes[0].request_data(), da['yy', 5]['zz', 8]) + + def test_creation_keep_one_dim_range_mode(self): + da = data_array(ndim=3) + sl = Slicer(da, keep=['xx'], mode='range') + assert sl.slider.value == {'zz': (0, 29), 'yy': (0, 39)} + assert sl.slider.controls['yy'].slider.max == da.sizes['yy'] - 1 + assert sl.slider.controls['zz'].slider.max == da.sizes['zz'] - 1 + assert_identical(sl.slice_nodes[0].request_data(), da['yy', 0:40]['zz', 0:30]) + assert_allclose( + sl.reduce_nodes[0].request_data(), + da['yy', 0:40]['zz', 0:30].sum(['yy', 'zz']), + ) + + def test_update_keep_one_dim_range_mode(self): + da = data_array(ndim=3) + sl = Slicer(da, keep=['xx'], mode='range') + assert sl.slider.value == {'zz': (0, 29), 'yy': (0, 39)} + assert_identical(sl.slice_nodes[0].request_data(), da['yy', 0:40]['zz', 0:30]) + sl.slider.controls['yy'].value = (5, 15) + assert sl.slider.value == {'zz': (0, 29), 'yy': (5, 15)} + assert_identical(sl.slice_nodes[0].request_data(), da['yy', 5:16]['zz', 0:30]) + assert_allclose( + sl.reduce_nodes[0].request_data(), + da['yy', 5:16]['zz', 0:30].sum(['yy', 'zz']), + ) + sl.slider.controls['zz'].value = (10, 20) + assert sl.slider.value == {'zz': (10, 20), 'yy': (5, 15)} + assert_identical(sl.slice_nodes[0].request_data(), da['yy', 5:16]['zz', 10:21]) + assert_allclose( + sl.reduce_nodes[0].request_data(), + da['yy', 5:16]['zz', 10:21].sum(['yy', 'zz']), + ) + + def test_creation_keep_one_dim_combined_mode(self): + da = data_array(ndim=3) + sl = Slicer(da, keep=['xx'], mode='combined') + assert sl.slider.value == {'zz': (0, 29), 'yy': (0, 39)} + assert sl.slider.controls['yy'].slider.max == da.sizes['yy'] - 1 + assert sl.slider.controls['zz'].slider.max == da.sizes['zz'] - 1 + assert_identical(sl.slice_nodes[0].request_data(), da['yy', 0:40]['zz', 0:30]) + assert_allclose( + sl.reduce_nodes[0].request_data(), + da['yy', 0:40]['zz', 0:30].sum(['yy', 'zz']), + ) + # now switch to single mode + sl.slider.controls['yy'].slider_toggler.value = "-o-" + sl.slider.controls['zz'].slider_toggler.value = "-o-" + assert sl.slider.value == {'zz': (14, 14), 'yy': (19, 19)} + assert_identical(sl.slice_nodes[0].request_data(), da['yy', 19:20]['zz', 14:15]) + + def test_update_keep_one_dim_combined_mode(self): + da = data_array(ndim=3) + sl = Slicer(da, keep=['xx'], mode='combined') + assert sl.slider.value == {'zz': (0, 29), 'yy': (0, 39)} + assert_identical(sl.slice_nodes[0].request_data(), da['yy', 0:40]['zz', 0:30]) + sl.slider.controls['yy'].value = (5, 15) + sl.slider.controls['zz'].value = (10, 20) + assert sl.slider.value == {'zz': (10, 20), 'yy': (5, 15)} + assert_identical(sl.slice_nodes[0].request_data(), da['yy', 5:16]['zz', 10:21]) + assert_allclose( + sl.reduce_nodes[0].request_data(), + da['yy', 5:16]['zz', 10:21].sum(['yy', 'zz']), + ) + # now switch to single mode + sl.slider.controls['yy'].slider_toggler.value = "-o-" + sl.slider.controls['zz'].slider_toggler.value = "-o-" + sl.slider.controls['yy'].value = (4, 4) + sl.slider.controls['zz'].value = (11, 11) + assert sl.slider.value == {'zz': (11, 11), 'yy': (4, 4)} + assert_identical(sl.slice_nodes[0].request_data(), da['yy', 4:5]['zz', 11:12]) def test_with_dataset(self): ds = dataset(ndim=2) - sl = Slicer(ds, keep=['xx'], slider_mode='single') + sl = Slicer(ds, keep=['xx'], mode='single') nodes = list(sl.figure.graph_nodes.values()) sl.slider.controls['yy'].value = 5 - assert sc.identical(nodes[0].request_data(), ds['a']['yy', 5]) - assert sc.identical(nodes[1].request_data(), ds['b']['yy', 5]) + assert_identical(nodes[0].request_data(), ds['a']['yy', 5]) + assert_identical(nodes[1].request_data(), ds['b']['yy', 5]) def test_with_data_group(self): da = data_array(ndim=2) dg = sc.DataGroup(a=da, b=da * 2.5) - sl = Slicer(dg, keep=['xx'], slider_mode='single') + sl = Slicer(dg, keep=['xx'], mode='single') nodes = list(sl.figure.graph_nodes.values()) sl.slider.controls['yy'].value = 5 - assert sc.identical(nodes[0].request_data(), dg['a']['yy', 5]) - assert sc.identical(nodes[1].request_data(), dg['b']['yy', 5]) + assert_identical(nodes[0].request_data(), dg['a']['yy', 5]) + assert_identical(nodes[1].request_data(), dg['b']['yy', 5]) def test_with_dict_of_data_arrays(self): a = data_array(ndim=2) b = data_array(ndim=2) * 2.5 - sl = Slicer({'a': a, 'b': b}, keep=['xx'], slider_mode='single') + sl = Slicer({'a': a, 'b': b}, keep=['xx'], mode='single') nodes = list(sl.figure.graph_nodes.values()) sl.slider.controls['yy'].value = 5 - assert sc.identical(nodes[0].request_data(), a['yy', 5]) - assert sc.identical(nodes[1].request_data(), b['yy', 5]) + assert_identical(nodes[0].request_data(), a['yy', 5]) + assert_identical(nodes[1].request_data(), b['yy', 5]) def test_with_data_arrays_same_shape_different_coord(self): a = data_array(ndim=2) b = data_array(ndim=2) * 2.5 b.coords['xx'] *= 1.5 - Slicer({'a': a, 'b': b}, keep=['xx'], slider_mode='single') + Slicer({'a': a, 'b': b}, keep=['xx'], mode='single') def test_with_data_arrays_different_shape_along_keep_dim(self): a = data_array(ndim=2) b = data_array(ndim=2) * 2.5 - Slicer({'a': a, 'b': b['xx', :10]}, keep=['xx'], slider_mode='single') + Slicer({'a': a, 'b': b['xx', :10]}, keep=['xx'], mode='single') def test_with_data_arrays_different_shape_along_non_keep_dim_raises(self): a = data_array(ndim=2) @@ -74,23 +145,23 @@ def test_with_data_arrays_different_shape_along_non_keep_dim_raises(self): with pytest.raises( ValueError, match='Slicer plot: all inputs must have the same sizes' ): - Slicer({'a': a, 'b': b['yy', :10]}, keep=['xx'], slider_mode='single') + Slicer({'a': a, 'b': b['yy', :10]}, keep=['xx'], mode='single') def test_raises_ValueError_when_given_binned_data(self): da = sc.data.table_xyz(100).bin(x=10, y=20) with pytest.raises(ValueError, match='Cannot plot binned data'): - Slicer(da, keep=['xx'], slider_mode='single') + Slicer(da, keep=['xx'], mode='single') def test_from_node_1d(self): da = data_array(ndim=2) - Slicer(Node(da), slider_mode='single') + Slicer(Node(da), mode='single') def test_mixing_raw_data_and_nodes(self): a = data_array(ndim=2) b = 6.7 * a - Slicer({'a': Node(a), 'b': Node(b)}, slider_mode='single') - Slicer({'a': a, 'b': Node(b)}, slider_mode='single') - Slicer({'a': Node(a), 'b': b}, slider_mode='single') + Slicer({'a': Node(a), 'b': Node(b)}, mode='single') + Slicer({'a': a, 'b': Node(b)}, mode='single') + Slicer({'a': Node(a), 'b': b}, mode='single') def test_raises_when_requested_keep_dims_do_not_exist(self): da = data_array(ndim=3) @@ -98,7 +169,7 @@ def test_raises_when_requested_keep_dims_do_not_exist(self): ValueError, match='Slicer plot: one or more of the requested dims to be kept', ): - Slicer(da, keep=['time'], slider_mode='single') + Slicer(da, keep=['time'], mode='single') def test_raises_when_number_of_keep_dims_requested_is_bad(self): da = data_array(ndim=4) @@ -106,34 +177,104 @@ def test_raises_when_number_of_keep_dims_requested_is_bad(self): ValueError, match='Slicer plot: the number of dims to be kept must be 1 or 2', ): - Slicer(da, keep=['xx', 'yy', 'zz'], slider_mode='single') + Slicer(da, keep=['xx', 'yy', 'zz'], mode='single') with pytest.raises( ValueError, match='Slicer plot: the list of dims to be kept cannot be empty' ): - Slicer(da, keep=[], slider_mode='single') + Slicer(da, keep=[], mode='single') + + def test_bounds_text_boxes(self): + da = data_array(ndim=3) + sl = Slicer(da, keep=['xx'], mode='range') + assert sl.slider.value == {'zz': (0, 29), 'yy': (0, 39)} + sl.slider.controls['yy'].bound_min.value = 12 + assert sl.slider.value == {'zz': (0, 29), 'yy': (12, 39)} + sl.slider.controls['yy'].bound_max.value = 20 + assert sl.slider.value == {'zz': (0, 29), 'yy': (12, 20)} + # Check that entered value snaps to nearest integer + sl.slider.controls['yy'].bound_min.value = 13.3 + assert sl.slider.value == {'zz': (0, 29), 'yy': (13, 20)} + sl.slider.controls['yy'].bound_max.value = 18.7 + assert sl.slider.value == {'zz': (0, 29), 'yy': (13, 19)} @pytest.mark.usefixtures("_parametrize_interactive_2d_backends") class TestSlicer2d: - def test_creation_keep_two_dims(self): + def test_creation_keep_two_dims_single_mode(self): da = data_array(ndim=3) - sl = Slicer(da, keep=['xx', 'yy'], slider_mode='single') + sl = Slicer(da, keep=['xx', 'yy'], mode='single') assert sl.slider.value == {'zz': 14} assert sl.slider.controls['zz'].slider.max == da.sizes['zz'] - 1 - assert sc.identical(sl.slice_nodes[0].request_data(), da['zz', 14]) + assert_identical(sl.slice_nodes[0].request_data(), da['zz', 14]) - def test_update_keep_two_dims(self): + def test_update_keep_two_dims_single_mode(self): da = data_array(ndim=3) - sl = Slicer(da, keep=['xx', 'yy'], slider_mode='single') + sl = Slicer(da, keep=['xx', 'yy'], mode='single') assert sl.slider.value == {'zz': 14} - assert sc.identical(sl.slice_nodes[0].request_data(), da['zz', 14]) + assert_identical(sl.slice_nodes[0].request_data(), da['zz', 14]) sl.slider.controls['zz'].value = 5 assert sl.slider.value == {'zz': 5} - assert sc.identical(sl.slice_nodes[0].request_data(), da['zz', 5]) + assert_identical(sl.slice_nodes[0].request_data(), da['zz', 5]) + + def test_creation_keep_two_dims_range_mode(self): + da = data_array(ndim=3) + sl = Slicer(da, keep=['xx', 'yy'], mode='range') + assert sl.slider.value == {'zz': (0, 29)} + assert sl.slider.controls['zz'].slider.max == da.sizes['zz'] - 1 + assert_identical(sl.slice_nodes[0].request_data(), da['zz', 0:30]) + assert_allclose( + sl.reduce_nodes[0].request_data(), + da['zz', 0:30].sum('zz'), + ) + + def test_update_keep_two_dims_range_mode(self): + da = data_array(ndim=3) + sl = Slicer(da, keep=['xx', 'yy'], mode='range') + assert sl.slider.value == {'zz': (0, 29)} + assert_identical(sl.slice_nodes[0].request_data(), da['zz', 0:30]) + sl.slider.controls['zz'].value = (5, 15) + assert sl.slider.value == {'zz': (5, 15)} + assert_identical(sl.slice_nodes[0].request_data(), da['zz', 5:16]) + assert_allclose( + sl.reduce_nodes[0].request_data(), + da['zz', 5:16].sum('zz'), + ) + + def test_creation_keep_two_dims_combined_mode(self): + da = data_array(ndim=3) + sl = Slicer(da, keep=['xx', 'yy'], mode='combined') + assert sl.slider.value == {'zz': (0, 29)} + assert sl.slider.controls['zz'].slider.max == da.sizes['zz'] - 1 + assert_identical(sl.slice_nodes[0].request_data(), da['zz', 0:30]) + assert_allclose( + sl.reduce_nodes[0].request_data(), + da['zz', 0:30].sum('zz'), + ) + # now switch to single mode + sl.slider.controls['zz'].slider_toggler.value = "-o-" + assert sl.slider.value == {'zz': (14, 14)} + assert_identical(sl.slice_nodes[0].request_data(), da['zz', 14:15]) + + def test_update_keep_two_dims_combined_mode(self): + da = data_array(ndim=3) + sl = Slicer(da, keep=['xx', 'yy'], mode='combined') + assert sl.slider.value == {'zz': (0, 29)} + assert_identical(sl.slice_nodes[0].request_data(), da['zz', 0:30]) + sl.slider.controls['zz'].value = (5, 15) + assert sl.slider.value == {'zz': (5, 15)} + assert_identical(sl.slice_nodes[0].request_data(), da['zz', 5:16]) + assert_allclose( + sl.reduce_nodes[0].request_data(), + da['zz', 5:16].sum('zz'), + ) + # now switch to single mode + sl.slider.controls['zz'].slider_toggler.value = "-o-" + assert sl.slider.value == {'zz': (10, 10)} + assert_identical(sl.slice_nodes[0].request_data(), da['zz', 10:11]) def test_from_node_2d(self): da = data_array(ndim=3) - Slicer(Node(da), slider_mode='single') + Slicer(Node(da), mode='single') def test_update_triggers_autoscale(self): da = sc.DataArray( @@ -144,7 +285,7 @@ def test_update_triggers_autoscale(self): # `autoscale=True` should be the default, but there is no guarantee that it will # not change in the future, so we explicitly set it here to make the test # robust. - sl = Slicer(da, keep=['y', 'x'], autoscale=True, slider_mode='single') + sl = Slicer(da, keep=['y', 'x'], autoscale=True, mode='single') cm = sl.figure.view.colormapper # Colormapper fits to the values in the initial slice (slider value in the # middle) @@ -161,7 +302,7 @@ def test_no_autoscale(self): dim='x', sizes={'z': 20, 'y': 10, 'x': 5} ) ) - sl = Slicer(da, keep=['y', 'x'], autoscale=False, slider_mode='single') + sl = Slicer(da, keep=['y', 'x'], autoscale=False, mode='single') cm = sl.figure.view.colormapper # Colormapper fits to the values in the initial slice (slider value in the # middle) @@ -171,3 +312,17 @@ def test_no_autoscale(self): # Colormapper range does not change assert cm.vmin == 5 * 10 * 9 assert cm.vmax == 5 * 10 * 10 - 1 + + def test_bounds_text_boxes(self): + da = data_array(ndim=3) + sl = Slicer(da, keep=['xx', 'yy'], mode='range') + assert sl.slider.value == {'zz': (0, 29)} + sl.slider.controls['zz'].bound_min.value = 12 + assert sl.slider.value == {'zz': (12, 29)} + sl.slider.controls['zz'].bound_max.value = 20 + assert sl.slider.value == {'zz': (12, 20)} + # Check that entered value snaps to nearest integer + sl.slider.controls['zz'].bound_min.value = 13.3 + assert sl.slider.value == {'zz': (13, 20)} + sl.slider.controls['zz'].bound_max.value = 18.7 + assert sl.slider.value == {'zz': (13, 19)} From a5e0431abbf47409403c5a34eccd69ab4c187445 Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Fri, 27 Feb 2026 15:18:47 +0100 Subject: [PATCH 13/32] spelling --- src/plopp/plotting/slicer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plopp/plotting/slicer.py b/src/plopp/plotting/slicer.py index 399124d7..f334245b 100644 --- a/src/plopp/plotting/slicer.py +++ b/src/plopp/plotting/slicer.py @@ -49,7 +49,7 @@ class Slicer: Class that slices out dimensions from the data and displays the resulting data as either a 1D line or a 2D image. - This class exists both for simplifying unit tests and for re-use by other plotting + This class exists both for simplifying unit tests and for reuse by other plotting functions that want to offer slicing functionality, such as the :func:`superplot` function. From cb8cabd65dbc7a77f05bc51d325a8768729f021f Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Fri, 27 Feb 2026 17:47:08 +0100 Subject: [PATCH 14/32] properly use value widgets --- src/plopp/widgets/slice.py | 328 +++++++++++++++++++++++++++++++------ 1 file changed, 279 insertions(+), 49 deletions(-) diff --git a/src/plopp/widgets/slice.py b/src/plopp/widgets/slice.py index b968f9df..f87189f6 100644 --- a/src/plopp/widgets/slice.py +++ b/src/plopp/widgets/slice.py @@ -2,16 +2,235 @@ # Copyright (c) 2023 Scipp contributors (https://github.com/scipp) from functools import partial -from typing import Any +# from typing import Any import ipywidgets as ipw import numpy as np import scipp as sc +# def _round_float(x: float, prec=3) -> float: +# try: +# return round(x, prec) +# except TypeError: +# return x +from traitlets import Any, Tuple + from ..core import node from .box import VBar +class BoundWidget(ipw.HBox, ipw.ValueWidget): + value = Any().tag(sync=True) + + def __init__(self, coord: sc.Variable, value: float): + self._coord = coord + self._lock = False + coord_min, coord_max = self._coord.values[0], self._coord.values[-1] + if self._coord.dtype != sc.DType.datetime64: + self._widget = ipw.BoundedFloatText( + continuous_update=False, + min=coord_min, + max=coord_max, + step=(coord_max - coord_min) / 999, + value=value, + layout={"width": "6em"}, + ) + self._is_float = True + else: + self._widget = ipw.Text( + continuous_update=False, value=str(value), layout={"width": "10em"} + ) + self._is_float = False + + # observe user edits + self._widget.observe(self._on_child_change, names="value") + # observe external value changes + self.observe(self._on_value_change, names="value") + + super().__init__([self._widget]) + self.value = self._widget.value + + def _on_child_change(self, _): + if self._lock: + return + + self._lock = True + self.value = float(self._widget.value) + self._lock = False + + def _on_value_change(self, change): + if self._lock: + return + + self._lock = True + if self._is_float: + self._widget.value = round(change["new"], 3) + else: + self._widget.value = str(change["new"]) + self._lock = False + + # def _on_subwidget_change(self, _=None): + # """ """ + # if self._is_float: + # self.value = round(value, 3) + # else: + # self._widget.value = str(value) + # self.value = {dim: slicer.value for dim, slicer in self.controls.items()} + + # @property + # def value(self) -> float: + # return float(self._widget.value) + + # @value.setter + # def value(self, value: float): + # if self._is_float: + # self._widget.value = round(value, 3) + # else: + # self._widget.value = str(value) + + def get_closest_index(self) -> int: + """ + Get the index of the coordinate value closest to the one in the widget. + """ + # if self._is_float: + # value = self.value + # else: + # value = sc.scalar(self._widget.value, dtype=self._coord.dtype) + return np.argmin(np.abs(self._coord.values - self.value)) + + +# def _set_bound_widget_value(widget: ipw.Widget, value: float): +# if isinstance(widget, ipw.BoundedFloatText): +# widget.value = value +# elif isinstance(widget, ipw.Text): +# widget.value = str(value) +class BoundsSingleWidget(ipw.HBox, ipw.ValueWidget): + value = Any().tag(sync=True) + + def __init__( + self, + coord: sc.Variable, + # value: float, + ): + # coord_min, coord_max = coord.values[0], coord.values[-1] + self._lock = False + self._widget = BoundWidget(coord=coord, value=coord.values[0]) + + # observe user edits + self._widget.observe(self._on_child_change, names="value") + # observe external value changes + self.observe(self._on_value_change, names="value") + + super().__init__([self._widget]) + + self.value = (self._widget.value,) + + def _on_child_change(self, _): + if self._lock: + return + + self._lock = True + self.value = (self._widget.value,) + self._lock = False + + def _on_value_change(self, change): + if self._lock: + return + + self._lock = True + self._widget.value = change["new"][0] + self._lock = False + + # @property + # def value(self) -> float: + # return self.widget.value + + # @value.setter + # def value(self, value: float): + # self.widget.value = value[0] + + # def _set_observe_callback(self, callback: callable, **kwargs): + # self.widget.observe(callback, **kwargs) + + def get_closest_indices(self) -> tuple[int]: + return (self._widget.get_closest_index(),) + + +class BoundsRangeWidget(ipw.HBox, ipw.ValueWidget): + value = Tuple(Any(), Any()).tag(sync=True) + + def __init__( + self, + coord: sc.Variable, + # value_min: float, + # value_max: float, + ): + # coord_min, coord_max = coord.values[0], coord.values[-1] + self._lock = False + self._min_widget = BoundWidget(coord=coord, value=coord.values[0]) + self._max_widget = BoundWidget(coord=coord, value=coord.values[-1]) + # ipw.link((self._min_widget, 'max'), (self._max_widget, 'value')) + # ipw.link((self._max_widget, 'min'), (self._min_widget, 'value')) + self._min_widget.observe(self._on_min_change, names='value') + self._max_widget.observe(self._on_max_change, names='value') + super().__init__([self._min_widget, ipw.Label(value=":"), self._max_widget]) + + self.value = self._min_widget.value, self._max_widget.value + + # def _set_observe_callback(self, callback: callable, **kwargs): + # self._min_widget.observe(callback, **kwargs) + # self._max_widget.observe(callback, **kwargs) + + def _on_min_change(self, change: dict): + if self._max_widget._is_float: + self._max_widget.min = change["new"] + + def _on_max_change(self, change: dict): + if self._min_widget._is_float: + self._min_widget.max = change["new"] + + def _on_child_change(self, _): + if self._lock: + return + + self._lock = True + self.value = self._min_widget.value, self._max_widget.value + self._lock = False + + def _on_value_change(self, change): + if self._lock: + return + + self._lock = True + # self._widget.value = change["new"][0] + if change["new"][0] > self._max_widget.value: + self._max_widget.value = change["new"][1] + self._min_widget.value = change["new"][0] + else: + self._min_widget.value = change["new"][0] + self._max_widget.value = change["new"][1] + self._lock = False + + # @property + # def value(self) -> tuple[float, float]: + # return self._min_widget.value, self._max_widget.value + + # @value.setter + # def value(self, value: tuple[float, float]): + # # new_bounds = tuple(_round_float(v) for v in self.coord[self.dim, inds].values) + # if value[0] > float(self._max_widget.value): + # self._max_widget.value = value[1] + # self._min_widget.value = value[0] + # else: + # self._min_widget.value = value[0] + # self._max_widget.value = value[1] + + def get_closest_indices(self) -> tuple[int, int]: + return tuple( + b.get_closest_index() for b in (self._min_widget, self._max_widget) + ) + + class DimSlicer(ipw.HBox): def __init__( self, @@ -55,28 +274,28 @@ def __init__( layout={"width": "1.52em"}, ) - step = (self.coord_max - self.coord_min) / 999 - self.bound_min = ipw.BoundedFloatText( - continuous_update=False, - min=self.coord_min, - max=self.coord_max, - step=step, - value=self.coord_min, - layout={"width": "6em"}, - ) if self._is_bin_edges or (self._kind == "range"): - self.bound_max = ipw.BoundedFloatText( - continuous_update=False, - min=self.coord_min, - max=self.coord_max, - step=step, - value=self.coord_max, - layout={"width": "6em"}, - ) - ipw.link((self.bound_min, 'max'), (self.bound_max, 'value')) - ipw.link((self.bound_max, 'min'), (self.bound_min, 'value')) + self.bounds = BoundsRangeWidget(coord=self.coord) else: - self.bound_max = None + self.bounds = BoundsSingleWidget(coord=self.coord) + + # self.bound_min = _make_bound_widget( + # coord=self.coord, + # coord_min=self.coord_min, + # coord_max=self.coord_max, + # value=self.coord_min, + # ) + # if self._is_bin_edges or (self._kind == "range"): + # self.bound_max = _make_bound_widget( + # coord=self.coord, + # coord_min=self.coord_min, + # coord_max=self.coord_max, + # value=self.coord_max, + # ) + # ipw.link((self.bound_min, 'max'), (self.bound_max, 'value')) + # ipw.link((self.bound_max, 'min'), (self.bound_min, 'value')) + # else: + # self.bound_max = None self.unit = ipw.Label( "" if self.coord.unit is None else f" [{self.coord.unit}]" @@ -89,11 +308,11 @@ def __init__( self.dim_label, self.slider, self.continuous_update, - self.bound_min, + self.bounds, ] - if self.bound_max is not None: - children.append(ipw.Label(value=":")) - children.append(self.bound_max) + # if self.bound_max is not None: + # children.append(ipw.Label(value=":")) + # children.append(self.bound_max) children.append(self.unit) if enable_player: self.player = ipw.Play( @@ -110,13 +329,11 @@ def __init__( self._bounds_lock = False self._update_label({"new": self.slider.value}) self.slider.observe(self._update_label, names='value') - self.bound_min.observe(self._move_slider_to_label, names='value') - if self.bound_max is not None: - self.bound_max.observe(self._move_slider_to_label, names='value') + self.bounds.observe(self._move_slider_to_label, names='value') super().__init__(children) - def _update_label(self, change: dict[str, Any]): + def _update_label(self, change: dict): """ Update the readout label with the coordinate value, instead of the integer readout index. @@ -128,40 +345,53 @@ def _update_label(self, change: dict[str, Any]): else: inds = (inds, inds + 1) self._bounds_lock = True - if isinstance(inds, tuple): - new_bounds = tuple(round(v, 3) for v in self.coord[self.dim, inds].values) - if new_bounds[0] > self.bound_max.value: - self.bound_max.value = new_bounds[1] - self.bound_min.value = new_bounds[0] - else: - self.bound_min.value = new_bounds[0] - self.bound_max.value = new_bounds[1] - else: - self.bound_min.value = round(self.coord[self.dim, inds].value, 3) + # if isinstance(inds, tuple): + # new_bounds = tuple( + # _round_float(v) for v in self.coord[self.dim, inds].values + # ) + # if new_bounds[0] > float(self.bound_max.value): + # self.bound_max.value = str(new_bounds[1]) + # self.bound_min.value = str(new_bounds[0]) + # else: + # self.bound_min.value = str(new_bounds[0]) + # self.bound_max.value = str(new_bounds[1]) + # else: + # self.bound_min.value = str(_round_float(self.coord[self.dim, inds].value)) + # if isinstance(inds, tuple): + # new_bounds = self.coord[self.dim, inds].values + + # else: + # new_bounds = self.coord[self.dim, inds].value + self.bounds.value = np.atleast_1d(self.coord[self.dim, inds].values).tolist() self._bounds_lock = False - def _move_slider_to_label(self, change: dict[str, Any]): + def _move_slider_to_label(self, change: dict): """ Move the slider to the position corresponding to the coordinate value in the label, if possible. """ + print("move slider to label", change["new"]) if self._bounds_lock: return - # Find the index of the coordinate value closest to the one in the label. - if self.bound_max is None: - self.slider.value = np.argmin(np.abs(self.coord.values - change["new"])) + # # Find the index of the coordinate value closest to the one in the label. + # if self.bound_max is None: + # self.slider.value = np.argmin(np.abs(self.coord.values - change["new"])) + # else: + # # vmin, vmax = self.bound_min.value, self.bound_max.value + # # bounds = tuple( + # # np.argmin(np.abs(self.coord.values - x)) for x in (vmin, vmax) + # # ) + inds = self.bounds.get_closest_indices() + if len(inds) == 1: + self.slider.value = inds[0] else: - vmin, vmax = self.bound_min.value, self.bound_max.value - bounds = tuple( - np.argmin(np.abs(self.coord.values - x)) for x in (vmin, vmax) - ) if self._kind == "range": - self.slider.value = bounds + self.slider.value = inds else: # Here it means that the user has entered a range in the label, # but the slider is a single slider. We move the slider to the middle # of the range. - self.slider.value = int(0.5 * sum(bounds)) + self.slider.value = int(0.5 * sum(inds)) @property def value(self) -> int | tuple[int, int]: From e996db4fae4462eaf12917ebafb6ce39f106114c Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Fri, 27 Feb 2026 18:23:13 +0100 Subject: [PATCH 15/32] fix single slider, still issues with range slider --- src/plopp/widgets/__init__.pyi | 2 +- src/plopp/widgets/{slice.py => slicing.py} | 28 +++++++++++++++------- 2 files changed, 21 insertions(+), 9 deletions(-) rename src/plopp/widgets/{slice.py => slicing.py} (95%) diff --git a/src/plopp/widgets/__init__.pyi b/src/plopp/widgets/__init__.pyi index 8354ff1e..244d007a 100644 --- a/src/plopp/widgets/__init__.pyi +++ b/src/plopp/widgets/__init__.pyi @@ -6,7 +6,7 @@ from .checkboxes import Checkboxes from .clip3d import Clip3dTool, ClippingManager from .drawing import DrawingTool, PointsTool, PolygonTool, RectangleTool from .linesave import LineSaveTool -from .slice import CombinedSliceWidget, RangeSliceWidget, SliceWidget, slice_dims +from .slicing import CombinedSliceWidget, RangeSliceWidget, SliceWidget, slice_dims from .toolbar import Toolbar, make_toolbar_canvas2d, make_toolbar_canvas3d from .tools import ButtonTool, ColorTool, ToggleTool diff --git a/src/plopp/widgets/slice.py b/src/plopp/widgets/slicing.py similarity index 95% rename from src/plopp/widgets/slice.py rename to src/plopp/widgets/slicing.py index f87189f6..0853d0b2 100644 --- a/src/plopp/widgets/slice.py +++ b/src/plopp/widgets/slicing.py @@ -171,8 +171,15 @@ def __init__( self._max_widget = BoundWidget(coord=coord, value=coord.values[-1]) # ipw.link((self._min_widget, 'max'), (self._max_widget, 'value')) # ipw.link((self._max_widget, 'min'), (self._min_widget, 'value')) - self._min_widget.observe(self._on_min_change, names='value') - self._max_widget.observe(self._on_max_change, names='value') + # self._min_widget.observe(self._on_min_change, names='value') + # self._max_widget.observe(self._on_max_change, names='value') + + # observe user edits + self._min_widget.observe(self._on_child_change, names="value") + self._max_widget.observe(self._on_child_change, names="value") + # observe external value changes + self.observe(self._on_value_change, names="value") + super().__init__([self._min_widget, ipw.Label(value=":"), self._max_widget]) self.value = self._min_widget.value, self._max_widget.value @@ -181,18 +188,23 @@ def __init__( # self._min_widget.observe(callback, **kwargs) # self._max_widget.observe(callback, **kwargs) - def _on_min_change(self, change: dict): - if self._max_widget._is_float: - self._max_widget.min = change["new"] + # def _on_min_change(self, change: dict): + # if self._max_widget._is_float: + # self._max_widget.min = change["new"] - def _on_max_change(self, change: dict): - if self._min_widget._is_float: - self._min_widget.max = change["new"] + # def _on_max_change(self, change: dict): + # if self._min_widget._is_float: + # self._min_widget.max = change["new"] def _on_child_change(self, _): if self._lock: return + if self._max_widget._is_float: + self._max_widget.min = self._min_widget.value + if self._min_widget._is_float: + self._min_widget.max = self._max_widget.value + self._lock = True self.value = self._min_widget.value, self._max_widget.value self._lock = False From 64ade6df51210a77be6626a7b97b83a9715aa614 Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Fri, 27 Feb 2026 21:31:11 +0100 Subject: [PATCH 16/32] use Any as traitlet and try to debug bound limits --- src/plopp/widgets/slicing.py | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/src/plopp/widgets/slicing.py b/src/plopp/widgets/slicing.py index 0853d0b2..b79a9778 100644 --- a/src/plopp/widgets/slicing.py +++ b/src/plopp/widgets/slicing.py @@ -69,6 +69,24 @@ def _on_value_change(self, change): self._widget.value = str(change["new"]) self._lock = False + @property + def min(self) -> float | None: + return getattr(self._widget, "min", None) + + @min.setter + def min(self, value: float): + if self._is_float: + self._widget.min = value + + @property + def max(self) -> float | None: + return getattr(self._widget, "max", None) + + @max.setter + def max(self, value: float): + if self._is_float: + self._widget.max = value + # def _on_subwidget_change(self, _=None): # """ """ # if self._is_float: @@ -157,7 +175,8 @@ def get_closest_indices(self) -> tuple[int]: class BoundsRangeWidget(ipw.HBox, ipw.ValueWidget): - value = Tuple(Any(), Any()).tag(sync=True) + # value = Tuple(Any(), Any()).tag(sync=True) + value = Any().tag(sync=True) def __init__( self, @@ -200,6 +219,12 @@ def _on_child_change(self, _): if self._lock: return + # print( + # "min w", self._min_widget.min, self._min_widget.max, self._min_widget.value + # ) + # print( + # "max w", self._max_widget.min, self._max_widget.max, self._max_widget.value + # ) if self._max_widget._is_float: self._max_widget.min = self._min_widget.value if self._min_widget._is_float: @@ -382,7 +407,7 @@ def _move_slider_to_label(self, change: dict): Move the slider to the position corresponding to the coordinate value in the label, if possible. """ - print("move slider to label", change["new"]) + # print("move slider to label", change["new"]) if self._bounds_lock: return # # Find the index of the coordinate value closest to the one in the label. From 9e439f85350289770d324d50de6c8e779848c47d Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Sat, 28 Feb 2026 00:00:00 +0100 Subject: [PATCH 17/32] do not go the ValueWidget way in the end, as the locking gets too messy --- src/plopp/widgets/slicing.py | 244 +++++++++++++++++++++++------------ 1 file changed, 163 insertions(+), 81 deletions(-) diff --git a/src/plopp/widgets/slicing.py b/src/plopp/widgets/slicing.py index b79a9778..a81904a1 100644 --- a/src/plopp/widgets/slicing.py +++ b/src/plopp/widgets/slicing.py @@ -24,7 +24,8 @@ class BoundWidget(ipw.HBox, ipw.ValueWidget): def __init__(self, coord: sc.Variable, value: float): self._coord = coord - self._lock = False + # self._child_lock = False + # self._value_lock = False coord_min, coord_max = self._coord.values[0], self._coord.values[-1] if self._coord.dtype != sc.DType.datetime64: self._widget = ipw.BoundedFloatText( @@ -50,24 +51,57 @@ def __init__(self, coord: sc.Variable, value: float): super().__init__([self._widget]) self.value = self._widget.value - def _on_child_change(self, _): - if self._lock: + def _on_child_change(self, change): + # print( + # "BOUND, on_child_change: value_lock", + # self._value_lock, + # "child_lock", + # self._child_lock, + # ) + # if self._child_lock: + # return + if self._widget.value == change["new"]: return - self._lock = True + self._value_lock = True self.value = float(self._widget.value) - self._lock = False + self._value_lock = False + + # def _on_value_change(self, change): + # if self._lock: + # return + + # self._lock = True + # if self._is_float: + # self._widget.value = round(change["new"], 3) + # else: + # self._widget.value = str(change["new"]) + # self._lock = False def _on_value_change(self, change): - if self._lock: + # if self._is_float: + # new = round(change["new"], 3) + # else: + # new = str(change["new"]) + + # if new != self._widget.value: + # self._widget.value = new + print( + "BOUND, on_VALUE_change: value_lock", + self._value_lock, + "child_lock", + self._child_lock, + ) + + if self._value_lock: return - self._lock = True + self._child_lock = True if self._is_float: self._widget.value = round(change["new"], 3) else: self._widget.value = str(change["new"]) - self._lock = False + self._child_lock = False @property def min(self) -> float | None: @@ -122,6 +156,8 @@ def get_closest_index(self) -> int: # widget.value = value # elif isinstance(widget, ipw.Text): # widget.value = str(value) + + class BoundsSingleWidget(ipw.HBox, ipw.ValueWidget): value = Any().tag(sync=True) @@ -131,7 +167,8 @@ def __init__( # value: float, ): # coord_min, coord_max = coord.values[0], coord.values[-1] - self._lock = False + # self._child_lock = False + # self._value_lock = False self._widget = BoundWidget(coord=coord, value=coord.values[0]) # observe user edits @@ -143,21 +180,27 @@ def __init__( self.value = (self._widget.value,) - def _on_child_change(self, _): - if self._lock: + # def _maybe_early_exit(self, change): + # if self._widget.value == change["new"]: + # return + + def _on_child_change(self, change): + if self._widget.value == change["new"]: return - self._lock = True + # self._value_lock = True self.value = (self._widget.value,) - self._lock = False + # self._value_lock = False def _on_value_change(self, change): - if self._lock: + if self._widget.value == change["new"]: return + # if self._value_lock: + # return - self._lock = True - self._widget.value = change["new"][0] - self._lock = False + # self._child_lock = True + self._widget.value = change["new"] + # self._child_lock = False # @property # def value(self) -> float: @@ -174,9 +217,13 @@ def get_closest_indices(self) -> tuple[int]: return (self._widget.get_closest_index(),) -class BoundsRangeWidget(ipw.HBox, ipw.ValueWidget): +# TODO +# make a class BoundsRangeWidgetDatetime + + +class BoundsRangeWidget(ipw.HBox): # value = Tuple(Any(), Any()).tag(sync=True) - value = Any().tag(sync=True) + # value = Any().tag(sync=True) def __init__( self, @@ -185,27 +232,54 @@ def __init__( # value_max: float, ): # coord_min, coord_max = coord.values[0], coord.values[-1] - self._lock = False - self._min_widget = BoundWidget(coord=coord, value=coord.values[0]) - self._max_widget = BoundWidget(coord=coord, value=coord.values[-1]) - # ipw.link((self._min_widget, 'max'), (self._max_widget, 'value')) - # ipw.link((self._max_widget, 'min'), (self._min_widget, 'value')) + # self._child_lock = False + # self._value_lock = False + + # self._min_widget = BoundWidget(coord=coord, value=coord.values[0]) + # self._max_widget = BoundWidget(coord=coord, value=coord.values[-1]) + + self._coord = coord + coord_min, coord_max = self._coord.values[0], self._coord.values[-1] + step = (coord_max - coord_min) / 999 + + self._min_widget = ipw.BoundedFloatText( + continuous_update=False, + min=coord_min, + max=coord_max, + step=step, + value=coord_min, + layout={"width": "6em"}, + ) + self._min_widget.is_lower_bound = True + + self._max_widget = ipw.BoundedFloatText( + continuous_update=False, + min=coord_min, + max=coord_max, + step=step, + value=coord_max, + layout={"width": "6em"}, + ) + self._max_widget.is_lower_bound = False + + ipw.link((self._min_widget, 'max'), (self._max_widget, 'value')) + ipw.link((self._max_widget, 'min'), (self._min_widget, 'value')) # self._min_widget.observe(self._on_min_change, names='value') # self._max_widget.observe(self._on_max_change, names='value') # observe user edits - self._min_widget.observe(self._on_child_change, names="value") - self._max_widget.observe(self._on_child_change, names="value") + # self._min_widget.observe(self._on_child_change, names="value") + # self._max_widget.observe(self._on_child_change, names="value") # observe external value changes - self.observe(self._on_value_change, names="value") + # self.observe(self._on_value_change, names="value") super().__init__([self._min_widget, ipw.Label(value=":"), self._max_widget]) - self.value = self._min_widget.value, self._max_widget.value + # self.value = self._min_widget.value, self._max_widget.value - # def _set_observe_callback(self, callback: callable, **kwargs): - # self._min_widget.observe(callback, **kwargs) - # self._max_widget.observe(callback, **kwargs) + def set_observe_callback(self, callback: callable, **kwargs): + self._min_widget.observe(callback, **kwargs) + self._max_widget.observe(callback, **kwargs) # def _on_min_change(self, change: dict): # if self._max_widget._is_float: @@ -215,57 +289,60 @@ def __init__( # if self._min_widget._is_float: # self._min_widget.max = change["new"] - def _on_child_change(self, _): - if self._lock: - return - - # print( - # "min w", self._min_widget.min, self._min_widget.max, self._min_widget.value - # ) - # print( - # "max w", self._max_widget.min, self._max_widget.max, self._max_widget.value - # ) - if self._max_widget._is_float: - self._max_widget.min = self._min_widget.value - if self._min_widget._is_float: - self._min_widget.max = self._max_widget.value - - self._lock = True - self.value = self._min_widget.value, self._max_widget.value - self._lock = False + # def _on_child_change(self, _): + # # # if self._max_widget._is_float: + # # self._max_widget.min = self._min_widget.value + # # # if self._min_widget._is_float: + # # self._min_widget.max = self._max_widget.value + + # if self._widget.value == change["new"]: + # return + + # if self._child_lock: + # return + + # print("bounds ON_CHILD_CHANGE", self._min_widget.value, self._max_widget.value) + # self._value_lock = True + # self.value = self._min_widget.value, self._max_widget.value + # self._value_lock = False + + # def _on_value_change(self, change): + # print( + # "bounds on_value_change", + # self._min_widget.value, + # self._max_widget.value, + # 'value_lock', + # self._value_lock, + # ) + # if self._value_lock: + # return + + # self._child_lock = True + # # self._widget.value = change["new"][0] + # if change["new"][0] > self._max_widget.value: + # self._max_widget.value = change["new"][1] + # self._min_widget.value = change["new"][0] + # else: + # self._min_widget.value = change["new"][0] + # self._max_widget.value = change["new"][1] + # self._child_lock = False - def _on_value_change(self, change): - if self._lock: - return + @property + def value(self) -> tuple[float, float]: + return self._min_widget.value, self._max_widget.value - self._lock = True - # self._widget.value = change["new"][0] - if change["new"][0] > self._max_widget.value: - self._max_widget.value = change["new"][1] - self._min_widget.value = change["new"][0] + @value.setter + def value(self, value: tuple[float, float]): + # new_bounds = tuple(_round_float(v) for v in self.coord[self.dim, inds].values) + if value[0] > float(self._max_widget.value): + self._max_widget.value = value[1] + self._min_widget.value = value[0] else: - self._min_widget.value = change["new"][0] - self._max_widget.value = change["new"][1] - self._lock = False - - # @property - # def value(self) -> tuple[float, float]: - # return self._min_widget.value, self._max_widget.value - - # @value.setter - # def value(self, value: tuple[float, float]): - # # new_bounds = tuple(_round_float(v) for v in self.coord[self.dim, inds].values) - # if value[0] > float(self._max_widget.value): - # self._max_widget.value = value[1] - # self._min_widget.value = value[0] - # else: - # self._min_widget.value = value[0] - # self._max_widget.value = value[1] + self._min_widget.value = value[0] + self._max_widget.value = value[1] def get_closest_indices(self) -> tuple[int, int]: - return tuple( - b.get_closest_index() for b in (self._min_widget, self._max_widget) - ) + return tuple(np.argmin(np.abs(self._coord.values - x)) for x in self.value) class DimSlicer(ipw.HBox): @@ -366,7 +443,7 @@ def __init__( self._bounds_lock = False self._update_label({"new": self.slider.value}) self.slider.observe(self._update_label, names='value') - self.bounds.observe(self._move_slider_to_label, names='value') + self.bounds.set_observe_callback(self._move_slider_to_label, names='value') super().__init__(children) @@ -375,6 +452,7 @@ def _update_label(self, change: dict): Update the readout label with the coordinate value, instead of the integer readout index. """ + print("updating label", change) inds = change["new"] if self._is_bin_edges: if isinstance(inds, tuple): @@ -399,6 +477,7 @@ def _update_label(self, change: dict): # else: # new_bounds = self.coord[self.dim, inds].value + print("updating bounds", inds, self.bounds.value) self.bounds.value = np.atleast_1d(self.coord[self.dim, inds].values).tolist() self._bounds_lock = False @@ -426,9 +505,12 @@ def _move_slider_to_label(self, change: dict): self.slider.value = inds else: # Here it means that the user has entered a range in the label, - # but the slider is a single slider. We move the slider to the middle - # of the range. - self.slider.value = int(0.5 * sum(inds)) + # but the slider is a single slider. We move the slider to the value + # requested by the bound that was changed. + if change["owner"].is_lower_bound: + self.slider.value = inds[0] + else: + self.slider.value = inds[1] @property def value(self) -> int | tuple[int, int]: From 1b7dbb1322a3f01da6025a70b142ea0a3fe96456 Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Mon, 2 Mar 2026 08:52:49 +0100 Subject: [PATCH 18/32] new plan: drop the valuewidget thing and make a widget with a single value for single bounds with bin edges --- src/plopp/widgets/slicing.py | 414 +++++++++++++++++++---------------- 1 file changed, 226 insertions(+), 188 deletions(-) diff --git a/src/plopp/widgets/slicing.py b/src/plopp/widgets/slicing.py index a81904a1..99e5c508 100644 --- a/src/plopp/widgets/slicing.py +++ b/src/plopp/widgets/slicing.py @@ -7,220 +7,241 @@ import ipywidgets as ipw import numpy as np import scipp as sc - -# def _round_float(x: float, prec=3) -> float: -# try: -# return round(x, prec) -# except TypeError: -# return x from traitlets import Any, Tuple from ..core import node from .box import VBar -class BoundWidget(ipw.HBox, ipw.ValueWidget): - value = Any().tag(sync=True) - - def __init__(self, coord: sc.Variable, value: float): - self._coord = coord - # self._child_lock = False - # self._value_lock = False - coord_min, coord_max = self._coord.values[0], self._coord.values[-1] - if self._coord.dtype != sc.DType.datetime64: - self._widget = ipw.BoundedFloatText( - continuous_update=False, - min=coord_min, - max=coord_max, - step=(coord_max - coord_min) / 999, - value=value, - layout={"width": "6em"}, - ) - self._is_float = True - else: - self._widget = ipw.Text( - continuous_update=False, value=str(value), layout={"width": "10em"} - ) - self._is_float = False - - # observe user edits - self._widget.observe(self._on_child_change, names="value") - # observe external value changes - self.observe(self._on_value_change, names="value") - - super().__init__([self._widget]) - self.value = self._widget.value - - def _on_child_change(self, change): - # print( - # "BOUND, on_child_change: value_lock", - # self._value_lock, - # "child_lock", - # self._child_lock, - # ) - # if self._child_lock: - # return - if self._widget.value == change["new"]: - return - - self._value_lock = True - self.value = float(self._widget.value) - self._value_lock = False - - # def _on_value_change(self, change): - # if self._lock: - # return - - # self._lock = True - # if self._is_float: - # self._widget.value = round(change["new"], 3) - # else: - # self._widget.value = str(change["new"]) - # self._lock = False - - def _on_value_change(self, change): - # if self._is_float: - # new = round(change["new"], 3) - # else: - # new = str(change["new"]) - - # if new != self._widget.value: - # self._widget.value = new - print( - "BOUND, on_VALUE_change: value_lock", - self._value_lock, - "child_lock", - self._child_lock, - ) - - if self._value_lock: - return - - self._child_lock = True - if self._is_float: - self._widget.value = round(change["new"], 3) - else: - self._widget.value = str(change["new"]) - self._child_lock = False - - @property - def min(self) -> float | None: - return getattr(self._widget, "min", None) - - @min.setter - def min(self, value: float): - if self._is_float: - self._widget.min = value - - @property - def max(self) -> float | None: - return getattr(self._widget, "max", None) - - @max.setter - def max(self, value: float): - if self._is_float: - self._widget.max = value - - # def _on_subwidget_change(self, _=None): - # """ """ - # if self._is_float: - # self.value = round(value, 3) - # else: - # self._widget.value = str(value) - # self.value = {dim: slicer.value for dim, slicer in self.controls.items()} - - # @property - # def value(self) -> float: - # return float(self._widget.value) - - # @value.setter - # def value(self, value: float): - # if self._is_float: - # self._widget.value = round(value, 3) - # else: - # self._widget.value = str(value) - - def get_closest_index(self) -> int: - """ - Get the index of the coordinate value closest to the one in the widget. - """ - # if self._is_float: - # value = self.value - # else: - # value = sc.scalar(self._widget.value, dtype=self._coord.dtype) - return np.argmin(np.abs(self._coord.values - self.value)) - - -# def _set_bound_widget_value(widget: ipw.Widget, value: float): -# if isinstance(widget, ipw.BoundedFloatText): -# widget.value = value -# elif isinstance(widget, ipw.Text): -# widget.value = str(value) - - -class BoundsSingleWidget(ipw.HBox, ipw.ValueWidget): - value = Any().tag(sync=True) - +def _round_float(x: float, prec=3) -> float: + try: + return round(x, prec) + except TypeError: + return x + + +# class BoundWidget(ipw.HBox, ipw.ValueWidget): +# value = Any().tag(sync=True) + +# def __init__(self, coord: sc.Variable, value: float): +# self._coord = coord +# # self._child_lock = False +# # self._value_lock = False +# coord_min, coord_max = self._coord.values[0], self._coord.values[-1] +# if self._coord.dtype != sc.DType.datetime64: +# self._widget = ipw.BoundedFloatText( +# continuous_update=False, +# min=coord_min, +# max=coord_max, +# step=(coord_max - coord_min) / 999, +# value=value, +# layout={"width": "6em"}, +# ) +# self._is_float = True +# else: +# self._widget = ipw.Text( +# continuous_update=False, value=str(value), layout={"width": "10em"} +# ) +# self._is_float = False + +# # observe user edits +# self._widget.observe(self._on_child_change, names="value") +# # observe external value changes +# self.observe(self._on_value_change, names="value") + +# super().__init__([self._widget]) +# self.value = self._widget.value + +# def _on_child_change(self, change): +# # print( +# # "BOUND, on_child_change: value_lock", +# # self._value_lock, +# # "child_lock", +# # self._child_lock, +# # ) +# # if self._child_lock: +# # return +# if self._widget.value == change["new"]: +# return + +# self._value_lock = True +# self.value = float(self._widget.value) +# self._value_lock = False + +# # def _on_value_change(self, change): +# # if self._lock: +# # return + +# # self._lock = True +# # if self._is_float: +# # self._widget.value = round(change["new"], 3) +# # else: +# # self._widget.value = str(change["new"]) +# # self._lock = False + +# def _on_value_change(self, change): +# # if self._is_float: +# # new = round(change["new"], 3) +# # else: +# # new = str(change["new"]) + +# # if new != self._widget.value: +# # self._widget.value = new +# print( +# "BOUND, on_VALUE_change: value_lock", +# self._value_lock, +# "child_lock", +# self._child_lock, +# ) + +# if self._value_lock: +# return + +# self._child_lock = True +# if self._is_float: +# self._widget.value = round(change["new"], 3) +# else: +# self._widget.value = str(change["new"]) +# self._child_lock = False + +# @property +# def min(self) -> float | None: +# return getattr(self._widget, "min", None) + +# @min.setter +# def min(self, value: float): +# if self._is_float: +# self._widget.min = value + +# @property +# def max(self) -> float | None: +# return getattr(self._widget, "max", None) + +# @max.setter +# def max(self, value: float): +# if self._is_float: +# self._widget.max = value + +# # def _on_subwidget_change(self, _=None): +# # """ """ +# # if self._is_float: +# # self.value = round(value, 3) +# # else: +# # self._widget.value = str(value) +# # self.value = {dim: slicer.value for dim, slicer in self.controls.items()} + +# # @property +# # def value(self) -> float: +# # return float(self._widget.value) + +# # @value.setter +# # def value(self, value: float): +# # if self._is_float: +# # self._widget.value = round(value, 3) +# # else: +# # self._widget.value = str(value) + +# def get_closest_index(self) -> int: +# """ +# Get the index of the coordinate value closest to the one in the widget. +# """ +# # if self._is_float: +# # value = self.value +# # else: +# # value = sc.scalar(self._widget.value, dtype=self._coord.dtype) +# return np.argmin(np.abs(self._coord.values - self.value)) + + +# # def _set_bound_widget_value(widget: ipw.Widget, value: float): +# # if isinstance(widget, ipw.BoundedFloatText): +# # widget.value = value +# # elif isinstance(widget, ipw.Text): +# # widget.value = str(value) + + +class BoundsSingleWidget(ipw.HBox): def __init__( self, coord: sc.Variable, + index: int, # value: float, ): - # coord_min, coord_max = coord.values[0], coord.values[-1] + self._coord = coord + coord_min, coord_max = self._coord.values[0], self._coord.values[-1] # self._child_lock = False # self._value_lock = False - self._widget = BoundWidget(coord=coord, value=coord.values[0]) + step = (coord_max - coord_min) / 999 + self._widget = ipw.BoundedFloatText( + continuous_update=False, + min=coord_min, + max=coord_max, + step=step, + value=_round_float(self._coord.values[index]), + layout={"width": "6em"}, + ) # observe user edits - self._widget.observe(self._on_child_change, names="value") + # self._widget.observe(self._on_child_change, names="value") # observe external value changes - self.observe(self._on_value_change, names="value") + # self.observe(self._on_value_change, names="value") super().__init__([self._widget]) - self.value = (self._widget.value,) + @property + def value(self) -> float: + return self._widget.value - # def _maybe_early_exit(self, change): - # if self._widget.value == change["new"]: - # return + @value.setter + def value(self, value: tuple[float]): + self._widget.value = _round_float(value[0]) - def _on_child_change(self, change): - if self._widget.value == change["new"]: - return + def set_observe_callback(self, callback: callable, **kwargs): + self._widget.observe(callback, **kwargs) - # self._value_lock = True - self.value = (self._widget.value,) - # self._value_lock = False + def get_closest_indices(self) -> tuple[int]: + return (np.argmin(np.abs(self._coord.values - self.value)),) - def _on_value_change(self, change): - if self._widget.value == change["new"]: - return - # if self._value_lock: - # return - # self._child_lock = True - self._widget.value = change["new"] - # self._child_lock = False +class BoundsSingleBinEdgesWidget(ipw.HBox): + def __init__(self, coord: sc.Variable, index: int): + # ind = coord.size + self._coord = coord + self._widget = ipw.Text( + continuous_update=False, + value=" : ".join( + str(_round_float(self._coord.values[i])) for i in (index, index + 1) + ), + layout={"width": "12em"}, + ) - # @property - # def value(self) -> float: - # return self.widget.value + @property + def value(self) -> float: + if ":" in self._widget.value: + return float(self._widget.value.split(":")[0]) + return float(self._widget.value) - # @value.setter - # def value(self, value: float): - # self.widget.value = value[0] + @value.setter + def value(self, value: tuple[float]): + self._widget.value = " : ".join(str(_round_float(v)) for v in value) - # def _set_observe_callback(self, callback: callable, **kwargs): - # self.widget.observe(callback, **kwargs) + def set_observe_callback(self, callback: callable, **kwargs): + self._widget.observe(callback, **kwargs) def get_closest_indices(self) -> tuple[int]: - return (self._widget.get_closest_index(),) + return (np.argmin(np.abs(self._coord.values - self.value)),) # TODO # make a class BoundsRangeWidgetDatetime +# New plan: +# - 2 bound widgets for range slider +# - 1 bound widget for single value midpoints +# - 1 new widget with Text for single value with bin edges: separate edges of current bin with : +# - make corresponding widgets for datetime which use Text + + class BoundsRangeWidget(ipw.HBox): # value = Tuple(Any(), Any()).tag(sync=True) # value = Any().tag(sync=True) @@ -228,6 +249,7 @@ class BoundsRangeWidget(ipw.HBox): def __init__( self, coord: sc.Variable, + index: tuple[int, int], # value_min: float, # value_max: float, ): @@ -247,7 +269,7 @@ def __init__( min=coord_min, max=coord_max, step=step, - value=coord_min, + value=_round_float(self._coord.values[index[0]]), layout={"width": "6em"}, ) self._min_widget.is_lower_bound = True @@ -257,9 +279,19 @@ def __init__( min=coord_min, max=coord_max, step=step, - value=coord_max, + value=_round_float(self._coord.values[index[1]]), layout={"width": "6em"}, ) + print( + "min val", + self._min_widget.value, + _round_float(self._coord.values[index[0]]), + ) + print( + "max val", + self._max_widget.value, + _round_float(self._coord.values[index[1]]), + ) self._max_widget.is_lower_bound = False ipw.link((self._min_widget, 'max'), (self._max_widget, 'value')) @@ -335,11 +367,11 @@ def value(self) -> tuple[float, float]: def value(self, value: tuple[float, float]): # new_bounds = tuple(_round_float(v) for v in self.coord[self.dim, inds].values) if value[0] > float(self._max_widget.value): - self._max_widget.value = value[1] - self._min_widget.value = value[0] + self._max_widget.value = _round_float(value[1]) + self._min_widget.value = _round_float(value[0]) else: - self._min_widget.value = value[0] - self._max_widget.value = value[1] + self._min_widget.value = _round_float(value[0]) + self._max_widget.value = _round_float(value[1]) def get_closest_indices(self) -> tuple[int, int]: return tuple(np.argmin(np.abs(self._coord.values - x)) for x in self.value) @@ -388,10 +420,16 @@ def __init__( layout={"width": "1.52em"}, ) - if self._is_bin_edges or (self._kind == "range"): - self.bounds = BoundsRangeWidget(coord=self.coord) + # if self._is_bin_edges or (self._kind == "range"): + # self.bounds = BoundsRangeWidget(coord=self.coord, index=value) + # else: + # self.bounds = BoundsSingleWidget(coord=self.coord, index=value) + if self._kind == "range": + self.bounds = BoundsRangeWidget(coord=self.coord, index=value) + elif self._is_bin_edges: + self.bounds = BoundsSingleBinEdgesWidget(coord=self.coord, index=value) else: - self.bounds = BoundsSingleWidget(coord=self.coord) + self.bounds = BoundsSingleWidget(coord=self.coord, index=value) # self.bound_min = _make_bound_widget( # coord=self.coord, From 99a16d67a0b1d7c5df51c7276410e01de2b53d45 Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Mon, 2 Mar 2026 23:11:03 +0100 Subject: [PATCH 19/32] use custom text widget instead of in-built bounded float text --- src/plopp/widgets/slicing.py | 234 ++++++++++++++++++++++++++--------- 1 file changed, 177 insertions(+), 57 deletions(-) diff --git a/src/plopp/widgets/slicing.py b/src/plopp/widgets/slicing.py index 99e5c508..a59110cd 100644 --- a/src/plopp/widgets/slicing.py +++ b/src/plopp/widgets/slicing.py @@ -13,7 +13,7 @@ from .box import VBar -def _round_float(x: float, prec=3) -> float: +def _format_float(x: float, prec=3) -> float: try: return round(x, prec) except TypeError: @@ -159,6 +159,120 @@ def _round_float(x: float, prec=3) -> float: # # widget.value = str(value) +class BoundedText(ipw.HBox, ipw.ValueWidget): + value = Any().tag(sync=True) + + def __init__( + self, + coord: sc.Variable, + index: int, + # value: float | str, + # min: float | None = None, + # max: float | None = None, + continuous_update: bool = False, + **kwargs, + ): + self._lock = False + self._coord = coord.values + # self._index = index + # coord_min, coord_max = self._coord.values[0], self._coord.values[-1] + self._widget = ipw.Text(continuous_update=continuous_update, value="", **kwargs) + self.min = 0 # self._coord[0] + self.max = len(self._coord) # [-1] + # observe user edits + self._widget.observe(self._on_child_change, names="value") + # observe external value changes + self.observe(self._on_value_change, names="value") + + super().__init__([self._widget]) + self.value = self._coord[index] + + def _find_closest_index(self, value: float) -> int: + return np.argmin(np.abs(self._coord - value)) + + def _on_child_change(self, change): + if self._lock: + return + + print("RECEIVED VALUE:", change["new"]) + new = self._find_closest_index(float(change["new"])) + print("closest index found:", new) + new = min(max(new, self.min), self.max) + print("clamped index:", new) + # Find closest value in allowed values in coord + # new = self._find_closest_index(new) + self._lock = True + self._widget.value = f"{self._coord[new]:.3E}" + self.value = new + self._lock = False + + def _on_value_change(self, change): + if self._lock: + return + + new = min(max(change["new"], self.min), self.max) + # new = self._find_closest_value(new) + self._lock = True + self._widget.value = f"{self._coord[new]:.3E}" + self._lock = False + + +class BoundedBinEdgeText(ipw.HBox, ipw.ValueWidget): + value = Any().tag(sync=True) + + def __init__( + self, + value: float | str, + min: float | None = None, + max: float | None = None, + continuous_update: bool = False, + **kwargs, + ): + self._lock = False + self._widget = ipw.Text(continuous_update=continuous_update, value="", **kwargs) + self.min = min + self.max = max + # observe user edits + self._widget.observe(self._on_child_change, names="value") + # observe external value changes + self.observe(self._on_value_change, names="value") + + super().__init__([self._widget]) + self.value = value + + def _on_child_change(self, change): + if self._lock: + return + + new = [min(max(float(x), self.min), self.max) for x in change["new"].split(":")] + # new = " : ".join( + # f"{min(max(float(x), self.min), self.max):.3E}" + # for x in change["new"].split(":") + # ) + self._lock = True + self._widget.value = " : ".join(f"{x:.3E}" for x in new) + self.value = new[0] + self._lock = False + + def _on_value_change(self, change): + if self._lock: + return + + # if ":" in change["new"]: + # val = float(change["new"].split(":")[0]) + # else: + # val = float(change["new"]) + + print("CHANGE NEW") + print(change["new"]) + print(list(float(x) for x in change["new"])) + + new = [min(max(float(x), self.min), self.max) for x in change["new"]] + self._lock = True + self._widget.value = " : ".join(f"{x:.3E}" for x in new) + self._lock = False + + class BoundsSingleWidget(ipw.HBox): def __init__( self, @@ -170,14 +284,15 @@ def __init__( coord_min, coord_max = self._coord.values[0], self._coord.values[-1] # self._child_lock = False # self._value_lock = False - step = (coord_max - coord_min) / 999 - self._widget = ipw.BoundedFloatText( + # step = (coord_max - coord_min) / 999 + self._widget = BoundedText( continuous_update=False, - min=coord_min, - max=coord_max, - step=step, - value=_round_float(self._coord.values[index]), - layout={"width": "6em"}, + # min=coord_min, + # max=coord_max, + # value=self._coord.values[index], + coord=self._coord, + index=index, + layout={"width": "7em"}, ) # observe user edits @@ -189,40 +304,43 @@ def __init__( @property def value(self) -> float: - return self._widget.value + return (self._widget.value,) @value.setter def value(self, value: tuple[float]): - self._widget.value = _round_float(value[0]) + self._widget.value = value[0] def set_observe_callback(self, callback: callable, **kwargs): self._widget.observe(callback, **kwargs) - def get_closest_indices(self) -> tuple[int]: - return (np.argmin(np.abs(self._coord.values - self.value)),) + # def get_closest_indices(self) -> tuple[int]: + # return (np.argmin(np.abs(self._coord.values - self.value)),) class BoundsSingleBinEdgesWidget(ipw.HBox): def __init__(self, coord: sc.Variable, index: int): # ind = coord.size self._coord = coord - self._widget = ipw.Text( + coord_min, coord_max = self._coord.values[0], self._coord.values[-1] + self._widget = BoundedBinEdgeText( continuous_update=False, - value=" : ".join( - str(_round_float(self._coord.values[i])) for i in (index, index + 1) - ), + min=coord_min, + max=coord_max, + value=tuple(self._coord.values[i] for i in (index, index + 1)), layout={"width": "12em"}, ) + super().__init__([self._widget]) @property def value(self) -> float: - if ":" in self._widget.value: - return float(self._widget.value.split(":")[0]) - return float(self._widget.value) + # if ":" in self._widget.value: + # return float(self._widget.value.split(":")[0]) + # return float(self._widget.value) + return self._widget.value @value.setter def value(self, value: tuple[float]): - self._widget.value = " : ".join(str(_round_float(v)) for v in value) + self._widget.value = value def set_observe_callback(self, callback: callable, **kwargs): self._widget.observe(callback, **kwargs) @@ -262,42 +380,42 @@ def __init__( self._coord = coord coord_min, coord_max = self._coord.values[0], self._coord.values[-1] - step = (coord_max - coord_min) / 999 + # step = (coord_max - coord_min) / 999 - self._min_widget = ipw.BoundedFloatText( + self._min_widget = BoundedText( continuous_update=False, min=coord_min, max=coord_max, - step=step, - value=_round_float(self._coord.values[index[0]]), - layout={"width": "6em"}, + # step=step, + value=self._coord.values[index[0]], + layout={"width": "7em"}, ) - self._min_widget.is_lower_bound = True + # self._min_widget.is_lower_bound = True - self._max_widget = ipw.BoundedFloatText( + self._max_widget = BoundedText( continuous_update=False, min=coord_min, max=coord_max, - step=step, - value=_round_float(self._coord.values[index[1]]), - layout={"width": "6em"}, - ) - print( - "min val", - self._min_widget.value, - _round_float(self._coord.values[index[0]]), + # step=step, + value=self._coord.values[index[1]], + layout={"width": "7em"}, ) - print( - "max val", - self._max_widget.value, - _round_float(self._coord.values[index[1]]), - ) - self._max_widget.is_lower_bound = False + # print( + # "min val", + # self._min_widget.value, + # _round_float(self._coord.values[index[0]]), + # ) + # print( + # "max val", + # self._max_widget.value, + # _round_float(self._coord.values[index[1]]), + # ) + # self._max_widget.is_lower_bound = False - ipw.link((self._min_widget, 'max'), (self._max_widget, 'value')) - ipw.link((self._max_widget, 'min'), (self._min_widget, 'value')) - # self._min_widget.observe(self._on_min_change, names='value') - # self._max_widget.observe(self._on_max_change, names='value') + # ipw.link((self._min_widget, 'max'), (self._max_widget, 'value')) + # ipw.link((self._max_widget, 'min'), (self._min_widget, 'value')) + self._min_widget.observe(self._on_min_change, names='value') + self._max_widget.observe(self._on_max_change, names='value') # observe user edits # self._min_widget.observe(self._on_child_change, names="value") @@ -313,13 +431,13 @@ def set_observe_callback(self, callback: callable, **kwargs): self._min_widget.observe(callback, **kwargs) self._max_widget.observe(callback, **kwargs) - # def _on_min_change(self, change: dict): - # if self._max_widget._is_float: - # self._max_widget.min = change["new"] + def _on_min_change(self, change: dict): + # if self._max_widget._is_float: + self._max_widget.min = change["new"] - # def _on_max_change(self, change: dict): - # if self._min_widget._is_float: - # self._min_widget.max = change["new"] + def _on_max_change(self, change: dict): + # if self._min_widget._is_float: + self._min_widget.max = change["new"] # def _on_child_change(self, _): # # # if self._max_widget._is_float: @@ -367,11 +485,11 @@ def value(self) -> tuple[float, float]: def value(self, value: tuple[float, float]): # new_bounds = tuple(_round_float(v) for v in self.coord[self.dim, inds].values) if value[0] > float(self._max_widget.value): - self._max_widget.value = _round_float(value[1]) - self._min_widget.value = _round_float(value[0]) + self._max_widget.value = value[1] + self._min_widget.value = value[0] else: - self._min_widget.value = _round_float(value[0]) - self._max_widget.value = _round_float(value[1]) + self._min_widget.value = value[0] + self._max_widget.value = value[1] def get_closest_indices(self) -> tuple[int, int]: return tuple(np.argmin(np.abs(self._coord.values - x)) for x in self.value) @@ -516,7 +634,8 @@ def _update_label(self, change: dict): # else: # new_bounds = self.coord[self.dim, inds].value print("updating bounds", inds, self.bounds.value) - self.bounds.value = np.atleast_1d(self.coord[self.dim, inds].values).tolist() + # self.bounds.value = np.atleast_1d(self.coord[self.dim, inds].values).tolist() + self.bounds.value = np.atleast_1d(inds).tolist() self._bounds_lock = False def _move_slider_to_label(self, change: dict): @@ -535,7 +654,8 @@ def _move_slider_to_label(self, change: dict): # # bounds = tuple( # # np.argmin(np.abs(self.coord.values - x)) for x in (vmin, vmax) # # ) - inds = self.bounds.get_closest_indices() + # inds = self.bounds.get_closest_indices() + inds = self.bounds.value if len(inds) == 1: self.slider.value = inds[0] else: From 317116630556039e53c5334830db18981ffac5d9 Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Tue, 3 Mar 2026 12:55:30 +0100 Subject: [PATCH 20/32] debugging bin edges single widget --- src/plopp/widgets/slicing.py | 54 +++++++++++++++++++++--------------- 1 file changed, 32 insertions(+), 22 deletions(-) diff --git a/src/plopp/widgets/slicing.py b/src/plopp/widgets/slicing.py index a59110cd..5b49bfb9 100644 --- a/src/plopp/widgets/slicing.py +++ b/src/plopp/widgets/slicing.py @@ -222,35 +222,43 @@ class BoundedBinEdgeText(ipw.HBox, ipw.ValueWidget): def __init__( self, - value: float | str, - min: float | None = None, - max: float | None = None, + coord: sc.Variable, + index: int, + # value: float | str, + # min: float | None = None, + # max: float | None = None, continuous_update: bool = False, **kwargs, ): self._lock = False + self._coord = coord.values self._widget = ipw.Text(continuous_update=continuous_update, value="", **kwargs) - self.min = min - self.max = max + self.min = 0 + self.max = len(self._coord) - 1 # observe user edits self._widget.observe(self._on_child_change, names="value") # observe external value changes self.observe(self._on_value_change, names="value") super().__init__([self._widget]) - self.value = value + self.value = self._coord[index] + + def _find_closest_index(self, value: float) -> int: + return np.argmin(np.abs(self._coord - value)) def _on_child_change(self, change): if self._lock: return - new = [min(max(float(x), self.min), self.max) for x in change["new"].split(":")] + new = [self._find_closest_index(float(x)) for x in change["new"].split(":")] + # new = self._find_closest_index() + new = [min(max(x, self.min), self.max) for x in new] # new = " : ".join( # f"{min(max(float(x), self.min), self.max):.3E}" # for x in change["new"].split(":") # ) self._lock = True - self._widget.value = " : ".join(f"{x:.3E}" for x in new) + self._widget.value = " : ".join(f"{self._coord[x]:.3E}" for x in new) self.value = new[0] self._lock = False @@ -263,13 +271,13 @@ def _on_value_change(self, change): # else: # val = float(change["new"]) - print("CHANGE NEW") - print(change["new"]) - print(list(float(x) for x in change["new"])) + # print("CHANGE NEW") + # print(change["new"]) + # print(list(float(x) for x in change["new"])) - new = [min(max(float(x), self.min), self.max) for x in change["new"]] + new = [min(max(x, self.min), self.max) for x in change["new"]] self._lock = True - self._widget.value = " : ".join(f"{x:.3E}" for x in new) + self._widget.value = " : ".join(f"{self._coord[x]:.3E}" for x in new) self._lock = False @@ -280,7 +288,7 @@ def __init__( index: int, # value: float, ): - self._coord = coord + # self._coord = coord coord_min, coord_max = self._coord.values[0], self._coord.values[-1] # self._child_lock = False # self._value_lock = False @@ -290,7 +298,7 @@ def __init__( # min=coord_min, # max=coord_max, # value=self._coord.values[index], - coord=self._coord, + coord=coord, index=index, layout={"width": "7em"}, ) @@ -320,13 +328,15 @@ def set_observe_callback(self, callback: callable, **kwargs): class BoundsSingleBinEdgesWidget(ipw.HBox): def __init__(self, coord: sc.Variable, index: int): # ind = coord.size - self._coord = coord - coord_min, coord_max = self._coord.values[0], self._coord.values[-1] + # self._coord = coord + # coord_min, coord_max = self._coord.values[0], self._coord.values[-1] self._widget = BoundedBinEdgeText( continuous_update=False, - min=coord_min, - max=coord_max, - value=tuple(self._coord.values[i] for i in (index, index + 1)), + coord=coord, + index=index, + # min=coord_min, + # max=coord_max, + # value=tuple(self._coord.values[i] for i in (index, index + 1)), layout={"width": "12em"}, ) super().__init__([self._widget]) @@ -345,8 +355,8 @@ def value(self, value: tuple[float]): def set_observe_callback(self, callback: callable, **kwargs): self._widget.observe(callback, **kwargs) - def get_closest_indices(self) -> tuple[int]: - return (np.argmin(np.abs(self._coord.values - self.value)),) + # def get_closest_indices(self) -> tuple[int]: + # return (np.argmin(np.abs(self._coord.values - self.value)),) # TODO From 10d50a453f32acccf4ca138840ce6abb8255a0a4 Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Tue, 3 Mar 2026 22:21:44 +0100 Subject: [PATCH 21/32] fix bin edges and range widget --- src/plopp/widgets/slicing.py | 73 ++++++++++++++++++++++-------------- 1 file changed, 45 insertions(+), 28 deletions(-) diff --git a/src/plopp/widgets/slicing.py b/src/plopp/widgets/slicing.py index 5b49bfb9..fa2041ef 100644 --- a/src/plopp/widgets/slicing.py +++ b/src/plopp/widgets/slicing.py @@ -185,7 +185,8 @@ def __init__( self.observe(self._on_value_change, names="value") super().__init__([self._widget]) - self.value = self._coord[index] + # self.value = self._coord[index] + self.value = index def _find_closest_index(self, value: float) -> int: return np.argmin(np.abs(self._coord - value)) @@ -194,11 +195,11 @@ def _on_child_change(self, change): if self._lock: return - print("RECEIVED VALUE:", change["new"]) + # print("RECEIVED VALUE:", change["new"]) new = self._find_closest_index(float(change["new"])) - print("closest index found:", new) + # print("closest index found:", new) new = min(max(new, self.min), self.max) - print("clamped index:", new) + # print("clamped index:", new) # Find closest value in allowed values in coord # new = self._find_closest_index(new) self._lock = True @@ -241,7 +242,7 @@ def __init__( self.observe(self._on_value_change, names="value") super().__init__([self._widget]) - self.value = self._coord[index] + self.value = index, index + 1 def _find_closest_index(self, value: float) -> int: return np.argmin(np.abs(self._coord - value)) @@ -251,15 +252,26 @@ def _on_child_change(self, change): return new = [self._find_closest_index(float(x)) for x in change["new"].split(":")] + + if (":" in change["new"]) and (":" in change["old"]): + old2 = float(change["old"].split(":")[1]) + new2 = float(change["new"].split(":")[1]) + if old2 == new2: + new = new[0], new[0] + 1 + else: + new = new[1], new[1] + 1 + # new = self._find_closest_index() new = [min(max(x, self.min), self.max) for x in new] # new = " : ".join( # f"{min(max(float(x), self.min), self.max):.3E}" # for x in change["new"].split(":") # ) + if len(new) == 1: + new = [new[0], new[0] + 1] self._lock = True self._widget.value = " : ".join(f"{self._coord[x]:.3E}" for x in new) - self.value = new[0] + self.value = new self._lock = False def _on_value_change(self, change): @@ -289,7 +301,7 @@ def __init__( # value: float, ): # self._coord = coord - coord_min, coord_max = self._coord.values[0], self._coord.values[-1] + # coord_min, coord_max = self._coord.values[0], self._coord.values[-1] # self._child_lock = False # self._value_lock = False # step = (coord_max - coord_min) / 999 @@ -388,26 +400,30 @@ def __init__( # self._min_widget = BoundWidget(coord=coord, value=coord.values[0]) # self._max_widget = BoundWidget(coord=coord, value=coord.values[-1]) - self._coord = coord - coord_min, coord_max = self._coord.values[0], self._coord.values[-1] + # self._coord = coord + # coord_min, coord_max = self._coord.values[0], self._coord.values[-1] # step = (coord_max - coord_min) / 999 self._min_widget = BoundedText( continuous_update=False, - min=coord_min, - max=coord_max, - # step=step, - value=self._coord.values[index[0]], + # min=coord_min, + # max=coord_max, + # # step=step, + # value=self._coord.values[index[0]], + coord=coord, + index=index[0], layout={"width": "7em"}, ) # self._min_widget.is_lower_bound = True self._max_widget = BoundedText( continuous_update=False, - min=coord_min, - max=coord_max, - # step=step, - value=self._coord.values[index[1]], + # min=coord_min, + # max=coord_max, + # # step=step, + # value=self._coord.values[index[1]], + coord=coord, + index=index[1], layout={"width": "7em"}, ) # print( @@ -501,8 +517,8 @@ def value(self, value: tuple[float, float]): self._min_widget.value = value[0] self._max_widget.value = value[1] - def get_closest_indices(self) -> tuple[int, int]: - return tuple(np.argmin(np.abs(self._coord.values - x)) for x in self.value) + # def get_closest_indices(self) -> tuple[int, int]: + # return tuple(np.argmin(np.abs(self._coord.values - x)) for x in self.value) class DimSlicer(ipw.HBox): @@ -618,7 +634,7 @@ def _update_label(self, change: dict): Update the readout label with the coordinate value, instead of the integer readout index. """ - print("updating label", change) + # print("updating label", change) inds = change["new"] if self._is_bin_edges: if isinstance(inds, tuple): @@ -643,7 +659,7 @@ def _update_label(self, change: dict): # else: # new_bounds = self.coord[self.dim, inds].value - print("updating bounds", inds, self.bounds.value) + # print("updating bounds", inds, self.bounds.value) # self.bounds.value = np.atleast_1d(self.coord[self.dim, inds].values).tolist() self.bounds.value = np.atleast_1d(inds).tolist() self._bounds_lock = False @@ -672,13 +688,14 @@ def _move_slider_to_label(self, change: dict): if self._kind == "range": self.slider.value = inds else: - # Here it means that the user has entered a range in the label, - # but the slider is a single slider. We move the slider to the value - # requested by the bound that was changed. - if change["owner"].is_lower_bound: - self.slider.value = inds[0] - else: - self.slider.value = inds[1] + self.slider.value = inds[0] + # # Here it means that the user has entered a range in the label, + # # but the slider is a single slider. We move the slider to the value + # # requested by the bound that was changed. + # if change["owner"].is_lower_bound: + # self.slider.value = inds[0] + # else: + # self.slider.value = inds[1] @property def value(self) -> int | tuple[int, int]: From 5b55d97b3e261a7d14ac151bcfd29ae497781dc0 Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Wed, 4 Mar 2026 10:12:02 +0100 Subject: [PATCH 22/32] squeeze if possible and fix bug in nanmean --- src/plopp/plotting/slicer.py | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/plopp/plotting/slicer.py b/src/plopp/plotting/slicer.py index f334245b..bdcafe3a 100644 --- a/src/plopp/plotting/slicer.py +++ b/src/plopp/plotting/slicer.py @@ -5,6 +5,7 @@ from itertools import groupby from typing import Literal +import numpy as np import scipp as sc from ..core import Node, widget_node @@ -21,8 +22,16 @@ def _maybe_reduce_dim(da, dims, op): to_be_reduced = set(dims) & set(da.dims) + + # Small optimization: squeezing is much faster than reducing + to_be_squeezed = {dim for dim in to_be_reduced if da.sizes[dim] == 1} + if to_be_squeezed: + da = da.squeeze() + to_be_reduced -= to_be_squeezed + if not to_be_reduced: return da + # If the operation is a mean, there is currently a bug in the implementation # in scipp where doing a mean over a subset of the array's dimensions gives the # wrong result: https://github.com/scipp/scipp/issues/3841 @@ -30,17 +39,18 @@ def _maybe_reduce_dim(da, dims, op): if 'mean' not in op: return getattr(da, op)(dims) - kept_dims = set(da.dims) - to_be_reduced - sliced = da - for dim in kept_dims: - sliced = sliced[dim, 0] + # kept_dims = set(da.dims) - to_be_reduced + # sliced = da + # for dim in kept_dims: + # sliced = sliced[dim, 0] - denominator = sliced.size + # denominator = sliced.size if 'nan' in op: numerator = da.nansum(dims) - denominator = denominator - sc.isnan(sliced.data).sum() + denominator = (~sc.isnan(da)).to(dtype=int).sum(dims) else: numerator = da.sum(dims) + denominator = np.prod([da.sizes[dim] for dim in dims if dim in to_be_reduced]) return numerator / denominator From c2d0dcdecb23910f3dc3c825e14bd27b1fd1e96d Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Wed, 4 Mar 2026 14:00:04 +0100 Subject: [PATCH 23/32] fixes for datetime types --- src/plopp/plotting/slicer.py | 12 ++---- src/plopp/widgets/slicing.py | 74 +++++++++++++++++++++++++++++------- 2 files changed, 64 insertions(+), 22 deletions(-) diff --git a/src/plopp/plotting/slicer.py b/src/plopp/plotting/slicer.py index bdcafe3a..1b9ae8d3 100644 --- a/src/plopp/plotting/slicer.py +++ b/src/plopp/plotting/slicer.py @@ -32,19 +32,13 @@ def _maybe_reduce_dim(da, dims, op): if not to_be_reduced: return da + if 'mean' not in op: + return getattr(da, op)(dims) + # If the operation is a mean, there is currently a bug in the implementation # in scipp where doing a mean over a subset of the array's dimensions gives the # wrong result: https://github.com/scipp/scipp/issues/3841 # Instead, we manually compute the mean - if 'mean' not in op: - return getattr(da, op)(dims) - - # kept_dims = set(da.dims) - to_be_reduced - # sliced = da - # for dim in kept_dims: - # sliced = sliced[dim, 0] - - # denominator = sliced.size if 'nan' in op: numerator = da.nansum(dims) denominator = (~sc.isnan(da)).to(dtype=int).sum(dims) diff --git a/src/plopp/widgets/slicing.py b/src/plopp/widgets/slicing.py index fa2041ef..34abd303 100644 --- a/src/plopp/widgets/slicing.py +++ b/src/plopp/widgets/slicing.py @@ -159,6 +159,10 @@ def _format_float(x: float, prec=3) -> float: # # widget.value = str(value) +# def _make_(x: int, scale_factor: float = 1.0) -> str: +# return f"{x * scale_factor}ch" + + class BoundedText(ipw.HBox, ipw.ValueWidget): value = Any().tag(sync=True) @@ -170,13 +174,32 @@ def __init__( # min: float | None = None, # max: float | None = None, continuous_update: bool = False, + layout=None, **kwargs, ): self._lock = False self._coord = coord.values + if self._coord.dtype not in (sc.DType.datetime64, sc.DType.string): + self._underlying = self._coord + self._fmt = ".3E" + if layout is None: + layout = {"width": "11.5ch"} + else: + if self._coord.dtype == sc.DType.datetime64: + self._underlying = coord.to(unit="ns").values.astype(int) + else: + self._underlying = self._coord + self._fmt = "" + if layout is None: + layout = {"width": f"{len(str(self._coord[-1])) * 1.02}ch"} + # self._index = index # coord_min, coord_max = self._coord.values[0], self._coord.values[-1] - self._widget = ipw.Text(continuous_update=continuous_update, value="", **kwargs) + # if layout is None: + # layout = {"width": "7em"} + self._widget = ipw.Text( + continuous_update=continuous_update, value="", layout=layout, **kwargs + ) self.min = 0 # self._coord[0] self.max = len(self._coord) # [-1] # observe user edits @@ -188,22 +211,33 @@ def __init__( # self.value = self._coord[index] self.value = index - def _find_closest_index(self, value: float) -> int: - return np.argmin(np.abs(self._coord - value)) + def _find_closest_index(self, value: str) -> int: + try: + v = float(value) + except ValueError: + v = sc.datetime(value).to(unit="ns").value.astype(int) + return np.argmin(np.abs(self._underlying - v)) def _on_child_change(self, change): if self._lock: return # print("RECEIVED VALUE:", change["new"]) - new = self._find_closest_index(float(change["new"])) + new = self._find_closest_index(change["new"]) + if new is None: + # Could not find a matching index (probably in the case of strings) + return # print("closest index found:", new) new = min(max(new, self.min), self.max) # print("clamped index:", new) # Find closest value in allowed values in coord # new = self._find_closest_index(new) self._lock = True - self._widget.value = f"{self._coord[new]:.3E}" + self._widget.value = f"{self._coord[new]:{self._fmt}}" + # if self._is_numeric: + # self._widget.value = f"{self._coord[new]:{self._fmt}}" + # else: + # self._widget.value = str(self._coord[new]) self.value = new self._lock = False @@ -214,7 +248,7 @@ def _on_value_change(self, change): new = min(max(change["new"], self.min), self.max) # new = self._find_closest_value(new) self._lock = True - self._widget.value = f"{self._coord[new]:.3E}" + self._widget.value = f"{self._coord[new]:{self._fmt}}" self._lock = False @@ -229,11 +263,25 @@ def __init__( # min: float | None = None, # max: float | None = None, continuous_update: bool = False, + layout=None, **kwargs, ): self._lock = False self._coord = coord.values - self._widget = ipw.Text(continuous_update=continuous_update, value="", **kwargs) + if self._coord.dtype not in (sc.DType.datetime64, sc.DType.string): + self._fmt = ".3E" + if layout is None: + # 10 + 10 characters for the two bounds, plus 3 for " : " = 23 + layout = {"width": "22.5ch"} + else: + self._fmt = "" + if layout is None: + # n_em = len(f"{self._coord[-2]} : {self._coord[-1]}") * 7 // 10 + layout = {"width": f"{0.92 * (len(str(self._coord[-1])) * 2 + 3)}ch"} + + self._widget = ipw.Text( + continuous_update=continuous_update, value="", layout=layout, **kwargs + ) self.min = 0 self.max = len(self._coord) - 1 # observe user edits @@ -270,7 +318,7 @@ def _on_child_change(self, change): if len(new) == 1: new = [new[0], new[0] + 1] self._lock = True - self._widget.value = " : ".join(f"{self._coord[x]:.3E}" for x in new) + self._widget.value = " : ".join(f"{self._coord[x]:{self._fmt}}" for x in new) self.value = new self._lock = False @@ -289,7 +337,7 @@ def _on_value_change(self, change): new = [min(max(x, self.min), self.max) for x in change["new"]] self._lock = True - self._widget.value = " : ".join(f"{self._coord[x]:.3E}" for x in new) + self._widget.value = " : ".join(f"{self._coord[x]:{self._fmt}}" for x in new) self._lock = False @@ -312,7 +360,7 @@ def __init__( # value=self._coord.values[index], coord=coord, index=index, - layout={"width": "7em"}, + # layout={"width": "7em"}, ) # observe user edits @@ -349,7 +397,7 @@ def __init__(self, coord: sc.Variable, index: int): # min=coord_min, # max=coord_max, # value=tuple(self._coord.values[i] for i in (index, index + 1)), - layout={"width": "12em"}, + # layout={"width": "12em"}, ) super().__init__([self._widget]) @@ -412,7 +460,7 @@ def __init__( # value=self._coord.values[index[0]], coord=coord, index=index[0], - layout={"width": "7em"}, + # layout={"width": "7em"}, ) # self._min_widget.is_lower_bound = True @@ -424,7 +472,7 @@ def __init__( # value=self._coord.values[index[1]], coord=coord, index=index[1], - layout={"width": "7em"}, + # layout={"width": "7em"}, ) # print( # "min val", From f430d542146807dd67121d96fb5ab032a534be8d Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Wed, 4 Mar 2026 14:18:05 +0100 Subject: [PATCH 24/32] add tests for datetime and binedges --- src/plopp/plotting/slicer.py | 10 +++--- tests/plotting/slicer_test.py | 64 ++++++++++++++++------------------- 2 files changed, 35 insertions(+), 39 deletions(-) diff --git a/src/plopp/plotting/slicer.py b/src/plopp/plotting/slicer.py index 1b9ae8d3..4abeca97 100644 --- a/src/plopp/plotting/slicer.py +++ b/src/plopp/plotting/slicer.py @@ -33,18 +33,18 @@ def _maybe_reduce_dim(da, dims, op): return da if 'mean' not in op: - return getattr(da, op)(dims) + return getattr(da, op)(to_be_reduced) # If the operation is a mean, there is currently a bug in the implementation # in scipp where doing a mean over a subset of the array's dimensions gives the # wrong result: https://github.com/scipp/scipp/issues/3841 # Instead, we manually compute the mean if 'nan' in op: - numerator = da.nansum(dims) - denominator = (~sc.isnan(da)).to(dtype=int).sum(dims) + numerator = da.nansum(to_be_reduced) + denominator = (~sc.isnan(da)).to(dtype=int).sum(to_be_reduced) else: - numerator = da.sum(dims) - denominator = np.prod([da.sizes[dim] for dim in dims if dim in to_be_reduced]) + numerator = da.sum(to_be_reduced) + denominator = np.prod([da.sizes[dim] for dim in to_be_reduced]) return numerator / denominator diff --git a/tests/plotting/slicer_test.py b/tests/plotting/slicer_test.py index 53abfe56..ef8c18ec 100644 --- a/tests/plotting/slicer_test.py +++ b/tests/plotting/slicer_test.py @@ -183,25 +183,19 @@ def test_raises_when_number_of_keep_dims_requested_is_bad(self): ): Slicer(da, keep=[], mode='single') - def test_bounds_text_boxes(self): - da = data_array(ndim=3) - sl = Slicer(da, keep=['xx'], mode='range') - assert sl.slider.value == {'zz': (0, 29), 'yy': (0, 39)} - sl.slider.controls['yy'].bound_min.value = 12 - assert sl.slider.value == {'zz': (0, 29), 'yy': (12, 39)} - sl.slider.controls['yy'].bound_max.value = 20 - assert sl.slider.value == {'zz': (0, 29), 'yy': (12, 20)} - # Check that entered value snaps to nearest integer - sl.slider.controls['yy'].bound_min.value = 13.3 - assert sl.slider.value == {'zz': (0, 29), 'yy': (13, 20)} - sl.slider.controls['yy'].bound_max.value = 18.7 - assert sl.slider.value == {'zz': (0, 29), 'yy': (13, 19)} - @pytest.mark.usefixtures("_parametrize_interactive_2d_backends") class TestSlicer2d: - def test_creation_keep_two_dims_single_mode(self): - da = data_array(ndim=3) + @pytest.mark.parametrize("binedges", [False, True]) + @pytest.mark.parametrize("datetime", [False, True]) + def test_creation_keep_two_dims_single_mode(self, binedges, datetime): + da = data_array(ndim=3, binedges=binedges) + if datetime: + da.coords['zz'] = sc.arange( + 'zz', + sc.datetime('2022-02-20T04:32:00'), + sc.datetime(f'2022-02-20T04:32:{da.sizes["zz"]}'), + ) sl = Slicer(da, keep=['xx', 'yy'], mode='single') assert sl.slider.value == {'zz': 14} assert sl.slider.controls['zz'].slider.max == da.sizes['zz'] - 1 @@ -216,8 +210,16 @@ def test_update_keep_two_dims_single_mode(self): assert sl.slider.value == {'zz': 5} assert_identical(sl.slice_nodes[0].request_data(), da['zz', 5]) - def test_creation_keep_two_dims_range_mode(self): - da = data_array(ndim=3) + @pytest.mark.parametrize("binedges", [False, True]) + @pytest.mark.parametrize("datetime", [False, True]) + def test_creation_keep_two_dims_range_mode(self, binedges, datetime): + da = data_array(ndim=3, binedges=binedges) + if datetime: + da.coords['zz'] = sc.arange( + 'zz', + sc.datetime('2022-02-20T04:32:00'), + sc.datetime(f'2022-02-20T04:32:{da.sizes["zz"]}'), + ) sl = Slicer(da, keep=['xx', 'yy'], mode='range') assert sl.slider.value == {'zz': (0, 29)} assert sl.slider.controls['zz'].slider.max == da.sizes['zz'] - 1 @@ -240,8 +242,16 @@ def test_update_keep_two_dims_range_mode(self): da['zz', 5:16].sum('zz'), ) - def test_creation_keep_two_dims_combined_mode(self): - da = data_array(ndim=3) + @pytest.mark.parametrize("binedges", [False, True]) + @pytest.mark.parametrize("datetime", [False, True]) + def test_creation_keep_two_dims_combined_mode(self, binedges, datetime): + da = data_array(ndim=3, binedges=binedges) + if datetime: + da.coords['zz'] = sc.arange( + 'zz', + sc.datetime('2022-02-20T04:32:00'), + sc.datetime(f'2022-02-20T04:32:{da.sizes["zz"]}'), + ) sl = Slicer(da, keep=['xx', 'yy'], mode='combined') assert sl.slider.value == {'zz': (0, 29)} assert sl.slider.controls['zz'].slider.max == da.sizes['zz'] - 1 @@ -312,17 +322,3 @@ def test_no_autoscale(self): # Colormapper range does not change assert cm.vmin == 5 * 10 * 9 assert cm.vmax == 5 * 10 * 10 - 1 - - def test_bounds_text_boxes(self): - da = data_array(ndim=3) - sl = Slicer(da, keep=['xx', 'yy'], mode='range') - assert sl.slider.value == {'zz': (0, 29)} - sl.slider.controls['zz'].bound_min.value = 12 - assert sl.slider.value == {'zz': (12, 29)} - sl.slider.controls['zz'].bound_max.value = 20 - assert sl.slider.value == {'zz': (12, 20)} - # Check that entered value snaps to nearest integer - sl.slider.controls['zz'].bound_min.value = 13.3 - assert sl.slider.value == {'zz': (13, 20)} - sl.slider.controls['zz'].bound_max.value = 18.7 - assert sl.slider.value == {'zz': (13, 19)} From be6af6a93b0a9e31555353abaae63f596d029709 Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Wed, 4 Mar 2026 14:23:58 +0100 Subject: [PATCH 25/32] cleanup --- src/plopp/widgets/slicing.py | 419 ++--------------------------------- 1 file changed, 14 insertions(+), 405 deletions(-) diff --git a/src/plopp/widgets/slicing.py b/src/plopp/widgets/slicing.py index 34abd303..70c6ceef 100644 --- a/src/plopp/widgets/slicing.py +++ b/src/plopp/widgets/slicing.py @@ -3,164 +3,21 @@ from functools import partial -# from typing import Any import ipywidgets as ipw import numpy as np import scipp as sc -from traitlets import Any, Tuple +from traitlets import Any from ..core import node from .box import VBar -def _format_float(x: float, prec=3) -> float: +def _find_closest_index(coord, value: str) -> int: try: - return round(x, prec) - except TypeError: - return x - - -# class BoundWidget(ipw.HBox, ipw.ValueWidget): -# value = Any().tag(sync=True) - -# def __init__(self, coord: sc.Variable, value: float): -# self._coord = coord -# # self._child_lock = False -# # self._value_lock = False -# coord_min, coord_max = self._coord.values[0], self._coord.values[-1] -# if self._coord.dtype != sc.DType.datetime64: -# self._widget = ipw.BoundedFloatText( -# continuous_update=False, -# min=coord_min, -# max=coord_max, -# step=(coord_max - coord_min) / 999, -# value=value, -# layout={"width": "6em"}, -# ) -# self._is_float = True -# else: -# self._widget = ipw.Text( -# continuous_update=False, value=str(value), layout={"width": "10em"} -# ) -# self._is_float = False - -# # observe user edits -# self._widget.observe(self._on_child_change, names="value") -# # observe external value changes -# self.observe(self._on_value_change, names="value") - -# super().__init__([self._widget]) -# self.value = self._widget.value - -# def _on_child_change(self, change): -# # print( -# # "BOUND, on_child_change: value_lock", -# # self._value_lock, -# # "child_lock", -# # self._child_lock, -# # ) -# # if self._child_lock: -# # return -# if self._widget.value == change["new"]: -# return - -# self._value_lock = True -# self.value = float(self._widget.value) -# self._value_lock = False - -# # def _on_value_change(self, change): -# # if self._lock: -# # return - -# # self._lock = True -# # if self._is_float: -# # self._widget.value = round(change["new"], 3) -# # else: -# # self._widget.value = str(change["new"]) -# # self._lock = False - -# def _on_value_change(self, change): -# # if self._is_float: -# # new = round(change["new"], 3) -# # else: -# # new = str(change["new"]) - -# # if new != self._widget.value: -# # self._widget.value = new -# print( -# "BOUND, on_VALUE_change: value_lock", -# self._value_lock, -# "child_lock", -# self._child_lock, -# ) - -# if self._value_lock: -# return - -# self._child_lock = True -# if self._is_float: -# self._widget.value = round(change["new"], 3) -# else: -# self._widget.value = str(change["new"]) -# self._child_lock = False - -# @property -# def min(self) -> float | None: -# return getattr(self._widget, "min", None) - -# @min.setter -# def min(self, value: float): -# if self._is_float: -# self._widget.min = value - -# @property -# def max(self) -> float | None: -# return getattr(self._widget, "max", None) - -# @max.setter -# def max(self, value: float): -# if self._is_float: -# self._widget.max = value - -# # def _on_subwidget_change(self, _=None): -# # """ """ -# # if self._is_float: -# # self.value = round(value, 3) -# # else: -# # self._widget.value = str(value) -# # self.value = {dim: slicer.value for dim, slicer in self.controls.items()} - -# # @property -# # def value(self) -> float: -# # return float(self._widget.value) - -# # @value.setter -# # def value(self, value: float): -# # if self._is_float: -# # self._widget.value = round(value, 3) -# # else: -# # self._widget.value = str(value) - -# def get_closest_index(self) -> int: -# """ -# Get the index of the coordinate value closest to the one in the widget. -# """ -# # if self._is_float: -# # value = self.value -# # else: -# # value = sc.scalar(self._widget.value, dtype=self._coord.dtype) -# return np.argmin(np.abs(self._coord.values - self.value)) - - -# # def _set_bound_widget_value(widget: ipw.Widget, value: float): -# # if isinstance(widget, ipw.BoundedFloatText): -# # widget.value = value -# # elif isinstance(widget, ipw.Text): -# # widget.value = str(value) - - -# def _make_(x: int, scale_factor: float = 1.0) -> str: -# return f"{x * scale_factor}ch" + v = float(value) + except ValueError: + v = sc.datetime(value).to(unit="ns").value.astype(int) + return np.argmin(np.abs(coord - v)) class BoundedText(ipw.HBox, ipw.ValueWidget): @@ -170,9 +27,6 @@ def __init__( self, coord: sc.Variable, index: int, - # value: float | str, - # min: float | None = None, - # max: float | None = None, continuous_update: bool = False, layout=None, **kwargs, @@ -193,51 +47,27 @@ def __init__( if layout is None: layout = {"width": f"{len(str(self._coord[-1])) * 1.02}ch"} - # self._index = index - # coord_min, coord_max = self._coord.values[0], self._coord.values[-1] - # if layout is None: - # layout = {"width": "7em"} self._widget = ipw.Text( continuous_update=continuous_update, value="", layout=layout, **kwargs ) - self.min = 0 # self._coord[0] - self.max = len(self._coord) # [-1] + self.min = 0 + self.max = len(self._coord) # observe user edits self._widget.observe(self._on_child_change, names="value") # observe external value changes self.observe(self._on_value_change, names="value") super().__init__([self._widget]) - # self.value = self._coord[index] self.value = index - def _find_closest_index(self, value: str) -> int: - try: - v = float(value) - except ValueError: - v = sc.datetime(value).to(unit="ns").value.astype(int) - return np.argmin(np.abs(self._underlying - v)) - def _on_child_change(self, change): if self._lock: return - # print("RECEIVED VALUE:", change["new"]) - new = self._find_closest_index(change["new"]) - if new is None: - # Could not find a matching index (probably in the case of strings) - return - # print("closest index found:", new) + new = _find_closest_index(coord=self._underlying, value=change["new"]) new = min(max(new, self.min), self.max) - # print("clamped index:", new) - # Find closest value in allowed values in coord - # new = self._find_closest_index(new) self._lock = True self._widget.value = f"{self._coord[new]:{self._fmt}}" - # if self._is_numeric: - # self._widget.value = f"{self._coord[new]:{self._fmt}}" - # else: - # self._widget.value = str(self._coord[new]) self.value = new self._lock = False @@ -246,7 +76,6 @@ def _on_value_change(self, change): return new = min(max(change["new"], self.min), self.max) - # new = self._find_closest_value(new) self._lock = True self._widget.value = f"{self._coord[new]:{self._fmt}}" self._lock = False @@ -259,9 +88,6 @@ def __init__( self, coord: sc.Variable, index: int, - # value: float | str, - # min: float | None = None, - # max: float | None = None, continuous_update: bool = False, layout=None, **kwargs, @@ -271,12 +97,10 @@ def __init__( if self._coord.dtype not in (sc.DType.datetime64, sc.DType.string): self._fmt = ".3E" if layout is None: - # 10 + 10 characters for the two bounds, plus 3 for " : " = 23 layout = {"width": "22.5ch"} else: self._fmt = "" if layout is None: - # n_em = len(f"{self._coord[-2]} : {self._coord[-1]}") * 7 // 10 layout = {"width": f"{0.92 * (len(str(self._coord[-1])) * 2 + 3)}ch"} self._widget = ipw.Text( @@ -292,14 +116,14 @@ def __init__( super().__init__([self._widget]) self.value = index, index + 1 - def _find_closest_index(self, value: float) -> int: - return np.argmin(np.abs(self._coord - value)) - def _on_child_change(self, change): if self._lock: return - new = [self._find_closest_index(float(x)) for x in change["new"].split(":")] + new = [ + _find_closest_index(coord=self._coord, value=x) + for x in change["new"].split(":") + ] if (":" in change["new"]) and (":" in change["old"]): old2 = float(change["old"].split(":")[1]) @@ -309,12 +133,7 @@ def _on_child_change(self, change): else: new = new[1], new[1] + 1 - # new = self._find_closest_index() new = [min(max(x, self.min), self.max) for x in new] - # new = " : ".join( - # f"{min(max(float(x), self.min), self.max):.3E}" - # for x in change["new"].split(":") - # ) if len(new) == 1: new = [new[0], new[0] + 1] self._lock = True @@ -326,15 +145,6 @@ def _on_value_change(self, change): if self._lock: return - # if ":" in change["new"]: - # val = float(change["new"].split(":")[0]) - # else: - # val = float(change["new"]) - - # print("CHANGE NEW") - # print(change["new"]) - # print(list(float(x) for x in change["new"])) - new = [min(max(x, self.min), self.max) for x in change["new"]] self._lock = True self._widget.value = " : ".join(f"{self._coord[x]:{self._fmt}}" for x in new) @@ -346,28 +156,13 @@ def __init__( self, coord: sc.Variable, index: int, - # value: float, ): - # self._coord = coord - # coord_min, coord_max = self._coord.values[0], self._coord.values[-1] - # self._child_lock = False - # self._value_lock = False - # step = (coord_max - coord_min) / 999 self._widget = BoundedText( continuous_update=False, - # min=coord_min, - # max=coord_max, - # value=self._coord.values[index], coord=coord, index=index, - # layout={"width": "7em"}, ) - # observe user edits - # self._widget.observe(self._on_child_change, names="value") - # observe external value changes - # self.observe(self._on_value_change, names="value") - super().__init__([self._widget]) @property @@ -381,31 +176,18 @@ def value(self, value: tuple[float]): def set_observe_callback(self, callback: callable, **kwargs): self._widget.observe(callback, **kwargs) - # def get_closest_indices(self) -> tuple[int]: - # return (np.argmin(np.abs(self._coord.values - self.value)),) - class BoundsSingleBinEdgesWidget(ipw.HBox): def __init__(self, coord: sc.Variable, index: int): - # ind = coord.size - # self._coord = coord - # coord_min, coord_max = self._coord.values[0], self._coord.values[-1] self._widget = BoundedBinEdgeText( continuous_update=False, coord=coord, index=index, - # min=coord_min, - # max=coord_max, - # value=tuple(self._coord.values[i] for i in (index, index + 1)), - # layout={"width": "12em"}, ) super().__init__([self._widget]) @property def value(self) -> float: - # if ":" in self._widget.value: - # return float(self._widget.value.split(":")[0]) - # return float(self._widget.value) return self._widget.value @value.setter @@ -415,149 +197,46 @@ def value(self, value: tuple[float]): def set_observe_callback(self, callback: callable, **kwargs): self._widget.observe(callback, **kwargs) - # def get_closest_indices(self) -> tuple[int]: - # return (np.argmin(np.abs(self._coord.values - self.value)),) - - -# TODO -# make a class BoundsRangeWidgetDatetime - - -# New plan: -# - 2 bound widgets for range slider -# - 1 bound widget for single value midpoints -# - 1 new widget with Text for single value with bin edges: separate edges of current bin with : -# - make corresponding widgets for datetime which use Text - class BoundsRangeWidget(ipw.HBox): - # value = Tuple(Any(), Any()).tag(sync=True) - # value = Any().tag(sync=True) - def __init__( self, coord: sc.Variable, index: tuple[int, int], - # value_min: float, - # value_max: float, ): - # coord_min, coord_max = coord.values[0], coord.values[-1] - # self._child_lock = False - # self._value_lock = False - - # self._min_widget = BoundWidget(coord=coord, value=coord.values[0]) - # self._max_widget = BoundWidget(coord=coord, value=coord.values[-1]) - - # self._coord = coord - # coord_min, coord_max = self._coord.values[0], self._coord.values[-1] - # step = (coord_max - coord_min) / 999 self._min_widget = BoundedText( continuous_update=False, - # min=coord_min, - # max=coord_max, - # # step=step, - # value=self._coord.values[index[0]], coord=coord, index=index[0], - # layout={"width": "7em"}, ) - # self._min_widget.is_lower_bound = True self._max_widget = BoundedText( continuous_update=False, - # min=coord_min, - # max=coord_max, - # # step=step, - # value=self._coord.values[index[1]], coord=coord, index=index[1], - # layout={"width": "7em"}, ) - # print( - # "min val", - # self._min_widget.value, - # _round_float(self._coord.values[index[0]]), - # ) - # print( - # "max val", - # self._max_widget.value, - # _round_float(self._coord.values[index[1]]), - # ) - # self._max_widget.is_lower_bound = False - - # ipw.link((self._min_widget, 'max'), (self._max_widget, 'value')) - # ipw.link((self._max_widget, 'min'), (self._min_widget, 'value')) self._min_widget.observe(self._on_min_change, names='value') self._max_widget.observe(self._on_max_change, names='value') - # observe user edits - # self._min_widget.observe(self._on_child_change, names="value") - # self._max_widget.observe(self._on_child_change, names="value") - # observe external value changes - # self.observe(self._on_value_change, names="value") - super().__init__([self._min_widget, ipw.Label(value=":"), self._max_widget]) - # self.value = self._min_widget.value, self._max_widget.value - def set_observe_callback(self, callback: callable, **kwargs): self._min_widget.observe(callback, **kwargs) self._max_widget.observe(callback, **kwargs) def _on_min_change(self, change: dict): - # if self._max_widget._is_float: self._max_widget.min = change["new"] def _on_max_change(self, change: dict): - # if self._min_widget._is_float: self._min_widget.max = change["new"] - # def _on_child_change(self, _): - # # # if self._max_widget._is_float: - # # self._max_widget.min = self._min_widget.value - # # # if self._min_widget._is_float: - # # self._min_widget.max = self._max_widget.value - - # if self._widget.value == change["new"]: - # return - - # if self._child_lock: - # return - - # print("bounds ON_CHILD_CHANGE", self._min_widget.value, self._max_widget.value) - # self._value_lock = True - # self.value = self._min_widget.value, self._max_widget.value - # self._value_lock = False - - # def _on_value_change(self, change): - # print( - # "bounds on_value_change", - # self._min_widget.value, - # self._max_widget.value, - # 'value_lock', - # self._value_lock, - # ) - # if self._value_lock: - # return - - # self._child_lock = True - # # self._widget.value = change["new"][0] - # if change["new"][0] > self._max_widget.value: - # self._max_widget.value = change["new"][1] - # self._min_widget.value = change["new"][0] - # else: - # self._min_widget.value = change["new"][0] - # self._max_widget.value = change["new"][1] - # self._child_lock = False - @property def value(self) -> tuple[float, float]: return self._min_widget.value, self._max_widget.value @value.setter def value(self, value: tuple[float, float]): - # new_bounds = tuple(_round_float(v) for v in self.coord[self.dim, inds].values) if value[0] > float(self._max_widget.value): self._max_widget.value = value[1] self._min_widget.value = value[0] @@ -565,9 +244,6 @@ def value(self, value: tuple[float, float]): self._min_widget.value = value[0] self._max_widget.value = value[1] - # def get_closest_indices(self) -> tuple[int, int]: - # return tuple(np.argmin(np.abs(self._coord.values - x)) for x in self.value) - class DimSlicer(ipw.HBox): def __init__( @@ -612,10 +288,6 @@ def __init__( layout={"width": "1.52em"}, ) - # if self._is_bin_edges or (self._kind == "range"): - # self.bounds = BoundsRangeWidget(coord=self.coord, index=value) - # else: - # self.bounds = BoundsSingleWidget(coord=self.coord, index=value) if self._kind == "range": self.bounds = BoundsRangeWidget(coord=self.coord, index=value) elif self._is_bin_edges: @@ -623,24 +295,6 @@ def __init__( else: self.bounds = BoundsSingleWidget(coord=self.coord, index=value) - # self.bound_min = _make_bound_widget( - # coord=self.coord, - # coord_min=self.coord_min, - # coord_max=self.coord_max, - # value=self.coord_min, - # ) - # if self._is_bin_edges or (self._kind == "range"): - # self.bound_max = _make_bound_widget( - # coord=self.coord, - # coord_min=self.coord_min, - # coord_max=self.coord_max, - # value=self.coord_max, - # ) - # ipw.link((self.bound_min, 'max'), (self.bound_max, 'value')) - # ipw.link((self.bound_max, 'min'), (self.bound_min, 'value')) - # else: - # self.bound_max = None - self.unit = ipw.Label( "" if self.coord.unit is None else f" [{self.coord.unit}]" ) @@ -648,15 +302,7 @@ def __init__( (self.continuous_update, 'value'), (self.slider, 'continuous_update') ) - children = [ - self.dim_label, - self.slider, - self.continuous_update, - self.bounds, - ] - # if self.bound_max is not None: - # children.append(ipw.Label(value=":")) - # children.append(self.bound_max) + children = [self.dim_label, self.slider, self.continuous_update, self.bounds] children.append(self.unit) if enable_player: self.player = ipw.Play( @@ -682,7 +328,6 @@ def _update_label(self, change: dict): Update the readout label with the coordinate value, instead of the integer readout index. """ - # print("updating label", change) inds = change["new"] if self._is_bin_edges: if isinstance(inds, tuple): @@ -690,25 +335,6 @@ def _update_label(self, change: dict): else: inds = (inds, inds + 1) self._bounds_lock = True - # if isinstance(inds, tuple): - # new_bounds = tuple( - # _round_float(v) for v in self.coord[self.dim, inds].values - # ) - # if new_bounds[0] > float(self.bound_max.value): - # self.bound_max.value = str(new_bounds[1]) - # self.bound_min.value = str(new_bounds[0]) - # else: - # self.bound_min.value = str(new_bounds[0]) - # self.bound_max.value = str(new_bounds[1]) - # else: - # self.bound_min.value = str(_round_float(self.coord[self.dim, inds].value)) - # if isinstance(inds, tuple): - # new_bounds = self.coord[self.dim, inds].values - - # else: - # new_bounds = self.coord[self.dim, inds].value - # print("updating bounds", inds, self.bounds.value) - # self.bounds.value = np.atleast_1d(self.coord[self.dim, inds].values).tolist() self.bounds.value = np.atleast_1d(inds).tolist() self._bounds_lock = False @@ -717,18 +343,8 @@ def _move_slider_to_label(self, change: dict): Move the slider to the position corresponding to the coordinate value in the label, if possible. """ - # print("move slider to label", change["new"]) if self._bounds_lock: return - # # Find the index of the coordinate value closest to the one in the label. - # if self.bound_max is None: - # self.slider.value = np.argmin(np.abs(self.coord.values - change["new"])) - # else: - # # vmin, vmax = self.bound_min.value, self.bound_max.value - # # bounds = tuple( - # # np.argmin(np.abs(self.coord.values - x)) for x in (vmin, vmax) - # # ) - # inds = self.bounds.get_closest_indices() inds = self.bounds.value if len(inds) == 1: self.slider.value = inds[0] @@ -737,13 +353,6 @@ def _move_slider_to_label(self, change: dict): self.slider.value = inds else: self.slider.value = inds[0] - # # Here it means that the user has entered a range in the label, - # # but the slider is a single slider. We move the slider to the value - # # requested by the bound that was changed. - # if change["owner"].is_lower_bound: - # self.slider.value = inds[0] - # else: - # self.slider.value = inds[1] @property def value(self) -> int | tuple[int, int]: From e35c8b8bf2fb31abf63f234cadc1b1c6d096c7de Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Wed, 4 Mar 2026 14:26:05 +0100 Subject: [PATCH 26/32] fix slider mode --- src/plopp/plotting/superplot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plopp/plotting/superplot.py b/src/plopp/plotting/superplot.py index 3f26a764..d4b66155 100644 --- a/src/plopp/plotting/superplot.py +++ b/src/plopp/plotting/superplot.py @@ -113,7 +113,7 @@ def superplot( slicer = Slicer( obj, keep=keep, - slider_mode='single', + mode='single', aspect=aspect, autoscale=autoscale, coords=coords, From cddf7328bcab6d009ea3f610b58f46e9aeb3a84f Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Wed, 4 Mar 2026 13:33:17 +0000 Subject: [PATCH 27/32] Apply automatic formatting --- src/plopp/widgets/slicing.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/plopp/widgets/slicing.py b/src/plopp/widgets/slicing.py index 70c6ceef..3f44fad0 100644 --- a/src/plopp/widgets/slicing.py +++ b/src/plopp/widgets/slicing.py @@ -204,7 +204,6 @@ def __init__( coord: sc.Variable, index: tuple[int, int], ): - self._min_widget = BoundedText( continuous_update=False, coord=coord, From 12d1790630e16adc56790a3747b68be47159bd1c Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Wed, 4 Mar 2026 15:36:24 +0100 Subject: [PATCH 28/32] fix one test, remove another because it would involve too many implementation details --- src/plopp/widgets/slicing.py | 8 +++++--- tests/widgets/slice_test.py | 19 ++----------------- 2 files changed, 7 insertions(+), 20 deletions(-) diff --git a/src/plopp/widgets/slicing.py b/src/plopp/widgets/slicing.py index 3f44fad0..a7960d88 100644 --- a/src/plopp/widgets/slicing.py +++ b/src/plopp/widgets/slicing.py @@ -294,9 +294,7 @@ def __init__( else: self.bounds = BoundsSingleWidget(coord=self.coord, index=value) - self.unit = ipw.Label( - "" if self.coord.unit is None else f" [{self.coord.unit}]" - ) + self.unit = ipw.Label("" if self.coord.unit is None else f"[{self.coord.unit}]") ipw.jslink( (self.continuous_update, 'value'), (self.slider, 'continuous_update') ) @@ -414,6 +412,10 @@ def toggle_slider_mode(self, change): def slider(self) -> ipw.Widget: return self.range_slicer.slider + @property + def dim_label(self) -> ipw.Label: + return self.range_slicer.dim_label + @property def value(self) -> int | tuple[int, int]: """ diff --git a/tests/widgets/slice_test.py b/tests/widgets/slice_test.py index 47208cfc..44a342a6 100644 --- a/tests/widgets/slice_test.py +++ b/tests/widgets/slice_test.py @@ -5,10 +5,10 @@ from scipp import identical from plopp.data.testing import data_array -from plopp.widgets import RangeSliceWidget, SliceWidget, slice_dims +from plopp.widgets import CombinedSliceWidget, RangeSliceWidget, SliceWidget, slice_dims -@pytest.mark.parametrize("widget", [SliceWidget, RangeSliceWidget]) +@pytest.mark.parametrize("widget", [SliceWidget, RangeSliceWidget, CombinedSliceWidget]) def test_slice_creation(widget): da = data_array(ndim=3) sw = widget(da, dims=['yy', 'xx']) @@ -29,21 +29,6 @@ def test_slice_value_property(): assert sw.value == {'xx': 10, 'yy': 15} -def test_slice_label_updates(): - da = data_array(ndim=3) - da.coords['xx'] *= 1.1 - da.coords['yy'] *= 3.3 - sw = SliceWidget(da, dims=['yy', 'xx']) - sw.controls['xx'].value = 0 - sw.controls['yy'].value = 0 - assert sw.controls['xx'].label.value == '0.0 [m]' - sw.controls['xx'].value = 10 - assert sw.controls['xx'].label.value == '11.0 [m]' - assert sw.controls['yy'].label.value == '0.0 [m]' - sw.controls['yy'].value = 15 - assert sw.controls['yy'].label.value == '49.5 [m]' - - def test_make_slice_widget_with_player(): da = data_array(ndim=3) sw = SliceWidget(da, dims=['zz'], enable_player=True) From 87e9c7814322f2f2cc067395b389e8613045c086 Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Wed, 4 Mar 2026 15:55:25 +0100 Subject: [PATCH 29/32] revert removed tests --- src/plopp/widgets/slicing.py | 12 ++++++++++ tests/widgets/slice_test.py | 46 ++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/src/plopp/widgets/slicing.py b/src/plopp/widgets/slicing.py index a7960d88..dbb17dba 100644 --- a/src/plopp/widgets/slicing.py +++ b/src/plopp/widgets/slicing.py @@ -173,6 +173,10 @@ def value(self) -> float: def value(self, value: tuple[float]): self._widget.value = value[0] + @property + def string_value(self) -> str: + return str(self._widget._widget.value) + def set_observe_callback(self, callback: callable, **kwargs): self._widget.observe(callback, **kwargs) @@ -194,6 +198,10 @@ def value(self) -> float: def value(self, value: tuple[float]): self._widget.value = value + @property + def string_value(self) -> str: + return str(self._widget._widget.value) + def set_observe_callback(self, callback: callable, **kwargs): self._widget.observe(callback, **kwargs) @@ -243,6 +251,10 @@ def value(self, value: tuple[float, float]): self._min_widget.value = value[0] self._max_widget.value = value[1] + @property + def string_value(self) -> str: + return f"{self._min_widget._widget.value} : {self._max_widget._widget.value}" + class DimSlicer(ipw.HBox): def __init__( diff --git a/tests/widgets/slice_test.py b/tests/widgets/slice_test.py index 44a342a6..01a8944d 100644 --- a/tests/widgets/slice_test.py +++ b/tests/widgets/slice_test.py @@ -29,6 +29,52 @@ def test_slice_value_property(): assert sw.value == {'xx': 10, 'yy': 15} +def test_slice_label_updates_single_slice(): + da = data_array(ndim=3) + da.coords['xx'] *= 1.1 + da.coords['yy'] *= 3.3 + sw = SliceWidget(da, dims=['yy', 'xx']) + sw.controls['xx'].value = 0 + sw.controls['yy'].value = 0 + assert sw.controls['xx'].unit.value == '[m]' + assert float(sw.controls['xx'].bounds.string_value) == 0.0 + sw.controls['xx'].value = 10 + assert float(sw.controls['xx'].bounds.string_value) == 11.0 + assert float(sw.controls['yy'].bounds.string_value) == 0.0 + sw.controls['yy'].value = 15 + assert float(sw.controls['yy'].bounds.string_value) == 49.5 + + +def test_slice_label_updates_single_binedge_slice(): + da = data_array(ndim=3, binedges=True) + da.coords['zz'] *= 2.2 + sw = SliceWidget(da, dims=['zz']) + sw.controls['zz'].value = 0 + assert sw.controls['zz'].unit.value == '[m]' + bounds = sw.controls['zz'].bounds.string_value.split(":") + assert float(bounds[0]) == 0.0 + assert float(bounds[1]) == 2.2 + sw.controls['zz'].value = 10 + bounds = sw.controls['zz'].bounds.string_value.split(":") + assert float(bounds[0]) == 22.0 + assert float(bounds[1]) == 24.2 + + +def test_slice_label_updates_range_slice(): + da = data_array(ndim=3) + da.coords['zz'] *= 2.2 + sw = RangeSliceWidget(da, dims=['zz']) + sw.controls['zz'].value = (0, 1) + assert sw.controls['zz'].unit.value == '[m]' + bounds = sw.controls['zz'].bounds.string_value.split(":") + assert float(bounds[0]) == 0.0 + assert float(bounds[1]) == 2.2 + sw.controls['zz'].value = (10, 16) + bounds = sw.controls['zz'].bounds.string_value.split(":") + assert float(bounds[0]) == 22.0 + assert float(bounds[1]) == 16 * 2.2 + + def test_make_slice_widget_with_player(): da = data_array(ndim=3) sw = SliceWidget(da, dims=['zz'], enable_player=True) From d8646fd2818f158566f5898fdc90c2e7ebb9d5d1 Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Wed, 4 Mar 2026 15:58:36 +0100 Subject: [PATCH 30/32] fix mode arge --- docs/plotting/slicer-plot.ipynb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/plotting/slicer-plot.ipynb b/docs/plotting/slicer-plot.ipynb index 5d6cf7ff..3bd8c207 100644 --- a/docs/plotting/slicer-plot.ipynb +++ b/docs/plotting/slicer-plot.ipynb @@ -106,7 +106,7 @@ "\n", "It is possible to display some animation controls (play button) next to the slider, by using the `enable_player=True` option.\n", "\n", - "Note that we need to use a slider with a single handle via `slider_mode='single'` for this to work:" + "Note that we need to use a slider with a single handle via `mode='single'` for this to work:" ] }, { @@ -116,7 +116,7 @@ "metadata": {}, "outputs": [], "source": [ - "pp.slicer(da, keep=['x', 'y'], logc=True, enable_player=True, slider_mode='single')" + "pp.slicer(da, keep=['x', 'y'], logc=True, enable_player=True, mode='single')" ] }, { From 1fb7d9c8de1a8889a5dc3a0cbd99899e5f06c0ce Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Wed, 4 Mar 2026 16:24:56 +0100 Subject: [PATCH 31/32] change datetime unit in xarray data --- docs/getting-started/numpy-pandas-xarray.ipynb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/getting-started/numpy-pandas-xarray.ipynb b/docs/getting-started/numpy-pandas-xarray.ipynb index fc65f8af..5ceeac64 100644 --- a/docs/getting-started/numpy-pandas-xarray.ipynb +++ b/docs/getting-started/numpy-pandas-xarray.ipynb @@ -166,6 +166,8 @@ "# The latitude and longitude coordinates have units unrecognised by Scipp\n", "air.coords['lat'].attrs['units'] = 'degrees'\n", "air.coords['lon'].attrs['units'] = 'degrees'\n", + "# Optional: change the datetimes from ns to s for better formatting\n", + "air.coords['time'] = air.coords['time'].astype('datetime64[s]')\n", "air" ] }, @@ -280,7 +282,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.12" + "version": "3.12.7" } }, "nbformat": 4, From b236cd53f61b78296a6138b8363bcdf4397be54f Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Thu, 5 Mar 2026 10:12:28 +0100 Subject: [PATCH 32/32] fix manually setting bounds with datetime binedge single slider --- src/plopp/widgets/slicing.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/plopp/widgets/slicing.py b/src/plopp/widgets/slicing.py index dbb17dba..6af78284 100644 --- a/src/plopp/widgets/slicing.py +++ b/src/plopp/widgets/slicing.py @@ -95,10 +95,15 @@ def __init__( self._lock = False self._coord = coord.values if self._coord.dtype not in (sc.DType.datetime64, sc.DType.string): + self._underlying = self._coord self._fmt = ".3E" if layout is None: layout = {"width": "22.5ch"} else: + if self._coord.dtype == sc.DType.datetime64: + self._underlying = coord.to(unit="ns").values.astype(int) + else: + self._underlying = self._coord self._fmt = "" if layout is None: layout = {"width": f"{0.92 * (len(str(self._coord[-1])) * 2 + 3)}ch"} @@ -121,13 +126,13 @@ def _on_child_change(self, change): return new = [ - _find_closest_index(coord=self._coord, value=x) - for x in change["new"].split(":") + _find_closest_index(coord=self._underlying, value=x) + for x in change["new"].split(" : ") ] - if (":" in change["new"]) and (":" in change["old"]): - old2 = float(change["old"].split(":")[1]) - new2 = float(change["new"].split(":")[1]) + if (" : " in change["new"]) and (" : " in change["old"]): + old2 = change["old"].split(" : ")[1] + new2 = change["new"].split(" : ")[1] if old2 == new2: new = new[0], new[0] + 1 else: