Repository: scipp/plopp

Issue:
Add legend control
We need options in the `plot` function to hide the legend and control its placement.
Would be something like
```Py
pp.plot(dict, legend=False)
pp.plot(dict, legend=(0.1, 0.9))
```
The latter would control the location, see the `loc` parameter in https://matplotlib.org/stable/api/_as_gen/matplotlib.pyplot.legend.html.

Discussion:

Patch:
diff --git a/src/plopp/backends/matplotlib/canvas.py b/src/plopp/backends/matplotlib/canvas.py
index 2fcb8c2..292100f 100644
--- a/src/plopp/backends/matplotlib/canvas.py
+++ b/src/plopp/backends/matplotlib/canvas.py
@@ -54,6 +54,9 @@ class Canvas:
         The aspect ratio for the axes.
     cbar:
         Add axes to host a colorbar if ``True``.
+    legend:
+        Show legend if ``True``. If ``legend`` is a tuple, it should contain the
+        ``(x, y)`` coordinates of the legend's anchor point in axes coordinates.
     """
 
     def __init__(
@@ -68,6 +71,7 @@ class Canvas:
         autoscale: Literal['auto', 'grow'] = 'auto',
         aspect: Literal['auto', 'equal'] = 'auto',
         cbar: bool = False,
+        legend: Union[bool, Tuple[float, float]] = True,
         **ignored,
     ):
         # Note on the `**ignored`` keyword arguments: the figure which owns the canvas
@@ -88,6 +92,7 @@ class Canvas:
         self.dims = {}
         self._own_axes = False
         self._autoscale = autoscale
+        self._legend = legend
 
         if self.ax is None:
             self._own_axes = True
diff --git a/src/plopp/backends/matplotlib/line.py b/src/plopp/backends/matplotlib/line.py
index cc6cb44..fa03143 100644
--- a/src/plopp/backends/matplotlib/line.py
+++ b/src/plopp/backends/matplotlib/line.py
@@ -159,8 +159,16 @@ class Line:
                 fmt="none",
             )
 
-        if self.label:
-            self._ax.legend()
+        if self.label and self._canvas._legend:
+            leg_args = {}
+            if isinstance(self._canvas._legend, (list, tuple)):
+                leg_args = {'loc': self._canvas._legend}
+            elif not isinstance(self._canvas._legend, bool):
+                raise TypeError(
+                    "Legend must be a bool, tuple, or a list, "
+                    f"not {type(self._canvas._legend)}"
+                )
+            self._ax.legend(**leg_args)
 
     def _make_data(self) -> dict:
         x = self._data.meta[self._dim]
diff --git a/src/plopp/graphics/lineview.py b/src/plopp/graphics/lineview.py
index 32173d5..371daa4 100644
--- a/src/plopp/graphics/lineview.py
+++ b/src/plopp/graphics/lineview.py
@@ -57,6 +57,9 @@ class LineView(View):
         Format of the figure displayed in the Jupyter notebook. If ``None``, a SVG is
         created as long as the number of markers in the figure is not too large. If too
         many markers are drawn, a PNG image is created instead.
+    legend:
+        Show legend if ``True``. If ``legend`` is a tuple, it should contain the
+        ``(x, y)`` coordinates of the legend's anchor point in axes coordinates.
     **kwargs:
         All other kwargs are forwarded to Matplotlib:
 
@@ -80,6 +83,7 @@ class LineView(View):
         title: Optional[str] = None,
         figsize: Tuple[float, float] = None,
         format: Optional[Literal['svg', 'png']] = None,
+        legend: Union[bool, Tuple[float, float]] = True,
         **kwargs
     ):
         super().__init__(*nodes)
@@ -98,6 +102,7 @@ class LineView(View):
             vmin=vmin,
             vmax=vmax,
             autoscale=autoscale,
+            legend=legend,
             **kwargs
         )
         self.canvas.yscale = norm
diff --git a/src/plopp/plotting/plot.py b/src/plopp/plotting/plot.py
index 1a0011b..db75d2a 100644
--- a/src/plopp/plotting/plot.py
+++ b/src/plopp/plotting/plot.py
@@ -30,6 +30,7 @@ def plot(
     vmin: Optional[Union[Variable, int, float]] = None,
     vmax: Optional[Union[Variable, int, float]] = None,
     autoscale: Literal['auto', 'grow'] = 'auto',
+    legend: Union[bool, Tuple[float, float]] = True,
     **kwargs,
 ):
     """Plot a Scipp object.
@@ -78,6 +79,9 @@ def plot(
         The behavior of the axis (1d plots) or the color range limits (2d plots).
         If ``auto``, the limits automatically adjusts every time the data changes.
         If ``grow``, the limits are allowed to grow with time but they do not shrink.
+    legend:
+        Show legend if ``True``. If ``legend`` is a tuple, it should contain the
+        ``(x, y)`` coordinates of the legend's anchor point in axes coordinates.
     **kwargs:
         All other kwargs are directly forwarded to Matplotlib, the underlying plotting
         library. The underlying functions called are the following:
@@ -123,6 +127,7 @@ def plot(
             *[Node(da) for da in data_arrays],
             errorbars=errorbars,
             mask_color=mask_color,
+            legend=legend,
             **common_args,
         )
     elif ndim == 2:


Test patch:
diff --git a/tests/plotting/plot_test.py b/tests/plotting/plot_test.py
index 74340d5..0fe53df 100644
--- a/tests/plotting/plot_test.py
+++ b/tests/plotting/plot_test.py
@@ -376,3 +376,31 @@ def test_plot_pandas_dataframe():
     assert p.canvas.units['x'] == 'dimensionless'
     assert p.canvas.units['y'] == 'dimensionless'
     assert len(p._view.artists) == 4
+
+
+def test_hide_legend():
+    da1 = data_array(ndim=1)
+    da2 = da1 * 3.3
+    p = pp.plot({'a': da1, 'b': da2}, legend=False)
+    leg = p.ax.get_legend()
+    assert leg is None
+
+
+def test_legend_location():
+    da1 = data_array(ndim=1)
+    da2 = da1 * 3.3
+    data = {'a': da1, 'b': da2}
+    leg1 = pp.plot(data, legend=(0.5, 0.5)).ax.get_legend().get_window_extent().bounds
+    leg2 = pp.plot(data, legend=(0.9, 0.5)).ax.get_legend().get_window_extent().bounds
+    leg3 = pp.plot(data, legend=(0.5, 0.9)).ax.get_legend().get_window_extent().bounds
+    assert leg2[0] > leg1[0]
+    assert leg2[1] == leg1[1]
+    assert leg3[1] > leg1[1]
+    assert leg3[0] == leg1[0]
+
+
+def test_hide_legend_bad_type():
+    da1 = data_array(ndim=1)
+    da2 = da1 * 3.3
+    with pytest.raises(TypeError, match='Legend must be a bool, tuple, or a list'):
+        pp.plot({'a': da1, 'b': da2}, legend='False')