Repository: scipp/plopp

Issue:
Add superplot for displaying 1d slices of 2d data, with ability to persist lines

Discussion:

Patch:
diff --git a/docs/index.rst b/docs/index.rst
index e792aa4..58c41d9 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -49,6 +49,7 @@ Get in touch
    user-guide/image-plot
    user-guide/slicer-plot
    user-guide/inspector-plot
+   user-guide/super-plot
    user-guide/scatter3d-plot
 
 .. toctree::
diff --git a/docs/reference.rst b/docs/reference.rst
index b45706a..a2290ac 100644
--- a/docs/reference.rst
+++ b/docs/reference.rst
@@ -21,4 +21,5 @@ Plotting
    plot
    slicer
    inspector
+   superplot
    scatter3d
diff --git a/docs/user-guide/super-plot.ipynb b/docs/user-guide/super-plot.ipynb
new file mode 100644
index 0000000..fba273e
--- /dev/null
+++ b/docs/user-guide/super-plot.ipynb
@@ -0,0 +1,109 @@
+{
+ "cells": [
+  {
+   "cell_type": "markdown",
+   "id": "a175931f-a0fd-48f7-83cc-9db14d454535",
+   "metadata": {},
+   "source": [
+    "# Super-plot\n",
+    "\n",
+    "The `superplot` was inspired the [LAMP](https://code.ill.fr/scientific-software/lamp)\n",
+    "software's [Superplot](https://github.com/mantidproject/documents/blob/main/Requirements/Visualisation_and_Analysis/superplot.md)\n",
+    "functionality.\n",
+    "\n",
+    "It offers the possibility of representing data with more than one dimensions as a one-dimensional slice with sliders controlling the extra dimensions.\n",
+    "In addition, buttons can be used to save the currently displayed line."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "00f7ac7c-794e-4b71-8dcd-c147fee1ffa4",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "%matplotlib widget\n",
+    "import plopp as pp\n",
+    "da = pp.data.dense_data_array(ndim=2)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "04bcabe8-0863-44aa-878b-f02b2b0e37bd",
+   "metadata": {},
+   "source": [
+    "The `superplot` function needs to know which dimension to `keep` (all remaining dims will be sliced):"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "85bf3158-732c-43a7-9aed-21baceb285ad",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "p = pp.superplot(da, keep='xx')"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "6deecdb6-a7af-4754-b02e-5e82da1eca68",
+   "metadata": {
+    "nbsphinx": "hidden"
+   },
+   "outputs": [],
+   "source": [
+    "b = p.children[0].children[1].children[0]\n",
+    "sl = p.children[1].controls['yy']['slider']\n",
+    "b.click()\n",
+    "sl.value = 20\n",
+    "b.click()\n",
+    "sl.value = 30"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "b9fd0b6a-8a0b-4dfd-93ce-f6aeaf7be3bc",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "p"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "6dcc6a81-68b4-4b67-8ee7-2606e4e46a83",
+   "metadata": {},
+   "source": [
+    "**Controls:**\n",
+    "\n",
+    "- To save the current line, click the `Save line` button on the right hand side\n",
+    "- To change the line color, use the color picker for that line\n",
+    "- To remove a line, click the **[&times;]** button"
+   ]
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "Python 3 (ipykernel)",
+   "language": "python",
+   "name": "python3"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.9.13"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/src/plopp/__init__.py b/src/plopp/__init__.py
index 88266a9..636ffd9 100644
--- a/src/plopp/__init__.py
+++ b/src/plopp/__init__.py
@@ -14,7 +14,7 @@ import matplotlib.pyplot as plt
 plt.ioff()
 
 from .core import Node, node, input_node, widget_node, show_graph, View
-from .functions import figure, plot, slicer, inspector, scatter3d
+from .functions import figure, plot, slicer, inspector, scatter3d, superplot
 
 from . import data
 
diff --git a/src/plopp/core/utils.py b/src/plopp/core/utils.py
index 385b843..7038ea2 100644
--- a/src/plopp/core/utils.py
+++ b/src/plopp/core/utils.py
@@ -91,3 +91,14 @@ def merge_masks(masks):
     Combine all masks into a single one using the OR operation.
     """
     return reduce(lambda a, b: a | b, masks.values())
+
+
+def coord_element_to_string(coord):
+    """
+    Convert a slice of a coordinate containing a single value (or two values in the
+    case of a bin-edge coordinate) to a string.
+    """
+    out = value_to_string(coord.values)
+    if coord.unit is not None:
+        out += f" [{coord.unit}]"
+    return out
diff --git a/src/plopp/functions/__init__.py b/src/plopp/functions/__init__.py
index bb9122e..fae417e 100644
--- a/src/plopp/functions/__init__.py
+++ b/src/plopp/functions/__init__.py
@@ -6,5 +6,6 @@
 from .figure import figure
 from .plot import plot
 from .inspector import inspector
-from .slicer import slicer
 from .scatter3d import scatter3d
+from .slicer import slicer
+from .superplot import superplot
diff --git a/src/plopp/functions/slicer.py b/src/plopp/functions/slicer.py
index c10357d..4a5fd1a 100644
--- a/src/plopp/functions/slicer.py
+++ b/src/plopp/functions/slicer.py
@@ -5,12 +5,38 @@ from .common import require_interactive_backend, preprocess
 from .figure import figure
 from ..core import input_node, widget_node
 
-from scipp import Variable
+from scipp import Variable, DataArray
 from scipp.typing import VariableLike
 from numpy import ndarray
 from typing import Union, Dict, List
 
 
+class Slicer:
+
+    def __init__(self,
+                 da: DataArray,
+                 keep: List[str] = None,
+                 *,
+                 crop: Dict[str, Dict[str, Variable]] = None,
+                 **kwargs):
+
+        from plopp.widgets import SliceWidget, slice_dims
+
+        self.data_node = input_node(da)
+
+        if keep is None:
+            keep = da.dims[-(2 if da.ndim > 2 else 1):]
+
+        if len(keep) == 0:
+            raise ValueError(
+                'Slicer plot: the list of dims to be kept cannot be empty.')
+
+        self.slider = SliceWidget(da, dims=list(set(da.dims) - set(keep)))
+        self.slider_node = widget_node(self.slider)
+        self.slice_node = slice_dims(self.data_node, self.slider_node)
+        self.fig = figure(self.slice_node, **{**{'crop': crop}, **kwargs})
+
+
 def slicer(obj: Union[VariableLike, ndarray],
            keep: List[str] = None,
            *,
@@ -43,15 +69,7 @@ def slicer(obj: Union[VariableLike, ndarray],
         A :class:`Box` which will contain a :class:`Figure` and slider widgets.
     """
     require_interactive_backend('slicer')
-
-    from plopp.widgets import SliceWidget, slice_dims, Box
     da = preprocess(obj, crop=crop, ignore_size=True)
-    a = input_node(da)
-
-    if keep is None:
-        keep = da.dims[-(2 if da.ndim > 2 else 1):]
-    sl = SliceWidget(da, dims=list(set(da.dims) - set(keep)))
-    w = widget_node(sl)
-    slice_node = slice_dims(a, w)
-    fig = figure(slice_node, **{**{'crop': crop}, **kwargs})
-    return Box([fig, sl])
+    from plopp.widgets import Box
+    sl = Slicer(da=da, keep=keep, crop=crop, **kwargs)
+    return Box([sl.fig, sl.slider])
diff --git a/src/plopp/functions/superplot.py b/src/plopp/functions/superplot.py
new file mode 100644
index 0000000..d83d8e6
--- /dev/null
+++ b/src/plopp/functions/superplot.py
@@ -0,0 +1,101 @@
+# SPDX-License-Identifier: BSD-3-Clause
+# Copyright (c) 2022 Scipp contributors (https://github.com/scipp)
+
+from .common import require_interactive_backend, preprocess
+from .slicer import Slicer
+from ..core.utils import coord_element_to_string
+from ..widgets import ColorTool
+
+from functools import partial
+import scipp as sc
+from numpy import ndarray
+from typing import Union, Dict
+from matplotlib.colors import to_hex
+import uuid
+
+
+class LineSaveTool:
+
+    def __init__(self, data_node, slider_node, fig):
+        import ipywidgets as ipw
+        self._data_node = data_node
+        self._slider_node = slider_node
+        self._fig = fig
+        self._lines = {}
+        self.button = ipw.Button(description='Save line')
+        self.button.on_click(self.save_line)
+        self.container = ipw.VBox()
+        self.widget = ipw.VBox([self.button, self.container], layout={'width': '350px'})
+
+    def _update_container(self):
+        self.container.children = [line['tool'] for line in self._lines.values()]
+
+    def save_line(self, change=None):
+        line_id = uuid.uuid4().hex
+        data = self._data_node.request_data()
+        self._fig.update(data, key=line_id)
+        slice_values = self._slider_node.request_data()
+        text = ', '.join(f'{k}: {coord_element_to_string(data.meta[k])}'
+                         for k, it in slice_values.items())
+        line = self._fig._children[line_id]
+        tool = ColorTool(text=text, color=to_hex(line.color))
+        self._lines[line_id] = {'line': line, 'tool': tool}
+        self._update_container()
+        tool.color.observe(partial(self.change_line_color, line_id=line_id),
+                           names='value')
+        tool.button.on_click(partial(self.remove_line, line_id=line_id))
+
+    def change_line_color(self, change, line_id):
+        self._lines[line_id]['line'].color = change['new']
+        self._fig.draw()
+
+    def remove_line(self, change, line_id):
+        self._lines[line_id]['line'].remove()
+        self._fig.draw()
+        del self._lines[line_id]
+        self._update_container()
+
+
+def superplot(obj: Union[sc.typing.VariableLike, ndarray],
+              keep: str = None,
+              *,
+              crop: Dict[str, Dict[str, sc.Variable]] = None,
+              **kwargs):
+    """
+    Plot a multi-dimensional object as a one-dimensional line, slicing all but one
+    dimension. This will produce one slider per sliced dimension, below the figure.
+    In addition, a tool for saving the currently displayed line is added on the right
+    hand side of the figure.
+
+    Parameters
+    ----------
+    obj:
+        The object to be plotted.
+    keep:
+        The single dimension to be kept, all remaining dimensions will be sliced.
+        This should be a single string. If no dim is provided, the last/inner dim will
+        be kept.
+    crop:
+        Set the axis limits. Limits should be given as a dict with one entry per
+        dimension to be cropped. Each entry should be a nested dict containing scalar
+        values for `'min'` and/or `'max'`. Example:
+        `da.plot(crop={'time': {'min': 2 * sc.Unit('s'), 'max': 40 * sc.Unit('s')}})`
+    **kwargs:
+        See :py:func:`plopp.plot` for the full list of line customization arguments.
+
+    Returns
+    -------
+    :
+        A :class:`Box` which will contain a :class:`Figure`, slider widgets and a tool
+        to save/delete lines.
+    """
+    require_interactive_backend('superplot')
+    da = preprocess(obj, crop=crop, ignore_size=True)
+    if keep is None:
+        keep = da.dims[-1]
+    sl = Slicer(da=da, keep=[keep], crop=crop, **kwargs)
+    save_tool = LineSaveTool(data_node=sl.slice_node,
+                             slider_node=sl.slider_node,
+                             fig=sl.fig)
+    from plopp.widgets import Box
+    return Box([[sl.fig, save_tool.widget], sl.slider])
diff --git a/src/plopp/graphics/line.py b/src/plopp/graphics/line.py
index d89eead..df1b7c9 100644
--- a/src/plopp/graphics/line.py
+++ b/src/plopp/graphics/line.py
@@ -209,3 +209,13 @@ class Line:
         self._mask.remove()
         if self._error is not None:
             self._error.remove()
+
+    @property
+    def color(self):
+        return self._line.get_color()
+
+    @color.setter
+    def color(self, val):
+        self._line.set_color(val)
+        if self._error is not None:
+            self._error.set_color(val)
diff --git a/src/plopp/widgets/__init__.py b/src/plopp/widgets/__init__.py
index 4f122fa..c165283 100644
--- a/src/plopp/widgets/__init__.py
+++ b/src/plopp/widgets/__init__.py
@@ -8,4 +8,4 @@ from .box import Box
 from .checkboxes import Checkboxes
 from .slice import SliceWidget, slice_dims
 from .toolbar import Toolbar
-from .tools import ButtonTool, ToggleTool, PointsTool
+from .tools import ButtonTool, ToggleTool, PointsTool, ColorTool
diff --git a/src/plopp/widgets/slice.py b/src/plopp/widgets/slice.py
index b898290..7243b97 100644
--- a/src/plopp/widgets/slice.py
+++ b/src/plopp/widgets/slice.py
@@ -2,20 +2,13 @@
 # Copyright (c) 2022 Scipp contributors (https://github.com/scipp)
 
 from scipp import DataArray
-from ..core.utils import value_to_string
+from ..core.utils import coord_element_to_string
 from ..core import node
 
 import ipywidgets as ipw
 from typing import Callable
 
 
-def _coord_to_string(coord):
-    out = value_to_string(coord.values)
-    if coord.unit is not None:
-        out += f" [{coord.unit}]"
-    return out
-
-
 class SliceWidget(ipw.VBox):
     """
     Widgets containing a slider for each of the input's dimensions, as well as
@@ -42,7 +35,7 @@ class SliceWidget(ipw.VBox):
                                              tooltip="Continuous update",
                                              indent=False,
                                              layout={"width": "20px"})
-            label = ipw.Label(value=_coord_to_string(coord[dim, 0]))
+            label = ipw.Label(value=coord_element_to_string(coord[dim, 0]))
             ipw.jslink((continuous_update, 'value'), (slider, 'continuous_update'))
 
             self.controls[dim] = {
@@ -59,7 +52,7 @@ class SliceWidget(ipw.VBox):
     def _update_label(self, change):
         dim = change['owner'].description
         coord = self.controls[dim]['coord'][dim, change['new']]
-        self.controls[dim]['label'].value = _coord_to_string(coord)
+        self.controls[dim]['label'].value = coord_element_to_string(coord)
 
     def _plopp_observe_(self, callback: Callable, **kwargs):
         for dim in self.controls:
diff --git a/src/plopp/widgets/tools.py b/src/plopp/widgets/tools.py
index 71a02e9..eceeeec 100644
--- a/src/plopp/widgets/tools.py
+++ b/src/plopp/widgets/tools.py
@@ -1,7 +1,7 @@
 # SPDX-License-Identifier: BSD-3-Clause
 # Copyright (c) 2022 Scipp contributors (https://github.com/scipp)
 
-from ipywidgets import Button
+import ipywidgets as ipw
 from typing import Callable
 
 LAYOUT_STYLE = {"layout": {"width": "34px", "padding": "0px 0px 0px 0px"}}
@@ -13,7 +13,7 @@ class ButtonTool:
         """
         Create a new button with a callback that is called when the button is clicked.
         """
-        self.widget = Button(**{**LAYOUT_STYLE, **kwargs})
+        self.widget = ipw.Button(**{**LAYOUT_STYLE, **kwargs})
         self._callback = callback
         self.widget.on_click(self)
 
@@ -30,7 +30,7 @@ class ToggleTool:
         cases, we need to toggle the button color without triggering the callback
         function.
         """
-        self.widget = Button(**{**LAYOUT_STYLE, **kwargs})
+        self.widget = ipw.Button(**{**LAYOUT_STYLE, **kwargs})
         self._callback = callback
         self.widget.on_click(self)
         self._value = value
@@ -72,3 +72,16 @@ class PointsTool(ToggleTool):
             self.points.start()
         else:
             self.points.stop()
+
+
+class ColorTool(ipw.HBox):
+
+    def __init__(self, text, color):
+        layout = ipw.Layout(display="flex", justify_content="flex-end", width='150px')
+        self.text = ipw.Label(value=text, layout=layout)
+        self.color = ipw.ColorPicker(concise=True,
+                                     value=color,
+                                     description='',
+                                     layout={'width': "30px"})
+        self.button = ipw.Button(icon='times', **LAYOUT_STYLE)
+        super().__init__([self.text, self.color, self.button])


Test patch:
diff --git a/tests/functions/slicer_test.py b/tests/functions/slicer_test.py
new file mode 100644
index 0000000..d345d03
--- /dev/null
+++ b/tests/functions/slicer_test.py
@@ -0,0 +1,46 @@
+# SPDX-License-Identifier: BSD-3-Clause
+# Copyright (c) 2022 Scipp contributors (https://github.com/scipp)
+
+from plopp.data import dense_data_array
+from plopp.functions.slicer import Slicer
+import scipp as sc
+
+
+def test_creation_keep_two_dims():
+    da = dense_data_array(ndim=3)
+    sl = Slicer(da=da, keep=['xx', 'yy'])
+    assert sl.slider.value == {'zz': 0}
+    assert sl.slider.controls['zz']['slider'].max == da.sizes['zz'] - 1
+    assert sc.identical(sl.slice_node.request_data(), da['zz', 0])
+
+
+def test_creation_keep_one_dim():
+    da = dense_data_array(ndim=3)
+    sl = Slicer(da=da, keep=['xx'])
+    assert sl.slider.value == {'zz': 0, 'yy': 0}
+    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_node.request_data(), da['yy', 0]['zz', 0])
+
+
+def test_update_keep_two_dims():
+    da = dense_data_array(ndim=3)
+    sl = Slicer(da=da, keep=['xx', 'yy'])
+    assert sl.slider.value == {'zz': 0}
+    assert sc.identical(sl.slice_node.request_data(), da['zz', 0])
+    sl.slider.controls['zz']['slider'].value = 5
+    assert sl.slider.value == {'zz': 5}
+    assert sc.identical(sl.slice_node.request_data(), da['zz', 5])
+
+
+def test_update_keep_one_dim():
+    da = dense_data_array(ndim=3)
+    sl = Slicer(da=da, keep=['xx'])
+    assert sl.slider.value == {'zz': 0, 'yy': 0}
+    assert sc.identical(sl.slice_node.request_data(), da['yy', 0]['zz', 0])
+    sl.slider.controls['yy']['slider'].value = 5
+    assert sl.slider.value == {'zz': 0, 'yy': 5}
+    assert sc.identical(sl.slice_node.request_data(), da['yy', 5]['zz', 0])
+    sl.slider.controls['zz']['slider'].value = 8
+    assert sl.slider.value == {'zz': 8, 'yy': 5}
+    assert sc.identical(sl.slice_node.request_data(), da['yy', 5]['zz', 8])
diff --git a/tests/functions/superplot_test.py b/tests/functions/superplot_test.py
new file mode 100644
index 0000000..6db4ffa
--- /dev/null
+++ b/tests/functions/superplot_test.py
@@ -0,0 +1,69 @@
+# SPDX-License-Identifier: BSD-3-Clause
+# Copyright (c) 2022 Scipp contributors (https://github.com/scipp)
+
+from plopp.data import dense_data_array
+from plopp.functions.slicer import Slicer
+from plopp.functions.superplot import LineSaveTool
+import scipp as sc
+
+
+def test_creation():
+    da = dense_data_array(ndim=2)
+    sl = Slicer(da=da, keep=['xx'])
+    tool = LineSaveTool(data_node=sl.slice_node, slider_node=sl.slider_node, fig=sl.fig)
+    assert len(tool._lines) == 0
+
+
+def test_save_line():
+    da = dense_data_array(ndim=2)
+    sl = Slicer(da=da, keep=['xx'])
+    tool = LineSaveTool(data_node=sl.slice_node, slider_node=sl.slider_node, fig=sl.fig)
+    assert len(tool._lines) == 0
+    tool.save_line()
+    assert len(tool._lines) == 1
+    line = list(tool._lines.values())[-1]
+    assert sc.identical(line['line']._data, da['yy', 0])
+    assert len(tool.container.children) == 1
+
+    sl.slider.controls['yy']['slider'].value = 5
+    tool.save_line()
+    assert len(tool._lines) == 2
+    line = list(tool._lines.values())[-1]
+    assert sc.identical(line['line']._data, da['yy', 5])
+    assert len(tool.container.children) == 2
+
+
+def test_remove_line():
+    da = dense_data_array(ndim=2)
+    sl = Slicer(da=da, keep=['xx'])
+    tool = LineSaveTool(data_node=sl.slice_node, slider_node=sl.slider_node, fig=sl.fig)
+    assert len(tool._lines) == 0
+    tool.save_line()
+    sl.slider.controls['yy']['slider'].value = 5
+    tool.save_line()
+    sl.slider.controls['yy']['slider'].value = 15
+    tool.save_line()
+    assert len(tool._lines) == 3
+    assert len(tool.container.children) == 3
+
+    keys = list(tool._lines.keys())
+    first_line = keys[0]
+    last_line = keys[2]
+
+    tool.remove_line(change=None, line_id=first_line)
+    assert first_line not in tool._lines
+    assert len(tool.container.children) == 2
+
+    tool.remove_line(change=None, line_id=last_line)
+    assert last_line not in tool._lines
+    assert len(tool.container.children) == 1
+
+
+def test_change_line_color():
+    da = dense_data_array(ndim=2)
+    sl = Slicer(da=da, keep=['xx'])
+    tool = LineSaveTool(data_node=sl.slice_node, slider_node=sl.slider_node, fig=sl.fig)
+    tool.save_line()
+    line_id = list(tool._lines.keys())[-1]
+    tool.change_line_color(change={'new': '#000000'}, line_id=line_id)
+    assert tool._lines[line_id]['line'].color == '#000000'